From 76e6f213d61be7a1d193e09a851dda49ecf1b5a1 Mon Sep 17 00:00:00 2001 From: Yuqing Date: Wed, 30 Sep 2026 16:21:49 +0800 Subject: [PATCH 01/30] feat: add UI-triggered Alpha redeployment (#256) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .env.example | 8 + README.md | 2 + apps/server/src/app.ts | 4 + .../security/canary-redeploy.route.test.ts | 73 +++++ .../modules/security/canary-redeploy.route.ts | 99 ++++++ .../modules/security/canary-redeploy.test.ts | 160 ++++++++++ .../src/modules/security/canary-redeploy.ts | 296 ++++++++++++++++++ apps/web/src/api/_routes.ts | 3 + apps/web/src/api/deployment.ts | 27 +- .../Settings/CanaryRedeploySettings.test.tsx | 111 +++++++ .../Settings/CanaryRedeploySettings.tsx | 191 +++++++++++ .../sections/GeneralSettings.test.tsx | 3 + .../Settings/sections/GeneralSettings.tsx | 2 + apps/web/src/i18n/resources/en/common.json | 23 +- apps/web/src/i18n/resources/zh-CN/common.json | 23 +- docs/README.md | 1 + docs/architecture/canary-deployment.md | 51 +++ packages/shared/src/types/api/deployment.ts | 45 +++ scripts/canary-redeploy-runner.mjs | 89 ++++++ scripts/start-huabu.sh | 163 ++++++++++ scripts/start-web.mjs | 7 + 21 files changed, 1378 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/modules/security/canary-redeploy.route.test.ts create mode 100644 apps/server/src/modules/security/canary-redeploy.route.ts create mode 100644 apps/server/src/modules/security/canary-redeploy.test.ts create mode 100644 apps/server/src/modules/security/canary-redeploy.ts create mode 100644 apps/web/src/components/Settings/CanaryRedeploySettings.test.tsx create mode 100644 apps/web/src/components/Settings/CanaryRedeploySettings.tsx create mode 100644 docs/architecture/canary-deployment.md create mode 100755 scripts/canary-redeploy-runner.mjs create mode 100755 scripts/start-huabu.sh diff --git a/.env.example b/.env.example index 5e1b43c25..64acf75ab 100644 --- a/.env.example +++ b/.env.example @@ -75,6 +75,14 @@ # HUABU_BASIC_AUTH_USER= # HUABU_BASIC_AUTH_PASS= +# ── Personal Alpha Canary redeployment ── +# Source-run `pnpm start:web` only. When enabled, the authenticated owner sees +# Settings controls that compare the running commit with origin/alpha and can +# run `scripts/start-huabu.sh alpha --non-interactive` on this host. The script +# updates the checkout in place and may leave the service offline on failure; +# this is a personal-development convenience, not a production deployer. +# HUABU_CANARY_REDEPLOY_ENABLED=1 + # ── Storage (restart required) ── # Structured records: disk (default), sqlite or postgres. The two axes are # independent, so every pairing of an implemented record backend with an diff --git a/README.md b/README.md index 11efdcbc6..fedbb5232 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,8 @@ Then run `pnpm start:web`. Huabu rejects a non-loopback bind when allowed hosts Huabu currently serves HTTP. Use a trusted private network or terminate HTTPS with deployment infrastructure such as Caddy, Nginx, Tailscale Serve, or a cloud load balancer. Do not put a Basic Auth deployment on an untrusted network without transport encryption. +For a personal Alpha Canary started from a repository checkout, set `HUABU_CANARY_REDEPLOY_ENABLED=1` before `pnpm start:web`. The authenticated owner can then compare the running commit with `origin/alpha` and invoke the checked-in `scripts/start-huabu.sh alpha --non-interactive` redeployment from Settings instead of connecting through SSH. This helper updates the checkout in place and does not provide rollback or service recovery; see [Alpha Canary deployment](docs/architecture/canary-deployment.md). + ### Local quality checks (optional) The repository ships opt-in git hooks that give you fast feedback before diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 692752ef2..8fbb46c58 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -46,6 +46,7 @@ import integrationsRoutes from './modules/integrations/integrations.route.js'; import interactiveViewRoutes from './modules/interactive-view/interactive-view.route.js'; import { isPublicRfsSkillBootstrapRequest } from './modules/remote_fs/public-skill.js'; import rfsRoutes from './modules/remote_fs/rfs.route.js'; +import canaryRedeployRoutes from './modules/security/canary-redeploy.route.js'; import { createCorsOptions } from './modules/security/cors.js'; import deploymentRoutes from './modules/security/deployment.route.js'; import { @@ -258,6 +259,9 @@ app.register(artifactRoute, { prefix: '/api/canvas' }); app.register(llmRoutes, { prefix: '/api/llm' }); app.register(integrationsRoutes, { prefix: '/api/integrations' }); app.register(deploymentRoutes, { prefix: '/api/deployment' }); +app.register(canaryRedeployRoutes, { + prefix: '/api/deployment/canary', +}); app.register(interactiveViewRoutes, { prefix: '/api/interactive-views' }); app.register(skillsRoutes, { prefix: '/api/skills' }); app.register(workspaceRoutes, { prefix: '/api/workspace' }); diff --git a/apps/server/src/modules/security/canary-redeploy.route.test.ts b/apps/server/src/modules/security/canary-redeploy.route.test.ts new file mode 100644 index 000000000..2f9d445ea --- /dev/null +++ b/apps/server/src/modules/security/canary-redeploy.route.test.ts @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import Fastify from 'fastify'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import canaryRedeployRoutes from './canary-redeploy.route.js'; + +import type { FastifyInstance } from 'fastify'; + +describe('Canary redeployment routes', () => { + let app: FastifyInstance; + const originalEnabled = process.env.HUABU_CANARY_REDEPLOY_ENABLED; + + beforeEach(async () => { + delete process.env.HUABU_CANARY_REDEPLOY_ENABLED; + app = Fastify({ logger: false }); + await app.register(canaryRedeployRoutes, { + prefix: '/api/deployment/canary', + }); + }); + + afterEach(async () => { + await app.close(); + if (originalEnabled === undefined) { + delete process.env.HUABU_CANARY_REDEPLOY_ENABLED; + } else { + process.env.HUABU_CANARY_REDEPLOY_ENABLED = originalEnabled; + } + }); + + it('lets the local owner inspect a disabled capability', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/deployment/canary', + }); + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + available: false, + reason: 'disabled', + branch: 'alpha', + }); + }); + + it('rejects non-owner callers', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/deployment/canary', + remoteAddress: '192.0.2.10', + }); + expect(response.statusCode).toBe(403); + }); + + it('validates action request bodies and reports unavailable redeployment', async () => { + const malformed = await app.inject({ + method: 'POST', + url: '/api/deployment/canary/redeploy', + payload: { branch: 'main' }, + }); + expect(malformed.statusCode).toBe(400); + expect(malformed.json()).toMatchObject({ code: 'validation_failed' }); + + const unavailable = await app.inject({ + method: 'POST', + url: '/api/deployment/canary/redeploy', + payload: {}, + }); + expect(unavailable.statusCode).toBe(503); + expect(unavailable.json()).toMatchObject({ + code: 'canary_redeploy_unavailable', + }); + }); +}); diff --git a/apps/server/src/modules/security/canary-redeploy.route.ts b/apps/server/src/modules/security/canary-redeploy.route.ts new file mode 100644 index 000000000..4442cded5 --- /dev/null +++ b/apps/server/src/modules/security/canary-redeploy.route.ts @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + canaryRedeployRequestSchema, + type ApiResult, + type CanaryRedeployRequest, + type CanaryRedeployStatusResponse, +} from '@huabu/shared'; + +import { + checkCanaryRemote, + getCanaryRedeployStatus, + requestCanaryRedeploy, +} from './canary-redeploy.js'; +import { isOwnerRequest } from './owner.js'; + +import type { FastifyPluginAsync } from 'fastify'; + +const canaryRedeployRoutes: FastifyPluginAsync = async (app) => { + app.get<{ Reply: ApiResult }>( + '/', + async (request, reply) => { + if (!isOwnerRequest(request)) { + return reply.status(403).send({ + message: + 'Forbidden: Canary redeployment requires owner authorization', + }); + } + return getCanaryRedeployStatus(); + }, + ); + + app.post<{ + Body: CanaryRedeployRequest; + Reply: ApiResult; + }>('/check', async (request, reply) => { + if (!isOwnerRequest(request)) { + return reply.status(403).send({ + message: 'Forbidden: Canary redeployment requires owner authorization', + }); + } + const parsed = canaryRedeployRequestSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + message: + parsed.error.issues[0]?.message ?? 'Invalid Canary check request', + code: 'validation_failed', + }); + } + try { + return await checkCanaryRemote(); + } catch (error) { + request.log.warn({ err: error }, 'Canary update check failed'); + return reply.status(502).send({ + message: 'Unable to resolve origin/alpha', + code: 'canary_check_failed', + }); + } + }); + + app.post<{ + Body: CanaryRedeployRequest; + Reply: ApiResult; + }>('/redeploy', async (request, reply) => { + if (!isOwnerRequest(request)) { + return reply.status(403).send({ + message: 'Forbidden: Canary redeployment requires owner authorization', + }); + } + const parsed = canaryRedeployRequestSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + message: + parsed.error.issues[0]?.message ?? 'Invalid Canary redeploy request', + code: 'validation_failed', + }); + } + try { + const status = await requestCanaryRedeploy(); + return reply.status(202).send(status); + } catch (error) { + const message = error instanceof Error ? error.message : ''; + if (message === 'Canary redeployment is already in progress') { + return reply.status(409).send({ + message, + code: 'canary_redeploy_in_progress', + }); + } + request.log.error({ err: error }, 'Unable to start Canary redeployment'); + return reply.status(503).send({ + message: 'Canary redeployment is unavailable', + code: 'canary_redeploy_unavailable', + }); + } + }); +}; + +export default canaryRedeployRoutes; diff --git a/apps/server/src/modules/security/canary-redeploy.test.ts b/apps/server/src/modules/security/canary-redeploy.test.ts new file mode 100644 index 000000000..e1ef186a5 --- /dev/null +++ b/apps/server/src/modules/security/canary-redeploy.test.ts @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { execFileSync, spawnSync } from 'node:child_process'; +import { + chmodSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + checkCanaryRemote, + getCanaryRedeployStatus, + resetCanaryRedeployStateForTest, + resolveCanaryCapability, + writeCanaryRedeployResult, +} from './canary-redeploy.js'; + +describe('Canary redeployment service', () => { + let root: string; + let remote: string; + let dataDir: string; + const originalEnv = { + enabled: process.env.HUABU_CANARY_REDEPLOY_ENABLED, + repoRoot: process.env.HUABU_REPO_ROOT, + deployedSha: process.env.HUABU_DEPLOYED_SHA, + dataDir: process.env.HUABU_DATA_DIR, + }; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'huabu-canary-repo-')); + remote = mkdtempSync(join(tmpdir(), 'huabu-canary-remote-')); + dataDir = mkdtempSync(join(tmpdir(), 'huabu-canary-data-')); + mkdirSync(join(root, 'scripts')); + for (const name of ['start-huabu.sh', 'canary-redeploy-runner.mjs']) { + const file = join(root, 'scripts', name); + writeFileSync(file, '#!/usr/bin/env bash\nexit 0\n'); + chmodSync(file, 0o755); + } + + execFileSync('git', ['init', '--bare', remote]); + execFileSync('git', ['init', '-b', 'alpha'], { cwd: root }); + execFileSync('git', ['config', 'user.email', 'canary@example.test'], { + cwd: root, + }); + execFileSync('git', ['config', 'user.name', 'Canary Test'], { cwd: root }); + writeFileSync(join(root, 'README.md'), 'canary\n'); + execFileSync('git', ['add', '.'], { cwd: root }); + execFileSync('git', ['commit', '-m', 'Initial Canary revision'], { + cwd: root, + }); + execFileSync('git', ['remote', 'add', 'origin', remote], { cwd: root }); + execFileSync('git', ['push', '-u', 'origin', 'alpha'], { cwd: root }); + + const sha = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: root, + encoding: 'utf8', + }).trim(); + process.env.HUABU_CANARY_REDEPLOY_ENABLED = '1'; + process.env.HUABU_REPO_ROOT = root; + process.env.HUABU_DEPLOYED_SHA = sha; + process.env.HUABU_DATA_DIR = dataDir; + resetCanaryRedeployStateForTest(); + }); + + afterEach(() => { + const restore = (key: string, value: string | undefined) => { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + }; + restore('HUABU_CANARY_REDEPLOY_ENABLED', originalEnv.enabled); + restore('HUABU_REPO_ROOT', originalEnv.repoRoot); + restore('HUABU_DEPLOYED_SHA', originalEnv.deployedSha); + restore('HUABU_DATA_DIR', originalEnv.dataDir); + resetCanaryRedeployStateForTest(); + rmSync(root, { recursive: true, force: true }); + rmSync(remote, { recursive: true, force: true }); + rmSync(dataDir, { recursive: true, force: true }); + }); + + it('requires explicit enablement and executable repository scripts', () => { + expect(resolveCanaryCapability()).toMatchObject({ + available: true, + reason: 'available', + repoRoot: root, + }); + + delete process.env.HUABU_CANARY_REDEPLOY_ENABLED; + expect(resolveCanaryCapability()).toMatchObject({ + available: false, + reason: 'disabled', + }); + }); + + it('compares the startup revision with origin/alpha', async () => { + const status = await checkCanaryRemote(); + expect(status).toMatchObject({ + available: true, + branch: 'alpha', + updateAvailable: false, + runningSha: status.remoteSha, + }); + expect(status.checkedAt).toEqual(expect.any(Number)); + }); + + it('persists only the bounded redeployment result contract', async () => { + await writeCanaryRedeployResult({ + state: 'failed', + startedAt: 10, + completedAt: 20, + exitCode: 1, + message: 'Redeploy script exited with status 1', + }); + + await expect(getCanaryRedeployStatus()).resolves.toMatchObject({ + redeploy: { + state: 'failed', + exitCode: 1, + }, + }); + }); + + it('records a detached runner failure without exposing command output', () => { + const hook = join(root, 'failing-hook.sh'); + const statusPath = join(dataDir, 'runner-status.json'); + const logPath = join(dataDir, 'runner.log'); + writeFileSync(hook, '#!/usr/bin/env bash\necho private-output\nexit 7\n'); + chmodSync(hook, 0o755); + + const runner = join( + process.cwd(), + '..', + '..', + 'scripts', + 'canary-redeploy-runner.mjs', + ); + const result = spawnSync( + process.execPath, + [runner, hook, statusPath, logPath, '100'], + { encoding: 'utf8' }, + ); + + expect(result.status).toBe(1); + const status = readFileSync(statusPath, 'utf8'); + expect(JSON.parse(status)).toMatchObject({ + state: 'failed', + startedAt: 100, + exitCode: 7, + }); + expect(status).not.toContain('private-output'); + expect(readFileSync(logPath, 'utf8')).toContain('private-output'); + }); +}); diff --git a/apps/server/src/modules/security/canary-redeploy.ts b/apps/server/src/modules/security/canary-redeploy.ts new file mode 100644 index 000000000..d66e47deb --- /dev/null +++ b/apps/server/src/modules/security/canary-redeploy.ts @@ -0,0 +1,296 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { execFile, spawn } from 'node:child_process'; +import { accessSync, constants, existsSync } from 'node:fs'; +import { access, mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { promisify } from 'node:util'; + +import { + canaryRedeployResultSchema, + canaryRedeployStatusResponseSchema, + type CanaryRedeployResult, + type CanaryRedeployStatusResponse, +} from '@huabu/shared'; + +import { getDataDir } from '../../data-dir.js'; +import { getLogger } from '../../utils/logger.js'; + +const execFileAsync = promisify(execFile); +const log = getLogger('canary-redeploy'); +const SHA_PATTERN = /^[0-9a-f]{40}$/; +const BRANCH = 'alpha' as const; + +interface CanaryCapability { + available: boolean; + reason: CanaryRedeployStatusResponse['reason']; + repoRoot: string | null; + scriptPath: string | null; + runnerPath: string | null; + runningSha: string | null; +} + +interface StoredRedeployResult { + result: CanaryRedeployResult; + runnerPid: number | null; +} + +let cachedRemote: + | { remoteSha: string; checkedAt: number } + | { remoteSha: null; checkedAt: number } + | null = null; +let redeployRequestInFlight = false; + +function enabled(env: NodeJS.ProcessEnv): boolean { + return env.HUABU_CANARY_REDEPLOY_ENABLED === '1'; +} + +function validSha(value: string | undefined): string | null { + const normalized = value?.trim().toLowerCase() ?? ''; + return SHA_PATTERN.test(normalized) ? normalized : null; +} + +export function resolveCanaryCapability( + env: NodeJS.ProcessEnv = process.env, +): CanaryCapability { + if (!enabled(env)) { + return { + available: false, + reason: 'disabled', + repoRoot: null, + scriptPath: null, + runnerPath: null, + runningSha: validSha(env.HUABU_DEPLOYED_SHA), + }; + } + + const configuredRoot = env.HUABU_REPO_ROOT; + if (!configuredRoot) { + return { + available: false, + reason: 'repository-unavailable', + repoRoot: null, + scriptPath: null, + runnerPath: null, + runningSha: validSha(env.HUABU_DEPLOYED_SHA), + }; + } + + const repoRoot = resolve(configuredRoot); + const scriptPath = join(repoRoot, 'scripts', 'start-huabu.sh'); + const runnerPath = join(repoRoot, 'scripts', 'canary-redeploy-runner.mjs'); + if (!existsSync(scriptPath) || !existsSync(runnerPath)) { + return { + available: false, + reason: 'script-unavailable', + repoRoot, + scriptPath, + runnerPath, + runningSha: validSha(env.HUABU_DEPLOYED_SHA), + }; + } + try { + accessSync(scriptPath, constants.X_OK); + } catch { + return { + available: false, + reason: 'script-unavailable', + repoRoot, + scriptPath, + runnerPath, + runningSha: validSha(env.HUABU_DEPLOYED_SHA), + }; + } + + return { + available: true, + reason: 'available', + repoRoot, + scriptPath, + runnerPath, + runningSha: validSha(env.HUABU_DEPLOYED_SHA), + }; +} + +export function canaryStatusPath(): string { + return join(getDataDir(), 'canary-redeploy-status.json'); +} + +export function canaryLogPath(): string { + return join(getDataDir(), 'logs', 'canary-redeploy.log'); +} + +async function readRedeployResult(): Promise { + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(canaryStatusPath(), 'utf8')); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') return null; + throw error; + } + const result = canaryRedeployResultSchema.safeParse(parsed); + if (!result.success) { + throw new Error('Canary redeployment status is invalid'); + } + const runnerPid = + parsed && + typeof parsed === 'object' && + 'runnerPid' in parsed && + Number.isSafeInteger(parsed.runnerPid) && + Number(parsed.runnerPid) > 0 + ? Number(parsed.runnerPid) + : null; + return { result: result.data, runnerPid }; +} + +function processIsRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM'; + } +} + +export async function writeCanaryRedeployResult( + result: CanaryRedeployResult, +): Promise { + const parsed = canaryRedeployResultSchema.parse(result); + const statusPath = canaryStatusPath(); + await mkdir(dirname(statusPath), { recursive: true }); + const temporaryPath = `${statusPath}.${process.pid}.${Date.now()}.tmp`; + await writeFile(temporaryPath, `${JSON.stringify(parsed, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o600, + }); + await rename(temporaryPath, statusPath); +} + +export async function getCanaryRedeployStatus(): Promise { + const capability = resolveCanaryCapability(); + const storedRedeploy = await readRedeployResult(); + let redeploy = storedRedeploy?.result ?? null; + if ( + redeploy?.state === 'running' && + storedRedeploy?.runnerPid !== null && + storedRedeploy?.runnerPid !== undefined && + !processIsRunning(storedRedeploy.runnerPid) + ) { + redeploy = { + state: 'failed', + startedAt: redeploy.startedAt, + completedAt: Date.now(), + exitCode: -1, + message: 'Redeploy runner stopped before recording an outcome', + }; + await writeCanaryRedeployResult(redeploy); + } + if (redeploy?.state === 'succeeded' || redeploy?.state === 'failed') { + redeployRequestInFlight = false; + } + const remoteSha = cachedRemote?.remoteSha ?? null; + return canaryRedeployStatusResponseSchema.parse({ + available: capability.available, + reason: capability.reason, + branch: BRANCH, + runningSha: capability.runningSha, + remoteSha, + updateAvailable: + capability.runningSha && remoteSha + ? capability.runningSha !== remoteSha + : null, + checkedAt: cachedRemote?.checkedAt ?? null, + redeploy, + }); +} + +export async function checkCanaryRemote(): Promise { + const capability = resolveCanaryCapability(); + if (!capability.available || !capability.repoRoot) { + return getCanaryRedeployStatus(); + } + + const { stdout } = await execFileAsync( + 'git', + ['ls-remote', 'origin', 'refs/heads/alpha'], + { + cwd: capability.repoRoot, + encoding: 'utf8', + timeout: 10_000, + maxBuffer: 64 * 1024, + }, + ); + const remoteSha = validSha(stdout.trim().split(/\s+/)[0]); + if (!remoteSha) { + throw new Error('origin/alpha did not resolve to a commit'); + } + cachedRemote = { remoteSha, checkedAt: Date.now() }; + return getCanaryRedeployStatus(); +} + +export async function requestCanaryRedeploy(): Promise { + const capability = resolveCanaryCapability(); + if ( + !capability.available || + !capability.repoRoot || + !capability.scriptPath || + !capability.runnerPath + ) { + throw new Error('Canary redeployment is unavailable'); + } + const storedRedeploy = await readRedeployResult(); + const persistentRunnerActive = + storedRedeploy?.result.state === 'running' && + storedRedeploy.runnerPid !== null && + processIsRunning(storedRedeploy.runnerPid); + if (redeployRequestInFlight || persistentRunnerActive) { + throw new Error('Canary redeployment is already in progress'); + } + + await access(capability.scriptPath, constants.X_OK); + const startedAt = Date.now(); + await writeCanaryRedeployResult({ state: 'requested', startedAt }); + + const child = spawn( + process.execPath, + [ + capability.runnerPath, + capability.scriptPath, + canaryStatusPath(), + canaryLogPath(), + String(startedAt), + ], + { + cwd: capability.repoRoot, + detached: true, + stdio: 'ignore', + env: process.env, + }, + ); + child.once('error', (error) => { + redeployRequestInFlight = false; + log.error({ err: error }, 'Canary redeploy runner failed to start'); + void writeCanaryRedeployResult({ + state: 'failed', + startedAt, + completedAt: Date.now(), + exitCode: -1, + message: 'Unable to start redeploy runner', + }).catch((statusError: unknown) => { + log.error( + { err: statusError }, + 'Unable to persist Canary runner start failure', + ); + }); + }); + child.unref(); + redeployRequestInFlight = true; + return getCanaryRedeployStatus(); +} + +export function resetCanaryRedeployStateForTest(): void { + cachedRemote = null; + redeployRequestInFlight = false; +} diff --git a/apps/web/src/api/_routes.ts b/apps/web/src/api/_routes.ts index 58d44bc9b..bdc5d7bcb 100644 --- a/apps/web/src/api/_routes.ts +++ b/apps/web/src/api/_routes.ts @@ -16,6 +16,9 @@ const enc = encodeURIComponent; export const routes = { // ── Deployment ──────────────────────────────────────────────────── deploymentReadiness: '/deployment/readiness', + canaryRedeployStatus: '/deployment/canary', + canaryRedeployCheck: '/deployment/canary/check', + canaryRedeploy: '/deployment/canary/redeploy', agentDefaults: '/agent/defaults', // ── Workspace ───────────────────────────────────────────────────── diff --git a/apps/web/src/api/deployment.ts b/apps/web/src/api/deployment.ts index 18436a235..d279386d1 100644 --- a/apps/web/src/api/deployment.ts +++ b/apps/web/src/api/deployment.ts @@ -4,10 +4,35 @@ import { apiFetch } from './_client'; import { routes } from './_routes'; -import type { DeploymentReadinessResponse } from '@huabu/shared'; +import type { + CanaryRedeployStatusResponse, + DeploymentReadinessResponse, +} from '@huabu/shared'; export function getDeploymentReadiness(): Promise { return apiFetch(routes.deploymentReadiness, { fallbackMessage: 'Failed to load deployment readiness', }); } + +export function getCanaryRedeployStatus(): Promise { + return apiFetch(routes.canaryRedeployStatus, { + fallbackMessage: 'Failed to load Canary redeployment status', + }); +} + +export function checkCanaryRedeploy(): Promise { + return apiFetch(routes.canaryRedeployCheck, { + method: 'POST', + json: {}, + fallbackMessage: 'Failed to check origin/alpha', + }); +} + +export function requestCanaryRedeploy(): Promise { + return apiFetch(routes.canaryRedeploy, { + method: 'POST', + json: {}, + fallbackMessage: 'Failed to start Canary redeployment', + }); +} diff --git a/apps/web/src/components/Settings/CanaryRedeploySettings.test.tsx b/apps/web/src/components/Settings/CanaryRedeploySettings.test.tsx new file mode 100644 index 000000000..2e4346328 --- /dev/null +++ b/apps/web/src/components/Settings/CanaryRedeploySettings.test.tsx @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { CanaryRedeploySettings } from './CanaryRedeploySettings'; + +import type { ModalProps } from '@/components/Common/Modal'; +import type { CanaryRedeployStatusResponse } from '@huabu/shared'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const mocks = vi.hoisted(() => ({ + getStatus: vi.fn(), + check: vi.fn(), + redeploy: vi.fn(), + toast: vi.fn(), + t: (key: string) => key, +})); + +vi.mock('@/api/deployment', () => ({ + getCanaryRedeployStatus: mocks.getStatus, + checkCanaryRedeploy: mocks.check, + requestCanaryRedeploy: mocks.redeploy, +})); +vi.mock('@/components/Common/Toast', () => ({ toast: mocks.toast })); +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: mocks.t, + }), +})); +vi.mock('@/components/Common/Modal', () => ({ + Modal: ({ isOpen, footer }: ModalProps) => + isOpen ?
{footer}
: null, +})); + +const availableStatus: CanaryRedeployStatusResponse = { + available: true, + reason: 'available', + branch: 'alpha', + runningSha: 'a'.repeat(40), + remoteSha: 'b'.repeat(40), + updateAvailable: true, + checkedAt: 1, + redeploy: null, +}; + +let root: Root; +let container: HTMLDivElement; + +beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + mocks.getStatus.mockResolvedValue(availableStatus); + mocks.check.mockResolvedValue(availableStatus); + mocks.redeploy.mockResolvedValue({ + ...availableStatus, + redeploy: { state: 'requested', startedAt: 2 }, + }); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); +}); + +async function renderSettings() { + await act(async () => { + root.render(); + }); +} + +function button(label: string): HTMLButtonElement { + const result = [...container.querySelectorAll('button')].find( + (candidate) => candidate.textContent === label, + ); + expect(result).toBeDefined(); + return result as HTMLButtonElement; +} + +describe('CanaryRedeploySettings', () => { + it('stays hidden when Canary redeployment is disabled', async () => { + mocks.getStatus.mockResolvedValueOnce({ + ...availableStatus, + available: false, + reason: 'disabled', + }); + await renderSettings(); + expect(container.textContent).toBe(''); + }); + + it('checks on mount and requires confirmation before redeploying', async () => { + await renderSettings(); + expect(mocks.check).toHaveBeenCalledOnce(); + + act(() => button('settings.canaryRedeployAction').click()); + await act(async () => { + button('settings.canaryConfirmAction').click(); + }); + + expect(mocks.redeploy).toHaveBeenCalledOnce(); + expect(mocks.toast).toHaveBeenCalledWith('settings.canaryRedeployStarted', { + tone: 'info', + duration: 10_000, + }); + }); +}); diff --git a/apps/web/src/components/Settings/CanaryRedeploySettings.tsx b/apps/web/src/components/Settings/CanaryRedeploySettings.tsx new file mode 100644 index 000000000..4474d893a --- /dev/null +++ b/apps/web/src/components/Settings/CanaryRedeploySettings.tsx @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + checkCanaryRedeploy, + getCanaryRedeployStatus, + requestCanaryRedeploy, +} from '@/api/deployment'; +import { Button } from '@/components/Common/Button'; +import { Modal } from '@/components/Common/Modal'; +import { toast } from '@/components/Common/Toast'; +import { SettingRow } from '@/components/Settings/Common/SettingRow'; + +import type { CanaryRedeployStatusResponse } from '@huabu/shared'; + +function shortSha(sha: string | null): string { + return sha?.slice(0, 7) ?? 'unknown'; +} + +export function CanaryRedeploySettings() { + const { t } = useTranslation(); + const [status, setStatus] = useState( + null, + ); + const [loading, setLoading] = useState(true); + const [checking, setChecking] = useState(false); + const [requesting, setRequesting] = useState(false); + const [confirming, setConfirming] = useState(false); + const confirmRef = useRef(null); + + const check = useCallback( + async (showToast: boolean) => { + setChecking(true); + try { + const next = await checkCanaryRedeploy(); + setStatus(next); + if (showToast) { + toast( + next.updateAvailable + ? t('settings.canaryUpdateAvailable') + : t('settings.canaryUpToDate'), + { tone: next.updateAvailable ? 'info' : 'success' }, + ); + } + } catch (error) { + toast( + error instanceof Error + ? error.message + : t('settings.canaryCheckFailed'), + { tone: 'danger' }, + ); + } finally { + setChecking(false); + } + }, + [t], + ); + + useEffect(() => { + let active = true; + void getCanaryRedeployStatus() + .then((initial) => { + if (!active) return; + setStatus(initial); + if (initial.available) void check(false); + }) + .catch((error: unknown) => { + if (!active) return; + setStatus(null); + toast( + error instanceof Error + ? error.message + : t('settings.canaryStatusFailed'), + { tone: 'danger' }, + ); + }) + .finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + }; + }, [check, t]); + + const redeploy = useCallback(async () => { + setRequesting(true); + try { + const next = await requestCanaryRedeploy(); + setStatus(next); + setConfirming(false); + toast(t('settings.canaryRedeployStarted'), { + tone: 'info', + duration: 10_000, + }); + } catch (error) { + toast( + error instanceof Error + ? error.message + : t('settings.canaryRedeployFailed'), + { tone: 'danger' }, + ); + } finally { + setRequesting(false); + } + }, [t]); + + if (loading || !status?.available) return null; + + const outcome = status.redeploy + ? t(`settings.canaryState_${status.redeploy.state}`) + : t('settings.canaryNeverRedeployed'); + const redeployInProgress = + status.redeploy?.state === 'requested' || + status.redeploy?.state === 'running'; + const description = t('settings.canaryDescription', { + running: shortSha(status.runningSha), + remote: shortSha(status.remoteSha), + outcome, + }); + + return ( + <> + +
+ + +
+
+ { + if (!requesting) setConfirming(false); + }} + title={t('settings.canaryConfirmTitle')} + description={t('settings.canaryConfirmDescription')} + initialFocusRef={confirmRef} + closeOnBackdropClick={!requesting} + closeOnEscape={!requesting} + footer={ + <> + + + + } + /> + + ); +} diff --git a/apps/web/src/components/Settings/sections/GeneralSettings.test.tsx b/apps/web/src/components/Settings/sections/GeneralSettings.test.tsx index 4347422d3..14f33fa79 100644 --- a/apps/web/src/components/Settings/sections/GeneralSettings.test.tsx +++ b/apps/web/src/components/Settings/sections/GeneralSettings.test.tsx @@ -44,6 +44,9 @@ vi.mock('@/api/agentChangeReview', () => ({ })); vi.mock('@/components/Common/Toast', () => ({ toast })); +vi.mock('@/components/Settings/CanaryRedeploySettings', () => ({ + CanaryRedeploySettings: () => null, +})); vi.mock('@/hooks/useAppUpdate', () => ({ canCheckForUpdates: () => false, useAppUpdate: () => ({ diff --git a/apps/web/src/components/Settings/sections/GeneralSettings.tsx b/apps/web/src/components/Settings/sections/GeneralSettings.tsx index 649708697..c017c46f3 100644 --- a/apps/web/src/components/Settings/sections/GeneralSettings.tsx +++ b/apps/web/src/components/Settings/sections/GeneralSettings.tsx @@ -17,6 +17,7 @@ import { Input } from '@/components/Common/Input'; import { Select } from '@/components/Common/Select'; import { toast } from '@/components/Common/Toast'; import { Toggle } from '@/components/Common/Toggle'; +import { CanaryRedeploySettings } from '@/components/Settings/CanaryRedeploySettings'; import { SettingRow } from '@/components/Settings/Common/SettingRow'; import { canCheckForUpdates, useAppUpdate } from '@/hooks/useAppUpdate'; import { getElectronBridge } from '@/hooks/useElectron'; @@ -300,6 +301,7 @@ export const GeneralSettings: React.FC = () => { )} + {!updaterAvailable && } Personal-development deployment workflow for the long-lived `alpha` branch. This is not the stable release or promotion path. + +## Branch and authorization model + +`main` is the stable branch. `alpha` is the rolling integration branch used by the personal Canary. Issue branches start from `origin/alpha` and target `alpha`; promotion from `alpha` to `main` remains a separate reviewed action. + +Canary use is additional end-to-end evidence only. It does not replace pull-request CI, review, documentation, release validation, or authorization to promote or publish. + +## Supported workflow + +The supported helper runs from a source checkout through `pnpm start:web`. `scripts/start-web.mjs` captures the startup commit in `HUABU_DEPLOYED_SHA` and exports the resolved checkout root as `HUABU_REPO_ROOT` before loading the bundled Server. + +Setting `HUABU_CANARY_REDEPLOY_ENABLED=1` enables an owner-only Settings surface. Opening Settings compares the captured startup commit with the current `origin/alpha` SHA using the fixed command `git ls-remote origin refs/heads/alpha`. The result identifies a different branch head; it does not independently attest CI status. + +The owner may confirm `Redeploy Alpha`. The HTTP request carries an empty body and cannot select a command, path, branch, SHA, or arguments. The Server launches a detached runner with the fixed executable and arguments: + +```text +/scripts/start-huabu.sh alpha --non-interactive +``` + +The runner persists `requested`, `running`, `succeeded`, or `failed` state under `HUABU_DATA_DIR`, appends a local log, and survives the current Server process exiting. It waits briefly before invoking the script so the Server can flush HTTP 202; that response means only that the runner started. Success means the script exited zero after its bounded readiness probe. + +## Script behavior + +`scripts/start-huabu.sh` derives the repository root from its own tracked path, so the checkout may live anywhere. Direct operator use accepts a branch argument; the UI invocation is always fixed to `alpha`. + +The script requires a clean checkout, stops listeners on ports 3001–3005, removes the previous `app` tmux session, checks out and fast-forwards the selected branch, installs locked dependencies, and starts `pnpm start:web` in a new `app` session. Interactive use tails `/tmp/huabu-app.log`; `--non-interactive` exits after readiness succeeds or times out. + +The script intentionally preserves the existing personal-development tradeoff: it updates one checkout in place and stops the old service before pull, install, and build complete. A failed redeployment can leave the Canary offline, and the port-range stop can affect another process using those ports. There is no rollback, immutable release directory, service preservation, self-restart supervisor, systemd unit, container deployment, or automatic installation. Inspect the persisted runner status and log, then repair manually through SSH when needed. + +## Security boundary + +Status, check, and redeploy routes require the existing single-owner boundary: loopback access or successful HTTP Basic Auth. Possession of the RFS connection token does not authorize redeployment. + +The feature is disabled by default and unavailable in packaged Desktop mode. The Server resolves one repository-owned script and supplies one fixed argument array without a shell. Browser input never reaches process spawning. Status responses are bounded and exclude environment values, credentials, repository paths, and raw command output. + +Remote browser access continues to require the bind, allowed-host, Basic Auth, and operator-managed HTTPS or trusted-private-network controls in [deployment security](./deployment-security.md). + +## Code entry points + +| File | Responsibility | +| ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| [`scripts/start-huabu.sh`](../../scripts/start-huabu.sh) | Path-independent tmux redeployment and readiness probe. | +| [`scripts/canary-redeploy-runner.mjs`](../../scripts/canary-redeploy-runner.mjs) | Detached execution, persistent result state, and local logging. | +| [`scripts/start-web.mjs`](../../scripts/start-web.mjs) | Captures repository root and deployed SHA for the standalone Server. | +| [`packages/shared/src/types/api/deployment.ts`](../../packages/shared/src/types/api/deployment.ts) | Canary status and action wire contracts. | +| [`apps/server/src/modules/security/canary-redeploy.ts`](../../apps/server/src/modules/security/canary-redeploy.ts) | Capability resolution, remote SHA check, status persistence, and fixed runner launch. | +| [`apps/server/src/modules/security/canary-redeploy.route.ts`](../../apps/server/src/modules/security/canary-redeploy.route.ts) | Owner-only status, check, and redeploy endpoints. | +| [`apps/web/src/components/Settings/CanaryRedeploySettings.tsx`](../../apps/web/src/components/Settings/CanaryRedeploySettings.tsx) | Settings status, check action, and confirmed redeploy action. | diff --git a/packages/shared/src/types/api/deployment.ts b/packages/shared/src/types/api/deployment.ts index 2f4cb3ea4..7be0e18ac 100644 --- a/packages/shared/src/types/api/deployment.ts +++ b/packages/shared/src/types/api/deployment.ts @@ -36,3 +36,48 @@ export const deploymentReadinessResponseSchema = z.object({ export type DeploymentReadinessResponse = z.infer< typeof deploymentReadinessResponseSchema >; + +export const canaryRedeployStateSchema = z.enum([ + 'requested', + 'running', + 'succeeded', + 'failed', +]); +export type CanaryRedeployState = z.infer; + +export const canaryRedeployResultSchema = z.object({ + state: canaryRedeployStateSchema, + startedAt: z.number().int().nonnegative(), + completedAt: z.number().int().nonnegative().optional(), + exitCode: z.number().int().optional(), + message: z.string().max(500).optional(), +}); +export type CanaryRedeployResult = z.infer; + +export const canaryRedeployStatusResponseSchema = z.object({ + available: z.boolean(), + reason: z.enum([ + 'available', + 'disabled', + 'repository-unavailable', + 'script-unavailable', + ]), + branch: z.literal('alpha'), + runningSha: z + .string() + .regex(/^[0-9a-f]{40}$/) + .nullable(), + remoteSha: z + .string() + .regex(/^[0-9a-f]{40}$/) + .nullable(), + updateAvailable: z.boolean().nullable(), + checkedAt: z.number().int().nonnegative().nullable(), + redeploy: canaryRedeployResultSchema.nullable(), +}); +export type CanaryRedeployStatusResponse = z.infer< + typeof canaryRedeployStatusResponseSchema +>; + +export const canaryRedeployRequestSchema = z.object({}).strict(); +export type CanaryRedeployRequest = z.infer; diff --git a/scripts/canary-redeploy-runner.mjs b/scripts/canary-redeploy-runner.mjs new file mode 100755 index 000000000..9818381a3 --- /dev/null +++ b/scripts/canary-redeploy-runner.mjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { createWriteStream } from 'node:fs'; +import { mkdir, rename, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; + +const [scriptPath, statusPath, logPath, startedAtValue] = process.argv.slice(2); +const startedAt = Number(startedAtValue); +const RESPONSE_GRACE_MS = 1500; + +if ( + !scriptPath || + !statusPath || + !logPath || + !Number.isSafeInteger(startedAt) || + startedAt < 0 +) { + process.exitCode = 2; +} else { + await mkdir(path.dirname(statusPath), { recursive: true }); + await mkdir(path.dirname(logPath), { recursive: true }); + + async function writeStatus(status) { + const temporaryPath = `${statusPath}.${process.pid}.${Date.now()}.tmp`; + await writeFile(temporaryPath, `${JSON.stringify(status, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o600, + }); + await rename(temporaryPath, statusPath); + } + + await writeStatus({ state: 'running', startedAt, runnerPid: process.pid }); + const log = createWriteStream(logPath, { flags: 'a', mode: 0o600 }); + log.write(`\n[${new Date().toISOString()}] Redeploying alpha\n`); + + await new Promise((resolveDelay) => + setTimeout(resolveDelay, RESPONSE_GRACE_MS), + ); + const child = spawn(scriptPath, ['alpha', '--non-interactive'], { + stdio: ['ignore', 'pipe', 'pipe'], + env: process.env, + }); + child.stdout.pipe(log, { end: false }); + child.stderr.pipe(log, { end: false }); + + const result = await new Promise((resolveResult) => { + child.once('error', (error) => { + log.write( + `[${new Date().toISOString()}] Unable to start redeploy script: ${error.message}\n`, + ); + resolveResult({ + exitCode: -1, + message: 'Unable to start redeploy script', + }); + }); + child.once('exit', (code, signal) => { + resolveResult({ + exitCode: code ?? -1, + message: signal + ? `Redeploy script exited after signal ${signal}` + : undefined, + }); + }); + }); + + const succeeded = result.exitCode === 0; + const status = { + state: succeeded ? 'succeeded' : 'failed', + startedAt, + completedAt: Date.now(), + exitCode: result.exitCode, + ...(!succeeded + ? { + message: + result.message ?? + `Redeploy script exited with status ${result.exitCode}`, + } + : {}), + }; + await writeStatus(status); + log.write( + `[${new Date().toISOString()}] Redeploy ${status.state} (exit ${result.exitCode})\n`, + ); + log.end(); + process.exitCode = succeeded ? 0 : 1; +} diff --git a/scripts/start-huabu.sh b/scripts/start-huabu.sh new file mode 100755 index 000000000..fa7da3b98 --- /dev/null +++ b/scripts/start-huabu.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +set -euo pipefail + +main() { + local branch_name='' + local interactive=1 + local script_dir + local huabu_dir + local tmux_session='app' + local log_file='/tmp/huabu-app.log' + local server_port="${SERVER_PORT:-${PORT:-3001}}" + + for argument in "$@"; do + case "$argument" in + --non-interactive) + interactive=0 + ;; + --*) + echo "Unknown option: $argument" >&2 + return 2 + ;; + *) + if [[ -n "$branch_name" ]]; then + echo "Usage: $0 [--non-interactive]" >&2 + return 2 + fi + branch_name="$argument" + ;; + esac + done + + if [[ -z "$branch_name" ]]; then + echo "Usage: $0 [--non-interactive]" >&2 + return 2 + fi + if [[ ! "$branch_name" =~ ^[A-Za-z0-9._/-]+$ ]] || + [[ "$branch_name" == -* ]] || + [[ "$branch_name" == *..* ]]; then + echo "Invalid branch name: $branch_name" >&2 + return 2 + fi + + script_dir="$( + cd -- "$(dirname -- "${BASH_SOURCE[0]}")" + pwd -P + )" + huabu_dir="$( + cd -- "$script_dir/.." + pwd -P + )" + + if [[ "$(git -C "$huabu_dir" rev-parse --show-toplevel)" != "$huabu_dir" ]]; then + echo "ERROR: $huabu_dir is not the Huabu repository root." >&2 + return 1 + fi + if [[ -n "$(git -C "$huabu_dir" status --porcelain)" ]]; then + echo "ERROR: Working tree has uncommitted changes." >&2 + return 1 + fi + + portlisten() { + local kill_mode=0 + local ports + local pid + local -a pids=() + + if [[ "${1:-}" == "-k" ]]; then + kill_mode=1 + shift + fi + ports="${1:-}" + if [[ ! "$ports" =~ ^[0-9]+(-[0-9]+)?$ ]]; then + echo "Usage: portlisten [-k] " >&2 + return 2 + fi + if (( !kill_mode )); then + command lsof -nP "-iTCP:${ports}" -sTCP:LISTEN + return + fi + while IFS= read -r pid; do + [[ -n "$pid" ]] && pids+=("$pid") + done < <( + command lsof -t -nP "-iTCP:${ports}" -sTCP:LISTEN | + sort -u + ) + if (( ${#pids[@]} == 0 )); then + return 0 + fi + echo "Sending SIGTERM to listeners on TCP port(s) ${ports}:" + command lsof -nP "-iTCP:${ports}" -sTCP:LISTEN + command kill -TERM "${pids[@]}" + } + + echo "==> Stopping services on ports 3001-3005" + portlisten -k 3001-3005 + for attempt in {1..20}; do + if [[ -z "$(portlisten 3001-3005)" ]]; then + break + fi + if [[ "$attempt" -eq 20 ]]; then + echo "ERROR: Ports 3001-3005 are still in use." >&2 + portlisten 3001-3005 + return 1 + fi + sleep 0.5 + done + + if tmux has-session -t "$tmux_session" 2>/dev/null; then + tmux kill-session -t "$tmux_session" + fi + + echo "==> Updating $branch_name in $huabu_dir" + git -C "$huabu_dir" checkout "$branch_name" + git -C "$huabu_dir" pull --ff-only origin "$branch_name" + + echo "==> Installing dependencies" + pnpm --dir "$huabu_dir" install --frozen-lockfile + + rm -f "$log_file" + touch "$log_file" + tmux new-session \ + -d \ + -s "$tmux_session" \ + -c "$huabu_dir" \ + "set -o pipefail; pnpm start:web 2>&1 | tee '$log_file'" + tmux set-option -t "$tmux_session" remain-on-exit on + + echo "==> Waiting for Huabu readiness on port $server_port" + for attempt in {1..120}; do + if node --input-type=module -e ' + const port = process.argv[1]; + const headers = {}; + const user = process.env.HUABU_BASIC_AUTH_USER; + const pass = process.env.HUABU_BASIC_AUTH_PASS; + if (user && pass) { + headers.Authorization = `Basic ${Buffer.from(`${user}:${pass}`).toString("base64")}`; + } + const response = await fetch(`http://127.0.0.1:${port}/api/deployment/readiness`, { + headers, + signal: AbortSignal.timeout(2000), + }); + if (!response.ok) process.exit(1); + ' "$server_port" 2>/dev/null; then + echo "==> Huabu is ready" + if (( interactive )); then + echo "Session: $tmux_session" + echo "Log: $log_file" + tail -f "$log_file" + fi + return 0 + fi + sleep 1 + done + + echo "ERROR: Huabu did not become ready within 120 seconds." >&2 + echo "Log: $log_file" >&2 + return 1 +} + +main "$@" diff --git a/scripts/start-web.mjs b/scripts/start-web.mjs index 55a7f4a45..4b41d602a 100644 --- a/scripts/start-web.mjs +++ b/scripts/start-web.mjs @@ -9,6 +9,7 @@ * launcher then points the bundled Fastify server at the compiled SPA so the * UI and API share one port, without Vite or file watchers. */ +import { execFileSync } from 'node:child_process'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -20,6 +21,12 @@ const here = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(here, '..'); const DEFAULT_SERVER_PORT = 3001; +process.env.HUABU_REPO_ROOT = repoRoot; +process.env.HUABU_DEPLOYED_SHA = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: repoRoot, + encoding: 'utf8', +}).trim(); + // The bundled server's source-relative root `.env` lookup no longer points at // the repository, so load it here before importing the bundle. Existing shell // variables retain higher precedence because dotenv does not override them. From 5db0c641a368dfd00afff1ffdcdf1f47a380eded Mon Sep 17 00:00:00 2001 From: Yuqing Date: Wed, 30 Sep 2026 16:38:38 +0800 Subject: [PATCH 02/30] fix: stream interactive Canary startup logs (#257) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .env.example | 2 ++ docs/architecture/canary-deployment.md | 2 +- scripts/start-huabu.sh | 24 ++++++++++++++++-------- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index 64acf75ab..92d807d82 100644 --- a/.env.example +++ b/.env.example @@ -82,6 +82,8 @@ # updates the checkout in place and may leave the service offline on failure; # this is a personal-development convenience, not a production deployer. # HUABU_CANARY_REDEPLOY_ENABLED=1 +# Non-interactive redeployment waits up to 300 seconds by default. +# HUABU_CANARY_READINESS_TIMEOUT_SECONDS=300 # ── Storage (restart required) ── # Structured records: disk (default), sqlite or postgres. The two axes are diff --git a/docs/architecture/canary-deployment.md b/docs/architecture/canary-deployment.md index 28163763b..1f15fe613 100644 --- a/docs/architecture/canary-deployment.md +++ b/docs/architecture/canary-deployment.md @@ -26,7 +26,7 @@ The runner persists `requested`, `running`, `succeeded`, or `failed` state under `scripts/start-huabu.sh` derives the repository root from its own tracked path, so the checkout may live anywhere. Direct operator use accepts a branch argument; the UI invocation is always fixed to `alpha`. -The script requires a clean checkout, stops listeners on ports 3001–3005, removes the previous `app` tmux session, checks out and fast-forwards the selected branch, installs locked dependencies, and starts `pnpm start:web` in a new `app` session. Interactive use tails `/tmp/huabu-app.log`; `--non-interactive` exits after readiness succeeds or times out. +The script requires a clean checkout, stops listeners on ports 3001–3005, removes the previous `app` tmux session, checks out and fast-forwards the selected branch, installs locked dependencies, and starts `pnpm start:web` in a new `app` session. Interactive use immediately tails `/tmp/huabu-app.log` while startup continues. `--non-interactive` instead waits for readiness and exits when it succeeds or when the configurable `HUABU_CANARY_READINESS_TIMEOUT_SECONDS` window expires; the default is 300 seconds. The script intentionally preserves the existing personal-development tradeoff: it updates one checkout in place and stops the old service before pull, install, and build complete. A failed redeployment can leave the Canary offline, and the port-range stop can affect another process using those ports. There is no rollback, immutable release directory, service preservation, self-restart supervisor, systemd unit, container deployment, or automatic installation. Inspect the persisted runner status and log, then repair manually through SSH when needed. diff --git a/scripts/start-huabu.sh b/scripts/start-huabu.sh index fa7da3b98..e764fa0b5 100755 --- a/scripts/start-huabu.sh +++ b/scripts/start-huabu.sh @@ -12,6 +12,7 @@ main() { local tmux_session='app' local log_file='/tmp/huabu-app.log' local server_port="${SERVER_PORT:-${PORT:-3001}}" + local readiness_timeout_seconds="${HUABU_CANARY_READINESS_TIMEOUT_SECONDS:-300}" for argument in "$@"; do case "$argument" in @@ -36,6 +37,10 @@ main() { echo "Usage: $0 [--non-interactive]" >&2 return 2 fi + if [[ ! "$readiness_timeout_seconds" =~ ^[1-9][0-9]*$ ]]; then + echo "HUABU_CANARY_READINESS_TIMEOUT_SECONDS must be a positive integer." >&2 + return 2 + fi if [[ ! "$branch_name" =~ ^[A-Za-z0-9._/-]+$ ]] || [[ "$branch_name" == -* ]] || [[ "$branch_name" == *..* ]]; then @@ -128,8 +133,16 @@ main() { "set -o pipefail; pnpm start:web 2>&1 | tee '$log_file'" tmux set-option -t "$tmux_session" remain-on-exit on - echo "==> Waiting for Huabu readiness on port $server_port" - for attempt in {1..120}; do + if (( interactive )); then + echo "==> Watching startup logs" + echo "Session: $tmux_session" + echo "Log: $log_file" + tail -f "$log_file" + return 0 + fi + + echo "==> Waiting up to ${readiness_timeout_seconds}s for Huabu readiness on port $server_port" + for ((attempt = 1; attempt <= readiness_timeout_seconds; attempt++)); do if node --input-type=module -e ' const port = process.argv[1]; const headers = {}; @@ -145,17 +158,12 @@ main() { if (!response.ok) process.exit(1); ' "$server_port" 2>/dev/null; then echo "==> Huabu is ready" - if (( interactive )); then - echo "Session: $tmux_session" - echo "Log: $log_file" - tail -f "$log_file" - fi return 0 fi sleep 1 done - echo "ERROR: Huabu did not become ready within 120 seconds." >&2 + echo "ERROR: Huabu did not become ready within ${readiness_timeout_seconds} seconds." >&2 echo "Log: $log_file" >&2 return 1 } From e1104404d36d56fd3cd3b3e11ade8263c582031d Mon Sep 17 00:00:00 2001 From: Yuqing Yang Date: Wed, 30 Sep 2026 08:46:37 +0000 Subject: [PATCH 03/30] fix(preview): restore nested panel scrolling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/web/e2e/overlay-panels.spec.ts | 45 ++++++++++++++++++++++++++ apps/web/src/index.css | 3 -- docs/architecture/preview-workspace.md | 2 +- 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/apps/web/e2e/overlay-panels.spec.ts b/apps/web/e2e/overlay-panels.spec.ts index 5de3ad20f..c0a199ddc 100644 --- a/apps/web/e2e/overlay-panels.spec.ts +++ b/apps/web/e2e/overlay-panels.spec.ts @@ -108,6 +108,51 @@ test('overlays never resize or pan Canvas and isolate mouse, wheel, touch and ke .not.toBe(before.transform); }); +test('panel descendants chain vertical wheel to their outer scroller', async ({ + page, +}) => { + await openNewCanvas(page); + await page.getByRole('button', { name: /open chat panel/i }).click(); + await settlePanels(page); + const before = await geometry(page); + const panel = page.locator('[data-canvas-panel="right"]'); + const fixture = await panel.evaluateHandle((element) => { + const outer = document.createElement('div'); + outer.dataset.testid = 'nested-scroll-outer'; + Object.assign(outer.style, { + position: 'absolute', + inset: '80px 24px auto 24px', + zIndex: '100', + height: '120px', + overflowY: 'auto', + }); + const target = document.createElement('div'); + target.dataset.testid = 'nested-scroll-target'; + Object.assign(target.style, { + height: '40px', + overflow: 'hidden', + }); + target.textContent = 'Wheel target'; + const spacer = document.createElement('div'); + spacer.style.height = '600px'; + outer.append(target, spacer); + element.append(outer); + return outer; + }); + + const target = page.getByTestId('nested-scroll-target'); + await target.hover(); + await page.mouse.wheel(0, 200); + await expect + .poll(() => + fixture.evaluate((element) => (element as HTMLElement).scrollTop), + ) + .toBeGreaterThan(0); + expect(await geometry(page)).toEqual(before); + + await fixture.dispose(); +}); + test('node preview never moves Canvas even when the target is obstructed', async ({ page, }, testInfo) => { diff --git a/apps/web/src/index.css b/apps/web/src/index.css index ebe441934..998582aef 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -742,9 +742,6 @@ body.node-resize-active * { [data-canvas-panel='right'][data-collapsed='true'] { transform: translateX(100%); } -[data-canvas-panel] * { - overscroll-behavior: contain; -} [data-overlay-layout] .react-flow__panel.left { left: var(--canvas-inset-left, 0px); } diff --git a/docs/architecture/preview-workspace.md b/docs/architecture/preview-workspace.md index f9ae8d14f..cdfc10339 100644 --- a/docs/architecture/preview-workspace.md +++ b/docs/architecture/preview-workspace.md @@ -187,7 +187,7 @@ For repeatable Chat activation measurements, explicitly enable the test-only pro Before a focused side panel becomes inert on collapse, `MainLayout` transfers focus without scrolling to the Canvas container. In fullscreen, collapsing Layers transfers focus to the visible Preview container; collapsing Preview transfers focus to the persistent layout container while Canvas remounts. Collapsing a panel that does not contain focus leaves focus unchanged. This applies to both header controls and programmatic collapse requests. -`MainLayout` owns the resizable right overlay and mounts `PreviewWorkspace`; Canvas remains full-size beneath both side panels. The outer width may grow beyond half the layout for wide document browsing and is capped by the expanded Layers width plus a minimum 100px uncovered area; narrow layouts clamp the rendered panel widths. Both panels share a 220ms slide without resizing Canvas. Panel toggles and node preview opening never move Canvas, including when the target becomes obscured; explicit search and focus actions retain their own viewport behavior. The bottom Canvas toolbar is horizontally centred on the uncovered Canvas area above both panels, retaining its existing dimensions and bottom offset, but slides out and becomes inert only while a side panel owns focus or pointer interaction and overlaps the toolbar's expanded footprint. It returns when interaction leaves the panels or resizing clears the overlap. Panel surfaces isolate pointer, scroll, and Canvas keyboard handling, including during exit motion. The internal split ratio is clamped so both groups remain usable. +`MainLayout` owns the resizable right overlay and mounts `PreviewWorkspace`; Canvas remains full-size beneath both side panels. The outer width may grow beyond half the layout for wide document browsing and is capped by the expanded Layers width plus a minimum 100px uncovered area; narrow layouts clamp the rendered panel widths. Both panels share a 220ms slide without resizing Canvas. Panel toggles and node preview opening never move Canvas, including when the target becomes obscured; explicit search and focus actions retain their own viewport behavior. The bottom Canvas toolbar is horizontally centred on the uncovered Canvas area above both panels, retaining its existing dimensions and bottom offset, but slides out and becomes inert only while a side panel owns focus or pointer interaction and overlaps the toolbar's expanded footprint. It returns when interaction leaves the panels or resizing clears the overlap. Panel surfaces isolate pointer, scroll, and Canvas keyboard handling, including during exit motion. Native scroll chaining remains enabled between descendants inside a panel so vertical wheel input over truncated or horizontal-overflow content can reach the renderer's outer scroller; overscroll containment applies at the panel root, where it prevents an exhausted internal scroll chain from reaching Canvas. The internal split ratio is clamped so both groups remain usable. Preview fullscreen replaces the visible centre area with Preview Workspace and unmounts the Canvas subtree entirely. Canvas document and selection remain in `canvasStore`, while its locally persisted viewport is restored by `useInitialCanvasViewport` when Canvas remounts after fullscreen; unmounting also guarantees that React Flow portals and compositor layers cannot leak stale Canvas pixels into Preview. Exiting fullscreen is deliberately two-phase: `MainLayout` first paints the ordinary split layout with a Canvas loading placeholder, then remounts Canvas after that feedback has reached one frame, preventing synchronous React Flow construction from making the restore control appear unresponsive. The fullscreen Preview slot clips renderer overflow so content cannot cover the Layer List when that list expands and narrows Preview. The existing Layer List remains available at the left with its normal resize, search, rename, lock, reorder, disclosure, and accessible tree behaviour; unmodified primary activation opens supported node targets in both ordinary and fullscreen layouts, skips Canvas reveal while React Flow is unmounted, and expands Frame hierarchy without invoking Preview. Modifier clicks retain Layer List multi-selection semantics. When the list is collapsed, `MainLayout` renders the existing Canvas header as a narrow vertical rail containing only the Layer List expansion control. The last Preview group exposes fullscreen and restore controls, `Escape` restores the ordinary layout unless an inner control consumes it, and collapsing Preview also exits fullscreen. From 4ad011d47b6896c298ba5532a26584cbd0ef3df5 Mon Sep 17 00:00:00 2001 From: Yuqing Yang Date: Wed, 30 Sep 2026 09:22:31 +0000 Subject: [PATCH 04/30] fix(acp): deduplicate selector options by value Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/architecture/agent-architecture.md | 1 + .../__tests__/acp-session-selectors.test.ts | 61 +++++++++++++++++++ .../shared/src/utils/acp-session-selectors.ts | 19 ++++-- 3 files changed, 76 insertions(+), 5 deletions(-) diff --git a/docs/architecture/agent-architecture.md b/docs/architecture/agent-architecture.md index 96a628c6e..ad22c735c 100644 --- a/docs/architecture/agent-architecture.md +++ b/docs/architecture/agent-architecture.md @@ -158,6 +158,7 @@ Functional text calls use [functional-text.ts](../../apps/server/src/modules/age - [`@agenetes/agentlet-host`](../../external/agenetes/packages/agentlet-host) mounts the durably stateless [`@agenetes/agentlet-gateway`](../../external/agenetes/packages/agentlet-gateway), supervises the local agentlet daemon, and injects host-owned authentication. The Gateway owns only live control/session connections, pending RPCs, reconnect buffers, and bounded pre-attach buffering; durable workload and conversation state remains in Agenetes. Ordinary control RPCs time out after 60 seconds, while `server/spawn` has a separate 240-second deadline because it includes ACP `initialize` plus session lifecycle bootstrap, whose two sequential requests may each take up to 90 seconds. - [`@agenetes/agent-profile`](../../external/agenetes/packages/agent-profile) owns ordinary Profile schemas, CRUD and persistence, without Team discovery, setup or Config dependencies. Profiles are editable templates with optimistic configuration revisions; cwd/launch edits additionally advance an execution revision. `buildAcpWorkloadSpec()` snapshots placement, wrapper launch, cwd, preferences, and execution revision at first realization. Existing persisted executions do not reread an edited template, including on restart. Every Profile maps to a wrapper: known harnesses compile capability-validated options, while Custom supports only user-authored raw commands. Retired manifest Profiles are not selectable or compiled into executable recipes. - [`@agenetes/acp-driver`](../../external/agenetes/packages/acp-driver) owns the canonical ACP spec/state schemas, session creation/resume, canonical-input flattening, ACP update translation, and durable state up-reporting. The static DriverMap binds `external` directly to this driver. Generic runtime-environment hooks remain available, but Huabu no longer injects manifest Configs or recovers Team recipes. Retired Team recipes are explicitly rejected rather than silently reinterpreted as ordinary commands. Live spawn and session caches are isolated by `(agentletId, threadId)`, and unavailable targets fail with `placement_unavailable`. Because ACP has no native system instruction channel, the driver prefixes joined `AgentSpec.initialPreamble` fragments to the first ordinary prompt. A first control causes the host to ensure the session from the canonical spec before calling `handle.control()`; it creates no Chat-V2 turn and does not consume the pending preamble. Session control state is deliberately split in two: the agent-reported surface (`currentModeId` / `currentModelId` / `configOptions[].currentValue`) and `selections`, a map of explicit per-thread user choices keyed by config-option id (`mode`, `model`, and agent-defined ids such as `allow_all`). Only a successful `set_mode` / `set_model` / `set_config_option` writes `selections`; agent pushes never do, because agents such as Copilot CLI implement config options as process-global user settings and broadcast one value to every live session, making the agent-reported value answer "what was picked last, anywhere" rather than "what was picked for this thread". `selections` travels with the rest of `AgentMetadata` and is the authoritative per-thread intent. On resume it is restored unconditionally and replayed onto the agent knob by knob before prompts or user controls proceed. A rejected knob is forgotten only when the agent definitively refuses it, so a retired model id cannot wedge the thread while a transport failure cannot destroy durable intent. +- [`buildAcpSessionSelectors`](../../packages/shared/src/utils/acp-session-selectors.ts) is the canonical read projection for ACP mode, model, and config-option controls. It prefers a modern config-option twin over the legacy channel and deduplicates each flattened select catalogue by exact control value while preserving first occurrence order and metadata; equal labels with different values remain distinct. The same projection serves live thread metadata, persisted thread metadata, and Profile capability observations, so upstream duplicate entries cannot diverge across pre-prompt and active-session UI. - External-agent idle suspension is host policy: General Settings persists `idleTimeoutSecs` (10 minutes by default, `0` disables suspension), and Huabu injects the current value when a new or resumed ACP process is spawned. Agentlet never suspends a session while a host JSON-RPC request remains in flight; transport teardown closes the ACP client so pending prompts reject and clean up immediately. The long-lived `AcpAgentHandle` self-repairs a suspended lower-level session lazily on the next turn. Direct driver controls still require a live session, so Huabu's control route first ensures or resumes that session from the canonical persisted spec and then calls `handle.control()`. - ACP has no native seam for injecting prior assistant messages, so when native resume is unavailable the driver replays history as one prepended text block. It first projects every durable turn through `projectTextHistoryTurn` (`@agenetes/runtime`), which replaces image bodies with a short placeholder — a base64 payload carries no meaning once flattened into text, and inlining it would only inflate the payload. The _projected_ turns are what gets authorized, so the admission estimate prices the block that is actually sent. - Opening Chat and opening the slash menu read only `GET /api/acp/threads/:threadId/cached-meta`. The response projects cached slash commands and selector catalogues from a live or persisted realized thread first, then from `profile-schema-cache`, and finally returns a successful empty observation. These reads never call `agenetes.create()`, spawn ACP, or create a WorkloadSpec. Profile-level mode/model values may be displayed as last observed; generic config-option values render without a selected value until the current thread reports them or records a successful explicit choice. Live metadata continues updating its thread, but Profile cache warm-starts and writes require the execution's frozen revision to match the current template; runtime-relevant Profile edits invalidate the cache. Huabu remembers successful explicit model and `thought_level` choices in a known-harness Profile's host-owned `customData` only at that matching revision. Custom wrapper model choices, modes, permission controls, booleans, and unknown config options remain thread-only. Live ACP controls are independent of generic wrapper configuration capabilities. A Profile that has never opened a session anywhere on this server (`source: 'none'`, no live entry, no per-thread record, no per-profile cache) has no schema to render at all — ACP only ever discloses its mode/model/config-option catalogue in the `session/new` / `session/load` response, so there is no no-spawn way to learn it. `AcpSessionSelectors` renders an explicit, user-opt-in placeholder pill for this case (`onWarm`), which POSTs `/api/acp/threads/:threadId/warm` to realize the workload and open a session with no accompanying `set_*` control, purely to seed the caches; the row never spawns a session on its own. Some agents disclose only part of their catalogue inline in the `session/new` response and push the rest a moment later via a trailing `session/update`; a real message turn has a live SSE stream open long enough to catch that straggler, but a warm-up has none, so `/warm` waits for the freshly opened entry's disclosed schema to go quiet (bounded, ~2s worst case) before responding, closing the race rather than returning a partially-populated row. diff --git a/packages/shared/src/utils/__tests__/acp-session-selectors.test.ts b/packages/shared/src/utils/__tests__/acp-session-selectors.test.ts index 5aad8b1b7..1eb357136 100644 --- a/packages/shared/src/utils/__tests__/acp-session-selectors.test.ts +++ b/packages/shared/src/utils/__tests__/acp-session-selectors.test.ts @@ -88,6 +88,67 @@ describe('buildAcpSessionSelectors', () => { expect(selectors[0].channel).toBe('config-option'); }); + it('deduplicates Copilot model options by their exact control value', () => { + const [selector] = buildAcpSessionSelectors( + source({ + configOptions: [ + { + id: 'model', + category: 'model', + name: 'Model', + type: 'select', + currentValue: 'gpt-5.6-sol', + options: [ + { + value: 'auto', + name: 'Auto', + description: 'Let Copilot pick the best model', + }, + { value: 'auto', name: 'Auto', description: 'Auto' }, + { value: 'gpt-5.6-sol', name: 'GPT-5.6 Sol' }, + { value: 'gpt-5.6-terra', name: 'GPT-5.6 Terra' }, + { value: 'gpt-5.6-luna', name: 'GPT-5.6 Luna' }, + { value: 'gpt-5.3-codex', name: 'GPT-5.3-Codex' }, + { value: 'auto', name: 'Auto', description: 'Auto' }, + { value: 'gpt-5.6-sol', name: 'GPT-5.6 Sol' }, + { value: 'gpt-5.6-terra', name: 'GPT-5.6 Terra' }, + { value: 'gpt-5.6-luna', name: 'GPT-5.6 Luna' }, + { value: 'gpt-5.3-codex', name: 'GPT-5.3-Codex' }, + ], + }, + ], + }), + ); + + expect(selector.options).toEqual([ + { + value: 'auto', + label: 'Auto', + description: 'Let Copilot pick the best model', + }, + { value: 'gpt-5.6-sol', label: 'GPT-5.6 Sol' }, + { value: 'gpt-5.6-terra', label: 'GPT-5.6 Terra' }, + { value: 'gpt-5.6-luna', label: 'GPT-5.6 Luna' }, + { value: 'gpt-5.3-codex', label: 'GPT-5.3-Codex' }, + ]); + }); + + it('keeps distinct control values even when their labels match', () => { + const [selector] = buildAcpSessionSelectors( + source({ + availableModels: [ + { modelId: 'model-stable', name: 'Model' }, + { modelId: 'model-preview', name: 'Model' }, + ], + }), + ); + + expect(selector.options).toEqual([ + { value: 'model-stable', label: 'Model' }, + { value: 'model-preview', label: 'Model' }, + ]); + }); + it('detects the twin by id when the agent publishes no category', () => { const selectors = buildAcpSessionSelectors( source({ diff --git a/packages/shared/src/utils/acp-session-selectors.ts b/packages/shared/src/utils/acp-session-selectors.ts index d4c5010ea..11328350f 100644 --- a/packages/shared/src/utils/acp-session-selectors.ts +++ b/packages/shared/src/utils/acp-session-selectors.ts @@ -114,14 +114,23 @@ const normalizeKey = (value: unknown): string => * Flatten the many shapes an agent may use for a select option list: * bare strings, `{ name, value }` / `{ label, id }` records, and group * records (`{ name, options: [...] }`) which are inlined with a - * `sectionLabel` on their first child. + * `sectionLabel` on their first child. Duplicate exact control values are + * removed in publish order because sending either entry invokes the same + * set-RPC value; equal labels with different values remain distinct. */ function flattenOptions(raw: unknown): AcpSessionSelectorOption[] { if (!Array.isArray(raw)) return []; const flat: AcpSessionSelectorOption[] = []; + const seenValues = new Set(); + const append = (option: AcpSessionSelectorOption): boolean => { + if (seenValues.has(option.value)) return false; + seenValues.add(option.value); + flat.push(option); + return true; + }; for (const entry of raw) { if (typeof entry === 'string') { - flat.push({ value: entry, label: entry }); + append({ value: entry, label: entry }); continue; } if (!entry || typeof entry !== 'object') continue; @@ -134,7 +143,7 @@ function flattenOptions(raw: unknown): AcpSessionSelectorOption[] { const s = asRecord(sub); const value = String(s.value ?? s.id ?? ''); if (!value) continue; - flat.push({ + const appended = append({ value, label: String(s.name ?? s.label ?? value), ...(isFirst ? { sectionLabel: groupLabel } : {}), @@ -142,14 +151,14 @@ function flattenOptions(raw: unknown): AcpSessionSelectorOption[] { ? { description: s.description } : {}), }); - isFirst = false; + if (appended) isFirst = false; } continue; } const value = String(e.value ?? e.id ?? ''); if (!value) continue; - flat.push({ + append({ value, label: String(e.name ?? e.label ?? value), ...(typeof e.description === 'string' From 975b920659da5d3e652b690b2c1449772c7855ee Mon Sep 17 00:00:00 2001 From: Yuqing Yang Date: Wed, 30 Sep 2026 09:53:19 +0000 Subject: [PATCH 05/30] Harden agentlet capacity reclamation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentlet/packages/local/src/agentlet.ts | 127 ++++++++++++++++-- external/agentlet/packages/local/src/cli.ts | 10 +- .../packages/local/tests/agentlet.test.ts | 19 +++ .../local/tests/daemon-integration.test.ts | 33 ++++- .../agentlet/packages/protocol/src/index.ts | 2 + .../packages/protocol/src/messages.ts | 27 +++- external/agentlet/spec/protocol.md | 6 +- 7 files changed, 203 insertions(+), 21 deletions(-) diff --git a/external/agentlet/packages/local/src/agentlet.ts b/external/agentlet/packages/local/src/agentlet.ts index 737394ed5..8b2ce5865 100644 --- a/external/agentlet/packages/local/src/agentlet.ts +++ b/external/agentlet/packages/local/src/agentlet.ts @@ -13,6 +13,7 @@ import { type AgentHelloResult, type SpawnParams, type StopParams, + type StopResult, type SendResourceParams, type JsonRpcMessage, type JsonRpcError, @@ -33,6 +34,7 @@ import type { AgentletOptions } from './cli.js' interface ManagedAgent { sessionId: string + workloadType?: 'Job' | 'Deployment' command: string cwd: string pid: number @@ -74,6 +76,8 @@ export class Agentlet { private readonly daemonId: string private controlWs: WebSocket | null = null private readonly agents = new Map() + private pendingSpawns = 0 + private readonly stopOperations = new Map>() private handshakeComplete = false /** @@ -310,6 +314,17 @@ export class Agentlet { this.sendDaemonResponse(requestId, undefined, { code: -32602, message: 'Missing required param: sessionSpec' }) return } + if ( + params.workloadType !== undefined && + params.workloadType !== 'Job' && + params.workloadType !== 'Deployment' + ) { + this.sendDaemonResponse(requestId, undefined, { + code: -32602, + message: 'Invalid workloadType', + }) + return + } if (sessionSpec && typeof sessionSpec === 'object' && 'agentTeam' in sessionSpec) { this.sendDaemonResponse(requestId, undefined, { @@ -357,11 +372,6 @@ export class Agentlet { return } - if (this.options.maxAgents && this.agents.size >= this.options.maxAgents) { - this.sendDaemonResponse(requestId, undefined, { code: -32000, message: `Max agents reached (${this.options.maxAgents})` }) - return - } - // Validate cwd: must be non-empty if provided, must exist on this machine let cwd: string if (sessionSpec.cwd && sessionSpec.cwd.trim()) { @@ -379,6 +389,36 @@ export class Agentlet { const autoRestart = sessionSpec.autoRestart ?? false + if ( + this.options.maxAgents && + this.agents.size + this.pendingSpawns >= this.options.maxAgents + ) { + const active = { + total: this.agents.size + this.pendingSpawns, + jobs: 0, + deployments: 0, + unknown: this.pendingSpawns, + stopping: 0, + } + for (const managed of this.agents.values()) { + if (managed.workloadType === 'Job') active.jobs++ + else if (managed.workloadType === 'Deployment') active.deployments++ + else active.unknown++ + if (managed.status === 'stopping') active.stopping++ + } + this.sendDaemonResponse(requestId, undefined, { + code: -32000, + message: `Max agents reached (${this.options.maxAgents})`, + data: { + code: 'capacity_exhausted', + limit: this.options.maxAgents, + active, + }, + }) + return + } + this.pendingSpawns++ + this.logger.info('spawning_agent', { command, cwd, sessionId: params.sessionId }) try { @@ -475,6 +515,7 @@ export class Agentlet { const managed: ManagedAgent = { sessionId, + ...(params.workloadType ? { workloadType: params.workloadType } : {}), command, cwd, pid, @@ -489,6 +530,7 @@ export class Agentlet { agent.on('exit', (code, signal) => { this.logger.info('agent_exited', { sessionId, code, signal }) + const wasStopping = managed.status === 'stopping' managed.status = 'stopped' // Drop the early-message buffer listener if the agent exits before // handshake_ok (idempotent if already detached). @@ -499,13 +541,27 @@ export class Agentlet { const exitNotification: JsonRpcMessage = { jsonrpc: '2.0', method: AgentMethods.EXITED, - params: { code, signal, willRestart: autoRestart && code !== 0 && !managed.idleSuspending }, + params: { + code, + signal, + willRestart: + autoRestart && + code !== 0 && + !managed.idleSuspending && + !wasStopping, + }, } managed.ws.send(exitNotification) } // Suppress autoRestart if this exit was caused by idle suspension - if (autoRestart && code !== 0 && !this.shutdownInProgress && !managed.idleSuspending) { + if ( + autoRestart && + code !== 0 && + !this.shutdownInProgress && + !managed.idleSuspending && + !wasStopping + ) { this.logger.info('agent_restarting', { sessionId }) setTimeout(() => { if (this.agents.has(sessionId) && !this.shutdownInProgress) { @@ -595,6 +651,8 @@ export class Agentlet { code: -32000, message: `Failed to spawn agent: ${err instanceof Error ? err.message : String(err)}`, }) + } finally { + this.pendingSpawns-- } } @@ -604,17 +662,43 @@ export class Agentlet { return } + const inFlight = this.stopOperations.get(params.sessionId) + if (inFlight) { + try { + this.sendDaemonResponse(requestId, await inFlight) + } catch (error) { + this.sendStopFailure(requestId, error) + } + return + } + const managed = this.agents.get(params.sessionId) if (!managed) { - this.sendDaemonResponse(requestId, undefined, { code: -32000, message: `Agent not found for session: ${params.sessionId}` }) + this.sendDaemonResponse(requestId, { + stopped: true, + disposition: 'already_absent', + } satisfies StopResult) return } - this.logger.info('stopping_agent', { sessionId: params.sessionId }) + const operation = this.stopManagedAgent(managed) + this.stopOperations.set(params.sessionId, operation) + try { + this.sendDaemonResponse(requestId, await operation) + } catch (error) { + this.sendStopFailure(requestId, error) + } finally { + if (this.stopOperations.get(params.sessionId) === operation) { + this.stopOperations.delete(params.sessionId) + } + } + } + + private async stopManagedAgent(managed: ManagedAgent): Promise { + this.logger.info('stopping_agent', { sessionId: managed.sessionId }) managed.status = 'stopping' managed.relay?.stop() - // Send goodbye on the agent's WS if (managed.ws?.connected) { const goodbye: JsonRpcMessage = { jsonrpc: '2.0', @@ -624,7 +708,6 @@ export class Agentlet { managed.ws.send(goodbye) } - // Gracefully stop the agent managed.agent.closeStdin() await this.waitForAgentExit(managed.agent, 5000) if (managed.agent.running) { @@ -633,12 +716,23 @@ export class Agentlet { } if (managed.agent.running) { managed.agent.kill() + await this.waitForAgentExit(managed.agent, 2000) + } + if (managed.agent.running) { + throw new Error(`Agent process did not exit for session: ${managed.sessionId}`) } managed.ws?.close() - this.agents.delete(params.sessionId) + this.agents.delete(managed.sessionId) + return { stopped: true, disposition: 'stopped' } + } - this.sendDaemonResponse(requestId, { stopped: true }) + private sendStopFailure(requestId: string | number, error: unknown): void { + this.sendDaemonResponse(requestId, undefined, { + code: -32000, + message: error instanceof Error ? error.message : String(error), + data: { code: 'agent_stop_failed', stillRunning: true }, + }) } private handleList(requestId: string | number): void { @@ -647,7 +741,12 @@ export class Agentlet { command: m.command, pid: m.pid, cwd: m.cwd, - status: m.status === 'running' ? 'running' as const : 'starting' as const, + ...(m.workloadType ? { workloadType: m.workloadType } : {}), + status: m.status === 'running' + ? 'running' as const + : m.status === 'stopping' + ? 'stopping' as const + : 'starting' as const, })) this.sendDaemonResponse(requestId, { agents }) } diff --git a/external/agentlet/packages/local/src/cli.ts b/external/agentlet/packages/local/src/cli.ts index 2694f63a4..7965d0bac 100644 --- a/external/agentlet/packages/local/src/cli.ts +++ b/external/agentlet/packages/local/src/cli.ts @@ -18,6 +18,14 @@ export interface AgentletOptions { /** Result of parsing the generic `agentlet daemon` command. */ export type CliResult = { mode: 'daemon'; options: AgentletOptions } +function positiveSafeInteger(value: string, option: string): number { + const parsed = Number(value) + if (!Number.isSafeInteger(parsed) || parsed < 1) { + throw new Error(`${option} must be a positive safe integer`) + } + return parsed +} + export function parseCli(argv: string[]): CliResult { const program = new Command() @@ -59,7 +67,7 @@ export function parseCli(argv: string[]): CliResult { logLevel: opts.logLevel as AgentletOptions['logLevel'], logFile: opts.logFile, agentletId: opts.agentletId?.trim() || undefined, - maxAgents: parseInt(opts.maxAgents, 10), + maxAgents: positiveSafeInteger(opts.maxAgents, '--max-agents'), }, } }) diff --git a/external/agentlet/packages/local/tests/agentlet.test.ts b/external/agentlet/packages/local/tests/agentlet.test.ts index 76070aeca..77a354913 100644 --- a/external/agentlet/packages/local/tests/agentlet.test.ts +++ b/external/agentlet/packages/local/tests/agentlet.test.ts @@ -30,6 +30,25 @@ describe('agentlet daemon identity', () => { options: { agentletId: 'machine-a' }, }) }) + + it.each(['0', '-1', '1.5', 'Infinity', '9007199254740992', '10agents'])( + 'rejects invalid max-agents value %s', + (maxAgents) => { + expect(() => + parseCli([ + 'node', + 'agentlet', + 'daemon', + '--server', + 'wss://example.test/api/bridge', + '--token', + 'test-token', + '--max-agents', + maxAgents, + ]), + ).toThrow('--max-agents must be a positive safe integer') + }, + ) }) describe('spawned agent environment', () => { diff --git a/external/agentlet/packages/local/tests/daemon-integration.test.ts b/external/agentlet/packages/local/tests/daemon-integration.test.ts index f997dd812..b19197e07 100644 --- a/external/agentlet/packages/local/tests/daemon-integration.test.ts +++ b/external/agentlet/packages/local/tests/daemon-integration.test.ts @@ -130,7 +130,7 @@ describe('agentlet daemon integration', () => { const daemon = new Agentlet({ server: `ws://127.0.0.1:${port}/api/bridge`, token: 'test-token', reconnectMax: 1, bufferLimit: 1000, heartbeat: 0, allowInsecure: true, - logLevel: 'error', agentletId: 'machine-a', maxAgents: 10, + logLevel: 'error', agentletId: 'machine-a', maxAgents: 1, }, new Logger('error')) await daemon.start() await waitUntil(() => controlHello !== undefined) @@ -156,7 +156,8 @@ describe('agentlet daemon integration', () => { })).toMatchObject({ error: { code: -32602 } }) expect(await request(10, ServerMethods.SPAWN, { - appId: 'thread-a', sessionSpec: { command: `node ${JSON.stringify(mockAgentPath)}` }, + appId: 'thread-a', workloadType: 'Job', + sessionSpec: { command: `node ${JSON.stringify(mockAgentPath)}` }, })).toMatchObject({ result: { sessionId: 'native-bootstrap', pid: expect.any(Number) } }) expect(sessionSocket).toBeUndefined() await waitUntil(() => sessionHello !== undefined) @@ -167,7 +168,33 @@ describe('agentlet daemon integration', () => { await waitUntil(() => sessionMessages.some( (message) => 'method' in message && message.method === 'session/update', )) - expect(await request(11, ServerMethods.STOP, { sessionId: 'native-bootstrap' })) + expect(await request(11, ServerMethods.SPAWN, { + appId: 'thread-b', workloadType: 'Deployment', + sessionSpec: { command: `node ${JSON.stringify(mockAgentPath)}` }, + })).toMatchObject({ + error: { + data: { + code: 'capacity_exhausted', + limit: 1, + active: { + total: 1, + jobs: 1, + deployments: 0, + unknown: 0, + stopping: 0, + }, + }, + }, + }) + expect(await request(12, ServerMethods.STOP, { sessionId: 'native-bootstrap' })) + .toMatchObject({ result: { stopped: true, disposition: 'stopped' } }) + expect(await request(13, ServerMethods.STOP, { sessionId: 'native-bootstrap' })) + .toMatchObject({ result: { stopped: true, disposition: 'already_absent' } }) + expect(await request(14, ServerMethods.SPAWN, { + appId: 'thread-c', workloadType: 'Deployment', + sessionSpec: { command: `node ${JSON.stringify(mockAgentPath)}` }, + })).toMatchObject({ result: { sessionId: 'native-bootstrap', pid: expect.any(Number) } }) + expect(await request(15, ServerMethods.STOP, { sessionId: 'native-bootstrap' })) .toMatchObject({ result: { stopped: true } }) }, 15_000) }) diff --git a/external/agentlet/packages/protocol/src/index.ts b/external/agentlet/packages/protocol/src/index.ts index 18e2a8f3e..413a75504 100644 --- a/external/agentlet/packages/protocol/src/index.ts +++ b/external/agentlet/packages/protocol/src/index.ts @@ -56,6 +56,8 @@ export type { SpawnParams, SpawnResult, SessionResumeUnavailableErrorData, + CapacityExhaustedErrorData, + AgentStopFailedErrorData, StopParams, StopResult, ListParams, diff --git a/external/agentlet/packages/protocol/src/messages.ts b/external/agentlet/packages/protocol/src/messages.ts index 5a9af92da..28b5c7dab 100644 --- a/external/agentlet/packages/protocol/src/messages.ts +++ b/external/agentlet/packages/protocol/src/messages.ts @@ -191,6 +191,8 @@ export interface ServerShutdownParams { export interface SpawnParams { /** Host-side correlation ID */ appId: string + /** Workload lifecycle used only for aggregate capacity diagnostics. */ + workloadType?: 'Job' | 'Deployment' /** If present, resume an existing session */ sessionId?: string /** How to spawn the agent */ @@ -209,6 +211,25 @@ export interface SessionResumeUnavailableErrorData { code: 'session_resume_unavailable' } +/** Structured daemon error data for capacity exhaustion. */ +export interface CapacityExhaustedErrorData { + code: 'capacity_exhausted' + limit: number + active: { + total: number + jobs: number + deployments: number + unknown: number + stopping: number + } +} + +/** Structured daemon error data when a process could not be reclaimed. */ +export interface AgentStopFailedErrorData { + code: 'agent_stop_failed' + stillRunning: true +} + /** server/stop — stop agent session */ export interface StopParams { sessionId: string @@ -216,7 +237,8 @@ export interface StopParams { /** Successful stop result */ export interface StopResult { - stopped: boolean + stopped: true + disposition: 'stopped' | 'already_absent' } /** server/list — list agent sessions */ @@ -230,7 +252,8 @@ export interface ListResult { command: string pid: number cwd: string - status: 'running' | 'starting' + workloadType?: 'Job' | 'Deployment' + status: 'running' | 'starting' | 'stopping' }> } diff --git a/external/agentlet/spec/protocol.md b/external/agentlet/spec/protocol.md index 6726c103a..feb0d053b 100644 --- a/external/agentlet/spec/protocol.md +++ b/external/agentlet/spec/protocol.md @@ -107,7 +107,7 @@ All JSON-RPC envelopes and method payloads are defined in [`messages.ts`](../pac ## 4. Spawn and bootstrap -`server/spawn` includes a host correlation `appId`, an optional native ACP `sessionId`, and a `sessionSpec`. +`server/spawn` includes a host correlation `appId`, an optional `workloadType` (`Job` or `Deployment`) used only for aggregate diagnostics, an optional native ACP `sessionId`, and a `sessionSpec`. The daemon uses the required `sessionSpec.command` and optional `sessionSpec.cwd` directly, then launches the process with `shell: true`. The host must therefore send only trusted commands. Any `sessionSpec.agentTeam` field is explicitly rejected with `-32602`, including when a command is also supplied; there is no manifest resolution or silent fallback. @@ -124,6 +124,10 @@ The host must include the required spawn parameters in every request. Agentlet h Historical Team manifest data is described in [`agent-team.md`](agent-team.md); it is not a runtime launch contract. +The daemon enforces its startup `--max-agents` value against the authoritative managed-process map. A rejected spawn returns JSON-RPC error data `{ code: "capacity_exhausted", limit, active: { total, jobs, deployments, unknown, stopping } }`; these counts reveal no session IDs, commands, or host correlation IDs. + +`server/stop` addresses one exact native `sessionId`. It is idempotent and returns `{ stopped: true, disposition: "stopped" | "already_absent" }`. For a present process, the daemon closes stdin, escalates through termination and kill with bounded exit waits, suppresses auto-restart, and removes the capacity slot only after exit is confirmed. Failure returns `data: { code: "agent_stop_failed", stillRunning: true }`. + ## 5. ACP relay After `agent/hello` succeeds, every WebSocket text frame on the session channel contains exactly one ACP JSON-RPC message. From ef86ca80283e332b05d400820b9ee70fad2130ed Mon Sep 17 00:00:00 2001 From: Yuqing Yang Date: Wed, 30 Sep 2026 09:53:19 +0000 Subject: [PATCH 06/30] Reclaim one-shot Agenetes jobs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- external/agenetes/README.md | 2 +- .../packages/acp-driver/src/errors.ts | 4 + .../packages/acp-driver/src/handle.ts | 9 +- .../packages/acp-driver/src/placement.test.ts | 98 +++++++++++++++++++ .../packages/acp-driver/src/recovery.test.ts | 2 +- .../src/session-self-repair.test.ts | 3 + .../packages/acp-driver/src/session.ts | 2 + .../acp-driver/src/spawn-orchestrator.ts | 95 +++++++++++++++--- .../packages/agenetes/src/instance.test.ts | 57 +++++++++++ .../packages/agenetes/src/instance.ts | 38 ++++++- .../agent-profile/src/profile-driver.ts | 6 +- .../agentlet-host/src/daemon-supervisor.ts | 6 ++ .../packages/agentlet-host/src/index.ts | 3 + .../agenetes/packages/runtime/src/driver.ts | 6 +- .../agenetes/packages/runtime/src/handle.ts | 11 +-- 15 files changed, 312 insertions(+), 30 deletions(-) diff --git a/external/agenetes/README.md b/external/agenetes/README.md index 539a5b335..1ef2d14b8 100644 --- a/external/agenetes/README.md +++ b/external/agenetes/README.md @@ -69,7 +69,7 @@ Each surface has an in-process programmatic form today, used when Agenetes is mo ## Structured ACP Profiles -The ACP driver accepts both `Job` and `Deployment`. They share session bootstrap, prompt streaming, controls, and current session-retention behavior. Each Job handle gets a unique private session key, even when its durable `threadId` is empty or shared with another workload; Deployment session keys remain their thread IDs. Agenetes still creates fresh Job handles, logs threaded Jobs, and skips persistence for empty-thread Jobs. ACP does not yet enforce single-run handles or automatically release Job clients/processes after completion, failure, or cancellation. `handle.close()` removes the local client/session entry but does not stop the Agentlet process. Automatic Job resource reclamation is a separate follow-up; normal host-configured idle suspension remains in effect. +The ACP driver accepts both `Job` and `Deployment`. They share session bootstrap, prompt streaming, and controls, but have different ownership: each Job handle gets a unique private session key, may run once, and is automatically closed by Agenetes when its generator completes, throws, or is returned early; Deployment session keys remain their thread IDs and stay live until explicit close or idle suspension. Job close removes the local ACP entry and waits for the exact Agentlet `{ agentletId, sessionId }` process to be confirmed stopped. Concurrent closes share one operation, an already-absent process is success, and an unconfirmed stop rejects rather than reporting successful completion. Agenetes logs threaded Jobs and skips persistence for empty-thread Jobs. ACP image content blocks require an explicit `agentCapabilities.promptCapabilities.image: true` in the initialized Agent's response; absent or unsupported capability fails before `session/prompt` rather than dropping pixels. Image bytes and MIME types pass through unchanged. Hosts may provide `AcpTurnCtx.drainHostEvents` to include validated side-channel results before the next driver event and before termination; these events pass through normal Agenetes logging and transcript folding. This does not alter session retention or add another transport. diff --git a/external/agenetes/packages/acp-driver/src/errors.ts b/external/agenetes/packages/acp-driver/src/errors.ts index 2ce2d44de..20f1a1410 100644 --- a/external/agenetes/packages/acp-driver/src/errors.ts +++ b/external/agenetes/packages/acp-driver/src/errors.ts @@ -54,11 +54,15 @@ export type AcpEnsureErrorCode = * typically a bad recipe (command not found, cwd missing) or a * daemon-side validation failure. */ | 'spawn_failed' + /** Agentlet rejected the spawn because its configured process capacity is full. */ + | 'capacity_exhausted' /** The agent process was spawned but never opened its WS connection * within the handshake window. Common for agents that need * interactive auth (e.g. Copilot OAuth expired) or were killed * immediately on startup. */ | 'connect_timeout' + /** A spawned process could not be confirmed stopped during compensation or teardown. */ + | 'cleanup_failed' /** Catch-all for unexpected throws — the route maps any non- * {@link AcpServiceError} to this. */ | 'internal'; diff --git a/external/agenetes/packages/acp-driver/src/handle.ts b/external/agenetes/packages/acp-driver/src/handle.ts index 2f72d1dcf..07b21a611 100644 --- a/external/agenetes/packages/acp-driver/src/handle.ts +++ b/external/agenetes/packages/acp-driver/src/handle.ts @@ -56,6 +56,7 @@ import { registerAcpStateListener, reportEntryState, } from './session.js'; +import { releaseThread } from './spawn-orchestrator.js'; import { acpUpdateToStreamEvent } from './translator.js'; import type { AcpTurnOverlay } from './overlay.js'; @@ -422,6 +423,7 @@ export class AcpAgentHandle< return ensureAcpSession({ agentletId: this.agentletId, threadId: this.sessionThreadId, + workloadType: this.spec.workloadType, binding: this.spec.spec.binding, profileExecutionRevision: this.spec.spec.profileExecutionRevision, namespace: this.spec.namespace, @@ -793,10 +795,11 @@ export class AcpAgentHandle< /** * Tear down the long-lived session: drop the live ACP entry for this - * session identity (which `shutdown()`s the client) and evict it from the - * registry. Does not stop the Agentlet process. Idempotent. + * session identity (which `shutdown()`s the client), evict it from the + * registry, and wait for the exact Agentlet process to be reclaimed. */ - close(): void { + async close(): Promise { acpSessionRegistry.remove(this.agentletId, this.sessionThreadId); + await releaseThread(this.agentletId, this.sessionThreadId); } } diff --git a/external/agenetes/packages/acp-driver/src/placement.test.ts b/external/agenetes/packages/acp-driver/src/placement.test.ts index 9df38a8a5..00e5d6cc1 100644 --- a/external/agenetes/packages/acp-driver/src/placement.test.ts +++ b/external/agenetes/packages/acp-driver/src/placement.test.ts @@ -1,3 +1,4 @@ +import { AgentletRequestError } from '@agenetes/agentlet-host'; import { afterEach, describe, expect, it, vi } from 'vitest'; const host = vi.hoisted(() => ({ @@ -21,6 +22,7 @@ import { acpSessionRegistry } from './session-registry.js'; import { _resetSpawnOrchestratorForTests, ensureAgentForThread, + releaseThread, } from './spawn-orchestrator.js'; import type { AcpBindingRecipe } from './binding-recipe.js'; @@ -71,6 +73,7 @@ describe('explicit ACP placement', () => { const first = await ensureAgentForThread('machine-a', 'typed', structured); expect(spawnOnAgentlet).toHaveBeenCalledWith('machine-a', { appId: 'typed', + workloadType: 'Deployment', sessionSpec: { launch, launchPlan, @@ -105,6 +108,37 @@ describe('explicit ACP placement', () => { ).rejects.toMatchObject({ code: 'spawn_failed' }); }); + it('classifies redacted capacity diagnostics separately from spawn failures', async () => { + host.gateway = { + getAgentlet: () => ({ agentletId: 'machine-a', status: 'connected' }), + spawnOnAgentlet: vi.fn(async () => { + throw new AgentletRequestError({ + code: -32000, + message: 'Max agents reached (10)', + data: { + code: 'capacity_exhausted', + limit: 10, + active: { + total: 10, + jobs: 7, + deployments: 2, + unknown: 1, + stopping: 1, + }, + }, + }); + }), + }; + + await expect( + ensureAgentForThread('machine-a', 'capacity-thread', recipe), + ).rejects.toMatchObject({ + code: 'capacity_exhausted', + message: + 'External agent capacity is exhausted: limit 10; active 10 (7 Jobs, 2 Deployments, 1 unclassified, 1 stopping)', + }); + }); + it('isolates live session registry entries by placement and thread', () => { const entryA = { agentletId: 'machine-a', @@ -186,6 +220,70 @@ describe('explicit ACP placement', () => { ); }); + it('reclaims the exact session once and treats repeated release as success', async () => { + const stopOnAgentlet = vi.fn(async () => ({ + stopped: true, + disposition: 'stopped' as const, + })); + host.gateway = { + getAgentlet: () => ({ agentletId: 'machine-a', status: 'connected' }), + getSession: () => ({ status: 'connected' }), + spawnOnAgentlet: vi.fn(async () => ({ + sessionId: 'native-session', + pid: 303, + })), + stopOnAgentlet, + }; + await ensureAgentForThread( + 'machine-a', + 'job-thread', + recipe, + undefined, + undefined, + 600, + 'Job', + ); + + await Promise.all([ + releaseThread('machine-a', 'job-thread'), + releaseThread('machine-a', 'job-thread'), + ]); + await releaseThread('machine-a', 'job-thread'); + + expect(stopOnAgentlet).toHaveBeenCalledOnce(); + expect(stopOnAgentlet).toHaveBeenCalledWith('machine-a', { + sessionId: 'native-session', + }); + }); + + it('retains ownership after stop failure so cleanup can be retried', async () => { + const stopOnAgentlet = vi + .fn() + .mockRejectedValueOnce(new Error('still running')) + .mockResolvedValueOnce({ + stopped: true, + disposition: 'already_absent', + }); + host.gateway = { + getAgentlet: () => ({ agentletId: 'machine-a', status: 'connected' }), + getSession: () => ({ status: 'connected' }), + spawnOnAgentlet: vi.fn(async () => ({ + sessionId: 'retry-session', + pid: 303, + })), + stopOnAgentlet, + }; + await ensureAgentForThread('machine-a', 'retry-thread', recipe); + + await expect( + releaseThread('machine-a', 'retry-thread'), + ).rejects.toMatchObject({ code: 'cleanup_failed' }); + await expect( + releaseThread('machine-a', 'retry-thread'), + ).resolves.toBeUndefined(); + expect(stopOnAgentlet).toHaveBeenCalledTimes(2); + }); + it('returns a structured placement error when the target is absent', async () => { vi.useFakeTimers(); host.gateway = { diff --git a/external/agenetes/packages/acp-driver/src/recovery.test.ts b/external/agenetes/packages/acp-driver/src/recovery.test.ts index cdf3796a9..f46e2ec51 100644 --- a/external/agenetes/packages/acp-driver/src/recovery.test.ts +++ b/external/agenetes/packages/acp-driver/src/recovery.test.ts @@ -200,7 +200,7 @@ describe('ACP durable history recovery', () => { expect(ids[2]).toBe(ids[0]); expect(prompt).toHaveBeenCalledTimes(3); expect(remove).not.toHaveBeenCalled(); - first.close(); + await first.close(); expect(remove).toHaveBeenCalledWith('machine-a', ids[0]); remove.mockRestore(); }, diff --git a/external/agenetes/packages/acp-driver/src/session-self-repair.test.ts b/external/agenetes/packages/acp-driver/src/session-self-repair.test.ts index 0f80a92bd..6a47ec5c7 100644 --- a/external/agenetes/packages/acp-driver/src/session-self-repair.test.ts +++ b/external/agenetes/packages/acp-driver/src/session-self-repair.test.ts @@ -189,6 +189,7 @@ describe('ACP Handle session self-repair', () => { 'original-session', undefined, undefined, + 'Deployment', ); expect(entry).toMatchObject({ cwd: '/original', @@ -250,6 +251,7 @@ describe('ACP Handle session self-repair', () => { 'session-old', undefined, undefined, + 'Deployment', ); finishSpawn?.({ sessionId: 'session-repaired', pid: 42 }); @@ -287,6 +289,7 @@ describe('ACP Handle session self-repair', () => { undefined, undefined, undefined, + 'Deployment', ); expect(repaired.persistedToDisk).toBe(false); }); diff --git a/external/agenetes/packages/acp-driver/src/session.ts b/external/agenetes/packages/acp-driver/src/session.ts index d45bb9915..a9b682be9 100644 --- a/external/agenetes/packages/acp-driver/src/session.ts +++ b/external/agenetes/packages/acp-driver/src/session.ts @@ -170,6 +170,7 @@ export interface EnsureAcpSessionOptions { /** Explicit execution-node placement for this session. */ agentletId: string; threadId: string; + workloadType?: 'Job' | 'Deployment'; /** External binding for the thread (see {@link RunAcpAgentOptions.binding}). */ binding: { alias: string; profileId: string }; profileExecutionRevision?: number; @@ -945,6 +946,7 @@ async function ensureAcpSessionInner( priorSessionId, opts.env, opts.idleTimeoutSecs, + opts.workloadType ?? 'Deployment', ); const conn = gateway.getSession(agentletId, agentSessionId); if (!conn || conn.status !== 'connected') { diff --git a/external/agenetes/packages/acp-driver/src/spawn-orchestrator.ts b/external/agenetes/packages/acp-driver/src/spawn-orchestrator.ts index d2f05fe07..a3b7a3dee 100644 --- a/external/agenetes/packages/acp-driver/src/spawn-orchestrator.ts +++ b/external/agenetes/packages/acp-driver/src/spawn-orchestrator.ts @@ -52,6 +52,7 @@ import { acpBindingRecipeSchema } from './binding-recipe.js'; import { AcpServiceError } from './errors.js'; import type { AcpBindingRecipe } from './binding-recipe.js'; +import type { WorkloadType } from '@agenetes/protocol'; export function isSessionResumeUnavailableError(error: unknown): boolean { if (!(error instanceof AgentletRequestError)) return false; @@ -86,6 +87,7 @@ interface CachedAgent { } const threadToAgent = new Map(); +const releaseOperations = new Map>(); /** @deprecated Use threadId directly — kept for backwards compat during migration. */ export function threadKey(_canvasId: string, threadId: string): string { @@ -193,6 +195,7 @@ export async function ensureAgentForThread( existingSessionId?: string, env?: Record, idleTimeoutSecs = 600, + workloadType: WorkloadType = 'Deployment', ): Promise<{ agentletId: string; sessionId: string; @@ -254,6 +257,7 @@ export async function ensureAgentForThread( try { const result = await gateway.spawnOnAgentlet(agentlet.agentletId, { appId: threadId, + workloadType, ...(existingSessionId ? { sessionId: existingSessionId } : {}), sessionSpec: { ...(recipe.launch @@ -274,17 +278,47 @@ export async function ensureAgentForThread( launchPlan = harnessLaunchPlanSchema.parse(result.launchPlan); } } catch (err) { + const message = err instanceof Error ? err.message : String(err); if (existingSessionId && isSessionResumeUnavailableError(err)) { throw new AcpServiceError( 'session_resume_unavailable', `External agent '${recipe.alias}' can no longer resume session '${existingSessionId}'`, ); } + if ( + err instanceof AgentletRequestError && + err.data && + typeof err.data === 'object' && + (err.data as { code?: unknown }).code === 'capacity_exhausted' + ) { + const data = err.data as { + limit?: unknown; + active?: { + total?: unknown; + jobs?: unknown; + deployments?: unknown; + unknown?: unknown; + stopping?: unknown; + }; + }; + const diagnostic = + Number.isSafeInteger(data.limit) && + Number.isSafeInteger(data.active?.total) && + Number.isSafeInteger(data.active?.jobs) && + Number.isSafeInteger(data.active?.deployments) && + Number.isSafeInteger(data.active?.unknown) && + Number.isSafeInteger(data.active?.stopping) + ? `limit ${String(data.limit)}; active ${String(data.active?.total)} (${String(data.active?.jobs)} Jobs, ${String(data.active?.deployments)} Deployments, ${String(data.active?.unknown)} unclassified, ${String(data.active?.stopping)} stopping)` + : message; + throw new AcpServiceError( + 'capacity_exhausted', + `External agent capacity is exhausted: ${diagnostic}`, + ); + } // The agentlet RPC itself rejected — typically a bad recipe // (command not found, cwd missing) or a daemon-side validation // failure. Preserve the daemon's message so the UI can surface // the specific reason (e.g. ENOENT path). - const message = err instanceof Error ? err.message : String(err); throw new AcpServiceError( 'spawn_failed', `Failed to spawn external agent '${recipe.alias}': ${message}`, @@ -302,6 +336,21 @@ export async function ensureAgentForThread( 3000, ); if (!connected) { + try { + const result = await gateway.stopOnAgentlet(agentlet.agentletId, { + sessionId, + }); + if (!result.stopped) { + throw new Error('Agentlet did not confirm process reclamation'); + } + } catch (error) { + throw new AcpServiceError( + 'cleanup_failed', + `External agent '${recipe.alias}' did not connect and its process could not be reclaimed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } throw new AcpServiceError( 'connect_timeout', `External agent '${recipe.alias}' started but did not respond within 3s. The agent may need to re-authenticate (e.g. Copilot OAuth) or has crashed on startup.`, @@ -323,30 +372,54 @@ export async function ensureAgentForThread( } /** - * Drop the cached mapping for `threadId` and best-effort ask the - * agentlet to stop the spawned agent. Called when a thread is deleted. + * Stop the exact spawned process and then drop the cached mapping. + * Concurrent calls share one stop operation; failures retain the mapping + * so the caller can retry instead of losing ownership. */ export async function releaseThread( agentletId: string, threadId: string, ): Promise { const key = agentletThreadKey(agentletId, threadId); + const inFlight = releaseOperations.get(key); + if (inFlight) return inFlight; const cached = threadToAgent.get(key); - threadToAgent.delete(key); if (!cached) return; const gateway = getAgentletGateway(); - if (!gateway) return; + if (!gateway) { + throw new AcpServiceError( + 'cleanup_failed', + `Cannot reclaim external agent for '${threadId}': Agentlet Gateway is not mounted`, + ); + } + const operation = (async () => { + try { + const result = await gateway.stopOnAgentlet(cached.agentletId, { + sessionId: cached.sessionId, + }); + if (!result.stopped) { + throw new Error('Agentlet did not confirm process reclamation'); + } + if (threadToAgent.get(key) === cached) threadToAgent.delete(key); + } catch (error) { + throw new AcpServiceError( + 'cleanup_failed', + `Failed to reclaim external agent for '${threadId}': ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + })(); + releaseOperations.set(key, operation); try { - await gateway.stopOnAgentlet(cached.agentletId, { - sessionId: cached.sessionId, - }); - } catch { - // Best-effort: a dying agentlet, already-stopped agent, or unknown - // id are all acceptable here. Caller already removed the thread. + await operation; + } finally { + if (releaseOperations.get(key) === operation) releaseOperations.delete(key); } } /** Test-only: clear the cache between vitest cases. */ export function _resetSpawnOrchestratorForTests(): void { threadToAgent.clear(); + releaseOperations.clear(); } diff --git a/external/agenetes/packages/agenetes/src/instance.test.ts b/external/agenetes/packages/agenetes/src/instance.test.ts index f6406e7c5..a776167c7 100644 --- a/external/agenetes/packages/agenetes/src/instance.test.ts +++ b/external/agenetes/packages/agenetes/src/instance.test.ts @@ -43,7 +43,23 @@ class StubHandle { readonly spec: StubSpec, readonly createContext: AgentCreateContext, ) {} + async *run(): AsyncGenerator< + { type: 'text_delta'; data: { content: string } }, + void + > { + if (this.spec.spec.note === 'run-fails') { + throw new Error('synthetic run failure'); + } + yield { type: 'text_delta', data: { content: 'first' } }; + yield { type: 'text_delta', data: { content: 'second' } }; + } + async control() { + return { ok: false as const, code: 'unsupported' as const }; + } close(): void { + if (this.spec.spec.note === 'close-fails') { + throw new Error('synthetic Job cleanup failure'); + } this.closed = true; } } @@ -626,6 +642,47 @@ describe('mounted Agenetes instance (M5 INST skeleton)', () => { expect((await inst.record(spec.namespace, 'thr_job'))?.spec.spec).toEqual( expect.objectContaining({ note: 'second' }), ); + + for await (const _ of h1.run()) { + // Drain the one-shot Job. + } + expect(h1.closed).toBe(true); + await expect(async () => { + for await (const _ of h1.run()) { + // A second run is rejected before reaching the driver. + } + }).rejects.toThrow('Job handles may run only once'); + }); + + it('closes Jobs after run failure, early return, and surfaces cleanup failure', async () => { + const inst = mount(); + const createJob = async (note: string) => + (await inst.create({ + threadId: `job-${note}`, + kind: 'external', + workloadType: 'Job', + namespace: ns('canvas_1'), + spec: { note }, + })) as unknown as StubHandle; + + const failed = await createJob('run-fails'); + await expect(async () => { + for await (const _ of failed.run()) { + // The driver throws before yielding. + } + }).rejects.toThrow('synthetic run failure'); + expect(failed.closed).toBe(true); + + const abandoned = await createJob('early-return'); + for await (const _ of abandoned.run()) break; + expect(abandoned.closed).toBe(true); + + const cleanupFailure = await createJob('close-fails'); + await expect(async () => { + for await (const _ of cleanupFailure.run()) { + // Drain the run so its automatic cleanup failure is observable. + } + }).rejects.toThrow('synthetic Job cleanup failure'); }); it('a transient Job (empty threadId) upserts no durable record (I9.4)', async () => { diff --git a/external/agenetes/packages/agenetes/src/instance.ts b/external/agenetes/packages/agenetes/src/instance.ts index 606b089ce..885e9a8eb 100644 --- a/external/agenetes/packages/agenetes/src/instance.ts +++ b/external/agenetes/packages/agenetes/src/instance.ts @@ -709,10 +709,44 @@ export function createAgenetesInstance( let needsUpReport = false; if (targetSpec.workloadType === 'Job') { const raw = driver.create(targetSpec, context); - handle = + const logged = targetSpec.threadId.length > 0 ? decorateForLogging(raw, targetSpec.namespace, targetSpec.threadId) : raw; + let runStarted = false; + let closePromise: Promise | undefined; + const closeOnce = (): Promise => { + closePromise ??= Promise.resolve(logged.close()); + return closePromise; + }; + handle = new Proxy(logged, { + get(target, prop) { + if (prop === 'run') { + return function ( + submission: AgentSubmission | null, + ctx: unknown, + ): AsyncGenerator { + if (runStarted) { + throw new AgenetesError( + 'invalid_workload', + 'Job handles may run only once', + ); + } + runStarted = true; + return (async function* () { + try { + return yield* target.run(submission, ctx); + } finally { + await closeOnce(); + } + })(); + }; + } + if (prop === 'close') return closeOnce; + const value = Reflect.get(target, prop, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); } else { const wasLive = runtime.get(targetSpec.threadId) !== undefined; handle = runtime.getOrCreate(targetSpec.threadId, () => @@ -1025,7 +1059,7 @@ export function createAgenetesInstance( }, async close(threadId: string): Promise { // Keep persistence and notifications wired if driver teardown fails. - runtime.close(threadId); + await runtime.close(threadId); try { // A snapshot reported during teardown is still this thread's. Persist // and deliver it before either notification scope ends — tearing the diff --git a/external/agenetes/packages/agent-profile/src/profile-driver.ts b/external/agenetes/packages/agent-profile/src/profile-driver.ts index 5644e240a..00cf7249c 100644 --- a/external/agenetes/packages/agent-profile/src/profile-driver.ts +++ b/external/agenetes/packages/agent-profile/src/profile-driver.ts @@ -161,7 +161,7 @@ class AgentProfileHandle< this.context, ) as AgentHandle; if (this.closed) { - delegate.close(); + await delegate.close(); throw new Error('Agent Profile handle is closed'); } this.delegate = delegate; @@ -188,11 +188,11 @@ class AgentProfileHandle< return delegate.control(msg); } - close(): void { + async close(): Promise { if (this.closed) return; this.closed = true; this.unsubscribeDelegateState?.(); - this.delegate?.close(); + if (this.delegate) await this.delegate.close(); this.stateListeners.clear(); } diff --git a/external/agenetes/packages/agentlet-host/src/daemon-supervisor.ts b/external/agenetes/packages/agentlet-host/src/daemon-supervisor.ts index ae0d32d4a..324b4c1d7 100644 --- a/external/agenetes/packages/agentlet-host/src/daemon-supervisor.ts +++ b/external/agenetes/packages/agentlet-host/src/daemon-supervisor.ts @@ -232,6 +232,8 @@ export interface AttachOptions { dataDir: string; /** Machine identity shared by the daemon and Gateway authenticator. */ agentletId?: string; + /** Resolve the host-owned process limit each time the daemon starts. */ + getMaxAgents?: () => number; /** * Host-namespaced environment isolation for the forked daemon (and, * transitively, every agent it spawns). When `hostEnvPrefix` is set, @@ -299,6 +301,7 @@ class DaemonSupervisor { */ private dataDir = ''; private agentletId = ''; + private getMaxAgents: (() => number) | undefined; private hostEnvPrefix: string | undefined; private hostEnvAllowlist: readonly string[] | undefined; @@ -312,6 +315,7 @@ class DaemonSupervisor { this.daemonEntryPath = opts.daemonEntryPath; this.dataDir = opts.dataDir; this.agentletId = opts.agentletId ?? hostname(); + this.getMaxAgents = opts.getMaxAgents; this.hostEnvPrefix = opts.hostEnvPrefix; this.hostEnvAllowlist = opts.hostEnvAllowlist; @@ -456,6 +460,7 @@ class DaemonSupervisor { return; } const serverUrl = `ws://127.0.0.1:${this.serverPort}/api/acp/agent`; + const maxAgents = this.getMaxAgents?.(); const args = [ 'daemon', '--server', @@ -464,6 +469,7 @@ class DaemonSupervisor { token, '--agentlet-id', this.agentletId, + ...(maxAgents === undefined ? [] : ['--max-agents', String(maxAgents)]), '--allow-insecure', ]; diff --git a/external/agenetes/packages/agentlet-host/src/index.ts b/external/agenetes/packages/agentlet-host/src/index.ts index a8facb88a..52188ba93 100644 --- a/external/agenetes/packages/agentlet-host/src/index.ts +++ b/external/agenetes/packages/agentlet-host/src/index.ts @@ -96,6 +96,8 @@ export interface MountAgenetesOptions { * owns this knowledge; this package never resolves paths. */ daemonEntryPath: string; + /** Resolve the host-owned process limit on each supervised daemon start. */ + getMaxAgents?: () => number; /** * Host-namespaced environment isolation for the forked daemon and * every agent it spawns. `hostEnvPrefix` names the host's env @@ -148,6 +150,7 @@ export function mountAgenetes( daemonEntryPath: opts.daemonEntryPath, dataDir: opts.dataDir, agentletId, + getMaxAgents: opts.getMaxAgents, hostEnvPrefix: opts.hostEnvPrefix, hostEnvAllowlist: opts.hostEnvAllowlist, }); diff --git a/external/agenetes/packages/runtime/src/driver.ts b/external/agenetes/packages/runtime/src/driver.ts index 2a0df0e93..c9ac9df5d 100644 --- a/external/agenetes/packages/runtime/src/driver.ts +++ b/external/agenetes/packages/runtime/src/driver.ts @@ -180,7 +180,7 @@ export interface AgentRuntime { readonly kinds: readonly string[]; get(threadId: string): AgentHandle | undefined; getOrCreate(threadId: string, createHandle: () => AgentHandle): AgentHandle; - close(threadId: string): void; + close(threadId: string): Promise; } export function createAgentRuntime(drivers: DriverMap): AgentRuntime { @@ -197,10 +197,10 @@ export function createAgentRuntime(drivers: DriverMap): AgentRuntime { handles.set(threadId, created); return created; }, - close(threadId) { + async close(threadId) { const handle = handles.get(threadId); if (!handle) return; - handle.close(); + await handle.close(); handles.delete(threadId); }, }; diff --git a/external/agenetes/packages/runtime/src/handle.ts b/external/agenetes/packages/runtime/src/handle.ts index 3b467413b..f0df04ef4 100644 --- a/external/agenetes/packages/runtime/src/handle.ts +++ b/external/agenetes/packages/runtime/src/handle.ts @@ -58,8 +58,8 @@ import type { * `@agenetes/protocol` `ControlMsg` vocabulary, gated by * {@link AgentHandle.capabilities}. Usable out-of-turn on a Deployment. * - `close()` — release this workload (teardown the session / drop the - * backing connection). A Job's `close` is a no-op (its run already - * ended it); a Deployment tears down its long-lived session. + * backing connection). Teardown may be asynchronous when the driver + * must confirm an external process has stopped. * - `capabilities` — the advertised capability descriptor. * * The type parameters keep the framework host-agnostic: `TSubmission` is @@ -113,11 +113,10 @@ export interface AgentHandle< control(msg: ControlMsg): Promise; /** - * Release this workload. For a long-lived Deployment this tears down - * the session (drops the backing connection); for a one-shot Job it is - * a no-op (the single `run` already ended its life). Idempotent. + * Release this workload. Idempotent. Agenetes invokes this automatically + * when a one-shot Job run settles; Deployment callers close explicitly. */ - close(): void; + close(): void | Promise; /** * The **up-report seam** (README I9.7): subscribe to this handle's From 3c2a7c5a7aeadc921ddb84b4c6b3cffb193f485e Mon Sep 17 00:00:00 2001 From: Yuqing Yang Date: Wed, 30 Sep 2026 09:53:19 +0000 Subject: [PATCH 07/30] Configure supervised agent capacity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/server/src/app.ts | 2 + apps/server/src/modules/agent/acp/index.ts | 1 + .../modules/agent/acp/runtime-config.test.ts | 31 ++++++-- .../src/modules/agent/acp/runtime-config.ts | 1 + .../src/modules/agent/functional-text.test.ts | 8 +- .../src/modules/agent/functional-text.ts | 3 + .../sections/GeneralSettings.test.tsx | 1 + .../Settings/sections/GeneralSettings.tsx | 76 ++++++++++++++++++- apps/web/src/i18n/resources/en/common.json | 4 + apps/web/src/i18n/resources/zh-CN/common.json | 4 + docs/architecture/agent-architecture.md | 1 + docs/architecture/agent-profiles.md | 6 +- docs/architecture/api-design.md | 4 + packages/shared/src/types/api/acp.ts | 6 ++ 14 files changed, 136 insertions(+), 12 deletions(-) diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 692752ef2..a55950863 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -22,6 +22,7 @@ import { acpProfilesRoutes, acpThreadsRoutes, externalAgentRuntimeConfigRoutes, + getExternalAgentRuntimeConfig, getAgentProfileRegistry, getSupervisedAgentletId, installAcpProfileCachePort, @@ -297,6 +298,7 @@ const agentletGateway = mountAgenetes(app, { connectionToken: getConnectionToken(), dataDir: getDataDir(), daemonEntryPath: resolveDaemonEntry() ?? '', + getMaxAgents: () => getExternalAgentRuntimeConfig().maxAgents, // Host-namespaced env isolation: the agentlet daemon and every external // agent it spawns are host-agnostic and must receive their Huabu // coordinates only through explicit injection (per-agent reachback env), diff --git a/apps/server/src/modules/agent/acp/index.ts b/apps/server/src/modules/agent/acp/index.ts index eda581423..e247ff41d 100644 --- a/apps/server/src/modules/agent/acp/index.ts +++ b/apps/server/src/modules/agent/acp/index.ts @@ -17,6 +17,7 @@ export { default as acpAgentCliRoutes } from './agent-cli.route.js'; export { default as acpProfilesRoutes } from './profiles.route.js'; export { default as acpAgentletRoutes } from './daemon.route.js'; export { default as externalAgentRuntimeConfigRoutes } from './runtime-config.route.js'; +export { getExternalAgentRuntimeConfig } from './runtime-config.js'; /** @deprecated Use {@link acpAgentletRoutes} instead. */ export { default as acpDaemonRoutes } from './daemon.route.js'; diff --git a/apps/server/src/modules/agent/acp/runtime-config.test.ts b/apps/server/src/modules/agent/acp/runtime-config.test.ts index 82989679f..b005dec41 100644 --- a/apps/server/src/modules/agent/acp/runtime-config.test.ts +++ b/apps/server/src/modules/agent/acp/runtime-config.test.ts @@ -31,17 +31,23 @@ describe('external-agent runtime config', () => { rmSync(dataDir, { recursive: true, force: true }); }); - it('uses ten minutes when no config has been persisted', () => { + it('uses ten minutes and ten agents when no config has been persisted', () => { expect(getExternalAgentRuntimeConfig()).toEqual( DEFAULT_EXTERNAL_AGENT_RUNTIME_CONFIG, ); }); it('persists disabled idle suspension atomically', () => { - expect(setExternalAgentRuntimeConfig({ idleTimeoutSecs: 0 })).toEqual({ + expect( + setExternalAgentRuntimeConfig({ idleTimeoutSecs: 0, maxAgents: 25 }), + ).toEqual({ + idleTimeoutSecs: 0, + maxAgents: 25, + }); + expect(getExternalAgentRuntimeConfig()).toEqual({ idleTimeoutSecs: 0, + maxAgents: 25, }); - expect(getExternalAgentRuntimeConfig()).toEqual({ idleTimeoutSecs: 0 }); expect( JSON.parse( readFileSync( @@ -49,18 +55,29 @@ describe('external-agent runtime config', () => { 'utf8', ), ), - ).toEqual({ idleTimeoutSecs: 0 }); + ).toEqual({ idleTimeoutSecs: 0, maxAgents: 25 }); }); it('rejects finite timeouts outside one minute through one day', () => { expect(() => - setExternalAgentRuntimeConfig({ idleTimeoutSecs: 59 }), + setExternalAgentRuntimeConfig({ idleTimeoutSecs: 59, maxAgents: 10 }), ).toThrow(); expect(() => - setExternalAgentRuntimeConfig({ idleTimeoutSecs: 86_401 }), + setExternalAgentRuntimeConfig({ + idleTimeoutSecs: 86_401, + maxAgents: 10, + }), ).toThrow(); expect(() => - setExternalAgentRuntimeConfig({ idleTimeoutSecs: 61 }), + setExternalAgentRuntimeConfig({ idleTimeoutSecs: 61, maxAgents: 10 }), ).toThrow(); }); + + it('rejects non-positive, fractional, and unsafe agent limits', () => { + for (const maxAgents of [0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1]) { + expect(() => + setExternalAgentRuntimeConfig({ idleTimeoutSecs: 600, maxAgents }), + ).toThrow(); + } + }); }); diff --git a/apps/server/src/modules/agent/acp/runtime-config.ts b/apps/server/src/modules/agent/acp/runtime-config.ts index 9a8c54e2f..c4abb8394 100644 --- a/apps/server/src/modules/agent/acp/runtime-config.ts +++ b/apps/server/src/modules/agent/acp/runtime-config.ts @@ -15,6 +15,7 @@ import type { ExternalAgentRuntimeConfig } from '@huabu/shared'; export const DEFAULT_EXTERNAL_AGENT_RUNTIME_CONFIG: ExternalAgentRuntimeConfig = { idleTimeoutSecs: 600, + maxAgents: 10, }; const log = getLogger('external-agent-runtime-config'); diff --git a/apps/server/src/modules/agent/functional-text.test.ts b/apps/server/src/modules/agent/functional-text.test.ts index e2bc2ef4e..5471208ce 100644 --- a/apps/server/src/modules/agent/functional-text.test.ts +++ b/apps/server/src/modules/agent/functional-text.test.ts @@ -201,7 +201,10 @@ describe('external functional text', () => { async (outcome) => { vi.useFakeTimers(); const controller = new AbortController(); - let resolveCreation!: (handle: { run: typeof mocks.run }) => void; + let resolveCreation!: (handle: { + run: typeof mocks.run; + close: typeof mocks.close; + }) => void; mocks.create.mockImplementationOnce( () => new Promise((resolve) => { @@ -222,9 +225,10 @@ describe('external functional text', () => { await vi.advanceTimersByTimeAsync(FUNCTIONAL_TEXT_TIMEOUT_MS); else controller.abort(new Error('caller cancelled')); await result; - resolveCreation({ run: mocks.run }); + resolveCreation({ run: mocks.run, close: mocks.close }); await vi.advanceTimersByTimeAsync(0); expect(mocks.run).not.toHaveBeenCalled(); + expect(mocks.close).toHaveBeenCalledOnce(); }, ); diff --git a/apps/server/src/modules/agent/functional-text.ts b/apps/server/src/modules/agent/functional-text.ts index af99758bf..deb298342 100644 --- a/apps/server/src/modules/agent/functional-text.ts +++ b/apps/server/src/modules/agent/functional-text.ts @@ -134,6 +134,9 @@ export async function runFunctionalText( const execute = async (): Promise => { signal.throwIfAborted(); const handle = await agenetes.create(spec); + if (signal.aborted) { + await handle.close(); + } signal.throwIfAborted(); const folder = createTranscriptFolder(); let completed = false; diff --git a/apps/web/src/components/Settings/sections/GeneralSettings.test.tsx b/apps/web/src/components/Settings/sections/GeneralSettings.test.tsx index 4347422d3..b7abe3198 100644 --- a/apps/web/src/components/Settings/sections/GeneralSettings.test.tsx +++ b/apps/web/src/components/Settings/sections/GeneralSettings.test.tsx @@ -34,6 +34,7 @@ vi.mock('@/i18n', () => ({ vi.mock('@/api/acp', () => ({ getExternalAgentRuntimeConfig: vi.fn(async () => ({ idleTimeoutSecs: 600, + maxAgents: 10, })), updateExternalAgentRuntimeConfig: vi.fn(), })); diff --git a/apps/web/src/components/Settings/sections/GeneralSettings.tsx b/apps/web/src/components/Settings/sections/GeneralSettings.tsx index 649708697..91ab01b3b 100644 --- a/apps/web/src/components/Settings/sections/GeneralSettings.tsx +++ b/apps/web/src/components/Settings/sections/GeneralSettings.tsx @@ -80,6 +80,8 @@ export const GeneralSettings: React.FC = () => { ); const effectiveInputMode = useEffectiveInputMode(); const [idleTimeoutSecs, setIdleTimeoutSecs] = useState(600); + const [maxAgents, setMaxAgents] = useState(10); + const [maxAgentsInput, setMaxAgentsInput] = useState('10'); const [idleTimeoutSelection, setIdleTimeoutSelection] = useState('600'); const [customMinutes, setCustomMinutes] = useState('10'); const [idleTimeoutLoading, setIdleTimeoutLoading] = useState(true); @@ -106,6 +108,8 @@ export const GeneralSettings: React.FC = () => { if (!active) return; const value = String(config.idleTimeoutSecs); setIdleTimeoutSecs(config.idleTimeoutSecs); + setMaxAgents(config.maxAgents); + setMaxAgentsInput(String(config.maxAgents)); setIdleTimeoutSelection( IDLE_TIMEOUT_PRESETS.has(value) ? value : 'custom', ); @@ -184,6 +188,7 @@ export const GeneralSettings: React.FC = () => { try { const saved = await updateExternalAgentRuntimeConfig({ idleTimeoutSecs: nextIdleTimeoutSecs, + maxAgents, }); setIdleTimeoutSecs(saved.idleTimeoutSecs); const value = String(saved.idleTimeoutSecs); @@ -208,9 +213,44 @@ export const GeneralSettings: React.FC = () => { setIdleTimeoutSaving(false); } }, - [idleTimeoutSecs, t], + [idleTimeoutSecs, maxAgents, t], ); + const parsedMaxAgents = Number(maxAgentsInput); + const maxAgentsValid = + Number.isSafeInteger(parsedMaxAgents) && parsedMaxAgents >= 1; + + const saveMaxAgents = useCallback(async () => { + if (!maxAgentsValid) return; + setIdleTimeoutSaving(true); + try { + const saved = await updateExternalAgentRuntimeConfig({ + idleTimeoutSecs, + maxAgents: parsedMaxAgents, + }); + setMaxAgents(saved.maxAgents); + setMaxAgentsInput(String(saved.maxAgents)); + toast(t('settings.externalAgentMaxAgentsSaved'), { tone: 'success' }); + } catch (error) { + setMaxAgentsInput(String(maxAgents)); + toast( + error instanceof Error + ? error.message + : t('settings.externalAgentMaxAgentsSaveFailed'), + { tone: 'danger' }, + ); + } finally { + setIdleTimeoutSaving(false); + } + }, [ + idleTimeoutSecs, + maxAgents, + maxAgentsInput, + maxAgentsValid, + parsedMaxAgents, + t, + ]); + const handleIdleTimeoutSelection = useCallback( (value: string) => { setIdleTimeoutSelection(value); @@ -390,6 +430,40 @@ export const GeneralSettings: React.FC = () => { )} + +
+ setMaxAgentsInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') void saveMaxAgents(); + }} + aria-label={t('settings.externalAgentMaxAgents')} + disabled={idleTimeoutLoading || idleTimeoutSaving} + /> + +
+
); }; diff --git a/apps/web/src/i18n/resources/en/common.json b/apps/web/src/i18n/resources/en/common.json index fabadbbdb..7f809477c 100644 --- a/apps/web/src/i18n/resources/en/common.json +++ b/apps/web/src/i18n/resources/en/common.json @@ -319,6 +319,10 @@ "externalAgentIdleTimeoutSaved": "External agent idle timeout updated", "externalAgentIdleTimeoutLoadFailed": "Failed to load the external agent idle timeout", "externalAgentIdleTimeoutSaveFailed": "Failed to save the external agent idle timeout", + "externalAgentMaxAgents": "Maximum external agents", + "externalAgentMaxAgentsDescription": "Maximum processes for the supervised agentlet daemon. The default is 10; any positive whole number is allowed. Changes take effect after the application restarts.", + "externalAgentMaxAgentsSaved": "Maximum external agents updated; restart the application to apply it", + "externalAgentMaxAgentsSaveFailed": "Failed to save the maximum external agents", "autoAcceptAgentChanges": "Automatically accept Agent Space changes", "autoAcceptAgentChangesDescription": "Do not ask to Keep successful Agent changes to the Space. You can still use Undo during the current session, but accepted changes are not retained for Revert after refresh.", "autoAcceptAgentChangesLoadFailed": "Failed to load the Agent change-review setting", diff --git a/apps/web/src/i18n/resources/zh-CN/common.json b/apps/web/src/i18n/resources/zh-CN/common.json index 5b748092b..bd67bb88c 100644 --- a/apps/web/src/i18n/resources/zh-CN/common.json +++ b/apps/web/src/i18n/resources/zh-CN/common.json @@ -319,6 +319,10 @@ "externalAgentIdleTimeoutSaved": "已更新外部 Agent 空闲超时", "externalAgentIdleTimeoutLoadFailed": "加载外部 Agent 空闲超时失败", "externalAgentIdleTimeoutSaveFailed": "保存外部 Agent 空闲超时失败", + "externalAgentMaxAgents": "外部 Agent 最大数量", + "externalAgentMaxAgentsDescription": "由 Huabu 管理的 agentlet daemon 可运行的最大进程数。默认值为 10,允许任意正整数;修改后需重启应用才能生效。", + "externalAgentMaxAgentsSaved": "已更新外部 Agent 最大数量;重启应用后生效", + "externalAgentMaxAgentsSaveFailed": "保存外部 Agent 最大数量失败", "autoAcceptAgentChanges": "自动接受 Agent 对 Space 的更改", "autoAcceptAgentChangesDescription": "不再要求保留已成功应用的 Agent Space 更改。当前会话仍可使用撤销,但刷新后不会保留这些更改的还原记录。", "autoAcceptAgentChangesLoadFailed": "加载 Agent 更改复核设置失败", diff --git a/docs/architecture/agent-architecture.md b/docs/architecture/agent-architecture.md index 96a628c6e..5aee236e3 100644 --- a/docs/architecture/agent-architecture.md +++ b/docs/architecture/agent-architecture.md @@ -24,6 +24,7 @@ Key runtime characteristics: long-running tools ([llm.ts](../../apps/server/src/modules/agent/llm.ts) / [oauth.ts](../../apps/server/src/modules/agent/oauth.ts)). - **Built-in chat is a Deployment**: `POST /api/agent` reuses one live `PiAgentHandle` per `threadId` (get-or-create by Agenetes). On restart, Agenetes supplies durable materialized history through `AgentCreateContext`; that history contains completed Tier-2 turns plus an optional read-time incomplete turn projected from the Tier-1 `turn_start` and event suffix. pi-driver lowers that history through its `materializeHistory` port and seeds the result through pi-agent-core's native `initialState.messages`. Huabu implements the port in [history-replay.ts](../../apps/server/src/modules/agent/agenetes/history-replay.ts) on top of `rebuildTurnMessages`, whose job is to restore the context the live handle would still be holding: each turn replays the canonical `rendered` input array persisted with its submission, so role attribution, `toolCall`/`toolResult` pairing, and images as real vision parts all come back byte-identical to what the model saw. The folded transcript is projected one round at a time, so a multi-round turn replays as `assistant → toolResult → assistant` instead of collapsing into a single block, and a tool call folded with `status: 'failed'` replays as an error result. Only records written before `rendered` existed fall back to re-rendering the stored envelope, and that path drops the neighbourhood, whose point-in-time snapshot would otherwise differ on every rebuild and break the provider's prefix cache. Replay deliberately does not trim: context growth belongs to the conversation, and budgeting only on recovery would make a recovered thread quietly forget what a never-restarted one remembers. Because the payload is not the durable record, the driver reports the materialized `estimatedSize` to `authorizeHistoryLoad`; the mounted `AutoRecoverPolicy` limit is `HISTORY_LOAD_SANITY_LIMIT`, a corruption guard sitting far above any genuine conversation, not a context budget. The route no longer rebuilds transcript context or persists turns. The workload's `initialPreamble` is mapped to pi-agent-core's native `systemPrompt`; later prompt changes use native `set_context`. The pi driver also re-resolves the symbolic `{ type: 'host', id: 'active' }` model ref at every turn boundary. +- **Jobs are framework-owned one-shot executions**: Agenetes returns a fresh Job handle, rejects a second `run`, and closes the driver handle in the run generator's `finally`, including normal completion, thrown failures, cancellation, and early iterator return. Asynchronous close is part of the handle contract, and cleanup failure rejects completion rather than being converted into success. Deployments retain their existing cached, multi-turn lifecycle and are never closed merely because one turn ended. - **RFS Agent creation and prompting are separate**: `POST /agent` creates a visible Agent Node and may start its first turn, while `POST /agent/:threadId/prompt` addresses an existing conversation. Both Huabu and configured Agent Profiles use the same node-backed invocation service; turns continue draining after the RFS socket disconnects and remain stoppable through the shared explicit stop path. - **Deployment turns are mutually exclusive**: `AgentThreadService` owns the shared per-`threadId` turn lease, abort controller, process-local active-invocation registry, and durable-turn-start barrier for UI, RFS, and Interactive View invocation. Stop distinguishes preparation from dispatch: it may resolve `not-started` before lazy dispatch begins, but once dispatch starts it waits for the execution path to report either durable acceptance or settlement without a turn start, so a concurrent Stop cannot overwrite an in-flight acceptance identity. The lease remains held until the run settles, including when a client disconnects. `GET /api/agent/stream/:threadId` validates the active invocation's owner Canvas and independently tails Agenetes Tier 1, so an RFS response and multiple Web tabs can observe one turn without draining each other. History reads include the uncovered Tier-1 suffix and wait for turn start when the matching invocation is active. - **Preparation is cancellable before turn start**: `POST /api/agent` registers a per-thread preparation token before owner resolution. After admission, `AgentThreadService` resolves fresh ownership, builds the deferred envelope, validates and canonically renders the submission, and checks cancellation before durable execution. The shared Stop path cancels matching pending preparations as well as active invocations; a stopped preparation has no acceptance identity and cannot start later. diff --git a/docs/architecture/agent-profiles.md b/docs/architecture/agent-profiles.md index 78b12a012..79262170d 100644 --- a/docs/architecture/agent-profiles.md +++ b/docs/architecture/agent-profiles.md @@ -81,9 +81,9 @@ Image labels reuse chat image resolution and its 4 MB decoded-image cap; unavail Default Profile selection saves immediately; the functional-model input saves after 600 ms of inactivity and flushes on blur or Settings unmount. There is no separate Save button. The shared Profile store serializes these writes across Settings mounts, publishes confirmed defaults for new conversations, and prevents older catalogue responses from overwriting a confirmed save. Save errors retain the editable draft and appear inline and as a toast, including when Settings has already closed; editing again or blurring a failed model input retries. Existing conversations and default-initialization ordering are unchanged. -External functional text tasks reuse Profile snapshot compilation and Agenetes event folding, without creating visible Agent Nodes or storing Agenetes conversations. An unconfigured default or unavailable Profile is an explicit failure, never an internal fallback. A background permission request fails the task and forwards cancellation without escalating approval; the task deadline also bounds unresponsive harnesses. ACP Job automatic resource release is deferred independently of this migration. Per-workflow overrides and historical conversation migration remain outside this step; both runtimes are retained. +External functional text tasks reuse Profile snapshot compilation and Agenetes event folding, without creating visible Agent Nodes or storing Agenetes conversations. An unconfigured default or unavailable Profile is an explicit failure, never an internal fallback. A background permission request fails the task and forwards cancellation without escalating approval; the task deadline also bounds unresponsive harnesses. Agenetes owns one-shot Job cleanup: normal completion, failure, or early iterator return closes the handle and waits for exact Agentlet process reclamation, while cleanup failure remains observable. Per-workflow overrides and historical conversation migration remain outside this step; both runtimes are retained. -Functional Jobs await the asynchronous Agenetes creation API inside the task deadline. Cancellation or timeout during creation returns promptly, and a handle that arrives afterward cannot dispatch the task. Creation failures propagate without fallback; this does not introduce automatic resource reclamation. +Functional Jobs await the asynchronous Agenetes creation API inside the task deadline. Cancellation or timeout during creation returns promptly, and a handle that arrives afterward cannot dispatch the task. Creation failures propagate without fallback; a process spawned before connection failure is compensatingly stopped, and failure to confirm that stop is reported as cleanup failure. New conversations and newly created Agent Nodes snapshot the configured default unless the caller supplies an explicit binding. Web creation reads the canonical defaults endpoint independently of external catalogue readiness; cached Profile data only supplies display aliases. Existing conversations, restored nodes, and explicit selections keep their original binding. A missing or deleted default produces an actionable error, not a silent switch to another Profile. Loading a Space and initializing a legacy thread association remain independent of default availability. @@ -97,6 +97,8 @@ Owner-only `POST /api/acp/profile-launch-preview` accepts `{ launch, profileId? Settings presents ordinary Profiles, their existing edit/delete actions, and the agentlet health banner. Template/member Config/setup controls are removed. Catalogue and Profile endpoints remain owner-only. Shared HTTP contracts remain under `packages/shared/src/types/api/`, with type-only imports in the Web app. +Owner-only `GET/PUT /api/acp/runtime-config` persists the external-agent idle timeout and supervised-daemon process limit in `/external-agent-runtime-config.json`. `maxAgents` defaults to `10` and accepts any positive JavaScript safe integer without a product-defined maximum. Huabu passes it as `--max-agents` whenever the supervised daemon starts; saving does not hot-update or automatically restart the daemon, and manually launched remote daemons continue to use their own CLI argument without a configuration handshake. + ## Code entry points | File or directory | Responsibility | diff --git a/docs/architecture/api-design.md b/docs/architecture/api-design.md index a4be2d89e..d77e79eaa 100644 --- a/docs/architecture/api-design.md +++ b/docs/architecture/api-design.md @@ -140,6 +140,10 @@ Success returns `{ threadId, turns, before?, hasMore }`. Each `turns[]` entry is Malformed request fields and malformed cursors return HTTP 400 with `code: "malformed_history_request"` or `code: "malformed_history_cursor"`. A cursor whose thread generation was replaced or rehomed returns HTTP 409 with `code: "stale_history_cursor"`. The existing `GET /api/agent/history/:threadId` remains the unbounded compatibility endpoint for current consumers; pagination is not applied implicitly to model recovery or complete-history callers. +## External-agent runtime configuration + +`GET/PUT /api/acp/runtime-config` uses `externalAgentRuntimeConfigSchema` from [`acp.ts`](../../packages/shared/src/types/api/acp.ts). The owner-only full replacement body contains `idleTimeoutSecs` and `maxAgents`; `maxAgents` is a positive JavaScript safe integer with default `10` and no product-defined upper bound. The value is persisted globally and supplied to the supervised Agentlet daemon as `--max-agents` on its next start; the API does not restart the daemon or configure manually launched remote daemons. + ## RFS Agent discovery `POST /api/rfs/:canvasId/agent/:threadId/ink-intent` uses `rfsInkIntentParamsSchema`, `rfsInkIntentRequestSchema`, and `rfsInkIntentResponseSchema` in `types/api/rfs.ts`, reusing `inkIntentReportSchema`. RFS decodes its raw JSON buffer, validates the target and body with `safeParse`, and delegates to the shared Ink writer. A per-turn invocation token must match the active external turn; inactive, expired, or wrong-scope reports return `409 ink_turn_inactive`. The token is a freshness guard, not a credential; the normal RFS Bearer requirement remains mandatory. diff --git a/packages/shared/src/types/api/acp.ts b/packages/shared/src/types/api/acp.ts index f98871274..f1d3f0586 100644 --- a/packages/shared/src/types/api/acp.ts +++ b/packages/shared/src/types/api/acp.ts @@ -48,6 +48,12 @@ export const externalAgentIdleTimeoutSecsSchema = z.union([ export const externalAgentRuntimeConfigSchema = z.object({ idleTimeoutSecs: externalAgentIdleTimeoutSecsSchema, + maxAgents: z + .number() + .int() + .positive() + .refine(Number.isSafeInteger, 'Maximum agents must be a safe integer') + .default(10), }); export type ExternalAgentRuntimeConfig = z.infer< From fb1fa8fae817847b4c8b98dcbf2c4b005faae84a Mon Sep 17 00:00:00 2001 From: Yuqing Yang Date: Wed, 30 Sep 2026 16:14:56 +0000 Subject: [PATCH 08/30] refactor(settings): unify Agent and capability surfaces Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../components/Panels/ChatPanel/agentMenu.tsx | 2 +- .../Settings/SettingsModal.test.tsx | 173 +++++++--- .../src/components/Settings/SettingsModal.tsx | 89 +++-- .../agent-profiles/ExternalAgentsSettings.tsx | 2 +- ...est.tsx => AgentBehaviorSettings.test.tsx} | 76 +---- .../sections/AgentBehaviorSettings.tsx | 116 +++++++ .../ExternalAgentRuntimeSettings.test.tsx | 83 +++++ .../sections/ExternalAgentRuntimeSettings.tsx | 229 +++++++++++++ .../Settings/sections/GeneralSettings.tsx | 319 +----------------- apps/web/src/i18n/resources/en/common.json | 6 + apps/web/src/i18n/resources/zh-CN/common.json | 6 + apps/web/src/store/settingsUiStore.ts | 2 +- docs/architecture/agent-architecture.md | 4 +- docs/architecture/agent-profiles.md | 6 +- docs/architecture/credential-storage.md | 2 +- docs/architecture/web-architecture.md | 8 +- 16 files changed, 642 insertions(+), 481 deletions(-) rename apps/web/src/components/Settings/sections/{GeneralSettings.test.tsx => AgentBehaviorSettings.test.tsx} (58%) create mode 100644 apps/web/src/components/Settings/sections/AgentBehaviorSettings.tsx create mode 100644 apps/web/src/components/Settings/sections/ExternalAgentRuntimeSettings.test.tsx create mode 100644 apps/web/src/components/Settings/sections/ExternalAgentRuntimeSettings.tsx diff --git a/apps/web/src/components/Panels/ChatPanel/agentMenu.tsx b/apps/web/src/components/Panels/ChatPanel/agentMenu.tsx index 03a120284..f21428391 100644 --- a/apps/web/src/components/Panels/ChatPanel/agentMenu.tsx +++ b/apps/web/src/components/Panels/ChatPanel/agentMenu.tsx @@ -234,5 +234,5 @@ export function useAddAgentEditor( _onRefreshProfiles?: () => void | Promise, ): { openEditor: () => void; editor: ReactNode } { const openSettings = useSettingsUiStore((s) => s.open); - return { openEditor: () => openSettings('agents'), editor: null }; + return { openEditor: () => openSettings('agent'), editor: null }; } diff --git a/apps/web/src/components/Settings/SettingsModal.test.tsx b/apps/web/src/components/Settings/SettingsModal.test.tsx index a64c92e48..8664aa27c 100644 --- a/apps/web/src/components/Settings/SettingsModal.test.tsx +++ b/apps/web/src/components/Settings/SettingsModal.test.tsx @@ -9,13 +9,15 @@ import { SettingsModal } from './SettingsModal'; import type { Root } from 'react-dom/client'; +type RequestedTab = 'builtIn' | 'capabilities' | null; + const mocks = vi.hoisted(() => ({ init: vi.fn(), load: vi.fn(), clear: vi.fn(), llmInit: vi.fn(), profileId: 'external', - requestedTab: null as 'builtIn' | null, + requestedTab: null as RequestedTab, })); vi.mock('react-i18next', () => ({ @@ -48,7 +50,7 @@ vi.mock('@/store/deploymentReadinessStore', () => ({ vi.mock('@/store/settingsUiStore', () => ({ useSettingsUiStore: ( selector: (state: { - requestedTab: 'builtIn' | null; + requestedTab: RequestedTab; clearRequestedTab: typeof mocks.clear; }) => unknown, ) => @@ -66,17 +68,26 @@ vi.mock('./agent-profiles/ExternalAgentsSettings', () => ({ vi.mock('./DeploymentReadinessNotice', () => ({ DeploymentReadinessNotice: () => null, })); +vi.mock('./sections/AgentBehaviorSettings', () => ({ + AgentBehaviorSettings: () =>
, +})); +vi.mock('./sections/ExternalAgentRuntimeSettings', () => ({ + ExternalAgentRuntimeSettings: () =>
, +})); vi.mock('./sections/GeneralSettings', () => ({ - GeneralSettings: () => null, + GeneralSettings: () =>
, })); vi.mock('./sections/LLMSettings', () => ({ - LLMSettings: () =>
, + LLMSettings: () =>
, })); vi.mock('./sections/ImageProviderSettings', () => ({ - ImageProviderSettings: () => null, + ImageProviderSettings: () =>
, })); vi.mock('./sections/IntegrationsSettings', () => ({ - IntegrationsSettings: () => null, + IntegrationsSettings: () =>
, +})); +vi.mock('./sections/InkOcrSettings', () => ({ + InkOcrSettings: () =>
, })); globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -91,83 +102,137 @@ beforeEach(() => { document.body.appendChild(container); root = createRoot(container); }); + afterEach(() => { act(() => root.unmount()); container.remove(); }); -describe('Settings default Agent placement', () => { - it('hides Pi provider settings for external defaults while retaining Profile management', async () => { - await act(async () => { - root.render(); - }); +async function renderModal(isOpen = true) { + await act(async () => { + root.render(); + }); +} + +function findTab(label: string): HTMLButtonElement { + const tab = [...container.querySelectorAll('nav button')].find( + (button) => button.textContent === label, + ); + expect(tab).toBeDefined(); + return tab as HTMLButtonElement; +} + +describe('Settings information architecture', () => { + it('co-locates Agent defaults, Profiles, behavior, and runtime settings', async () => { + await renderModal(); + expect( - container.querySelectorAll('[data-testid="agent-defaults"]'), - ).toHaveLength(1); - expect(container.querySelector('[data-testid="legacy-llm"]')).toBeNull(); - expect(mocks.llmInit).not.toHaveBeenCalled(); + container.querySelector('[data-testid="agent-defaults"]'), + ).not.toBeNull(); expect( container.querySelector('[data-testid="profile-management"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="agent-behavior"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="agent-runtime"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="built-in-settings"]'), ).toBeNull(); + expect(mocks.init).toHaveBeenCalled(); + expect(mocks.llmInit).not.toHaveBeenCalled(); + }); + + it('keeps Huabu-owned capabilities separate from Agent configuration', async () => { + await renderModal(); - const tabs = [...container.querySelectorAll('nav button')]; - const externalTab = tabs.find( - (tab) => tab.textContent === 'settings.externalAgents', - ) as HTMLButtonElement; - await act(async () => externalTab.click()); + await act(async () => { + findTab('settings.capabilities').click(); + }); + + expect(container.textContent).toContain('settings.capabilitiesDescription'); + expect( + container.querySelector('[data-testid="image-settings"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="integration-settings"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="ocr-settings"]'), + ).not.toBeNull(); expect( container.querySelector('[data-testid="agent-defaults"]'), ).toBeNull(); expect( container.querySelector('[data-testid="profile-management"]'), + ).toBeNull(); + expect(mocks.llmInit).not.toHaveBeenCalled(); + }); + + it('leaves only non-Agent preferences in General', async () => { + await renderModal(); + + await act(async () => { + findTab('settings.general').click(); + }); + + expect( + container.querySelector('[data-testid="general-settings"]'), ).not.toBeNull(); + expect( + container.querySelector('[data-testid="agent-behavior"]'), + ).toBeNull(); + expect(container.querySelector('[data-testid="agent-runtime"]')).toBeNull(); + expect(container.querySelector('[data-testid="ocr-settings"]')).toBeNull(); + }); +}); + +describe('Built-In Pi placement', () => { + it('shows Built-In settings with the unified Agent surface when selected by default', async () => { + mocks.profileId = 'huabu'; + await renderModal(); - const huabuTab = tabs.find( - (tab) => tab.textContent === 'settings.huabuAgent', - ) as HTMLButtonElement; - await act(async () => huabuTab.click()); expect( - container.querySelectorAll('[data-testid="agent-defaults"]'), - ).toHaveLength(1); - expect(container.querySelector('[data-testid="legacy-llm"]')).toBeNull(); + container.querySelector('[data-testid="built-in-settings"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="agent-defaults"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="profile-management"]'), + ).not.toBeNull(); + expect(mocks.llmInit).toHaveBeenCalled(); }); - it('hides Pi settings again after closing an explicit repair visit', async () => { + it('preserves the focused Built-In repair visit without changing the default', async () => { mocks.requestedTab = 'builtIn'; - await act(async () => - root.render(), - ); + await renderModal(); + expect( - container.querySelector('[data-testid="legacy-llm"]'), + container.querySelector('[data-testid="built-in-settings"]'), ).not.toBeNull(); expect( container.querySelector('[data-testid="agent-defaults"]'), ).toBeNull(); + expect( + container.querySelector('[data-testid="profile-management"]'), + ).toBeNull(); + expect(mocks.init).not.toHaveBeenCalled(); + mocks.requestedTab = null; - await act(async () => - root.render(), - ); - await act(async () => - root.render(), - ); - expect(container.querySelector('[data-testid="legacy-llm"]')).toBeNull(); + await renderModal(false); + await renderModal(); + + expect( + container.querySelector('[data-testid="built-in-settings"]'), + ).toBeNull(); expect( container.querySelector('[data-testid="agent-defaults"]'), ).not.toBeNull(); + expect( + container.querySelector('[data-testid="profile-management"]'), + ).not.toBeNull(); }); - - it.each(['default', 'thread repair'])( - 'shows Built-In providers for %s without changing the default', - async (source) => { - if (source === 'default') mocks.profileId = 'huabu'; - else mocks.requestedTab = 'builtIn'; - await act(async () => - root.render(), - ); - expect( - container.querySelector('[data-testid="legacy-llm"]'), - ).not.toBeNull(); - expect(mocks.llmInit).toHaveBeenCalled(); - }, - ); }); diff --git a/apps/web/src/components/Settings/SettingsModal.tsx b/apps/web/src/components/Settings/SettingsModal.tsx index 78548673a..52109a55a 100644 --- a/apps/web/src/components/Settings/SettingsModal.tsx +++ b/apps/web/src/components/Settings/SettingsModal.tsx @@ -22,6 +22,8 @@ import { type ExternalAgentsNavigation, } from './agent-profiles/ExternalAgentsSettings'; import { DeploymentReadinessNotice } from './DeploymentReadinessNotice'; +import { AgentBehaviorSettings } from './sections/AgentBehaviorSettings'; +import { ExternalAgentRuntimeSettings } from './sections/ExternalAgentRuntimeSettings'; import { GeneralSettings } from './sections/GeneralSettings'; import { ImageProviderSettings } from './sections/ImageProviderSettings'; import { InkOcrSettings } from './sections/InkOcrSettings'; @@ -34,15 +36,12 @@ type SettingsTab = SettingsTabId; interface TabDef { id: SettingsTab; /** i18n key for the tab label. */ - labelKey: - | 'settings.general' - | 'settings.huabuAgent' - | 'settings.externalAgents'; + labelKey: 'settings.general' | 'settings.agent' | 'settings.capabilities'; } const TABS: TabDef[] = [ - { id: 'huabuAgent', labelKey: 'settings.huabuAgent' }, - { id: 'agents', labelKey: 'settings.externalAgents' }, + { id: 'agent', labelKey: 'settings.agent' }, + { id: 'capabilities', labelKey: 'settings.capabilities' }, { id: 'general', labelKey: 'settings.general' }, ]; @@ -57,10 +56,9 @@ interface SettingsModalProps { * pane, so the panel height stays fixed as more settings are added. * * Each tab renders the existing self-contained `*Settings` components: - * - **General** — language and canvas display preferences - * - **Huabu Agent** — global defaults, backend-specific models and optional capabilities - * (image generation, web search, YouTube transcripts) - * - **External Agents** — ACP profile management + * - **Agent** — global defaults, Built-In Pi setup, external Profiles, and behavior + * - **Capabilities** — Huabu-owned image, search, transcript, and OCR services + * - **General** — application, canvas, input, and update preferences * * The app version sits at the bottom of the left tab rail (a product-wide * fact, decoupled from any single tab). @@ -80,6 +78,7 @@ export const SettingsModal: React.FC = ({ const clearRequestedTab = useSettingsUiStore((s) => s.clearRequestedTab); const [activeTab, setActiveTab] = useState(TABS[0].id); const showBuiltIn = activeTab === 'builtIn' || defaultProfileId === 'huabu'; + const isAgentTab = activeTab === 'agent' || activeTab === 'builtIn'; const [externalAgentsNavigation, setExternalAgentsNavigation] = useState(null); const titleId = useId(); @@ -110,7 +109,7 @@ export const SettingsModal: React.FC = ({ }, [isOpen, requestedTab, clearRequestedTab]); useEffect(() => { - if (!isOpen && activeTab === 'builtIn') setActiveTab('huabuAgent'); + if (!isOpen && activeTab === 'builtIn') setActiveTab('agent'); }, [isOpen, activeTab]); useEffect(() => { @@ -121,10 +120,15 @@ export const SettingsModal: React.FC = ({ // Load each registry only when its owning tab is visible. useEffect(() => { if (!isOpen) return; - if ((activeTab === 'huabuAgent' || activeTab === 'builtIn') && showBuiltIn) + const targetTab = requestedTab ?? activeTab; + if (targetTab === 'agent') void acpInit(); + if ( + targetTab === 'builtIn' || + (targetTab === 'agent' && defaultProfileId === 'huabu') + ) { void llmInit(); - if (activeTab === 'agents') void acpInit(); - }, [isOpen, activeTab, showBuiltIn, llmInit, acpInit]); + } + }, [isOpen, requestedTab, activeTab, defaultProfileId, llmInit, acpInit]); // Close on Escape. useEffect(() => { @@ -152,7 +156,7 @@ export const SettingsModal: React.FC = ({ const activeLabelKey = TABS.find((tab) => tab.id === activeTab)?.labelKey ?? 'settings.general'; const contentTitle = - activeTab === 'agents' && externalAgentsNavigation + activeTab === 'agent' && externalAgentsNavigation ? externalAgentsNavigation.title : activeTab === 'builtIn' ? t('settings.builtInPi') @@ -199,8 +203,7 @@ export const SettingsModal: React.FC = ({ {TABS.map(({ id, labelKey }) => { const active = - id === activeTab || - (id === 'huabuAgent' && activeTab === 'builtIn'); + id === activeTab || (id === 'agent' && activeTab === 'builtIn'); return (
diff --git a/apps/web/src/components/Settings/agent-profiles/ExternalAgentsSettings.tsx b/apps/web/src/components/Settings/agent-profiles/ExternalAgentsSettings.tsx index 1f1176a45..92f7ca0af 100644 --- a/apps/web/src/components/Settings/agent-profiles/ExternalAgentsSettings.tsx +++ b/apps/web/src/components/Settings/agent-profiles/ExternalAgentsSettings.tsx @@ -290,7 +290,7 @@ export function ExternalAgentsSettings({
) : (
- + {loading ? ( diff --git a/apps/web/src/components/Settings/sections/GeneralSettings.test.tsx b/apps/web/src/components/Settings/sections/AgentBehaviorSettings.test.tsx similarity index 58% rename from apps/web/src/components/Settings/sections/GeneralSettings.test.tsx rename to apps/web/src/components/Settings/sections/AgentBehaviorSettings.test.tsx index 5e4027d3e..1a22017a0 100644 --- a/apps/web/src/components/Settings/sections/GeneralSettings.test.tsx +++ b/apps/web/src/components/Settings/sections/AgentBehaviorSettings.test.tsx @@ -5,38 +5,16 @@ import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const { - getAgentChangeReviewConfig, - updateAgentChangeReviewConfig, - toast, - t, - i18n, -} = vi.hoisted(() => ({ - getAgentChangeReviewConfig: vi.fn(), - updateAgentChangeReviewConfig: vi.fn(), - toast: vi.fn(), - t: (key: string) => key, - i18n: { - language: 'en', - resolvedLanguage: 'en', - changeLanguage: vi.fn(), - }, -})); +const { getAgentChangeReviewConfig, updateAgentChangeReviewConfig, toast, t } = + vi.hoisted(() => ({ + getAgentChangeReviewConfig: vi.fn(), + updateAgentChangeReviewConfig: vi.fn(), + toast: vi.fn(), + t: (key: string) => key, + })); vi.mock('react-i18next', () => ({ - useTranslation: () => ({ t, i18n }), -})); - -vi.mock('@/i18n', () => ({ - supportedLngs: ['en', 'zh-CN'], -})); - -vi.mock('@/api/acp', () => ({ - getExternalAgentRuntimeConfig: vi.fn(async () => ({ - idleTimeoutSecs: 600, - maxAgents: 10, - })), - updateExternalAgentRuntimeConfig: vi.fn(), + useTranslation: () => ({ t }), })); vi.mock('@/api/agentChangeReview', () => ({ @@ -45,33 +23,6 @@ vi.mock('@/api/agentChangeReview', () => ({ })); vi.mock('@/components/Common/Toast', () => ({ toast })); -vi.mock('@/components/Settings/CanaryRedeploySettings', () => ({ - CanaryRedeploySettings: () => null, -})); -vi.mock('@/hooks/useAppUpdate', () => ({ - canCheckForUpdates: () => false, - useAppUpdate: () => ({ - status: { state: 'idle' }, - check: vi.fn(), - }), -})); -vi.mock('@/hooks/useElectron', () => ({ - getElectronBridge: () => undefined, -})); -vi.mock('@/hooks/useInputMode', () => ({ - useEffectiveInputMode: () => 'mouse', -})); -vi.mock('@/store/canvasStore', () => ({ - default: (selector: (state: object) => unknown) => - selector({ minimapEnabled: true, toggleMinimap: vi.fn() }), -})); -vi.mock('@/store/toolStore', () => ({ - useToolStore: (selector: (state: object) => unknown) => - selector({ - inputModePreference: 'auto', - setInputModePreference: vi.fn(), - }), -})); vi.mock('@/store/chatPreferencesStore', () => ({ MAX_RECENT_CHAT_TURNS: 20, MIN_RECENT_CHAT_TURNS: 1, @@ -81,12 +32,7 @@ vi.mock('@/store/chatPreferencesStore', () => ({ setRecentTurnCount: vi.fn(), }), })); -vi.mock('@/store/workspaceStore', () => ({ - useWorkspaceStore: (selector: (state: object) => unknown) => - selector({ worldEnabled: true, setWorldEnabled: vi.fn() }), -})); - -import { GeneralSettings } from './GeneralSettings'; +import { AgentBehaviorSettings } from './AgentBehaviorSettings'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -113,7 +59,7 @@ afterEach(() => { async function renderSettings(): Promise { await act(async () => { - root.render(); + root.render(); }); const toggle = container.querySelector( '[role="switch"][aria-label="settings.autoAcceptAgentChanges"]', @@ -122,7 +68,7 @@ async function renderSettings(): Promise { return toggle as HTMLButtonElement; } -describe('GeneralSettings Agent change review preference', () => { +describe('AgentBehaviorSettings', () => { it('loads the server value and persists a toggle', async () => { const toggle = await renderSettings(); expect(toggle.getAttribute('aria-checked')).toBe('false'); diff --git a/apps/web/src/components/Settings/sections/AgentBehaviorSettings.tsx b/apps/web/src/components/Settings/sections/AgentBehaviorSettings.tsx new file mode 100644 index 000000000..32f250544 --- /dev/null +++ b/apps/web/src/components/Settings/sections/AgentBehaviorSettings.tsx @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + getAgentChangeReviewConfig, + updateAgentChangeReviewConfig, +} from '@/api/agentChangeReview'; +import { Select } from '@/components/Common/Select'; +import { toast } from '@/components/Common/Toast'; +import { Toggle } from '@/components/Common/Toggle'; +import { SettingRow } from '@/components/Settings/Common/SettingRow'; +import { + MAX_RECENT_CHAT_TURNS, + MIN_RECENT_CHAT_TURNS, + useChatPreferencesStore, +} from '@/store/chatPreferencesStore'; + +const RECENT_TURN_OPTIONS = Array.from( + { length: MAX_RECENT_CHAT_TURNS - MIN_RECENT_CHAT_TURNS + 1 }, + (_, index) => { + const value = String(index + MIN_RECENT_CHAT_TURNS); + return { value, label: value }; + }, +); + +export function AgentBehaviorSettings() { + const { t } = useTranslation(); + const recentTurnCount = useChatPreferencesStore( + (state) => state.recentTurnCount, + ); + const setRecentTurnCount = useChatPreferencesStore( + (state) => state.setRecentTurnCount, + ); + const [autoAcceptSpaceChanges, setAutoAcceptSpaceChanges] = useState(false); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + useEffect(() => { + let active = true; + void getAgentChangeReviewConfig() + .then((config) => { + if (active) setAutoAcceptSpaceChanges(config.autoAcceptSpaceChanges); + }) + .catch((error) => { + if (!active) return; + toast( + error instanceof Error + ? error.message + : t('settings.autoAcceptAgentChangesLoadFailed'), + { tone: 'danger' }, + ); + }) + .finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + }; + }, [t]); + + const saveAutoAccept = useCallback( + async (enabled: boolean) => { + const previous = autoAcceptSpaceChanges; + setAutoAcceptSpaceChanges(enabled); + setSaving(true); + try { + const saved = await updateAgentChangeReviewConfig({ + autoAcceptSpaceChanges: enabled, + }); + setAutoAcceptSpaceChanges(saved.autoAcceptSpaceChanges); + } catch (error) { + setAutoAcceptSpaceChanges(previous); + toast( + error instanceof Error + ? error.message + : t('settings.autoAcceptAgentChangesSaveFailed'), + { tone: 'danger' }, + ); + } finally { + setSaving(false); + } + }, + [autoAcceptSpaceChanges, t], + ); + + return ( + <> + + void saveAutoAccept(enabled)} + disabled={loading || saving} + label={t('settings.autoAcceptAgentChanges')} + /> + + + + {idleTimeoutSelection === 'custom' ? ( + <> + setCustomMinutes(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' && customMinutesValid) { + void saveIdleTimeout(parsedCustomMinutes * 60); + } + }} + aria-label={t('settings.customIdleTimeoutMinutes')} + disabled={saving} + /> + + {t('settings.minutes')} + + + + ) : null} +
+ + +
+ setMaxAgentsInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') void saveMaxAgents(); + }} + aria-label={t('settings.externalAgentMaxAgents')} + disabled={loading || saving} + /> + +
+
+ + ); +} diff --git a/apps/web/src/components/Settings/sections/GeneralSettings.tsx b/apps/web/src/components/Settings/sections/GeneralSettings.tsx index e04b6af4e..daf0a7b64 100644 --- a/apps/web/src/components/Settings/sections/GeneralSettings.tsx +++ b/apps/web/src/components/Settings/sections/GeneralSettings.tsx @@ -1,21 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { - getExternalAgentRuntimeConfig, - updateExternalAgentRuntimeConfig, -} from '@/api/acp'; -import { - getAgentChangeReviewConfig, - updateAgentChangeReviewConfig, -} from '@/api/agentChangeReview'; import { Button } from '@/components/Common/Button'; -import { Input } from '@/components/Common/Input'; import { Select } from '@/components/Common/Select'; -import { toast } from '@/components/Common/Toast'; import { Toggle } from '@/components/Common/Toggle'; import { CanaryRedeploySettings } from '@/components/Settings/CanaryRedeploySettings'; import { SettingRow } from '@/components/Settings/Common/SettingRow'; @@ -24,11 +14,6 @@ import { getElectronBridge } from '@/hooks/useElectron'; import { useEffectiveInputMode } from '@/hooks/useInputMode'; import { supportedLngs, type SupportedLanguage } from '@/i18n'; import useCanvasStore from '@/store/canvasStore'; -import { - MAX_RECENT_CHAT_TURNS, - MIN_RECENT_CHAT_TURNS, - useChatPreferencesStore, -} from '@/store/chatPreferencesStore'; import { useToolStore, type InputModePreference } from '@/store/toolStore'; import { useWorkspaceStore } from '@/store/workspaceStore'; @@ -43,15 +28,6 @@ const LANGUAGE_OPTIONS = supportedLngs.map((lng) => ({ label: LANGUAGE_LABELS[lng], })); -const IDLE_TIMEOUT_PRESETS = new Set(['0', '300', '600', '1800', '3600']); -const RECENT_TURN_OPTIONS = Array.from( - { length: MAX_RECENT_CHAT_TURNS - MIN_RECENT_CHAT_TURNS + 1 }, - (_, index) => { - const value = String(index + MIN_RECENT_CHAT_TURNS); - return { value, label: value }; - }, -); - /** * General application settings. Language changes persist to `localStorage` * (`huabu.language`) via i18next's language detector cache, while the @@ -73,23 +49,7 @@ export const GeneralSettings: React.FC = () => { const setInputModePreference = useToolStore( (state) => state.setInputModePreference, ); - const recentTurnCount = useChatPreferencesStore( - (state) => state.recentTurnCount, - ); - const setRecentTurnCount = useChatPreferencesStore( - (state) => state.setRecentTurnCount, - ); const effectiveInputMode = useEffectiveInputMode(); - const [idleTimeoutSecs, setIdleTimeoutSecs] = useState(600); - const [maxAgents, setMaxAgents] = useState(10); - const [maxAgentsInput, setMaxAgentsInput] = useState('10'); - const [idleTimeoutSelection, setIdleTimeoutSelection] = useState('600'); - const [customMinutes, setCustomMinutes] = useState('10'); - const [idleTimeoutLoading, setIdleTimeoutLoading] = useState(true); - const [idleTimeoutSaving, setIdleTimeoutSaving] = useState(false); - const [autoAcceptSpaceChanges, setAutoAcceptSpaceChanges] = useState(false); - const [autoAcceptLoading, setAutoAcceptLoading] = useState(true); - const [autoAcceptSaving, setAutoAcceptSaving] = useState(false); const { status: updateStatus, check: checkForUpdates } = useAppUpdate(); const updaterAvailable = !!getElectronBridge()?.updater; @@ -102,170 +62,6 @@ export const GeneralSettings: React.FC = () => { [i18n], ); - useEffect(() => { - let active = true; - void getExternalAgentRuntimeConfig() - .then((config) => { - if (!active) return; - const value = String(config.idleTimeoutSecs); - setIdleTimeoutSecs(config.idleTimeoutSecs); - setMaxAgents(config.maxAgents); - setMaxAgentsInput(String(config.maxAgents)); - setIdleTimeoutSelection( - IDLE_TIMEOUT_PRESETS.has(value) ? value : 'custom', - ); - if (config.idleTimeoutSecs > 0) { - setCustomMinutes(String(config.idleTimeoutSecs / 60)); - } - }) - .catch((error) => { - if (!active) return; - toast( - error instanceof Error - ? error.message - : t('settings.externalAgentIdleTimeoutLoadFailed'), - { tone: 'danger' }, - ); - }) - .finally(() => { - if (active) setIdleTimeoutLoading(false); - }); - return () => { - active = false; - }; - }, [t]); - - useEffect(() => { - let active = true; - void getAgentChangeReviewConfig() - .then((config) => { - if (active) setAutoAcceptSpaceChanges(config.autoAcceptSpaceChanges); - }) - .catch((error) => { - if (!active) return; - toast( - error instanceof Error - ? error.message - : t('settings.autoAcceptAgentChangesLoadFailed'), - { tone: 'danger' }, - ); - }) - .finally(() => { - if (active) setAutoAcceptLoading(false); - }); - return () => { - active = false; - }; - }, [t]); - - const saveAutoAccept = useCallback( - async (enabled: boolean) => { - const previous = autoAcceptSpaceChanges; - setAutoAcceptSpaceChanges(enabled); - setAutoAcceptSaving(true); - try { - const saved = await updateAgentChangeReviewConfig({ - autoAcceptSpaceChanges: enabled, - }); - setAutoAcceptSpaceChanges(saved.autoAcceptSpaceChanges); - } catch (error) { - setAutoAcceptSpaceChanges(previous); - toast( - error instanceof Error - ? error.message - : t('settings.autoAcceptAgentChangesSaveFailed'), - { tone: 'danger' }, - ); - } finally { - setAutoAcceptSaving(false); - } - }, - [autoAcceptSpaceChanges, t], - ); - - const saveIdleTimeout = useCallback( - async (nextIdleTimeoutSecs: number) => { - setIdleTimeoutSaving(true); - try { - const saved = await updateExternalAgentRuntimeConfig({ - idleTimeoutSecs: nextIdleTimeoutSecs, - maxAgents, - }); - setIdleTimeoutSecs(saved.idleTimeoutSecs); - const value = String(saved.idleTimeoutSecs); - setIdleTimeoutSelection( - IDLE_TIMEOUT_PRESETS.has(value) ? value : 'custom', - ); - toast(t('settings.externalAgentIdleTimeoutSaved'), { - tone: 'success', - }); - } catch (error) { - const previous = String(idleTimeoutSecs); - setIdleTimeoutSelection( - IDLE_TIMEOUT_PRESETS.has(previous) ? previous : 'custom', - ); - toast( - error instanceof Error - ? error.message - : t('settings.externalAgentIdleTimeoutSaveFailed'), - { tone: 'danger' }, - ); - } finally { - setIdleTimeoutSaving(false); - } - }, - [idleTimeoutSecs, maxAgents, t], - ); - - const parsedMaxAgents = Number(maxAgentsInput); - const maxAgentsValid = - Number.isSafeInteger(parsedMaxAgents) && parsedMaxAgents >= 1; - - const saveMaxAgents = useCallback(async () => { - if (!maxAgentsValid) return; - setIdleTimeoutSaving(true); - try { - const saved = await updateExternalAgentRuntimeConfig({ - idleTimeoutSecs, - maxAgents: parsedMaxAgents, - }); - setMaxAgents(saved.maxAgents); - setMaxAgentsInput(String(saved.maxAgents)); - toast(t('settings.externalAgentMaxAgentsSaved'), { tone: 'success' }); - } catch (error) { - setMaxAgentsInput(String(maxAgents)); - toast( - error instanceof Error - ? error.message - : t('settings.externalAgentMaxAgentsSaveFailed'), - { tone: 'danger' }, - ); - } finally { - setIdleTimeoutSaving(false); - } - }, [ - idleTimeoutSecs, - maxAgents, - maxAgentsInput, - maxAgentsValid, - parsedMaxAgents, - t, - ]); - - const handleIdleTimeoutSelection = useCallback( - (value: string) => { - setIdleTimeoutSelection(value); - if (value !== 'custom') void saveIdleTimeout(Number(value)); - }, - [saveIdleTimeout], - ); - - const parsedCustomMinutes = Number(customMinutes); - const customMinutesValid = - Number.isInteger(parsedCustomMinutes) && - parsedCustomMinutes >= 1 && - parsedCustomMinutes <= 1440; - return ( <> { } /> - - void saveAutoAccept(enabled)} - disabled={autoAcceptLoading || autoAcceptSaving} - label={t('settings.autoAcceptAgentChanges')} - /> - {updaterAvailable && ( { ariaLabel={t('settings.inputMode')} /> - - - {idleTimeoutSelection === 'custom' && ( - <> - setCustomMinutes(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter' && customMinutesValid) { - void saveIdleTimeout(parsedCustomMinutes * 60); - } - }} - aria-label={t('settings.customIdleTimeoutMinutes')} - disabled={idleTimeoutSaving} - /> - - {t('settings.minutes')} - - - - )} -
- - -
- setMaxAgentsInput(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter') void saveMaxAgents(); - }} - aria-label={t('settings.externalAgentMaxAgents')} - disabled={idleTimeoutLoading || idleTimeoutSaving} - /> - -
-
); }; diff --git a/apps/web/src/i18n/resources/en/common.json b/apps/web/src/i18n/resources/en/common.json index 1199e9eb5..fef441007 100644 --- a/apps/web/src/i18n/resources/en/common.json +++ b/apps/web/src/i18n/resources/en/common.json @@ -133,6 +133,12 @@ "title": "Settings", "open": "Open settings", "general": "General", + "agent": "Agent", + "capabilities": "Capabilities", + "capabilitiesDescription": "Configure services managed by Huabu. Availability to an external Agent depends on that Agent's own capabilities.", + "agentBehavior": "Agent behavior", + "externalAgentRuntime": "External Agent runtime", + "agentProfiles": "Agent Profiles", "inkOcr": { "title": "Handwriting Recognition (Azure AI Vision)", "description": "Sends selected ink to Azure for OCR when you submit an Ink Query.", diff --git a/apps/web/src/i18n/resources/zh-CN/common.json b/apps/web/src/i18n/resources/zh-CN/common.json index ee205379a..284ddbf49 100644 --- a/apps/web/src/i18n/resources/zh-CN/common.json +++ b/apps/web/src/i18n/resources/zh-CN/common.json @@ -133,6 +133,12 @@ "title": "设置", "open": "打开设置", "general": "通用", + "agent": "Agent", + "capabilities": "能力", + "capabilitiesDescription": "配置由 Huabu 管理的服务。外部 Agent 能否使用这些服务,取决于该 Agent 自身支持的能力。", + "agentBehavior": "Agent 行为", + "externalAgentRuntime": "外部 Agent 运行时", + "agentProfiles": "Agent 配置", "inkOcr": { "title": "手写识别(Azure AI Vision)", "description": "提交 Ink Query 时,将所选笔画发送至 Azure 进行 OCR 识别。", diff --git a/apps/web/src/store/settingsUiStore.ts b/apps/web/src/store/settingsUiStore.ts index a8c3956bc..c032f77af 100644 --- a/apps/web/src/store/settingsUiStore.ts +++ b/apps/web/src/store/settingsUiStore.ts @@ -19,7 +19,7 @@ import { create } from 'zustand'; * open and then cleared so a later plain `open()` reopens on the last * tab the user was viewing rather than snapping back. */ -export type SettingsTabId = 'general' | 'huabuAgent' | 'agents' | 'builtIn'; +export type SettingsTabId = 'general' | 'agent' | 'capabilities' | 'builtIn'; interface SettingsUiState { isOpen: boolean; diff --git a/docs/architecture/agent-architecture.md b/docs/architecture/agent-architecture.md index 66dd812f5..fd57fbd66 100644 --- a/docs/architecture/agent-architecture.md +++ b/docs/architecture/agent-architecture.md @@ -149,7 +149,7 @@ The [shared title contracts](../../packages/shared/src/types/api/conversation-ti ## 6. External agents (ACP) -Functional text calls use [functional-text.ts](../../apps/server/src/modules/agent/functional-text.ts), not the interactive chat adapter. The runner selects the global backend once per invocation. Built-In tasks reuse role-aware `llmComplete`; they do not inject the saved external functional-model override. External tasks share [profile-snapshot.ts](../../apps/server/src/modules/agent/acp/profile-snapshot.ts) with the chat builder for frozen launch identity and recipe compilation. Each external task submits task-specific instructions and canonical input to an empty-thread Agenetes Job, consumes the result directly, and does not create a visible Node or write conversation history. The ACP driver assigns a unique private session identity to each Job handle; Deployments retain their existing thread-based reuse. Jobs currently share Deployment session-retention behavior: no automatic handle/client/process release is added. Completion/error handling and a five-minute cancellation deadline belong to the external caller; reliable Job resource reclamation is tracked separately in [#235](https://github.com/microsoft/Huabu/issues/235). +Functional text calls use [functional-text.ts](../../apps/server/src/modules/agent/functional-text.ts), not the interactive chat adapter. The runner selects the global backend once per invocation. Built-In tasks reuse role-aware `llmComplete`; they do not inject the saved external functional-model override. External tasks share [profile-snapshot.ts](../../apps/server/src/modules/agent/acp/profile-snapshot.ts) with the chat builder for frozen launch identity and recipe compilation. Each external task submits task-specific instructions and canonical input to an empty-thread Agenetes Job, consumes the result directly, and does not create a visible Node or write conversation history. The ACP driver assigns a unique private session identity to each Job handle; Deployments retain their existing thread-based reuse. Agenetes closes each one-shot Job handle in the run generator's `finally` and awaits exact Agentlet process reclamation across normal completion, failure, cancellation, and early iterator return. Cleanup failure remains observable rather than being converted into task success; the external caller still owns the five-minute cancellation deadline. [acp/](../../apps/server/src/modules/agent/acp) is the integration layer for external agents. Agentlet owns the single trusted harness catalogue and probes its own machine for GitHub Copilot, Claude Agent, Gemini, Codex, Qwen Code, Kimi Code CLI, OpenCode, Cursor, CodeBuddy, and Hermes Agent. Huabu automatically provisions ordinary persisted Profiles from discovery; the manual editor reads the same agentlet-backed catalogue and retains Custom command. New automatic structured Profiles default to auto-approval only when the wrapper explicitly reports support; users can disable it through Edit. Existing Profiles and realized executions are never rewritten by discovery, and compatibility command Profiles receive no injected approval flags. See [Agent Profiles](./agent-profiles.md) for ownership, default workspaces, deduplication and retirement of manifest execution. @@ -160,7 +160,7 @@ Functional text calls use [functional-text.ts](../../apps/server/src/modules/age - [`@agenetes/agent-profile`](../../external/agenetes/packages/agent-profile) owns ordinary Profile schemas, CRUD and persistence, without Team discovery, setup or Config dependencies. Profiles are editable templates with optimistic configuration revisions; cwd/launch edits additionally advance an execution revision. `buildAcpWorkloadSpec()` snapshots placement, wrapper launch, cwd, preferences, and execution revision at first realization. Existing persisted executions do not reread an edited template, including on restart. Every Profile maps to a wrapper: known harnesses compile capability-validated options, while Custom supports only user-authored raw commands. Retired manifest Profiles are not selectable or compiled into executable recipes. - [`@agenetes/acp-driver`](../../external/agenetes/packages/acp-driver) owns the canonical ACP spec/state schemas, session creation/resume, canonical-input flattening, ACP update translation, and durable state up-reporting. The static DriverMap binds `external` directly to this driver. Generic runtime-environment hooks remain available, but Huabu no longer injects manifest Configs or recovers Team recipes. Retired Team recipes are explicitly rejected rather than silently reinterpreted as ordinary commands. Live spawn and session caches are isolated by `(agentletId, threadId)`, and unavailable targets fail with `placement_unavailable`. Because ACP has no native system instruction channel, the driver prefixes joined `AgentSpec.initialPreamble` fragments to the first ordinary prompt. A first control causes the host to ensure the session from the canonical spec before calling `handle.control()`; it creates no Chat-V2 turn and does not consume the pending preamble. Session control state is deliberately split in two: the agent-reported surface (`currentModeId` / `currentModelId` / `configOptions[].currentValue`) and `selections`, a map of explicit per-thread user choices keyed by config-option id (`mode`, `model`, and agent-defined ids such as `allow_all`). Only a successful `set_mode` / `set_model` / `set_config_option` writes `selections`; agent pushes never do, because agents such as Copilot CLI implement config options as process-global user settings and broadcast one value to every live session, making the agent-reported value answer "what was picked last, anywhere" rather than "what was picked for this thread". `selections` travels with the rest of `AgentMetadata` and is the authoritative per-thread intent. On resume it is restored unconditionally and replayed onto the agent knob by knob before prompts or user controls proceed. A rejected knob is forgotten only when the agent definitively refuses it, so a retired model id cannot wedge the thread while a transport failure cannot destroy durable intent. - [`buildAcpSessionSelectors`](../../packages/shared/src/utils/acp-session-selectors.ts) is the canonical read projection for ACP mode, model, and config-option controls. It prefers a modern config-option twin over the legacy channel and deduplicates each flattened select catalogue by exact control value while preserving first occurrence order and metadata; equal labels with different values remain distinct. The same projection serves live thread metadata, persisted thread metadata, and Profile capability observations, so upstream duplicate entries cannot diverge across pre-prompt and active-session UI. -- External-agent idle suspension is host policy: General Settings persists `idleTimeoutSecs` (10 minutes by default, `0` disables suspension), and Huabu injects the current value when a new or resumed ACP process is spawned. Agentlet never suspends a session while a host JSON-RPC request remains in flight; transport teardown closes the ACP client so pending prompts reject and clean up immediately. The long-lived `AcpAgentHandle` self-repairs a suspended lower-level session lazily on the next turn. Direct driver controls still require a live session, so Huabu's control route first ensures or resumes that session from the canonical persisted spec and then calls `handle.control()`. +- External-agent idle suspension is host policy: Agent Settings persists `idleTimeoutSecs` (10 minutes by default, `0` disables suspension), and Huabu injects the current value when a new or resumed ACP process is spawned. The same External Agent runtime section exposes the supervised daemon's persisted `maxAgents` limit without changing its restart-only application contract. Agentlet never suspends a session while a host JSON-RPC request remains in flight; transport teardown closes the ACP client so pending prompts reject and clean up immediately. The long-lived `AcpAgentHandle` self-repairs a suspended lower-level session lazily on the next turn. Direct driver controls still require a live session, so Huabu's control route first ensures or resumes that session from the canonical persisted spec and then calls `handle.control()`. - ACP has no native seam for injecting prior assistant messages, so when native resume is unavailable the driver replays history as one prepended text block. It first projects every durable turn through `projectTextHistoryTurn` (`@agenetes/runtime`), which replaces image bodies with a short placeholder — a base64 payload carries no meaning once flattened into text, and inlining it would only inflate the payload. The _projected_ turns are what gets authorized, so the admission estimate prices the block that is actually sent. - Opening Chat and opening the slash menu read only `GET /api/acp/threads/:threadId/cached-meta`. The response projects cached slash commands and selector catalogues from a live or persisted realized thread first, then from `profile-schema-cache`, and finally returns a successful empty observation. These reads never call `agenetes.create()`, spawn ACP, or create a WorkloadSpec. Profile-level mode/model values may be displayed as last observed; generic config-option values render without a selected value until the current thread reports them or records a successful explicit choice. Live metadata continues updating its thread, but Profile cache warm-starts and writes require the execution's frozen revision to match the current template; runtime-relevant Profile edits invalidate the cache. Huabu remembers successful explicit model and `thought_level` choices in a known-harness Profile's host-owned `customData` only at that matching revision. Custom wrapper model choices, modes, permission controls, booleans, and unknown config options remain thread-only. Live ACP controls are independent of generic wrapper configuration capabilities. A Profile that has never opened a session anywhere on this server (`source: 'none'`, no live entry, no per-thread record, no per-profile cache) has no schema to render at all — ACP only ever discloses its mode/model/config-option catalogue in the `session/new` / `session/load` response, so there is no no-spawn way to learn it. `AcpSessionSelectors` renders an explicit, user-opt-in placeholder pill for this case (`onWarm`), which POSTs `/api/acp/threads/:threadId/warm` to realize the workload and open a session with no accompanying `set_*` control, purely to seed the caches; the row never spawns a session on its own. Some agents disclose only part of their catalogue inline in the `session/new` response and push the rest a moment later via a trailing `session/update`; a real message turn has a live SSE stream open long enough to catch that straggler, but a warm-up has none, so `/warm` waits for the freshly opened entry's disclosed schema to go quiet (bounded, ~2s worst case) before responding, closing the race rather than returning a partially-populated row. - Which knob is rendered, and which value it shows, is decided exactly once by `buildAcpSessionSelectors` in [`@huabu/shared`](../../packages/shared/src/utils/acp-session-selectors.ts). It projects a session-meta snapshot into a flat list of selector descriptors, each carrying the channel a change must be routed back through (`mode` / `model` / `config-option`) and whether the shown value came from this thread's `selections` or from the agent's own report. Modern `configOptions` win over the legacy `availableModes` / `availableModels` lists — some agents publish both, and the legacy model list flattens every base model × reasoning effort — but the legacy lists are normalised into the same descriptor shape rather than dropped, because agents that publish no config options at all still depend on them. A recorded selection is ignored when it no longer fits the knob (wrong primitive type, or a value the agent no longer offers) so a retired model id cannot render an empty pill. Chat reads that list and nothing else. Selecting the `agent-full-access` value of a mode selector opens a compact confirmation popover above and left-aligned with ChatInput before Huabu sends the change; cancelling leaves the active mode unchanged without blocking the canvas behind a full-screen modal. diff --git a/docs/architecture/agent-profiles.md b/docs/architecture/agent-profiles.md index 79262170d..8cdc94655 100644 --- a/docs/architecture/agent-profiles.md +++ b/docs/architecture/agent-profiles.md @@ -73,7 +73,7 @@ Owner-only `GET /api/agent/defaults` and `PUT /api/agent/defaults` expose `{ pro When no record exists, provisioning chooses a stable ordered external Profile on a connected Agentlet and saves its identity. With no candidate, setup guides the user to connect an external Agent or explicitly choose Built-In Pi. Existing records, including null/deleted/offline selections, remain authoritative. Old provider credentials do not implicitly enable Pi, and switching backends does not clear credentials or models. Reads do not discover agents, initialize defaults, or create sessions. Deleting or disconnecting the selected Profile does not choose a replacement. -Settings > Huabu Agent offers Built-In Pi alongside external Profiles. The external functional-model field is hidden for Built-In; its saved value survives switching. Built-In provider, login, Chat and Utility model controls load and render only for a Built-In global default or an explicit configuration visit from a Built-In conversation. The conversation's configuration button remains available after binding is locked and does not change the global default. External Agents remains the Profile-management surface. Independent image-generation configuration loads without Pi provider/model discovery; OCR and other integrations keep their own settings. +Settings > Agent is the single conversational-Agent surface. It presents the global default, Built-In Pi and external Profile identities, ordinary external Profile management, Agent behavior, and external-Agent runtime controls without changing their separate persistence owners. The external functional-model field is hidden for Built-In; its saved value survives switching. Built-In provider, login, Chat and Utility model controls load and render only for a Built-In global default or an explicit configuration visit from a Built-In conversation. The conversation's configuration button remains available after binding is locked and does not change the global default. Settings > Capabilities separately owns Huabu-managed image generation, OCR, Web Search, and YouTube transcript configuration; these services do not imply that an external Agent can invoke the corresponding Huabu tools. Huabu-owned auxiliary tasks outside an Agent Node's bound thread follow the global default, including summaries, keywords, content/Frame titles, conversation titles, and image labels. The source conversation's backend does not determine its automatic title backend. Each invocation selects its backend once; changing defaults does not reroute in-flight work. Built-In tasks reuse `llmComplete` with the existing `contentMeta`, `frameLabel`, and `imageLabel` roles, preserving Utility/Chat model resolution and vision handling. External tasks use independent ACP Jobs. Thread-owned work and its tools retain the thread binding; explicit internal Skill authoring remains available. Legacy background Memory starts only for a Built-In global default; external Memory and unified Skill authoring remain separate follow-ups. @@ -95,9 +95,9 @@ Ink submission without an existing Question target also loads the canonical defa Owner-only `POST /api/acp/profile-launch-preview` accepts `{ launch, profileId? }`, selects the saved Profile's machine when editing, and returns the daemon's validated `exec`/`shell` plan. It never spawns an Agent or prepares a workspace. Unsupported/offline daemon previews fail explicitly. Profile creation and runtime-relevant patches independently validate structured launch support and options, so client-side controls are not the validation boundary. `PATCH /api/acp/profiles/:id` requires `expectedRevision` and permits mutable template fields only; display-only edits do not require a connected daemon. -Settings presents ordinary Profiles, their existing edit/delete actions, and the agentlet health banner. Template/member Config/setup controls are removed. Catalogue and Profile endpoints remain owner-only. Shared HTTP contracts remain under `packages/shared/src/types/api/`, with type-only imports in the Web app. +The Agent Settings surface presents ordinary Profiles, their existing edit/delete actions, and the agentlet health banner alongside the global default and backend-specific configuration. Opening a Profile editor temporarily focuses that nested view without duplicating or rewriting Profile state. Template/member Config/setup controls are removed. Catalogue and Profile endpoints remain owner-only. Shared HTTP contracts remain under `packages/shared/src/types/api/`, with type-only imports in the Web app. -Owner-only `GET/PUT /api/acp/runtime-config` persists the external-agent idle timeout and supervised-daemon process limit in `/external-agent-runtime-config.json`. `maxAgents` defaults to `10` and accepts any positive JavaScript safe integer without a product-defined maximum. Huabu passes it as `--max-agents` whenever the supervised daemon starts; saving does not hot-update or automatically restart the daemon, and manually launched remote daemons continue to use their own CLI argument without a configuration handshake. +Owner-only `GET/PUT /api/acp/runtime-config` persists the external-agent idle timeout and supervised-daemon process limit in `/external-agent-runtime-config.json`; both controls appear under Settings > Agent > External Agent runtime. `maxAgents` defaults to `10` and accepts any positive JavaScript safe integer without a product-defined maximum. Huabu passes it as `--max-agents` whenever the supervised daemon starts; saving does not hot-update or automatically restart the daemon, and manually launched remote daemons continue to use their own CLI argument without a configuration handshake. ## Code entry points diff --git a/docs/architecture/credential-storage.md b/docs/architecture/credential-storage.md index 0f92ecd8a..32d02463b 100644 --- a/docs/architecture/credential-storage.md +++ b/docs/architecture/credential-storage.md @@ -33,7 +33,7 @@ Settings API updates for optional capability credentials use an explicit three-s ### Azure AI Vision handwriting OCR -Settings > General exposes optional handwriting recognition as a compact Azure AI Vision row, matching the key icon and Set API Key / Update Key interaction used by other optional capabilities. One click opens visibly labeled Endpoint and API Key inputs in spaced, full-width field groups below the title and description, followed by Save and Cancel, without configuration-source paragraphs or instructional text. The row identifies Azure AI Vision and briefly discloses selected-stroke processing; the endpoint and key must belong to the same Azure resource. Errors and read-only restrictions remain explicit. There is no provider selector, connectivity probe, or generic OCR compatibility claim. +Settings > Capabilities exposes optional handwriting recognition as a compact Azure AI Vision row alongside other Huabu-managed service capabilities, matching the key icon and Set API Key / Update Key interaction used by those services. One click opens visibly labeled Endpoint and API Key inputs in spaced, full-width field groups below the title and description, followed by Save and Cancel, without configuration-source paragraphs or instructional text. The row identifies Azure AI Vision and briefly discloses selected-stroke processing; the endpoint and key must belong to the same Azure resource. Errors and read-only restrictions remain explicit. There is no provider selector, connectivity probe, or generic OCR compatibility claim. Owner-only `GET` and `PUT /api/integrations/ink-ocr/config` use the shared OCR configuration schemas. Endpoint and API key overrides are stored together as one versioned JSON record under `integration:azure-vision:config` in the existing `SecretStore`. Serialized read-modify-write updates replace that single encrypted record, so recognition observes a complete old or new configuration rather than a partially written pair. This uses the existing standalone encrypted-file and Electron safeStorage backends; it does not depend on multi-secret desktop batching or a rollback across two files. diff --git a/docs/architecture/web-architecture.md b/docs/architecture/web-architecture.md index 958f0c81d..5e9784e03 100644 --- a/docs/architecture/web-architecture.md +++ b/docs/architecture/web-architecture.md @@ -184,9 +184,11 @@ The page orders Frame styling, Note styling, the current zoom-readability compar Space Shortcut retains the `spacePreview` node type and renders the canonical icon, current target title, and a muted node-count/update-time summary using shared node chrome. It reads shared workspace metadata, never a target scene or nested React Flow, and supports bounded automatic width and minimum-only custom width with content-owned height. See [space-preview.md](./space-preview.md). -### External Agent Settings +### Settings information architecture -External Agents Settings uses `components/Settings/agent-profiles/` and the canonical `acpProfilesStore`. It has ordinary command Profile creation/editing/deletion and no Agent Team templates, Configs or setup lifecycle. The manual editor reads the agentlet-backed catalogue; automatic defaults arrive through the same persisted Profile list used by selectors. See [Agent Profiles](./agent-profiles.md). +The tabbed Settings modal has three product-owned surfaces. **Agent** combines global Agent defaults, conditional Built-In Pi provider/model setup, ordinary external Profile management, Agent behavior, and external-Agent runtime controls while preserving each component's existing store and API ownership. **Capabilities** contains Huabu-managed image generation, Web Search, YouTube transcript, and Ink OCR configuration; its copy does not imply external-Agent tool parity. **General** contains application, Canvas, input, update, and deployment preferences that do not configure an Agent or service capability. The hidden Built-In repair deep link remains focused on Pi provider/model setup and returns to the Agent surface after closing. + +Agent Profile management uses `components/Settings/agent-profiles/` and the canonical `acpProfilesStore`. It has ordinary command Profile creation/editing/deletion and no Agent Team templates, Configs or setup lifecycle. The manual editor reads the agentlet-backed catalogue; automatic defaults arrive through the same persisted Profile list used by selectors. Opening an editor focuses the nested Agent view while retaining the existing Profile revision and save contracts. See [Agent Profiles](./agent-profiles.md). ### Toast duration contract @@ -394,7 +396,7 @@ The handbook is owned, built, and deployed from the public [microsoft/Huabu repo Network deployment follows the single-owner boundary in [`deployment-security.md`](./deployment-security.md). Non-loopback `start:web` binds fail closed unless allowed hosts and complete Basic Auth are configured. Vite keeps zero-configuration loopback development but rejects non-loopback clients before serving assets or proxying APIs unless they pass the same Basic Auth gate. Settings reads the redacted deployment readiness endpoint and disables credential mutations when the standalone secret store is read-only. -Settings → General also owns the server-persisted **Automatically accept Agent Space changes** preference. [`GeneralSettings.tsx`](../../apps/web/src/components/Settings/sections/GeneralSettings.tsx) loads and updates it through the owner-only Agent Change Review API, optimistically reflects a toggle, and restores the last confirmed value on write failure. The preference is application-global: it suppresses future pending Keep/Revert records but does not delete existing records or convert current-session Canvas undo into durable Revert. +Settings → Agent → Agent behavior owns the server-persisted **Automatically accept Agent Space changes** preference. [`AgentBehaviorSettings.tsx`](../../apps/web/src/components/Settings/sections/AgentBehaviorSettings.tsx) loads and updates it through the owner-only Agent Change Review API, optimistically reflects a toggle, and restores the last confirmed value on write failure. The preference is application-global: it suppresses future pending Keep/Revert records but does not delete existing records or convert current-session Canvas undo into durable Revert. ## 9. Desktop troubleshooting actions From c6c50a9d6207da0f44e9f7f18f23f24d661d3b4d Mon Sep 17 00:00:00 2001 From: Yuqing Yang Date: Thu, 1 Oct 2026 01:59:07 +0000 Subject: [PATCH 09/30] fix(settings): refine Agent and capability layout Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Settings/SettingsModal.test.tsx | 11 ++ .../src/components/Settings/SettingsModal.tsx | 6 +- .../AgentDefaultsSettings.test.tsx | 7 + .../agent-profiles/AgentDefaultsSettings.tsx | 161 +++++++++--------- .../sections/ImageProviderSettings.test.tsx | 69 ++++++++ .../sections/ImageProviderSettings.tsx | 7 +- .../sections/IntegrationsSettings.tsx | 6 +- apps/web/src/i18n/resources/en/common.json | 4 +- apps/web/src/i18n/resources/zh-CN/common.json | 4 +- docs/architecture/agent-profiles.md | 2 +- docs/architecture/web-architecture.md | 2 +- 11 files changed, 186 insertions(+), 93 deletions(-) create mode 100644 apps/web/src/components/Settings/sections/ImageProviderSettings.test.tsx diff --git a/apps/web/src/components/Settings/SettingsModal.test.tsx b/apps/web/src/components/Settings/SettingsModal.test.tsx index 8664aa27c..d80919103 100644 --- a/apps/web/src/components/Settings/SettingsModal.test.tsx +++ b/apps/web/src/components/Settings/SettingsModal.test.tsx @@ -141,6 +141,17 @@ describe('Settings information architecture', () => { expect( container.querySelector('[data-testid="built-in-settings"]'), ).toBeNull(); + const profiles = container.querySelector( + '[data-testid="profile-management"]', + ); + const defaults = container.querySelector('[data-testid="agent-defaults"]'); + if (!profiles || !defaults) { + throw new Error('Expected Agent Profiles and Default Agent sections'); + } + expect( + profiles.compareDocumentPosition(defaults) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); expect(mocks.init).toHaveBeenCalled(); expect(mocks.llmInit).not.toHaveBeenCalled(); }); diff --git a/apps/web/src/components/Settings/SettingsModal.tsx b/apps/web/src/components/Settings/SettingsModal.tsx index 52109a55a..58c7945d0 100644 --- a/apps/web/src/components/Settings/SettingsModal.tsx +++ b/apps/web/src/components/Settings/SettingsModal.tsx @@ -279,15 +279,15 @@ export const SettingsModal: React.FC = ({ ) : ( <> + {externalAgentsNavigation ? null : ( <> {showBuiltIn ? : null} )} - {externalAgentsNavigation ? null : ( <> diff --git a/apps/web/src/components/Settings/agent-profiles/AgentDefaultsSettings.test.tsx b/apps/web/src/components/Settings/agent-profiles/AgentDefaultsSettings.test.tsx index be29d10f2..643e706e1 100644 --- a/apps/web/src/components/Settings/agent-profiles/AgentDefaultsSettings.test.tsx +++ b/apps/web/src/components/Settings/agent-profiles/AgentDefaultsSettings.test.tsx @@ -145,6 +145,13 @@ async function editModel(value: string) { } describe('Agent defaults Settings', () => { + it('explains that the default serves new conversations and utility tasks', async () => { + await render(); + expect(container.textContent).toContain( + 'settings.agentDefaultsSectionDescription', + ); + }); + it('allows Built-In while the external catalogue is unavailable, retaining the external model', async () => { mocks.state.loaded = false; mocks.state.error = new Error('Registry unavailable'); diff --git a/apps/web/src/components/Settings/agent-profiles/AgentDefaultsSettings.tsx b/apps/web/src/components/Settings/agent-profiles/AgentDefaultsSettings.tsx index 730ab335e..ace5500f3 100644 --- a/apps/web/src/components/Settings/agent-profiles/AgentDefaultsSettings.tsx +++ b/apps/web/src/components/Settings/agent-profiles/AgentDefaultsSettings.tsx @@ -131,92 +131,97 @@ export function AgentDefaultsSettings() { : 'unknown'; return ( - - {!draft ? ( -

- {error ?? t('settings.loadingAgents')} -

- ) : ( - <> - +

+ {t('settings.agentDefaultsSectionDescription')} +

+ + {!draft ? ( +

- void refresh()} + onChange={(profileId) => { + edit({ ...draft, profileId }, true); }} /> - )} -

- {missing ? ( -

- {t('settings.agentDefaultsDeleted')} -

- ) : draft.profileId === null ? ( -

- {t('settings.agentDefaultsUnconfigured')} -

- ) : snapshot?.defaults.profileId === draft.profileId && - snapshot.selectionState === 'offline' ? ( -

- {t('settings.agentDefaultsOffline')} -

- ) : null} - {!isBuiltIn && - draft.functionalModel.trim() && - modelCapability !== 'supported' && ( + {!isBuiltIn && ( + + { + edit({ ...draft, functionalModel: event.target.value }); + }} + onBlur={() => { + if (error) edit(draft, true); + else debouncedSave.flush(); + }} + /> + + )} +
+ {missing ? ( +

+ {t('settings.agentDefaultsDeleted')} +

+ ) : draft.profileId === null ? ( +

+ {t('settings.agentDefaultsUnconfigured')} +

+ ) : snapshot?.defaults.profileId === draft.profileId && + snapshot.selectionState === 'offline' ? (

- {modelCapability === 'unsupported' - ? t('settings.agentDefaultsModelUnsupported') - : t('settings.agentDefaultsModelUnknown')} + {t('settings.agentDefaultsOffline')} +

+ ) : null} + {!isBuiltIn && + draft.functionalModel.trim() && + modelCapability !== 'supported' && ( +

+ {modelCapability === 'unsupported' + ? t('settings.agentDefaultsModelUnsupported') + : t('settings.agentDefaultsModelUnknown')} +

+ )} + {(error || profilesError) && ( +

+ {error ?? profilesError?.message}

)} - {(error || profilesError) && ( -

- {error ?? profilesError?.message} -

- )} - {(saving || saved) && ( -

- {saving - ? t('settings.saving') - : t('settings.agentDefaultsSaved')} -

- )} -
- - )} - + {(saving || saved) && ( +

+ {saving + ? t('settings.saving') + : t('settings.agentDefaultsSaved')} +

+ )} +
+ + )} +
+ ); } diff --git a/apps/web/src/components/Settings/sections/ImageProviderSettings.test.tsx b/apps/web/src/components/Settings/sections/ImageProviderSettings.test.tsx new file mode 100644 index 000000000..1edf72296 --- /dev/null +++ b/apps/web/src/components/Settings/sections/ImageProviderSettings.test.tsx @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const loadImageConfig = vi.fn(); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock('@/store/deploymentReadinessStore', () => ({ + useDeploymentReadinessStore: (selector: (state: object) => unknown) => + selector({ readiness: { credentials: { writable: true } } }), +})); +vi.mock('@/store/llmStore', () => ({ + useLLMStore: (selector: (state: object) => unknown) => + selector({ + imageConfig: null, + loadImageConfig, + imageError: null, + imageSaving: false, + updateImageConfig: vi.fn(), + }), +})); + +import { ImageProviderSettings } from './ImageProviderSettings'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +let root: Root; +let container: HTMLDivElement; + +beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); +}); + +describe('ImageProviderSettings', () => { + it('starts collapsed and expands on demand', async () => { + await act(async () => { + root.render(); + }); + + const toggle = container.querySelector( + 'button[aria-expanded="false"]', + ); + expect(toggle?.textContent).toContain('settings.imageGeneration'); + expect( + container.querySelector('[aria-label="settings.endpoint"]'), + ).toBeNull(); + + await act(async () => { + toggle?.click(); + }); + + expect( + container.querySelector('[aria-label="settings.endpoint"]'), + ).not.toBeNull(); + }); +}); diff --git a/apps/web/src/components/Settings/sections/ImageProviderSettings.tsx b/apps/web/src/components/Settings/sections/ImageProviderSettings.tsx index dcae55438..b947c94c4 100644 --- a/apps/web/src/components/Settings/sections/ImageProviderSettings.tsx +++ b/apps/web/src/components/Settings/sections/ImageProviderSettings.tsx @@ -115,7 +115,12 @@ export const ImageProviderSettings: React.FC = () => { ); return ( - + {imageError && (

{imageError} diff --git a/apps/web/src/components/Settings/sections/IntegrationsSettings.tsx b/apps/web/src/components/Settings/sections/IntegrationsSettings.tsx index 6d60fde1e..9de1ecfef 100644 --- a/apps/web/src/components/Settings/sections/IntegrationsSettings.tsx +++ b/apps/web/src/components/Settings/sections/IntegrationsSettings.tsx @@ -35,11 +35,7 @@ export const IntegrationsSettings: React.FC = () => { }, [error]); return ( - + Agent is the single conversational-Agent surface. It presents the global default, Built-In Pi and external Profile identities, ordinary external Profile management, Agent behavior, and external-Agent runtime controls without changing their separate persistence owners. The external functional-model field is hidden for Built-In; its saved value survives switching. Built-In provider, login, Chat and Utility model controls load and render only for a Built-In global default or an explicit configuration visit from a Built-In conversation. The conversation's configuration button remains available after binding is locked and does not change the global default. Settings > Capabilities separately owns Huabu-managed image generation, OCR, Web Search, and YouTube transcript configuration; these services do not imply that an external Agent can invoke the corresponding Huabu tools. +Settings > Agent is the single conversational-Agent surface. It presents external Profile management first, followed by the Default Agent used for new conversations and Huabu utility tasks, conditional Built-In Pi configuration, Agent behavior, and external-Agent runtime controls without changing their separate persistence owners. Existing conversations retain their bindings when the default changes. The external functional-model field is hidden for Built-In; its saved value survives switching. Built-In provider, login, Chat and Utility model controls load and render only for a Built-In global default or an explicit configuration visit from a Built-In conversation. The conversation's configuration button remains available after binding is locked and does not change the global default. Settings > Capabilities separately owns Huabu-managed image generation, OCR, Web Search, and YouTube transcript configuration; these services appear as peer capabilities and do not imply that an external Agent can invoke the corresponding Huabu tools. The larger Image Generation form starts collapsed and expands on demand. Huabu-owned auxiliary tasks outside an Agent Node's bound thread follow the global default, including summaries, keywords, content/Frame titles, conversation titles, and image labels. The source conversation's backend does not determine its automatic title backend. Each invocation selects its backend once; changing defaults does not reroute in-flight work. Built-In tasks reuse `llmComplete` with the existing `contentMeta`, `frameLabel`, and `imageLabel` roles, preserving Utility/Chat model resolution and vision handling. External tasks use independent ACP Jobs. Thread-owned work and its tools retain the thread binding; explicit internal Skill authoring remains available. Legacy background Memory starts only for a Built-In global default; external Memory and unified Skill authoring remain separate follow-ups. diff --git a/docs/architecture/web-architecture.md b/docs/architecture/web-architecture.md index 5e9784e03..0bd08fd8e 100644 --- a/docs/architecture/web-architecture.md +++ b/docs/architecture/web-architecture.md @@ -186,7 +186,7 @@ Space Shortcut retains the `spacePreview` node type and renders the canonical ic ### Settings information architecture -The tabbed Settings modal has three product-owned surfaces. **Agent** combines global Agent defaults, conditional Built-In Pi provider/model setup, ordinary external Profile management, Agent behavior, and external-Agent runtime controls while preserving each component's existing store and API ownership. **Capabilities** contains Huabu-managed image generation, Web Search, YouTube transcript, and Ink OCR configuration; its copy does not imply external-Agent tool parity. **General** contains application, Canvas, input, update, and deployment preferences that do not configure an Agent or service capability. The hidden Built-In repair deep link remains focused on Pi provider/model setup and returns to the Agent surface after closing. +The tabbed Settings modal has three product-owned surfaces. **Agent** presents ordinary external Profile management first, then the Default Agent for new conversations and Huabu utility tasks, conditional Built-In Pi provider/model setup, Agent behavior, and external-Agent runtime controls while preserving each component's existing store and API ownership. **Capabilities** presents Huabu-managed image generation, Web Search, YouTube transcript, and Ink OCR configuration as peer capabilities without an extra “Other Capabilities” grouping; the larger Image Generation form starts collapsed, and the surface copy does not imply external-Agent tool parity. **General** contains application, Canvas, input, update, and deployment preferences that do not configure an Agent or service capability. The hidden Built-In repair deep link remains focused on Pi provider/model setup and returns to the Agent surface after closing. Agent Profile management uses `components/Settings/agent-profiles/` and the canonical `acpProfilesStore`. It has ordinary command Profile creation/editing/deletion and no Agent Team templates, Configs or setup lifecycle. The manual editor reads the agentlet-backed catalogue; automatic defaults arrive through the same persisted Profile list used by selectors. Opening an editor focuses the nested Agent view while retaining the existing Profile revision and save contracts. See [Agent Profiles](./agent-profiles.md). From 30d596e932b99a66d62df4b27d808792f6ce5136 Mon Sep 17 00:00:00 2001 From: Yuqing Yang Date: Thu, 1 Oct 2026 02:21:22 +0000 Subject: [PATCH 10/30] fix(agent): separate Utility Agent from conversations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/server/src/app.ts | 4 + .../src/modules/agent/agent-defaults.route.ts | 6 +- .../src/modules/agent/agent-defaults.ts | 2 +- .../src/modules/agent/agent-node.service.ts | 15 +- .../agent/conversation-agent.route.test.ts | 92 +++++++ .../modules/agent/conversation-agent.route.ts | 109 ++++++++ .../modules/agent/conversation-agent.test.ts | 49 ++++ .../src/modules/agent/conversation-agent.ts | 76 ++++++ .../src/modules/agent/functional-text.ts | 2 +- .../modules/agent/selectable-agent-profile.ts | 4 +- .../modules/canvas/agent-node-edit.test.ts | 18 +- .../src/modules/canvas/agent-node-edit.ts | 6 +- apps/web/src/api/_routes.ts | 1 + apps/web/src/api/agentDefaults.ts | 20 ++ .../Nodes/question/questionCompose.test.ts | 35 ++- .../StrokeSelectionToolbar.test.tsx | 46 ++-- .../StrokeSelectionToolbar.tsx | 10 +- .../src/components/Panels/ChatPanel/index.tsx | 18 +- .../PreviewWorkspace.test.tsx | 6 +- .../Settings/SettingsModal.test.tsx | 2 +- apps/web/src/i18n/resources/en/common.json | 19 +- apps/web/src/i18n/resources/zh-CN/common.json | 19 +- apps/web/src/store/acpProfilesStore.test.ts | 249 +++++++++--------- apps/web/src/store/acpProfilesStore.ts | 106 +++++++- .../canvasStore.postCreateEditing.test.ts | 26 +- .../src/store/chatStore.sessionScope.test.ts | 17 +- apps/web/src/store/conversationOwner.test.ts | 11 + apps/web/src/store/conversationOwner.ts | 12 + docs/architecture/agent-architecture.md | 2 +- docs/architecture/agent-memory.md | 2 +- docs/architecture/agent-profiles.md | 20 +- docs/architecture/agent-reachback.md | 2 +- docs/architecture/api-design.md | 4 + docs/architecture/deployment-security.md | 2 +- docs/architecture/node-preprocessing.md | 2 +- docs/architecture/preview-workspace.md | 4 +- docs/architecture/question-node.md | 2 +- docs/architecture/sketch-node.md | 2 +- docs/architecture/web-architecture.md | 2 +- .../src/types/api/agent-defaults.test.ts | 27 +- .../shared/src/types/api/agent-defaults.ts | 20 ++ 41 files changed, 815 insertions(+), 256 deletions(-) create mode 100644 apps/server/src/modules/agent/conversation-agent.route.test.ts create mode 100644 apps/server/src/modules/agent/conversation-agent.route.ts create mode 100644 apps/server/src/modules/agent/conversation-agent.test.ts create mode 100644 apps/server/src/modules/agent/conversation-agent.ts diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 3c107f09d..39666968a 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -35,6 +35,7 @@ import { initializeAgentDefaults } from './modules/agent/agent-defaults.js'; import agentDefaultsRoutes from './modules/agent/agent-defaults.route.js'; import agentRoutes from './modules/agent/agent.route.js'; import agentChangeReviewConfigRoutes from './modules/agent/change-review-config.route.js'; +import conversationAgentRoutes from './modules/agent/conversation-agent.route.js'; import llmRoutes from './modules/agent/llm.route.js'; import { registerOpCounterHook } from './modules/agent/memory/op-counter-hook.js'; import skillsRoutes from './modules/agent/skills.route.js'; @@ -365,6 +366,9 @@ app.addHook('onListen', async () => { installAcpProfileCachePort(); app.register(acpProfilesRoutes, { prefix: '/api/acp' }); app.register(agentDefaultsRoutes, { prefix: '/api/agent/defaults' }); +app.register(conversationAgentRoutes, { + prefix: '/api/agent/conversation-profile', +}); app.register(acpAgentletRoutes, { prefix: '/api/acp' }); app.register(acpAgentCliRoutes, { prefix: '/api/acp' }); app.register(acpThreadsRoutes, { prefix: '/api/acp' }); diff --git a/apps/server/src/modules/agent/agent-defaults.route.ts b/apps/server/src/modules/agent/agent-defaults.route.ts index bb205ebcd..49d537e35 100644 --- a/apps/server/src/modules/agent/agent-defaults.route.ts +++ b/apps/server/src/modules/agent/agent-defaults.route.ts @@ -78,7 +78,8 @@ const agentDefaultsRoutes: FastifyPluginAsync = async (app) => { app.addHook('preHandler', async (request, reply) => { if (!isOwnerRequest(request)) { return reply.status(403).send({ - message: 'Forbidden: Agent defaults require owner authorization', + message: + 'Forbidden: Utility Agent settings require owner authorization', }); } }); @@ -96,7 +97,8 @@ const agentDefaultsRoutes: FastifyPluginAsync = async (app) => { const parsed = agentDefaultsSchema.safeParse(request.body); if (!parsed.success) { return reply.status(400).send({ - message: parsed.error.issues[0]?.message ?? 'Invalid Agent defaults', + message: + parsed.error.issues[0]?.message ?? 'Invalid Utility Agent settings', code: 'validation_failed', }); } diff --git a/apps/server/src/modules/agent/agent-defaults.ts b/apps/server/src/modules/agent/agent-defaults.ts index 8b1a3e5c1..3d5c3af62 100644 --- a/apps/server/src/modules/agent/agent-defaults.ts +++ b/apps/server/src/modules/agent/agent-defaults.ts @@ -85,7 +85,7 @@ export class AgentDefaultsService { if (profileId === null) { throw new AgentDefaultsError( 'default_profile_unconfigured', - 'Select a default Agent in Settings', + 'Select a Utility Agent in Settings', ); } return profileId; diff --git a/apps/server/src/modules/agent/agent-node.service.ts b/apps/server/src/modules/agent/agent-node.service.ts index adddd7306..5954fbf09 100644 --- a/apps/server/src/modules/agent/agent-node.service.ts +++ b/apps/server/src/modules/agent/agent-node.service.ts @@ -13,11 +13,14 @@ import { type Point, } from '@huabu/shared'; -import { getAgentDefaults } from './agent-defaults.js'; import { InvalidAgentLaunchOverridesError, parseAgentLaunchOverrides, } from './agent-launch-overrides.js'; +import { + getEffectiveConversationAgentProfileId, + rememberConversationAgentProfileId, +} from './conversation-agent.js'; import { requireSelectableAgentProfile, SelectableAgentProfileError, @@ -90,6 +93,7 @@ interface StoredNode { interface AgentNodeServiceDependencies { getProfileRegistry: () => AgentProfileRegistryPort | null; getDefaultProfileId?: () => string | null; + rememberProfileId?: (profileId: string) => void; readCanvasNodes: (canvasId: string) => Promise; execute: (input: { canvasId: string; @@ -108,6 +112,8 @@ async function defaultReadCanvasNodes( const DEFAULT_DEPENDENCIES: AgentNodeServiceDependencies = { getProfileRegistry: () => null, + getDefaultProfileId: getEffectiveConversationAgentProfileId, + rememberProfileId: rememberConversationAgentProfileId, readCanvasNodes: defaultReadCanvasNodes, execute: executeOnServer, }; @@ -219,11 +225,11 @@ export class AgentNodeService { input.profileId ?? (this.dependencies.getDefaultProfileId ? this.dependencies.getDefaultProfileId() - : getAgentDefaults().profileId); + : getEffectiveConversationAgentProfileId()); if (!profileId) { throw new AgentNodeCreationError( 'default_profile_unconfigured', - 'Connect an external Agent or select Built-In Pi as the default in Settings.', + 'Connect an external Agent before creating a conversation.', ); } let binding: AgentBinding; @@ -299,6 +305,9 @@ export class AgentNodeService { 'Canvas rejected Agent Node creation', ); } + if (input.profileId) { + this.dependencies.rememberProfileId?.(profileId); + } let parentConnection: CreateAgentNodeResult['parentConnection'] = input.anchor ? 'failed' : 'not_requested'; if (sourceNodeId) { diff --git a/apps/server/src/modules/agent/conversation-agent.route.test.ts b/apps/server/src/modules/agent/conversation-agent.route.test.ts new file mode 100644 index 000000000..d2fef6159 --- /dev/null +++ b/apps/server/src/modules/agent/conversation-agent.route.test.ts @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import Fastify from 'fastify'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import conversationAgentRoutes from './conversation-agent.route.js'; + +import type { ConversationAgentPreference } from '@huabu/shared'; +import type { FastifyInstance } from 'fastify'; + +const mocks = vi.hoisted(() => ({ + preference: { profileId: null } as ConversationAgentPreference, + set: vi.fn(), + profiles: new Map([ + ['first', { id: 'first', agentletId: 'machine-a' }], + ['second', { id: 'second', agentletId: 'machine-b' }], + ]), +})); + +vi.mock('./conversation-agent.js', () => ({ + getConversationAgentPreference: () => mocks.preference, + getEffectiveConversationAgentProfileId: () => + mocks.preference.profileId ?? 'first', + setConversationAgentPreference: mocks.set.mockImplementation( + (preference: ConversationAgentPreference) => { + mocks.preference = preference; + return preference; + }, + ), +})); + +vi.mock('@agenetes/agentlet-host', () => ({ + getAgentProfileRegistry: () => ({ + getProfile: (profileId: string) => mocks.profiles.get(profileId), + listSelectableProfileIds: () => [...mocks.profiles.keys()], + }), + getAgentletGateway: () => ({ + getAgentlet: () => ({ status: 'connected' }), + }), +})); + +let app: FastifyInstance; +const url = '/api/agent/conversation-profile'; + +beforeEach(async () => { + mocks.preference = { profileId: null }; + mocks.set.mockClear(); + app = Fastify({ logger: false }); + await app.register(conversationAgentRoutes, { prefix: url }); +}); + +afterEach(async () => { + await app.close(); +}); + +describe('conversation Agent preference route', () => { + it('projects the first selectable Profile without persisting a fallback', async () => { + const response = await app.inject(url); + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + preference: { profileId: null }, + effectiveProfileId: 'first', + selectionState: 'available', + }); + expect(mocks.set).not.toHaveBeenCalled(); + }); + + it('validates and persists an explicit conversational choice', async () => { + const response = await app.inject({ + method: 'PUT', + url, + payload: { profileId: 'second' }, + }); + expect(response.statusCode).toBe(200); + expect(mocks.set).toHaveBeenCalledWith({ profileId: 'second' }); + expect(response.json()).toMatchObject({ + effectiveProfileId: 'second', + selectionState: 'available', + }); + }); + + it('rejects an unknown Profile without changing the preference', async () => { + const response = await app.inject({ + method: 'PUT', + url, + payload: { profileId: 'missing' }, + }); + expect(response.statusCode).toBe(400); + expect(mocks.set).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/modules/agent/conversation-agent.route.ts b/apps/server/src/modules/agent/conversation-agent.route.ts new file mode 100644 index 000000000..e2e784dbb --- /dev/null +++ b/apps/server/src/modules/agent/conversation-agent.route.ts @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + getAgentProfileRegistry, + getAgentletGateway, +} from '@agenetes/agentlet-host'; + +import { + conversationAgentPreferenceSchema, + HUABU_AGENT_PROFILE_ID, +} from '@huabu/shared'; + +import { + getConversationAgentPreference, + getEffectiveConversationAgentProfileId, + setConversationAgentPreference, +} from './conversation-agent.js'; +import { isOwnerRequest } from '../security/owner.js'; + +import type { + ApiResult, + ConversationAgentPreference, + ConversationAgentPreferenceResponse, +} from '@huabu/shared'; +import type { FastifyPluginAsync } from 'fastify'; + +function projectPreference(): ConversationAgentPreferenceResponse { + const preference = getConversationAgentPreference(); + const effectiveProfileId = getEffectiveConversationAgentProfileId(); + if (effectiveProfileId === HUABU_AGENT_PROFILE_ID) { + return { preference, effectiveProfileId, selectionState: 'available' }; + } + const registry = getAgentProfileRegistry(); + const profile = effectiveProfileId + ? registry?.getProfile(effectiveProfileId) + : undefined; + return { + preference, + effectiveProfileId, + selectionState: + effectiveProfileId === null + ? 'unconfigured' + : !registry + ? 'offline' + : !profile + ? 'deleted' + : getAgentletGateway()?.getAgentlet(profile.agentletId)?.status === + 'connected' + ? 'available' + : 'offline', + }; +} + +const conversationAgentRoutes: FastifyPluginAsync = async (app) => { + app.addHook('preHandler', async (request, reply) => { + if (!isOwnerRequest(request)) { + return reply.status(403).send({ + message: + 'Forbidden: conversation Agent preference requires owner authorization', + }); + } + }); + + app.get<{ Reply: ApiResult }>( + '/', + { prefixTrailingSlash: 'both' }, + async () => projectPreference(), + ); + + app.put<{ + Body: ConversationAgentPreference; + Reply: ApiResult; + }>('/', { prefixTrailingSlash: 'both' }, async (request, reply) => { + const parsed = conversationAgentPreferenceSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + message: + parsed.error.issues[0]?.message ?? + 'Invalid conversation Agent preference', + code: 'validation_failed', + }); + } + if ( + parsed.data.profileId !== null && + parsed.data.profileId !== HUABU_AGENT_PROFILE_ID + ) { + const registry = getAgentProfileRegistry(); + if (!registry) { + return reply.status(503).send({ + message: 'Agent Profile registry is not ready', + code: 'profile_registry_unavailable', + }); + } + if ( + !new Set(registry.listSelectableProfileIds()).has(parsed.data.profileId) + ) { + return reply.status(400).send({ + message: 'Select Built-In Pi or an existing external Agent Profile', + code: 'profile_not_found', + }); + } + } + setConversationAgentPreference(parsed.data); + return projectPreference(); + }); +}; + +export default conversationAgentRoutes; diff --git a/apps/server/src/modules/agent/conversation-agent.test.ts b/apps/server/src/modules/agent/conversation-agent.test.ts new file mode 100644 index 000000000..e6c92299a --- /dev/null +++ b/apps/server/src/modules/agent/conversation-agent.test.ts @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { describe, expect, it, vi } from 'vitest'; + +import { ConversationAgentService } from './conversation-agent.js'; + +import type { ConversationAgentPreference } from '@huabu/shared'; + +function harness( + stored: ConversationAgentPreference | undefined, + selectable: string[] = [], +) { + let value = stored; + const write = vi.fn((next: ConversationAgentPreference) => { + value = next; + }); + const service = new ConversationAgentService( + { + read: () => value, + write, + }, + () => selectable, + ); + return { service, write }; +} + +describe('ConversationAgentService', () => { + it('falls back to the first selectable Profile without persisting it', () => { + const { service, write } = harness(undefined, ['first', 'second']); + expect(service.effectiveProfileId()).toBe('first'); + expect(service.getPreference()).toEqual({ profileId: null }); + expect(write).not.toHaveBeenCalled(); + }); + + it('keeps a remembered identity authoritative even when it is stale', () => { + const { service } = harness({ profileId: 'missing' }, ['first']); + expect(service.effectiveProfileId()).toBe('missing'); + }); + + it('persists an explicit conversational choice independently', () => { + const { service, write } = harness(undefined, ['first']); + expect(service.setPreference({ profileId: 'chosen' })).toEqual({ + profileId: 'chosen', + }); + expect(write).toHaveBeenCalledWith({ profileId: 'chosen' }); + expect(service.effectiveProfileId()).toBe('chosen'); + }); +}); diff --git a/apps/server/src/modules/agent/conversation-agent.ts b/apps/server/src/modules/agent/conversation-agent.ts new file mode 100644 index 000000000..485ab4905 --- /dev/null +++ b/apps/server/src/modules/agent/conversation-agent.ts @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { getAgentProfileRegistry } from '@agenetes/agentlet-host'; + +import { conversationAgentPreferenceSchema } from '@huabu/shared'; + +import { getDataDir } from '../../data-dir.js'; +import { atomicWriteJson } from '../../utils/fs.js'; + +import type { ConversationAgentPreference } from '@huabu/shared'; + +interface ConversationAgentStorage { + read: () => unknown; + write: (preference: ConversationAgentPreference) => void; +} + +function configPath(): string { + return join(getDataDir(), 'conversation-agent.json'); +} + +const diskStorage: ConversationAgentStorage = { + read() { + let text: string; + try { + text = readFileSync(configPath(), 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + } + return JSON.parse(text) as unknown; + }, + write: (preference) => atomicWriteJson(configPath(), preference), +}; + +export class ConversationAgentService { + constructor( + private readonly storage: ConversationAgentStorage = diskStorage, + private readonly listSelectableProfileIds: () => string[] = () => + getAgentProfileRegistry()?.listSelectableProfileIds() ?? [], + ) {} + + getPreference(): ConversationAgentPreference { + const stored = this.storage.read(); + if (stored === undefined) return { profileId: null }; + return conversationAgentPreferenceSchema.parse(stored); + } + + setPreference( + preference: ConversationAgentPreference, + ): ConversationAgentPreference { + const parsed = conversationAgentPreferenceSchema.parse(preference); + this.storage.write(parsed); + return parsed; + } + + effectiveProfileId(): string | null { + const { profileId } = this.getPreference(); + if (profileId) return profileId; + return this.listSelectableProfileIds()[0] ?? null; + } +} + +const service = new ConversationAgentService(); + +export const getConversationAgentPreference = () => service.getPreference(); +export const setConversationAgentPreference = ( + preference: ConversationAgentPreference, +) => service.setPreference(preference); +export const getEffectiveConversationAgentProfileId = () => + service.effectiveProfileId(); +export const rememberConversationAgentProfileId = (profileId: string) => + service.setPreference({ profileId }); diff --git a/apps/server/src/modules/agent/functional-text.ts b/apps/server/src/modules/agent/functional-text.ts index deb298342..9615268c6 100644 --- a/apps/server/src/modules/agent/functional-text.ts +++ b/apps/server/src/modules/agent/functional-text.ts @@ -38,7 +38,7 @@ export async function runFunctionalText( if (!profileId) { throw new AgentDefaultsError( 'default_profile_unconfigured', - 'Select a default Agent in Settings to generate metadata', + 'Select a Utility Agent in Settings to generate metadata', ); } if (profileId === 'huabu') { diff --git a/apps/server/src/modules/agent/selectable-agent-profile.ts b/apps/server/src/modules/agent/selectable-agent-profile.ts index 0b3a7bcb3..4d0ba96f1 100644 --- a/apps/server/src/modules/agent/selectable-agent-profile.ts +++ b/apps/server/src/modules/agent/selectable-agent-profile.ts @@ -5,7 +5,7 @@ import { getAgentProfileRegistry } from '@agenetes/agentlet-host'; import { HUABU_AGENT_PROFILE_ID } from '@huabu/shared'; -import { getAgentDefaults } from './agent-defaults.js'; +import { getEffectiveConversationAgentProfileId } from './conversation-agent.js'; import type { CustomData } from '@huabu/shared'; @@ -70,7 +70,7 @@ export function requireAvailableAgentProfile( export function listAvailableAgentProfiles( registry: AgentProfileRegistryPort | null = getAgentProfileRegistry(), - defaultProfileId: string | null = getAgentDefaults().profileId, + defaultProfileId: string | null = getEffectiveConversationAgentProfileId(), ): AvailableAgentProfileSummary[] { const huabu = { id: HUABU_AGENT_PROFILE_ID, diff --git a/apps/server/src/modules/canvas/agent-node-edit.test.ts b/apps/server/src/modules/canvas/agent-node-edit.test.ts index b7e42d201..898ee2f31 100644 --- a/apps/server/src/modules/canvas/agent-node-edit.test.ts +++ b/apps/server/src/modules/canvas/agent-node-edit.test.ts @@ -9,6 +9,7 @@ import { } from './agent-node-edit.js'; import { SelectableAgentProfileError } from '../agent/selectable-agent-profile.js'; +import type * as ConversationAgent from '../agent/conversation-agent.js'; import type * as SelectableProfiles from '../agent/selectable-agent-profile.js'; const mocks = vi.hoisted(() => ({ @@ -16,8 +17,9 @@ const mocks = vi.hoisted(() => ({ profile: vi.fn(), })); -vi.mock('../agent/agent-defaults.js', () => ({ - getAgentDefaults: mocks.defaults, +vi.mock('../agent/conversation-agent.js', async (importOriginal) => ({ + ...(await importOriginal()), + getEffectiveConversationAgentProfileId: mocks.defaults, })); vi.mock('../agent/selectable-agent-profile.js', async (importOriginal) => ({ @@ -28,10 +30,7 @@ vi.mock('../agent/selectable-agent-profile.js', async (importOriginal) => ({ describe('new Agent Node default binding', () => { beforeEach(() => { vi.resetAllMocks(); - mocks.defaults.mockReturnValue({ - profileId: 'external-default', - functionalModel: '', - }); + mocks.defaults.mockReturnValue('external-default'); mocks.profile.mockReturnValue({ id: 'external-default', alias: 'External', @@ -61,10 +60,7 @@ describe('new Agent Node default binding', () => { }); it('uses an explicit Built-In default without resolving external Profiles', () => { - mocks.defaults.mockReturnValue({ - profileId: 'huabu', - functionalModel: 'external-model', - }); + mocks.defaults.mockReturnValue('huabu'); expect(withDefaultAgentBinding({ label: 'New Agent' })).toEqual({ label: 'New Agent', agentBinding: { kind: 'internal' }, @@ -73,7 +69,7 @@ describe('new Agent Node default binding', () => { }); it('reports an unconfigured default instead of silently choosing internal', () => { - mocks.defaults.mockReturnValue({ profileId: null, functionalModel: '' }); + mocks.defaults.mockReturnValue(null); expect(() => withDefaultAgentBinding({})).toThrow(AgentNodeEditError); expect(mocks.profile).not.toHaveBeenCalled(); }); diff --git a/apps/server/src/modules/canvas/agent-node-edit.ts b/apps/server/src/modules/canvas/agent-node-edit.ts index a2365729a..e0153f3b6 100644 --- a/apps/server/src/modules/canvas/agent-node-edit.ts +++ b/apps/server/src/modules/canvas/agent-node-edit.ts @@ -11,10 +11,10 @@ import { } from '@huabu/shared/canvas-engine'; import { agenetes } from '../agent/agenetes/drivers.js'; -import { getAgentDefaults } from '../agent/agent-defaults.js'; import { parseAgentLaunchOverrides } from '../agent/agent-launch-overrides.js'; import { agentNodeBinding } from '../agent/agent-node-binding.js'; import { agentThreadResolver } from '../agent/agent-thread-resolver.js'; +import { getEffectiveConversationAgentProfileId } from '../agent/conversation-agent.js'; import { effectiveConversationTitle } from '../agent/conversation-title.service.js'; import { requireSelectableAgentProfile, @@ -43,10 +43,10 @@ export function withDefaultAgentBinding( data: Record, ): Record { if (data.agentBinding) return data; - const profileId = getAgentDefaults().profileId; + const profileId = getEffectiveConversationAgentProfileId(); if (!profileId) { throw new AgentNodeEditError( - 'Connect an external Agent or select Built-In Pi as the default in Settings.', + 'Connect an external Agent before creating a conversation.', ); } if (profileId === HUABU_AGENT_PROFILE_ID) { diff --git a/apps/web/src/api/_routes.ts b/apps/web/src/api/_routes.ts index bdc5d7bcb..550a03ffa 100644 --- a/apps/web/src/api/_routes.ts +++ b/apps/web/src/api/_routes.ts @@ -20,6 +20,7 @@ export const routes = { canaryRedeployCheck: '/deployment/canary/check', canaryRedeploy: '/deployment/canary/redeploy', agentDefaults: '/agent/defaults', + conversationAgent: '/agent/conversation-profile', // ── Workspace ───────────────────────────────────────────────────── workspace: '/workspace', diff --git a/apps/web/src/api/agentDefaults.ts b/apps/web/src/api/agentDefaults.ts index 3584f9cab..20378b48a 100644 --- a/apps/web/src/api/agentDefaults.ts +++ b/apps/web/src/api/agentDefaults.ts @@ -5,6 +5,10 @@ import { apiFetch } from './_client'; import { routes } from './_routes'; import type { AgentDefaults, AgentDefaultsResponse } from '@huabu/shared'; +import type { + ConversationAgentPreference, + ConversationAgentPreferenceResponse, +} from '@huabu/shared'; export function getAgentDefaults(): Promise { return apiFetch(routes.agentDefaults, { @@ -21,3 +25,19 @@ export function updateAgentDefaults( fallbackMessage: 'Failed to save Agent defaults', }); } + +export function getConversationAgentPreference(): Promise { + return apiFetch(routes.conversationAgent, { + fallbackMessage: 'Failed to load conversation Agent preference', + }); +} + +export function updateConversationAgentPreference( + preference: ConversationAgentPreference, +): Promise { + return apiFetch(routes.conversationAgent, { + method: 'PUT', + json: preference, + fallbackMessage: 'Failed to save conversation Agent preference', + }); +} diff --git a/apps/web/src/components/Nodes/question/questionCompose.test.ts b/apps/web/src/components/Nodes/question/questionCompose.test.ts index 6ea5dae2f..ff3dcb0b8 100644 --- a/apps/web/src/components/Nodes/question/questionCompose.test.ts +++ b/apps/web/src/components/Nodes/question/questionCompose.test.ts @@ -5,9 +5,9 @@ import { assert, beforeEach, describe, expect, it, vi } from 'vitest'; const saveDraft = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); const associateNode = vi.hoisted(() => vi.fn()); -const getDefaults = vi.hoisted(() => vi.fn()); +const getConversationAgent = vi.hoisted(() => vi.fn()); vi.mock('@/api/agentDefaults', () => ({ - getAgentDefaults: getDefaults, + getConversationAgentPreference: getConversationAgent, })); vi.mock('@/components/Common/Toast', () => ({ toast: vi.fn() })); vi.mock('@/api/canvas', async (importOriginal) => ({ @@ -57,13 +57,10 @@ const view = { beforeEach(() => { saveDraft.mockClear(); associateNode.mockReset(); - getDefaults.mockReset().mockResolvedValue({ - defaults: { - profileId: 'global-profile', - functionalModel: 'utility-model', - }, + getConversationAgent.mockReset().mockResolvedValue({ + preference: { profileId: 'global-profile' }, + effectiveProfileId: 'global-profile', selectionState: 'available', - modelCapability: 'unknown', }); useAcpProfilesStore.setState({ loaded: false, @@ -71,6 +68,8 @@ beforeEach(() => { profiles: [], agentDefaults: null, defaultsError: null, + conversationAgent: null, + conversationAgentError: null, }); vi.mocked(toast).mockClear(); useCanvasStore.getState()._setStateNoAutosave({ @@ -227,10 +226,10 @@ describe('Question conversation presentation', () => { }); it('does not create or open a node when defaults are unconfigured', async () => { - getDefaults.mockResolvedValueOnce({ - defaults: { profileId: null, functionalModel: '' }, + getConversationAgent.mockResolvedValueOnce({ + preference: { profileId: null }, + effectiveProfileId: null, selectionState: 'unconfigured', - modelCapability: 'unknown', }); const addNode = vi.fn(); @@ -248,10 +247,10 @@ describe('Question conversation presentation', () => { }); it('creates a Built-In Question in operate mode without loading external Profiles', async () => { - getDefaults.mockResolvedValueOnce({ - defaults: { profileId: 'huabu', functionalModel: '' }, + getConversationAgent.mockResolvedValueOnce({ + preference: { profileId: 'huabu' }, + effectiveProfileId: 'huabu', selectionState: 'available', - modelCapability: 'supported', }); const addNode = vi.fn().mockReturnValue('question-built-in'); const created = await createQuestionNodeAndCompose({ @@ -272,7 +271,7 @@ describe('Question conversation presentation', () => { it('discards delayed creation after the Canvas changes', async () => { let resolve!: (value: unknown) => void; - getDefaults.mockReturnValueOnce( + getConversationAgent.mockReturnValueOnce( new Promise((done) => { resolve = done; }), @@ -285,9 +284,9 @@ describe('Question conversation presentation', () => { }); useCanvasStore.setState({ canvasId: 'canvas-2' }); resolve({ - defaults: { profileId: 'global-profile', functionalModel: '' }, + preference: { profileId: 'global-profile' }, + effectiveProfileId: 'global-profile', selectionState: 'available', - modelCapability: 'unknown', }); expect(await pending).toBeNull(); expect(addNode).not.toHaveBeenCalled(); @@ -354,7 +353,7 @@ describe('Question conversation presentation', () => { 'ask', ); expect(saveDraft).not.toHaveBeenCalled(); - expect(getDefaults).not.toHaveBeenCalled(); + expect(getConversationAgent).not.toHaveBeenCalled(); }, ); diff --git a/apps/web/src/components/Panels/Canvas/FloatingToolbars/StrokeSelectionToolbar.test.tsx b/apps/web/src/components/Panels/Canvas/FloatingToolbars/StrokeSelectionToolbar.test.tsx index 315182fba..3753aa7cc 100644 --- a/apps/web/src/components/Panels/Canvas/FloatingToolbars/StrokeSelectionToolbar.test.tsx +++ b/apps/web/src/components/Panels/Canvas/FloatingToolbars/StrokeSelectionToolbar.test.tsx @@ -29,12 +29,12 @@ const mocks = vi.hoisted(() => ({ captureGrounding: vi.fn(), blobToDataUrl: vi.fn(), getViewport: vi.fn(), - getDefaults: vi.fn(), + getConversationAgent: vi.fn(), popoverAnchor: null as unknown, })); vi.mock('@/api/agentDefaults', () => ({ - getAgentDefaults: mocks.getDefaults, + getConversationAgentPreference: mocks.getConversationAgent, })); vi.mock('@/components/Common/Toast', () => ({ toast: vi.fn() })); @@ -148,14 +148,20 @@ beforeEach(() => { useAcpProfilesStore.setState({ profiles, agentDefaults: { profileId: 'default-profile', functionalModel: '' }, + conversationAgent: { + preference: { profileId: 'default-profile' }, + effectiveProfileId: 'default-profile', + selectionState: 'available', + }, + conversationAgentError: null, loaded: true, error: null, defaultsError: null, }); - mocks.getDefaults.mockReset().mockResolvedValue({ - defaults: { profileId: 'default-profile', functionalModel: '' }, + mocks.getConversationAgent.mockReset().mockResolvedValue({ + preference: { profileId: 'default-profile' }, + effectiveProfileId: 'default-profile', selectionState: 'available', - modelCapability: 'unknown', }); useGesturePreviewStore.setState({ sketchStrokeSelection: { 'sketch-1': ['stroke-1'] }, @@ -215,7 +221,7 @@ describe('StrokeSelectionToolbar Ink submission', () => { mocks.dispatch.mockResolvedValueOnce({ status: 'completed' }); const button = await renderToolbar(); await act(async () => button.click()); - expect(mocks.getDefaults).toHaveBeenCalledOnce(); + expect(mocks.getConversationAgent).toHaveBeenCalledOnce(); expect(mocks.createQuestion).toHaveBeenCalledWith( expect.objectContaining({ binding: { @@ -233,7 +239,9 @@ describe('StrokeSelectionToolbar Ink submission', () => { }); it('keeps the Ink selection and creates nothing when defaults are unavailable', async () => { - mocks.getDefaults.mockRejectedValueOnce(new Error('Server unavailable')); + mocks.getConversationAgent.mockRejectedValueOnce( + new Error('Server unavailable'), + ); const button = await renderToolbar(); await act(async () => button.click()); expect(mocks.createQuestion).not.toHaveBeenCalled(); @@ -249,10 +257,10 @@ describe('StrokeSelectionToolbar Ink submission', () => { }); it('restores operate mode for new Ink Questions with a Built-In default', async () => { - mocks.getDefaults.mockResolvedValueOnce({ - defaults: { profileId: 'huabu', functionalModel: '' }, + mocks.getConversationAgent.mockResolvedValueOnce({ + preference: { profileId: 'huabu' }, + effectiveProfileId: 'huabu', selectionState: 'available', - modelCapability: 'supported', }); const button = await renderToolbar(); await act(async () => button.click()); @@ -268,10 +276,10 @@ describe('StrokeSelectionToolbar Ink submission', () => { }); it('requires a configured default instead of falling back to the internal Agent', async () => { - mocks.getDefaults.mockResolvedValueOnce({ - defaults: { profileId: null, functionalModel: '' }, + mocks.getConversationAgent.mockResolvedValueOnce({ + preference: { profileId: null }, + effectiveProfileId: null, selectionState: 'unconfigured', - modelCapability: 'unknown', }); const button = await renderToolbar(); await act(async () => button.click()); @@ -284,7 +292,7 @@ describe('StrokeSelectionToolbar Ink submission', () => { 'does not create an Ink Question after %s changes during default loading', async (change) => { let resolveDefaults!: (value: unknown) => void; - mocks.getDefaults.mockImplementationOnce( + mocks.getConversationAgent.mockImplementationOnce( () => new Promise((resolve) => { resolveDefaults = resolve; @@ -304,9 +312,9 @@ describe('StrokeSelectionToolbar Ink submission', () => { }); } resolveDefaults({ - defaults: { profileId: 'default-profile', functionalModel: '' }, + preference: { profileId: 'default-profile' }, + effectiveProfileId: 'default-profile', selectionState: 'available', - modelCapability: 'unknown', }); }); expect(mocks.createQuestion).not.toHaveBeenCalled(); @@ -601,7 +609,7 @@ describe('StrokeSelectionToolbar Ink submission', () => { expect(mocks.prepare).toHaveBeenCalledWith( expect.objectContaining({ mode: 'operate' }), ); - expect(mocks.getDefaults).not.toHaveBeenCalled(); + expect(mocks.getConversationAgent).not.toHaveBeenCalled(); expect(mocks.createQuestion).not.toHaveBeenCalled(); }); @@ -647,7 +655,7 @@ describe('StrokeSelectionToolbar Ink submission', () => { }), }), ); - expect(mocks.getDefaults).not.toHaveBeenCalled(); + expect(mocks.getConversationAgent).not.toHaveBeenCalled(); }); it('creates and dispatches at most once for rapid activation', async () => { @@ -704,7 +712,7 @@ describe('StrokeSelectionToolbar Ink submission', () => { expect(mocks.createQuestion).toHaveBeenCalledTimes(1); expect(mocks.dispatch).toHaveBeenCalledTimes(2); - expect(mocks.getDefaults).toHaveBeenCalledTimes(1); + expect(mocks.getConversationAgent).toHaveBeenCalledTimes(1); }); it('retains an ambiguous reservation until Stop confirms no acceptance', async () => { diff --git a/apps/web/src/components/Panels/Canvas/FloatingToolbars/StrokeSelectionToolbar.tsx b/apps/web/src/components/Panels/Canvas/FloatingToolbars/StrokeSelectionToolbar.tsx index a4c19c0f9..d8f7d98c5 100644 --- a/apps/web/src/components/Panels/Canvas/FloatingToolbars/StrokeSelectionToolbar.tsx +++ b/apps/web/src/components/Panels/Canvas/FloatingToolbars/StrokeSelectionToolbar.tsx @@ -150,8 +150,8 @@ export const StrokeSelectionToolbar = () => { targetThreadId ? selectThreadLastAction(state, targetThreadId) : null, ); const agentProfiles = useAcpProfilesStore((state) => state.profiles); - const defaultProfileId = useAcpProfilesStore( - (state) => state.agentDefaults?.profileId, + const recentProfileId = useAcpProfilesStore( + (state) => state.conversationAgent?.effectiveProfileId, ); const currentLassoIdentity = useCallback( @@ -529,9 +529,9 @@ export const StrokeSelectionToolbar = () => { } if (!candidate.target) { const name = - defaultProfileId === 'huabu' + recentProfileId === 'huabu' ? t('settings.builtInPi') - : (agentProfiles.find((profile) => profile.id === defaultProfileId) + : (agentProfiles.find((profile) => profile.id === recentProfileId) ?.alias ?? t('toolbar.defaultInkAgentTarget')); return { label: t('toolbar.newInkAgentTarget', { name }), @@ -554,7 +554,7 @@ export const StrokeSelectionToolbar = () => { }; }, [ agentProfiles, - defaultProfileId, + recentProfileId, cachedTargetBinding, cachedTargetMode, candidate, diff --git a/apps/web/src/components/Panels/ChatPanel/index.tsx b/apps/web/src/components/Panels/ChatPanel/index.tsx index e33afb503..147581b5f 100644 --- a/apps/web/src/components/Panels/ChatPanel/index.tsx +++ b/apps/web/src/components/Panels/ChatPanel/index.tsx @@ -30,7 +30,10 @@ import { useActivelyViewingQuestionNode } from '@/hooks/useActivelyViewingQuesti import { useBuiltinThreadSettings } from '@/hooks/useBuiltinThreadSettings'; import { ChatSessionProvider, type ChatSession } from '@/hooks/useChatSession'; import { useInternalSlashCommands } from '@/hooks/useInternalSlashCommands'; -import { useAcpProfilesStore } from '@/store/acpProfilesStore'; +import { + rememberConversationAgentBinding, + useAcpProfilesStore, +} from '@/store/acpProfilesStore'; import { useAcpThreadChangesStore } from '@/store/acpThreadChangesStore'; import useCanvasStore from '@/store/canvasStore'; import { useChatPreferencesStore } from '@/store/chatPreferencesStore'; @@ -846,6 +849,19 @@ export const ChatPanel = ({ setSavingAgentDraft(false); } } + if (!activeConversationView) { + try { + await rememberConversationAgentBinding(choice.binding); + } catch (error) { + toast( + error instanceof Error + ? error.message + : 'Failed to save recent Agent selection', + { tone: 'danger' }, + ); + return; + } + } setAgentBinding(threadId, choice.binding, canvasId || undefined); setThreadLastAction(threadId, choice.mode); onCommit?.(); diff --git a/apps/web/src/components/Panels/PreviewWorkspace/PreviewWorkspace.test.tsx b/apps/web/src/components/Panels/PreviewWorkspace/PreviewWorkspace.test.tsx index 132f9b6aa..8100b97cf 100644 --- a/apps/web/src/components/Panels/PreviewWorkspace/PreviewWorkspace.test.tsx +++ b/apps/web/src/components/Panels/PreviewWorkspace/PreviewWorkspace.test.tsx @@ -80,10 +80,10 @@ vi.mock('@/api/acp', async (importOriginal) => ({ }), })); vi.mock('@/api/agentDefaults', () => ({ - getAgentDefaults: async () => ({ - defaults: { profileId: 'global-profile', functionalModel: '' }, + getConversationAgentPreference: async () => ({ + preference: { profileId: 'global-profile' }, + effectiveProfileId: 'global-profile', selectionState: 'available', - modelCapability: 'unknown', }), })); diff --git a/apps/web/src/components/Settings/SettingsModal.test.tsx b/apps/web/src/components/Settings/SettingsModal.test.tsx index d80919103..199801a9a 100644 --- a/apps/web/src/components/Settings/SettingsModal.test.tsx +++ b/apps/web/src/components/Settings/SettingsModal.test.tsx @@ -146,7 +146,7 @@ describe('Settings information architecture', () => { ); const defaults = container.querySelector('[data-testid="agent-defaults"]'); if (!profiles || !defaults) { - throw new Error('Expected Agent Profiles and Default Agent sections'); + throw new Error('Expected Agent Profiles and Utility Agent sections'); } expect( profiles.compareDocumentPosition(defaults) & diff --git a/apps/web/src/i18n/resources/en/common.json b/apps/web/src/i18n/resources/en/common.json index d6caee327..4705316be 100644 --- a/apps/web/src/i18n/resources/en/common.json +++ b/apps/web/src/i18n/resources/en/common.json @@ -216,14 +216,14 @@ "profileChangesNewExecutions": "Command, launch options, and directory changes apply only to new executions. Existing sessions and resumed executions keep their original configuration. The wrapper and machine cannot be changed.", "profileEditConflict": "This Profile changed elsewhere. Reload the Profiles list and reopen the editor before saving; your changes have not overwritten the newer version.", "profileSaveFailed": "Failed to save profile", - "agentDefaultsTitle": "Default Agent", - "agentDefaultsSectionDescription": "Used for new conversations and Huabu utility tasks such as summaries, titles, and labels. Existing conversations keep their current Agent.", + "agentDefaultsTitle": "Utility Agent", + "agentDefaultsSectionDescription": "Used only for Huabu utility tasks such as summaries, titles, labels, and keywords. New conversations use the most recently selected conversational Agent.", "builtInPi": "Built-In Pi", "builtInPiSetup": "Uses Huabu provider credentials; setup may be required.", "builtInPiConfigure": "Configure Built-In Pi providers and models", "structuredLaunchUnavailable": "This Agentlet cannot launch every detected harness with structured configuration. Use a compatible executable or a Custom command.", - "agentDefaultsProfile": "Default Agent Profile", - "agentDefaultsDescription": "Used for new Agent Nodes, unbound chats, and text/image metadata tasks. Existing conversations keep their Agent.", + "agentDefaultsProfile": "Utility Agent Profile", + "agentDefaultsDescription": "Choose a low-latency Agent for Huabu-managed background work without changing the Agent used by conversations.", "agentDefaultsModel": "Functional-task model", "agentDefaultsModelDescription": "Used for summaries, keywords, titles, and image labels when the Agent advertises the model. Image tasks require vision support. Does not change Profile or chat model preferences.", "agentDefaultsInherit": "Inherit the Profile model", @@ -233,8 +233,8 @@ "agentDefaultsOffline": "The selected Profile's machine is offline. Its selection is retained.", "agentDefaultsModelUnknown": "Model-selection support is unknown. This override is saved but has not been verified with the selected Agent.", "agentDefaultsModelUnsupported": "The selected Agent does not support model selection. This override cannot be applied.", - "agentDefaultsSaveFailed": "Failed to save Agent defaults", - "agentDefaultsSaved": "Agent defaults saved", + "agentDefaultsSaveFailed": "Failed to save Utility Agent settings", + "agentDefaultsSaved": "Utility Agent settings saved", "agentDirectoryRequired": "Agent directory is required", "commandRequired": "Command is required", "workingDirectoryRequired": "Working directory is required", @@ -1039,7 +1039,7 @@ "sendingInkRequest": "Sending ink request", "multipleQuestionTargets": "Select only one Agent Node to continue", "invalidQuestionTarget": "This Agent Node cannot receive an ink request", - "defaultInkAgentTarget": "Default Agent", + "defaultInkAgentTarget": "Recent Agent", "newInkAgentTarget": "New · {{name}}", "newInkAgentTargetDescription": "Create a new Agent Node with {{name}}", "inkAgentTarget": "Target agent: {{name}}", @@ -1141,8 +1141,9 @@ } }, "errors": { - "agentDefaultUnconfigured": "Connect an external Agent or select Built-In Pi as the default in Settings before starting a new conversation.", - "agentDefaultsUnavailable": "Agent defaults could not be loaded. Check the server connection and configure a default Profile in Settings.", + "conversationAgentUnconfigured": "Connect an external Agent before starting a new conversation.", + "conversationAgentUnavailable": "The recent conversation Agent could not be loaded. Check the server connection and try again.", + "conversationAgentStale": "The recently used Agent is unavailable. Select an available Agent before starting a new conversation.", "nodeSaveFailed": "Couldn't write \"{{name}}\" to disk — the file may be locked or not writable.", "rateLimited": "Too many requests. Try again in {{seconds}} seconds." } diff --git a/apps/web/src/i18n/resources/zh-CN/common.json b/apps/web/src/i18n/resources/zh-CN/common.json index 6d9710612..f59625ca8 100644 --- a/apps/web/src/i18n/resources/zh-CN/common.json +++ b/apps/web/src/i18n/resources/zh-CN/common.json @@ -216,14 +216,14 @@ "profileChangesNewExecutions": "命令、启动选项和目录的修改仅对新执行生效。现有会话及恢复的执行仍使用原配置。封装器和机器不可更改。", "profileEditConflict": "此配置已在其他位置修改。请重新加载配置列表并重新打开编辑器后再保存;您的修改尚未覆盖新版本。", "profileSaveFailed": "保存配置失败", - "agentDefaultsTitle": "默认 Agent", - "agentDefaultsSectionDescription": "用于新对话,以及摘要、标题和标签等 Huabu 辅助任务。现有对话继续使用当前 Agent。", + "agentDefaultsTitle": "Utility Agent", + "agentDefaultsSectionDescription": "仅用于摘要、标题、标签和关键词等 Huabu 辅助任务。新对话使用最近选择的对话 Agent。", "builtInPi": "Built-In Pi", "builtInPiSetup": "使用 Huabu 内的提供商凭据,可能需要配置。", "builtInPiConfigure": "配置 Built-In Pi 提供商和模型", "structuredLaunchUnavailable": "此 Agentlet 无法通过结构化配置启动所有已发现的 harness。请使用兼容的可执行文件或自定义命令。", - "agentDefaultsProfile": "默认 Agent 配置", - "agentDefaultsDescription": "用于新建 Agent 节点、未绑定的聊天和文本/图片元数据任务。现有会话保持原有 Agent。", + "agentDefaultsProfile": "Utility Agent 配置", + "agentDefaultsDescription": "为 Huabu 管理的后台任务选择低延迟 Agent,不会改变对话使用的 Agent。", "agentDefaultsModel": "功能任务模型", "agentDefaultsModelDescription": "当 Agent 声明支持所选模型时,用于摘要、关键词、标题和图片标签生成;图片任务需要视觉能力。不修改 Agent 配置或聊天模型偏好。", "agentDefaultsInherit": "继承 Agent 配置的模型", @@ -233,8 +233,8 @@ "agentDefaultsOffline": "所选配置所在的机器已离线,仍保留此选择。", "agentDefaultsModelUnknown": "模型选择支持情况未知。此设置会保存,但尚未通过所选 Agent 验证。", "agentDefaultsModelUnsupported": "所选 Agent 不支持模型选择,无法应用此模型覆盖设置。", - "agentDefaultsSaveFailed": "保存 Agent 默认设置失败", - "agentDefaultsSaved": "Agent 默认设置已保存", + "agentDefaultsSaveFailed": "保存 Utility Agent 设置失败", + "agentDefaultsSaved": "Utility Agent 设置已保存", "agentDirectoryRequired": "必须填写 Agent 目录", "commandRequired": "必须填写命令", "workingDirectoryRequired": "必须填写工作目录", @@ -1039,7 +1039,7 @@ "sendingInkRequest": "正在发送笔迹请求", "multipleQuestionTargets": "请只选择一个要继续的 Agent 节点", "invalidQuestionTarget": "此 Agent 节点无法接收笔迹请求", - "defaultInkAgentTarget": "默认 Agent", + "defaultInkAgentTarget": "最近使用的 Agent", "newInkAgentTarget": "新建 · {{name}}", "newInkAgentTargetDescription": "使用 {{name}} 新建 Agent 节点", "inkAgentTarget": "目标 Agent:{{name}}", @@ -1141,8 +1141,9 @@ } }, "errors": { - "agentDefaultUnconfigured": "请先连接外部 Agent,或在设置中将 Built-In Pi 设为默认,再开始新会话。", - "agentDefaultsUnavailable": "无法加载 Agent 默认设置。请检查服务器连接,并在设置中选择默认配置。", + "conversationAgentUnconfigured": "请先连接外部 Agent,再开始新对话。", + "conversationAgentUnavailable": "无法加载最近使用的对话 Agent。请检查服务器连接后重试。", + "conversationAgentStale": "最近使用的 Agent 当前不可用。请先选择一个可用 Agent,再开始新对话。", "nodeSaveFailed": "「{{name}}」写盘失败,可能文件被占用或没有写入权限。", "rateLimited": "请求过于频繁,请在 {{seconds}} 秒后重试。" } diff --git a/apps/web/src/store/acpProfilesStore.test.ts b/apps/web/src/store/acpProfilesStore.test.ts index 2daf78908..f0303896b 100644 --- a/apps/web/src/store/acpProfilesStore.test.ts +++ b/apps/web/src/store/acpProfilesStore.test.ts @@ -4,25 +4,34 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const listProfiles = vi.hoisted(() => vi.fn()); -const defaultsApi = vi.hoisted(() => ({ - get: vi.fn(), - update: vi.fn(), +const api = vi.hoisted(() => ({ + getDefaults: vi.fn(), + updateDefaults: vi.fn(), + getConversation: vi.fn(), + updateConversation: vi.fn(), toast: vi.fn(), })); vi.mock('@/api/acp', () => ({ listAcpProfiles: listProfiles })); vi.mock('@/api/agentDefaults', () => ({ - getAgentDefaults: defaultsApi.get, - updateAgentDefaults: defaultsApi.update, + getAgentDefaults: api.getDefaults, + updateAgentDefaults: api.updateDefaults, + getConversationAgentPreference: api.getConversation, + updateConversationAgentPreference: api.updateConversation, })); -vi.mock('@/components/Common/Toast', () => ({ toast: defaultsApi.toast })); +vi.mock('@/components/Common/Toast', () => ({ toast: api.toast })); import { getDefaultAgentBinding, loadDefaultAgentBinding, + rememberConversationAgentBinding, useAcpProfilesStore, } from './acpProfilesStore'; -import type { AgentDefaults, AgentDefaultsResponse } from '@huabu/shared'; +import type { + AgentDefaults, + AgentDefaultsResponse, + ConversationAgentPreferenceResponse, +} from '@huabu/shared'; const profile = { id: 'profile-default', @@ -33,37 +42,60 @@ const profile = { }; const snapshot = { profiles: [profile], - selectableProfileIds: [], + selectableProfileIds: [profile.id], agentlet: null, - agentDefaults: { profileId: profile.id, functionalModel: 'utility-only' }, + agentDefaults: { profileId: 'huabu', functionalModel: 'utility-only' }, }; +function conversation( + profileId: string | null, + selectionState: + | 'unconfigured' + | 'deleted' + | 'offline' + | 'available' = profileId ? 'available' : 'unconfigured', +): ConversationAgentPreferenceResponse { + return { + preference: { profileId }, + effectiveProfileId: profileId, + selectionState, + }; +} + beforeEach(() => { listProfiles.mockReset().mockResolvedValue(snapshot); - defaultsApi.get.mockReset().mockResolvedValue({ + api.getDefaults.mockReset().mockResolvedValue({ defaults: snapshot.agentDefaults, selectionState: 'available', - modelCapability: 'unknown', + modelCapability: 'supported', }); - defaultsApi.toast.mockReset(); - defaultsApi.update.mockReset().mockImplementation( + api.updateDefaults.mockReset().mockImplementation( async (defaults: AgentDefaults): Promise => ({ defaults, selectionState: 'available', modelCapability: 'unknown', }), ); + api.getConversation.mockReset().mockResolvedValue(conversation(profile.id)); + api.updateConversation + .mockReset() + .mockImplementation(async ({ profileId }: { profileId: string | null }) => + conversation(profileId), + ); + api.toast.mockReset(); useAcpProfilesStore.setState({ loaded: false, error: null, profiles: [profile], agentDefaults: null, defaultsError: null, + conversationAgent: null, + conversationAgentError: null, }); }); -describe('default Agent snapshot', () => { - it('awaits shared initialization and selects the configured identity even while offline', async () => { +describe('conversation Agent snapshot', () => { + it('deduplicates canonical preference loading and resolves the effective Profile', async () => { const [first, second] = await Promise.all([ loadDefaultAgentBinding(), loadDefaultAgentBinding(), @@ -74,97 +106,96 @@ describe('default Agent snapshot', () => { alias: profile.alias, }); expect(second).toEqual(first); - expect(defaultsApi.get).toHaveBeenCalledOnce(); - expect(listProfiles).not.toHaveBeenCalled(); - expect(useAcpProfilesStore.getState().agentDefaults).toEqual( - snapshot.agentDefaults, - ); - expect(first).not.toHaveProperty('functionalModel'); + expect(api.getConversation).toHaveBeenCalledOnce(); + expect(api.getDefaults).not.toHaveBeenCalled(); }); - it('keeps a deleted default ID rather than selecting the remaining Profile', async () => { - defaultsApi.get.mockResolvedValueOnce({ - defaults: { profileId: 'deleted', functionalModel: '' }, - selectionState: 'deleted', - modelCapability: 'unknown', + it.each(['deleted', 'offline'] as const)( + 'rejects a %s recently used Profile instead of silently falling back', + async (selectionState) => { + api.getConversation.mockResolvedValueOnce( + conversation('stale-profile', selectionState), + ); + await expect(loadDefaultAgentBinding()).rejects.toThrow(); + expect(() => getDefaultAgentBinding()).toThrow(); + }, + ); + + it('accepts the server-projected first Profile when no preference exists', async () => { + api.getConversation.mockResolvedValueOnce({ + preference: { profileId: null }, + effectiveProfileId: profile.id, + selectionState: 'available', }); - expect(await loadDefaultAgentBinding()).toEqual({ + await expect(loadDefaultAgentBinding()).resolves.toMatchObject({ kind: 'external', - profileId: 'deleted', - alias: 'deleted', + profileId: profile.id, }); }); - it.each([{ profileId: null, functionalModel: '' }])( - 'rejects unsupported or unconfigured defaults: %o', - async (agentDefaults) => { - defaultsApi.get.mockResolvedValueOnce({ - defaults: agentDefaults, - selectionState: 'unconfigured', - modelCapability: 'unknown', - }); - await expect(loadDefaultAgentBinding()).rejects.toThrow(); - expect(useAcpProfilesStore.getState().agentDefaults).toEqual( - agentDefaults ?? null, - ); - }, - ); - - it('does not use a previous snapshot after a failed refresh', async () => { - await loadDefaultAgentBinding(); - defaultsApi.get.mockRejectedValueOnce(new Error('offline')); - await expect(loadDefaultAgentBinding()).rejects.toThrow(); - expect(() => getDefaultAgentBinding()).toThrow(); - expect(useAcpProfilesStore.getState().profiles).toEqual([profile]); + it('supports Built-In Pi as an explicit conversational choice', async () => { + api.getConversation.mockResolvedValueOnce(conversation('huabu')); + await expect(loadDefaultAgentBinding()).resolves.toEqual({ + kind: 'internal', + }); }); - it('loads Built-In without an external catalogue, even after catalogue errors', async () => { - useAcpProfilesStore.setState({ - loaded: false, - error: new Error('registry offline'), + it('remembers explicit conversational use independently of Utility Agent settings', async () => { + await rememberConversationAgentBinding({ + kind: 'external', + profileId: profile.id, + alias: profile.alias, }); - defaultsApi.get.mockResolvedValueOnce({ - defaults: { profileId: 'huabu', functionalModel: 'external-model' }, - selectionState: 'available', - modelCapability: 'supported', + expect(api.updateConversation).toHaveBeenCalledWith({ + profileId: profile.id, }); - await expect(loadDefaultAgentBinding()).resolves.toEqual({ - kind: 'internal', + expect(getDefaultAgentBinding()).toMatchObject({ + profileId: profile.id, }); - expect(listProfiles).not.toHaveBeenCalled(); + expect(useAcpProfilesStore.getState().agentDefaults).toBeNull(); }); - it('does not let an in-flight defaults read undo a saved backend switch', async () => { - let finish!: (response: AgentDefaultsResponse) => void; - defaultsApi.get.mockImplementationOnce( + it('does not let Utility Agent changes reroute new conversations', async () => { + await loadDefaultAgentBinding(); + await useAcpProfilesStore.getState().saveDefaults({ + profileId: 'huabu', + functionalModel: '', + }); + expect(getDefaultAgentBinding()).toMatchObject({ + kind: 'external', + profileId: profile.id, + }); + }); + + it('serializes conversational choices so the last explicit selection wins', async () => { + let finish!: (response: ConversationAgentPreferenceResponse) => void; + api.updateConversation.mockImplementationOnce( () => - new Promise((resolve) => { + new Promise((resolve) => { finish = resolve; }), ); - const state = useAcpProfilesStore.getState(); - const loading = state.loadDefaults(); + const first = useAcpProfilesStore + .getState() + .rememberConversationAgent('first'); + const second = useAcpProfilesStore + .getState() + .rememberConversationAgent('second'); await Promise.resolve(); - const defaults = { profileId: 'huabu', functionalModel: 'external-model' }; - await state.saveDefaults(defaults); - defaultsApi.get.mockResolvedValue({ - defaults, - selectionState: 'available', - modelCapability: 'supported', - }); - finish({ - defaults: snapshot.agentDefaults, - selectionState: 'available', - modelCapability: 'unknown', - }); - expect((await loading).defaults).toEqual(defaults); - expect(getDefaultAgentBinding()).toEqual({ kind: 'internal' }); + expect(api.updateConversation).toHaveBeenCalledTimes(1); + finish(conversation('first')); + await Promise.all([first, second]); + expect( + api.updateConversation.mock.calls.map(([value]) => value.profileId), + ).toEqual(['first', 'second']); + expect(getDefaultAgentBinding()).toMatchObject({ profileId: 'second' }); }); +}); - it('serializes saves and publishes the last confirmed default for new chats', async () => { - await loadDefaultAgentBinding(); +describe('Utility Agent settings', () => { + it('serializes saves and publishes the last confirmed settings', async () => { let finish!: (response: AgentDefaultsResponse) => void; - defaultsApi.update.mockImplementationOnce( + api.updateDefaults.mockImplementationOnce( () => new Promise((resolve) => { finish = resolve; @@ -180,7 +211,7 @@ describe('default Agent snapshot', () => { functionalModel: 'fast', }); await Promise.resolve(); - expect(defaultsApi.update).toHaveBeenCalledTimes(1); + expect(api.updateDefaults).toHaveBeenCalledTimes(1); finish({ defaults: { profileId: 'first', functionalModel: '' }, selectionState: 'available', @@ -188,16 +219,16 @@ describe('default Agent snapshot', () => { }); await Promise.all([first, second]); expect( - defaultsApi.update.mock.calls.map(([config]) => config.profileId), + api.updateDefaults.mock.calls.map(([config]) => config.profileId), ).toEqual(['first', 'last']); - expect(getDefaultAgentBinding()).toMatchObject({ - kind: 'external', + expect(useAcpProfilesStore.getState().agentDefaults).toEqual({ profileId: 'last', + functionalModel: 'fast', }); }); it('reports failed saves without blocking subsequent edits', async () => { - defaultsApi.update.mockRejectedValueOnce(new Error('read-only')); + api.updateDefaults.mockRejectedValueOnce(new Error('read-only')); const state = useAcpProfilesStore.getState(); const first = state.saveDefaults({ profileId: 'first', @@ -209,15 +240,13 @@ describe('default Agent snapshot', () => { }); await expect(first).rejects.toThrow('read-only'); await second; - expect(defaultsApi.toast).toHaveBeenCalledWith('read-only', { - tone: 'danger', - }); + expect(api.toast).toHaveBeenCalledWith('read-only', { tone: 'danger' }); expect(useAcpProfilesStore.getState().agentDefaults?.profileId).toBe( 'last', ); }); - it('does not let an older catalogue refresh overwrite a saved default', async () => { + it('does not let an older catalogue refresh overwrite a saved setting', async () => { let finish!: (value: typeof snapshot) => void; listProfiles.mockImplementationOnce( () => @@ -228,41 +257,13 @@ describe('default Agent snapshot', () => { const refreshing = useAcpProfilesStore.getState().refresh(); await Promise.resolve(); await useAcpProfilesStore.getState().saveDefaults({ - profileId: 'new-default', + profileId: 'new-utility', functionalModel: '', }); finish(snapshot); await refreshing; - expect(getDefaultAgentBinding()).toMatchObject({ - kind: 'external', - profileId: 'new-default', - }); - }); - - it('waits for closing Settings saves before loading defaults again', async () => { - let finish!: (response: AgentDefaultsResponse) => void; - defaultsApi.update.mockImplementationOnce( - () => - new Promise((resolve) => { - finish = resolve; - }), + expect(useAcpProfilesStore.getState().agentDefaults?.profileId).toBe( + 'new-utility', ); - const state = useAcpProfilesStore.getState(); - const saving = state.saveDefaults({ - profileId: 'new-default', - functionalModel: '', - }); - const loading = state.loadDefaults(); - await Promise.resolve(); - expect(defaultsApi.get).not.toHaveBeenCalled(); - const response: AgentDefaultsResponse = { - defaults: { profileId: 'new-default', functionalModel: '' }, - selectionState: 'available', - modelCapability: 'unknown', - }; - defaultsApi.get.mockResolvedValue(response); - finish(response); - await saving; - expect(await loading).toEqual(response); }); }); diff --git a/apps/web/src/store/acpProfilesStore.ts b/apps/web/src/store/acpProfilesStore.ts index 0504407b9..5b8ee6705 100644 --- a/apps/web/src/store/acpProfilesStore.ts +++ b/apps/web/src/store/acpProfilesStore.ts @@ -40,7 +40,12 @@ import { create } from 'zustand'; import { listAcpProfiles } from '@/api/acp'; -import { getAgentDefaults, updateAgentDefaults } from '@/api/agentDefaults'; +import { + getAgentDefaults, + getConversationAgentPreference, + updateAgentDefaults, + updateConversationAgentPreference, +} from '@/api/agentDefaults'; import { toast } from '@/components/Common/Toast'; import { i18n } from '@/i18n'; @@ -49,12 +54,17 @@ import type { AgentBinding, AgentDefaults, AgentDefaultsResponse, + ConversationAgentPreferenceResponse, } from '@huabu/shared'; let inFlightRefresh: Promise | null = null; let inFlightDefaults: Promise | null = null; +let inFlightConversationAgent: Promise | null = + null; let defaultsSaveQueue = Promise.resolve(); let defaultsRevision = 0; +let conversationAgentSaveQueue = Promise.resolve(); +let conversationAgentRevision = 0; interface AcpProfilesState { /** Every profile the user has created. Empty until the first fetch. */ @@ -66,6 +76,8 @@ interface AcpProfilesState { /** Absent on older servers; never infer a default from list ordering. */ agentDefaults: AgentDefaults | null; defaultsError: Error | null; + conversationAgent: ConversationAgentPreferenceResponse | null; + conversationAgentError: Error | null; /** * `true` once a fetch has *succeeded* at least once. A failed initial * fetch leaves this `false` (and {@link profiles} empty), so consumers @@ -86,6 +98,10 @@ interface AcpProfilesState { refresh: () => Promise; loadDefaults: () => Promise; saveDefaults: (config: AgentDefaults) => Promise; + loadConversationAgent: () => Promise; + rememberConversationAgent: ( + profileId: string, + ) => Promise; } export const useAcpProfilesStore = create()((set, get) => ({ @@ -94,6 +110,8 @@ export const useAcpProfilesStore = create()((set, get) => ({ agentlet: null, agentDefaults: null, defaultsError: null, + conversationAgent: null, + conversationAgentError: null, loaded: false, error: null, loading: false, @@ -122,9 +140,18 @@ export const useAcpProfilesStore = create()((set, get) => ({ window.addEventListener('workspace-changed', () => { set({ error: null }); void get().refresh(); + void get() + .loadConversationAgent() + .catch(() => undefined); }); } - await get().refresh(); + await Promise.all([ + get().refresh(), + get() + .loadConversationAgent() + .then(() => undefined) + .catch(() => undefined), + ]); }, loadDefaults: async () => { await defaultsSaveQueue; @@ -183,6 +210,58 @@ export const useAcpProfilesStore = create()((set, get) => ({ ); return request; }, + loadConversationAgent: async () => { + await conversationAgentSaveQueue; + if (inFlightConversationAgent) return inFlightConversationAgent; + const revision = conversationAgentRevision; + const request = getConversationAgentPreference().then( + async (response) => { + if (revision !== conversationAgentRevision) { + inFlightConversationAgent = null; + return get().loadConversationAgent(); + } + conversationAgentRevision++; + set({ + conversationAgent: response, + conversationAgentError: null, + }); + return response; + }, + (error) => { + if (revision === conversationAgentRevision) { + set({ + conversationAgentError: + error instanceof Error ? error : new Error(String(error)), + }); + } + throw error; + }, + ); + inFlightConversationAgent = request; + const clear = () => { + if (inFlightConversationAgent === request) { + inFlightConversationAgent = null; + } + }; + void request.then(clear, clear); + return request; + }, + rememberConversationAgent: (profileId) => { + const request = conversationAgentSaveQueue.then(async () => { + const response = await updateConversationAgentPreference({ profileId }); + conversationAgentRevision++; + set({ + conversationAgent: response, + conversationAgentError: null, + }); + return response; + }); + conversationAgentSaveQueue = request.then( + () => {}, + () => {}, + ); + return request; + }, refresh: async () => { await defaultsSaveQueue; if (inFlightRefresh) return inFlightRefresh; @@ -222,12 +301,16 @@ export const useAcpProfilesStore = create()((set, get) => ({ /** Snapshot only the chat identity; functional-model routing is unrelated. */ export function getDefaultAgentBinding(): AgentBinding { const state = useAcpProfilesStore.getState(); - if (!state.agentDefaults || state.defaultsError) { - throw new Error(i18n.t('errors.agentDefaultsUnavailable')); + if (!state.conversationAgent || state.conversationAgentError) { + throw new Error(i18n.t('errors.conversationAgentUnavailable')); } - const profileId = state.agentDefaults?.profileId; - if (!profileId) { - throw new Error(i18n.t('errors.agentDefaultUnconfigured')); + const { effectiveProfileId: profileId, selectionState } = + state.conversationAgent; + if (!profileId || selectionState === 'unconfigured') { + throw new Error(i18n.t('errors.conversationAgentUnconfigured')); + } + if (selectionState !== 'available') { + throw new Error(i18n.t('errors.conversationAgentStale')); } if (profileId === 'huabu') return { kind: 'internal' }; const profile = state.profiles.find((entry) => entry.id === profileId); @@ -240,6 +323,13 @@ export function getDefaultAgentBinding(): AgentBinding { /** User-initiated creation waits for the canonical server snapshot. */ export async function loadDefaultAgentBinding(): Promise { - await useAcpProfilesStore.getState().loadDefaults(); + await useAcpProfilesStore.getState().loadConversationAgent(); return getDefaultAgentBinding(); } + +export async function rememberConversationAgentBinding( + binding: AgentBinding, +): Promise { + const profileId = binding.kind === 'internal' ? 'huabu' : binding.profileId; + await useAcpProfilesStore.getState().rememberConversationAgent(profileId); +} diff --git a/apps/web/src/store/canvasStore.postCreateEditing.test.ts b/apps/web/src/store/canvasStore.postCreateEditing.test.ts index 626331ee1..e2616c668 100644 --- a/apps/web/src/store/canvasStore.postCreateEditing.test.ts +++ b/apps/web/src/store/canvasStore.postCreateEditing.test.ts @@ -3,10 +3,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const getDefaults = vi.hoisted(() => vi.fn()); +const getConversationAgent = vi.hoisted(() => vi.fn()); vi.mock('@/components/Common/Toast', () => ({ toast: vi.fn() })); vi.mock('@/api/agentDefaults', () => ({ - getAgentDefaults: getDefaults, + getConversationAgentPreference: getConversationAgent, })); import { toast } from '@/components/Common/Toast'; @@ -53,10 +53,10 @@ function resetStore() { beforeEach(() => { vi.useFakeTimers(); vi.mocked(toast).mockClear(); - getDefaults.mockResolvedValue({ - defaults: { profileId: 'global-profile', functionalModel: '' }, + getConversationAgent.mockResolvedValue({ + preference: { profileId: 'global-profile' }, + effectiveProfileId: 'global-profile', selectionState: 'available', - modelCapability: 'unknown', }); resetStore(); }); @@ -88,7 +88,7 @@ describe('post-create editing', () => { }); it('focuses the most recently active existing Chat', async () => { - getDefaults.mockClear(); + getConversationAgent.mockClear(); const preview = usePreviewWorkspaceStore.getState(); const first = preview.openPreviewTarget({ kind: 'chat', @@ -114,14 +114,14 @@ describe('post-create editing', () => { expect( usePreviewWorkspaceStore.getState().workspace.tabs[second], ).toBeDefined(); - expect(getDefaults).not.toHaveBeenCalled(); + expect(getConversationAgent).not.toHaveBeenCalled(); }); it('prompts to configure defaults without creating a fallback Chat', async () => { - getDefaults.mockResolvedValueOnce({ - defaults: { profileId: null, functionalModel: '' }, + getConversationAgent.mockResolvedValueOnce({ + preference: { profileId: null }, + effectiveProfileId: null, selectionState: 'unconfigured', - modelCapability: 'unknown', }); const threads = useChatStore.getState().threadsById; expect(await openChat()).toBe(''); @@ -133,7 +133,7 @@ describe('post-create editing', () => { it('does not open a delayed Chat in a different Canvas or changed workspace', async () => { let resolve!: (value: unknown) => void; - getDefaults.mockReturnValueOnce( + getConversationAgent.mockReturnValueOnce( new Promise((done) => { resolve = done; }), @@ -142,9 +142,9 @@ describe('post-create editing', () => { const pending = openNewChat(); openPreviewNode('node-note'); resolve({ - defaults: { profileId: 'global-profile', functionalModel: '' }, + preference: { profileId: 'global-profile' }, + effectiveProfileId: 'global-profile', selectionState: 'available', - modelCapability: 'unknown', }); expect(await pending).toBe(''); expect(useChatStore.getState().threadsById).toBe(threads); diff --git a/apps/web/src/store/chatStore.sessionScope.test.ts b/apps/web/src/store/chatStore.sessionScope.test.ts index ba885a525..9d36a5a9e 100644 --- a/apps/web/src/store/chatStore.sessionScope.test.ts +++ b/apps/web/src/store/chatStore.sessionScope.test.ts @@ -41,6 +41,12 @@ function resetStore() { profileId: EXTERNAL.profileId, functionalModel: 'utility-only', }, + conversationAgent: { + preference: { profileId: EXTERNAL.profileId }, + effectiveProfileId: EXTERNAL.profileId, + selectionState: 'available', + }, + conversationAgentError: null, }); useChatStore.setState({ threadsById: {}, @@ -169,7 +175,10 @@ describe('chatStore thread creation', () => { threadMap: { 'canvas-legacy': 'thread-legacy' }, bindingByThread: { 'thread-legacy': INTERNAL }, }); - useAcpProfilesStore.setState({ agentDefaults: null, loaded: false }); + useAcpProfilesStore.setState({ + conversationAgent: null, + loaded: false, + }); expect(useChatStore.getState().ensureCanvasThread('canvas-legacy')).toBe( 'thread-legacy', ); @@ -180,7 +189,11 @@ describe('chatStore thread creation', () => { it('refuses unconfigured creation without leaving an internal thread', () => { useAcpProfilesStore.setState({ - agentDefaults: { profileId: null, functionalModel: '' }, + conversationAgent: { + preference: { profileId: null }, + effectiveProfileId: null, + selectionState: 'unconfigured', + }, }); expect(() => useChatStore.getState().createThread()).toThrow(); expect(() => diff --git a/apps/web/src/store/conversationOwner.test.ts b/apps/web/src/store/conversationOwner.test.ts index bb0b0a76f..df2d98279 100644 --- a/apps/web/src/store/conversationOwner.test.ts +++ b/apps/web/src/store/conversationOwner.test.ts @@ -13,6 +13,17 @@ vi.mock('@/api/canvas', async (importOriginal) => ({ postCanvasExecute, acknowledgeAgentNodeResult, })); +vi.mock('@/api/agentDefaults', () => ({ + updateConversationAgentPreference: async ({ + profileId, + }: { + profileId: string | null; + }) => ({ + preference: { profileId }, + effectiveProfileId: profileId, + selectionState: profileId ? 'available' : 'unconfigured', + }), +})); import useCanvasStore from './canvasStore'; import { useChatStore } from './chatStore'; diff --git a/apps/web/src/store/conversationOwner.ts b/apps/web/src/store/conversationOwner.ts index 35b175453..4cc415b94 100644 --- a/apps/web/src/store/conversationOwner.ts +++ b/apps/web/src/store/conversationOwner.ts @@ -5,6 +5,8 @@ import { getQuestionNodeStatus } from '@huabu/shared'; import { projectAgentNodeEditableData } from '@huabu/shared/canvas-engine'; import { acknowledgeAgentNodeResult, postCanvasExecute } from '@/api/canvas'; +import { toast } from '@/components/Common/Toast'; +import { rememberConversationAgentBinding } from '@/store/acpProfilesStore'; import useCanvasStore, { awaitQuestionCreation } from '@/store/canvasStore'; import type { AgentBinding, AgentConversationView } from '@huabu/shared'; @@ -202,6 +204,16 @@ export function saveConversationDraft( 'Agent selection changed before the draft was acknowledged', ); } + try { + await rememberConversationAgentBinding(patch.agentBinding); + } catch (error) { + toast( + error instanceof Error + ? error.message + : 'Failed to save recent Agent selection', + { tone: 'danger' }, + ); + } }); draftSaves.set(draftKey(view), save); // Keep a rejected save available to the send guard until an explicit retry. diff --git a/docs/architecture/agent-architecture.md b/docs/architecture/agent-architecture.md index fd57fbd66..ecca24bb2 100644 --- a/docs/architecture/agent-architecture.md +++ b/docs/architecture/agent-architecture.md @@ -141,7 +141,7 @@ Panel Chat and Question nodes share one naming policy through [ConversationTitle Source priority is `user > generated > acp > fallback`: lower-priority updates are rejected rather than saved, and manual naming retains no hidden automatic candidate. Non-empty user/agent-owned node labels are protected from automatic changes; service-written automatic labels use `labelSource: 'auto'` and retain their exact source in `conversationTitleSource`. Unbound Chat persists one current `{ title, source }` through `Agenetes.updateHostMetadata`; Disk and SQLite preserve it across restart, driver snapshots, and rehome. When no higher-priority host title exists, reads may use the driver's current valid ACP title; the last valid ACP title is persisted only as the current `acp` title, not a separate candidate. The zod-free [shared title utility](../../packages/shared/src/utils/conversation-title.ts), exported as `@huabu/shared/conversation-title`, supplies host/manual whitespace normalization and 120-character truncation. `normalizeAcpConversationTitle` rejects non-string, blank, multiline, or normalized values longer than 120 characters, without prompt-text or fixed-prefix filtering. Invalid updates leave the accepted title unchanged; invalid driver metadata can fall back to a valid persisted current ACP title. There is no migration for the old unmerged candidate-field shape. Driver `sessionInfo.title` and ACP prompt assembly are never rewritten. -Question preprocessing delegates to `initializeQuestion()` instead of independently generating a label. It can establish a fallback and generate from existing Question content before any durable chat thread exists; generation starts a separate functional task, never the Question's interactive session. Node-backed send admission calls `ensureFallback()` after lifecycle start and before slow Agent preparation, preserving a name even if preparation fails. Both built-in Deployment and ACP message adapters call `start()` after durable realization, for either Chat or Question ownership: fallback preparation is awaited, title generation is not. Initialization uses the first persisted user prompt when available, otherwise Question content or the current submission, and attempts generation even when ACP supplies a title. `ProviderManager.generateContentMeta` follows the global default, independently of the source thread's binding: Built-In uses Pi's `contentMeta` role; external uses the functional model preference through an isolated, non-persisted Agenetes Job. There is no cross-backend fallback. Unavailable Profiles, invalid output, and task failures are reported through the existing title-service logger; they retain the accepted ACP/fallback title and permit a later turn/initialize retry. Successful generated titles and protected names suppress generation. Concurrent initialization shares one in-flight attempt per Canvas/thread (or node before association). Completion rechecks current ownership, source, and applicable content freshness under the Canvas write boundary, so late results cannot overwrite manual names or stale Question content. Reads never create a handle, spawn ACP, generate a title, or repair stored metadata; legacy unbound threads may derive a read-only fallback from their first persisted user prompt. +Question preprocessing delegates to `initializeQuestion()` instead of independently generating a label. It can establish a fallback and generate from existing Question content before any durable chat thread exists; generation starts a separate functional task, never the Question's interactive session. Node-backed send admission calls `ensureFallback()` after lifecycle start and before slow Agent preparation, preserving a name even if preparation fails. Both built-in Deployment and ACP message adapters call `start()` after durable realization, for either Chat or Question ownership: fallback preparation is awaited, title generation is not. Initialization uses the first persisted user prompt when available, otherwise Question content or the current submission, and attempts generation even when ACP supplies a title. `ProviderManager.generateContentMeta` follows the Utility Agent independently of the source thread's binding and conversational recency: Built-In uses Pi's `contentMeta` role; external uses the functional model preference through an isolated, non-persisted Agenetes Job. There is no cross-backend fallback. Unavailable Profiles, invalid output, and task failures are reported through the existing title-service logger; they retain the accepted ACP/fallback title and permit a later turn/initialize retry. Successful generated titles and protected names suppress generation. Concurrent initialization shares one in-flight attempt per Canvas/thread (or node before association). Completion rechecks current ownership, source, and applicable content freshness under the Canvas write boundary, so late results cannot overwrite manual names or stale Question content. Reads never create a handle, spawn ACP, generate a title, or repair stored metadata; legacy unbound threads may derive a read-only fallback from their first persisted user prompt. Canonical external realization installs the persist-then-notify metadata subscriber for both Chat and Question owners before session bootstrap, including control-first realization. Valid ACP updates pass through the same source policy and cannot replace generated/manual titles or protected node labels. Conversion transfers naming authority at canonical node creation, adopting the latest effective backend title when the incoming label is not protected. Manual titles become user labels; nonmanual titles become automatic labels with provenance. Subsequent ACP updates and in-flight generation resolve the new node owner and may continue updating its label; no dual-title synchronization loop exists. See [Question conversion](./question-node.md#3-node-lifecycle). diff --git a/docs/architecture/agent-memory.md b/docs/architecture/agent-memory.md index befd50e41..46a327b2f 100644 --- a/docs/architecture/agent-memory.md +++ b/docs/architecture/agent-memory.md @@ -31,7 +31,7 @@ Two independent write paths: ### 2.1 Background curator (automatic) -The legacy Pi curator is enabled only when the explicit global Agent default is Built-In Pi (`huabu`). An individual Built-In conversation does not enable it while the global default is external or unconfigured. The request hook does not accumulate new operations while disabled, and the worker checks the default again before starting queued or coalesced work. An already-started pass may finish after a default switch; new passes do not start. Existing memory, counters, credentials and explicit internal Skill authoring are preserved. A defaults-read failure is logged and does not start Pi. External Memory curation/consumption is tracked separately in #243. +The legacy Pi curator is enabled only when the Utility Agent is Built-In Pi (`huabu`). An individual Built-In conversation does not enable it while the Utility Agent is external or unconfigured. The request hook does not accumulate new operations while disabled, and the worker checks the Utility Agent again before starting queued or coalesced work. An already-started pass may finish after a utility switch; new passes do not start. Existing memory, counters, credentials and explicit internal Skill authoring are preserved. A settings-read failure is logged and does not start Pi. External Memory curation/consumption is tracked separately in #243. - Each canvas keeps an op counter in `/.memory/state.json`. - Every _mutating_ HTTP request (PUT / POST / PATCH / DELETE for that canvas) is counted by a Fastify hook ([memory/op-counter-hook.ts](../../apps/server/src/modules/agent/memory/op-counter-hook.ts)). diff --git a/docs/architecture/agent-profiles.md b/docs/architecture/agent-profiles.md index b3a8a2be8..7301000ae 100644 --- a/docs/architecture/agent-profiles.md +++ b/docs/architecture/agent-profiles.md @@ -2,7 +2,7 @@ Ordinary external agents use persisted Profiles and the existing ACP runtime. Agentlet owns the supported harness catalogue, detection, capability descriptions, and structured launch compilation; Agenetes owns the generic Profile registry; Huabu owns automatic Profile creation, the application default, and the Settings/API projection. -Profiles describe Agent harness providers, not standalone service APIs. Optional OCR, web search, and image generation integrations retain their own configuration and server-held credentials independently of the default Agent. Submission-time Ink OCR supplies auxiliary evidence to the chosen Agent; it is not an internal-Agent fallback. A future model-provider or function-provider abstraction is separate from the Profile contract. +Profiles describe Agent harness providers, not standalone service APIs. Optional OCR, web search, and image generation integrations retain their own configuration and server-held credentials independently of the Utility Agent or conversational recency preference. Submission-time Ink OCR supplies auxiliary evidence to the chosen Agent; it is not an internal-Agent fallback. A future model-provider or function-provider abstraction is separate from the Profile contract. ## Discovery and provisioning @@ -55,7 +55,7 @@ Huabu stores the reserved automatic-source marker in `customData.discoveredAgent The tuple deduplicates only automatic defaults. For discovery advertising `launchVersion: 1`, an existing automatic Profile suppresses creation only when its actual launch is `acp-harness` for the same harness. A legacy automatic `acp-command` Profile therefore remains untouched while discovery creates a new typed Profile with a distinct ID. Reconnection and restart reuse that typed identity, preserving its edits; discovery without typed support continues to deduplicate against either launch kind rather than creating downgrade duplicates. Manual Profiles without automatic provenance do not suppress automatic creation. -Profile aliases are display labels, not unique keys: defaults, edits, execution snapshots and thread bindings use Profile IDs. When a new automatic typed Profile's standard alias is already occupied, its initial alias adds the harness ID in brackets, such as `GitHub Copilot (huabu) [copilot]`; this is a display hint, not an alias-uniqueness constraint. Existing aliases, commands, launch options, directories, saved global defaults and conversation bindings are not rewritten or migrated. The create API rejects caller-supplied automatic provenance; patches preserve the original marker even when replacing or clearing other custom data, and reject attempts to change it. The generic Agenetes registry does not interpret this Huabu-owned field. +Profile aliases are display labels, not unique keys: Utility Agent settings, conversational recency, edits, execution snapshots and thread bindings use Profile IDs. When a new automatic typed Profile's standard alias is already occupied, its initial alias adds the harness ID in brackets, such as `GitHub Copilot (huabu) [copilot]`; this is a display hint, not an alias-uniqueness constraint. Existing aliases, commands, launch options, directories, saved settings and conversation bindings are not rewritten or migrated. The create API rejects caller-supplied automatic provenance; patches preserve the original marker even when replacing or clearing other custom data, and reject attempts to change it. The generic Agenetes registry does not interpret this Huabu-owned field. Deleting an automatic Profile is ordinary deletion. A later discovery may create a new default with a new Profile ID. There is no tombstone, reset endpoint, persisted availability state machine or reconciliation controller. Missing harnesses and failed probes do not delete existing Profiles. Existing ordinary workload snapshots remain independent of subsequent Profile edits or deletion. @@ -69,23 +69,23 @@ The repository's `agent-teams/` manifest, prompt and Skill folders remain data a ## Settings and APIs -Owner-only `GET /api/agent/defaults` and `PUT /api/agent/defaults` expose `{ profileId: string | null, functionalModel: string }`, persisted atomically in `/agent-defaults.json`. The reserved `huabu` identity selects Built-In Pi explicitly; other identities select external Profiles and `null` means unconfigured. Built-In reads and writes require no external registry or connected Agentlet. Its `available` state means the backend exists, not that provider authentication has been verified; missing credentials are repaired through Built-In settings and never cause a backend fallback. +Owner-only `GET /api/agent/defaults` and `PUT /api/agent/defaults` expose the Utility Agent configuration `{ profileId: string | null, functionalModel: string }`, persisted atomically in `/agent-defaults.json`. The reserved `huabu` identity selects Built-In Pi explicitly; other identities select external Profiles and `null` means unconfigured. Built-In reads and writes require no external registry or connected Agentlet. Its `available` state means the backend exists, not that provider authentication has been verified; missing credentials are repaired through Built-In settings and never cause a backend fallback. -When no record exists, provisioning chooses a stable ordered external Profile on a connected Agentlet and saves its identity. With no candidate, setup guides the user to connect an external Agent or explicitly choose Built-In Pi. Existing records, including null/deleted/offline selections, remain authoritative. Old provider credentials do not implicitly enable Pi, and switching backends does not clear credentials or models. Reads do not discover agents, initialize defaults, or create sessions. Deleting or disconnecting the selected Profile does not choose a replacement. +When no Utility Agent record exists, provisioning chooses a stable ordered external Profile on a connected Agentlet and saves its identity. With no candidate, setup guides the user to connect an external Agent or explicitly choose Built-In Pi. Existing records, including null/deleted/offline selections, remain authoritative. Old provider credentials do not implicitly enable Pi, and switching utility backends does not clear credentials or models. Reads do not discover agents, initialize settings, or create sessions. Deleting or disconnecting the selected Profile does not choose a replacement. -Settings > Agent is the single conversational-Agent surface. It presents external Profile management first, followed by the Default Agent used for new conversations and Huabu utility tasks, conditional Built-In Pi configuration, Agent behavior, and external-Agent runtime controls without changing their separate persistence owners. Existing conversations retain their bindings when the default changes. The external functional-model field is hidden for Built-In; its saved value survives switching. Built-In provider, login, Chat and Utility model controls load and render only for a Built-In global default or an explicit configuration visit from a Built-In conversation. The conversation's configuration button remains available after binding is locked and does not change the global default. Settings > Capabilities separately owns Huabu-managed image generation, OCR, Web Search, and YouTube transcript configuration; these services appear as peer capabilities and do not imply that an external Agent can invoke the corresponding Huabu tools. The larger Image Generation form starts collapsed and expands on demand. +Settings > Agent is the single Agent configuration surface. It presents external Profile management first, followed by the Utility Agent used only for Huabu-owned auxiliary work, conditional Built-In Pi configuration, Agent behavior, and external-Agent runtime controls without changing their separate persistence owners. Changing the Utility Agent never changes a conversation binding or conversational recency. The external functional-model field is hidden for Built-In; its saved value survives switching. Built-In provider, login, Chat and Utility model controls load and render only for a Built-In Utility Agent or an explicit configuration visit from a Built-In conversation. The conversation's configuration button remains available after binding is locked and does not change Utility Agent settings. Settings > Capabilities separately owns Huabu-managed image generation, OCR, Web Search, and YouTube transcript configuration; these services appear as peer capabilities and do not imply that an external Agent can invoke the corresponding Huabu tools. The larger Image Generation form starts collapsed and expands on demand. -Huabu-owned auxiliary tasks outside an Agent Node's bound thread follow the global default, including summaries, keywords, content/Frame titles, conversation titles, and image labels. The source conversation's backend does not determine its automatic title backend. Each invocation selects its backend once; changing defaults does not reroute in-flight work. Built-In tasks reuse `llmComplete` with the existing `contentMeta`, `frameLabel`, and `imageLabel` roles, preserving Utility/Chat model resolution and vision handling. External tasks use independent ACP Jobs. Thread-owned work and its tools retain the thread binding; explicit internal Skill authoring remains available. Legacy background Memory starts only for a Built-In global default; external Memory and unified Skill authoring remain separate follow-ups. +Huabu-owned auxiliary tasks outside an Agent Node's bound thread follow the Utility Agent, including summaries, keywords, content/Frame titles, conversation titles, and image labels. The source conversation's backend does not determine its automatic title backend. Each invocation selects its backend once; changing Utility Agent settings does not reroute in-flight work. Built-In tasks reuse `llmComplete` with the existing `contentMeta`, `frameLabel`, and `imageLabel` roles, preserving Utility/Chat model resolution and vision handling. External tasks use independent ACP Jobs. Thread-owned work and its tools retain the thread binding; explicit internal Skill authoring remains available. Legacy background Memory starts only for a Built-In Utility Agent; external Memory and unified Skill authoring remain separate follow-ups. Image labels reuse chat image resolution and its 4 MB decoded-image cap; unavailable pixels and invalid labels fail explicitly. For external tasks, the trimmed functional-model value inherits the Profile's remembered model when empty, then the harness default. A known wrapper receives the preference only through advertised live ACP model controls. Unsupported or unknown model capability is a warning, not a fabricated guarantee; unavailable values are logged and the harness default remains. Custom commands receive no generic model injection. Functional execution does not modify Profile launch configuration or interactive chat preferences. ACP images require `promptCapabilities.image: true` and a vision-capable harness/model; failures never fall back to Pi. -Default Profile selection saves immediately; the functional-model input saves after 600 ms of inactivity and flushes on blur or Settings unmount. There is no separate Save button. The shared Profile store serializes these writes across Settings mounts, publishes confirmed defaults for new conversations, and prevents older catalogue responses from overwriting a confirmed save. Save errors retain the editable draft and appear inline and as a toast, including when Settings has already closed; editing again or blurring a failed model input retries. Existing conversations and default-initialization ordering are unchanged. +Utility Profile selection saves immediately; the functional-model input saves after 600 ms of inactivity and flushes on blur or Settings unmount. There is no separate Save button. The shared Profile store serializes these writes across Settings mounts and prevents older catalogue responses from overwriting a confirmed save. Save errors retain the editable draft and appear inline and as a toast, including when Settings has already closed; editing again or blurring a failed model input retries. Utility settings do not publish or modify a conversational binding. -External functional text tasks reuse Profile snapshot compilation and Agenetes event folding, without creating visible Agent Nodes or storing Agenetes conversations. An unconfigured default or unavailable Profile is an explicit failure, never an internal fallback. A background permission request fails the task and forwards cancellation without escalating approval; the task deadline also bounds unresponsive harnesses. Agenetes owns one-shot Job cleanup: normal completion, failure, or early iterator return closes the handle and waits for exact Agentlet process reclamation, while cleanup failure remains observable. Per-workflow overrides and historical conversation migration remain outside this step; both runtimes are retained. +External functional text tasks reuse Profile snapshot compilation and Agenetes event folding, without creating visible Agent Nodes or storing Agenetes conversations. An unconfigured Utility Agent or unavailable Utility Profile is an explicit failure, never an internal fallback. A background permission request fails the task and forwards cancellation without escalating approval; the task deadline also bounds unresponsive harnesses. Agenetes owns one-shot Job cleanup: normal completion, failure, or early iterator return closes the handle and waits for exact Agentlet process reclamation, while cleanup failure remains observable. Per-workflow overrides and historical conversation migration remain outside this step; both runtimes are retained. Functional Jobs await the asynchronous Agenetes creation API inside the task deadline. Cancellation or timeout during creation returns promptly, and a handle that arrives afterward cannot dispatch the task. Creation failures propagate without fallback; a process spawned before connection failure is compensatingly stopped, and failure to confirm that stop is reported as cleanup failure. -New conversations and newly created Agent Nodes snapshot the configured default unless the caller supplies an explicit binding. Web creation reads the canonical defaults endpoint independently of external catalogue readiness; cached Profile data only supplies display aliases. Existing conversations, restored nodes, and explicit selections keep their original binding. A missing or deleted default produces an actionable error, not a silent switch to another Profile. Loading a Space and initializing a legacy thread association remain independent of default availability. +Owner-only `GET` and `PUT /api/agent/conversation-profile` expose `{ profileId: string | null }`, persisted separately in `/conversation-agent.json`. It records the most recently explicitly used conversational Agent and never changes during utility execution. New conversations and newly created Agent Nodes snapshot this identity unless the caller supplies an explicit binding. When no conversational identity has ever been recorded, resolution uses the first selectable external Profile without writing that fallback; when a recorded identity is deleted or unavailable, creation fails explicitly instead of silently rerouting. Web, Ink, server-created Agent Nodes, and RFS creation share this canonical resolution. Existing conversations, restored nodes, and explicit selections keep their original binding. Ink submission without an existing Question target also loads the canonical default before creating its Question, using internal `operate` or external `ask` mode. It reuses the shared default-binding loader and rejects a changed Space or selection after that await. Failed loading leaves the Ink selection intact and creates no node; an already selected Agent target and a retry of the same created Question keep their binding. @@ -95,7 +95,7 @@ Ink submission without an existing Question target also loads the canonical defa Owner-only `POST /api/acp/profile-launch-preview` accepts `{ launch, profileId? }`, selects the saved Profile's machine when editing, and returns the daemon's validated `exec`/`shell` plan. It never spawns an Agent or prepares a workspace. Unsupported/offline daemon previews fail explicitly. Profile creation and runtime-relevant patches independently validate structured launch support and options, so client-side controls are not the validation boundary. `PATCH /api/acp/profiles/:id` requires `expectedRevision` and permits mutable template fields only; display-only edits do not require a connected daemon. -The Agent Settings surface presents ordinary Profiles, their existing edit/delete actions, and the agentlet health banner alongside the global default and backend-specific configuration. Opening a Profile editor temporarily focuses that nested view without duplicating or rewriting Profile state. Template/member Config/setup controls are removed. Catalogue and Profile endpoints remain owner-only. Shared HTTP contracts remain under `packages/shared/src/types/api/`, with type-only imports in the Web app. +The Agent Settings surface presents ordinary Profiles, their existing edit/delete actions, and the agentlet health banner alongside Utility Agent and backend-specific configuration. Opening a Profile editor temporarily focuses that nested view without duplicating or rewriting Profile state. Template/member Config/setup controls are removed. Catalogue, Profile, Utility Agent, and conversational preference endpoints remain owner-only. Shared HTTP contracts remain under `packages/shared/src/types/api/`, with type-only imports in the Web app. Owner-only `GET/PUT /api/acp/runtime-config` persists the external-agent idle timeout and supervised-daemon process limit in `/external-agent-runtime-config.json`; both controls appear under Settings > Agent > External Agent runtime. `maxAgents` defaults to `10` and accepts any positive JavaScript safe integer without a product-defined maximum. Huabu passes it as `--max-agents` whenever the supervised daemon starts; saving does not hot-update or automatically restart the daemon, and manually launched remote daemons continue to use their own CLI argument without a configuration handshake. diff --git a/docs/architecture/agent-reachback.md b/docs/architecture/agent-reachback.md index 95cba0109..82f8804c0 100644 --- a/docs/architecture/agent-reachback.md +++ b/docs/architecture/agent-reachback.md @@ -85,7 +85,7 @@ Uploads are inert payloads stored under `.upload/`. Names must be explicit and c `POST /agent/:threadId/ink-intent` accepts `{ invocationToken, report }` only for the matching active external Ink turn in this Space. The report is `{ status: "inferred", text }` (one line, at most 120 characters) or `{ status: "clarify" | "unsupported" }`. The authenticated turn prompt supplies the endpoint and a fresh invocation token; this token fences stale reports and is not a substitute for RFS authentication. Completion, cancellation, and failure invalidate it. The shared Ink writer preserves manual titles and only renames the untouched pending Ink Question; the response is `{ report, renamed }`, and an inactive or mismatched turn returns `409 ink_turn_inactive`. Confirmed reports enter the normal turn event stream and durable transcript through the ACP driver's host-event drain, preserving the existing inferred-intent Chat display without invoking an internal Agent. -`POST /agent` always creates a visible Agent Node. A plain-text body uses the configured global default plus an immediate first prompt; the full JSON form optionally selects another available Profile, position, launch options, optional parent thread, and optional prompt. Omitting `profileId` uses the same saved default (external Profile or explicit `huabu` for Built-In Pi) and fails explicitly if it is unconfigured or unavailable. `X-Huabu-Agent-Start: false` creates an idle Agent from JSON without submitting a turn. +`POST /agent` always creates a visible Agent Node. A plain-text body uses the canonical recent conversational Agent plus an immediate first prompt; the full JSON form optionally selects another available Profile, position, launch options, optional parent thread, and optional prompt. Omitting `profileId` uses the most recently selected conversational Profile, or the first selectable external Profile when no selection has ever been recorded, and fails explicitly when a recorded identity is unavailable. `X-Huabu-Agent-Start: false` creates an idle Agent from JSON without submitting a turn. Parent lineage is best effort. The route resolves `parentThreadId` or `X-Huabu-Host-Thread-Id` to any Question Node in the current Space and attempts an ordinary Canvas edge after creating the Agent Node. A missing parent or rejected edge is returned as non-blocking creation metadata and never rolls back or rejects the new Agent. diff --git a/docs/architecture/api-design.md b/docs/architecture/api-design.md index d77e79eaa..23ffaf116 100644 --- a/docs/architecture/api-design.md +++ b/docs/architecture/api-design.md @@ -144,6 +144,10 @@ Malformed request fields and malformed cursors return HTTP 400 with `code: "malf `GET/PUT /api/acp/runtime-config` uses `externalAgentRuntimeConfigSchema` from [`acp.ts`](../../packages/shared/src/types/api/acp.ts). The owner-only full replacement body contains `idleTimeoutSecs` and `maxAgents`; `maxAgents` is a positive JavaScript safe integer with default `10` and no product-defined upper bound. The value is persisted globally and supplied to the supervised Agentlet daemon as `--max-agents` on its next start; the API does not restart the daemon or configure manually launched remote daemons. +## Utility and conversation Agent selection + +`GET/PUT /api/agent/defaults` uses `agentDefaultsSchema` for the Utility Agent only: its Profile and optional functional-model override serve Huabu-owned auxiliary work and never choose a conversational binding. `GET/PUT /api/agent/conversation-profile` uses `conversationAgentPreferenceSchema` and `conversationAgentPreferenceResponseSchema` for the separately persisted most recently selected conversational Agent. A null preference resolves to the first selectable external Profile without persisting that fallback; a stale persisted identity is returned with `deleted` or `offline` state and is never silently replaced. + ## RFS Agent discovery `POST /api/rfs/:canvasId/agent/:threadId/ink-intent` uses `rfsInkIntentParamsSchema`, `rfsInkIntentRequestSchema`, and `rfsInkIntentResponseSchema` in `types/api/rfs.ts`, reusing `inkIntentReportSchema`. RFS decodes its raw JSON buffer, validates the target and body with `safeParse`, and delegates to the shared Ink writer. A per-turn invocation token must match the active external turn; inactive, expired, or wrong-scope reports return `409 ink_turn_inactive`. The token is a freshness guard, not a credential; the normal RFS Bearer requirement remains mandatory. diff --git a/docs/architecture/deployment-security.md b/docs/architecture/deployment-security.md index 437d4da45..ddf909bcd 100644 --- a/docs/architecture/deployment-security.md +++ b/docs/architecture/deployment-security.md @@ -15,7 +15,7 @@ The connection token is a separate machine credential used by RFS and the embedd The global Agent Change Review configuration follows the same owner boundary. `GET` and `PUT /api/agent-change-review/config` are available only to loopback or Basic-authenticated owner requests; possession of the RFS connection token does not authorize reading or changing the automatic-acceptance policy. -The default Agent (external Profile or explicit Built-In Pi) and external functional-model preference follow the same boundary through `GET` and `PUT /api/agent/defaults`. These settings do not grant new tool permissions, change native harness approval policy, or authorize callers holding only an RFS connection token to mutate the global selection. +The Utility Agent (external Profile or explicit Built-In Pi) and external functional-model preference follow the same owner boundary through `GET` and `PUT /api/agent/defaults`; the independent recent conversational Agent follows that boundary through `GET` and `PUT /api/agent/conversation-profile`. These settings do not grant new tool permissions, change native harness approval policy, or authorize callers holding only an RFS connection token to mutate either selection. Optional submission-time Ink OCR is an explicit outbound data boundary. Configuring an Azure AI Vision endpoint and key through Settings > General or `VISION_ENDPOINT` / `VISION_KEY` opts the Server into sending a transient raster containing only the selected Ink strokes to that resource when the owner submits an Ink Query. Settings sends newly entered keys to the owner-authorized Server for secure storage; reads never return a plaintext key, and the browser never calls Azure directly. Both reads and writes at `/api/integrations/ink-ocr/config` require owner authorization. Only Azure AI Vision's Image Analysis Read protocol is supported. Successful OCR evidence persists both in the structured envelope at `AgentSubmission.content.focus.selection.inkRecognition` and in the canonical inputs at `AgentSubmission.rendered`. Normal provider diagnostics record only outcome, duration, HTTP status, raster dimensions, node count, and line count; they exclude credentials, endpoint values, image bytes, and recognized text. diff --git a/docs/architecture/node-preprocessing.md b/docs/architecture/node-preprocessing.md index c12afb079..a590cd94e 100644 --- a/docs/architecture/node-preprocessing.md +++ b/docs/architecture/node-preprocessing.md @@ -37,7 +37,7 @@ Fresh remote PDFs are localized during preprocessing. Extract downloads the PDF Before preprocessing, an agent-authored `web.src` is normalized at the server executor boundary: any canvas-local `.html` file is imported into `.artifacts/` and persisted as a bare artifact key (uploads staged under `.upload/` are reclaimed), while live `http(s)://` and self-contained `data:` URLs remain unchanged; other local extensions are not imported or reclaimed. Input Resolve then maps the artifact key to an absolute local path for extraction, while remote and `data:` sources continue through the URL path. -Text enrichment and Frame titles use the explicit global default through `ProviderManager` -> `runFunctionalText()`. Built-In Pi reuses `llmComplete` with the existing `contentMeta`/`frameLabel` roles and Utility/Chat model resolution. External defaults use an Agenetes ACP Job. Existing prompts and projection/persistence paths remain unchanged. The caller supplies the Space identity; each external task uses a separate ACP session without creating a visible Agent Node, reusing a chat session, or recording a conversation in Agenetes. Results are consumed directly from the run event stream with the existing transcript folder. The external functional model override takes precedence over the Profile's remembered model; absent both, the harness default applies. Only advertised ACP model controls are used, and functional execution does not write interactive Profile preferences. Custom commands receive no model injection. Unsupported model preferences produce a warning rather than guessed CLI flags. No backend failure triggers a switch to the other backend. +Text enrichment and Frame titles use the explicit Utility Agent through `ProviderManager` -> `runFunctionalText()`. Built-In Pi reuses `llmComplete` with the existing `contentMeta`/`frameLabel` roles and Utility/Chat model resolution. External Utility Agents use an Agenetes ACP Job. Existing prompts and projection/persistence paths remain unchanged. The caller supplies the Space identity; each external task uses a separate ACP session without creating a visible Agent Node, reusing a chat session, recording a conversation in Agenetes, or changing conversational recency. Results are consumed directly from the run event stream with the existing transcript folder. The external functional model override takes precedence over the Profile's remembered model; absent both, the harness default applies. Only advertised ACP model controls are used, and functional execution does not write interactive Profile preferences. Custom commands receive no model injection. Unsupported model preferences produce a warning rather than guessed CLI flags. No backend failure triggers a switch to the other backend. Missing/deleted/offline Profiles, Agent errors, invalid or incomplete output, and timeouts propagate to the existing `ENRICH_FAILED` diagnostic without marking enrichment capabilities complete. Metadata must include all requested non-empty fields with the expected types; unrequested fields are not applied. Frame titles must be non-empty, single-line, and at most 60 characters. A five-minute task deadline bounds caller waiting and forwards cancellation; an interactive permission request aborts the background task rather than granting permission or waiting for unseen UI. These task instructions are behavioral guidance, not a sandbox. ACP Job process/client reclamation remains deferred, and tasks retain the current idle-suspension behavior. diff --git a/docs/architecture/preview-workspace.md b/docs/architecture/preview-workspace.md index cdfc10339..c70885d07 100644 --- a/docs/architecture/preview-workspace.md +++ b/docs/architecture/preview-workspace.md @@ -95,7 +95,7 @@ Layers primary activation uses transient semantic node targets and the passive e Note and Chat links share the pointer cursor through `data-link-activation="plain"`. Activation is not inferred from callback presence: canvas `NoteNode` uses `modifier`, suppressing native plain-click navigation while allowing the event to bubble for node selection. Platform-modifier clicks open externally on both Note and Chat rather than invoking their host callback. All Milkdown link handlers suppress drag and repeated-click navigation, including surfaces without a callback; the first eligible stationary click opens synchronously and cannot be cancelled by a later double-click. See [Note link activation](./note-node.md#6-link-activation) for the shared gesture contract and single editable-editor link panel used by toolbar, shortcut, and hover. Expanded Note supports creating links from selected text and editing existing links; Chat remains read-only and has no link-edit form. -`openChat` activates the most recently used unbound Chat target or creates a new thread and tab when none exists. New conversation always creates an independent `threadId` and snapshots the configured default Agent (external Profile or explicit Built-In Pi) unless supplied another binding. Missing or deleted defaults produce an error rather than silently selecting Built-In Pi or another Profile. Existing threads retain their persisted selection; legacy Canvas-thread initialization does not require an external default merely to load a Space. +`openChat` activates the most recently used unbound Chat target or creates a new thread and tab when none exists. A new conversation always creates an independent `threadId` and snapshots the most recently selected conversational Agent unless supplied another binding; before any selection has been recorded it uses the first selectable external Profile. A deleted or unavailable remembered identity produces an error rather than silently selecting Built-In Pi or another Profile. Existing threads retain their persisted selection; Utility Agent changes and legacy Canvas-thread initialization do not alter conversational bindings. Open to Side moves the existing semantic target into the other group instead of duplicating it and preserves whether the tab is transient or permanent. Saving an unbound Chat as a Question replaces that tab's target in place, preserving tab identity, position, messages, and draft continuity. @@ -133,7 +133,7 @@ When a conversation is visible beside an ordinary node, its composer offers that Ordinary Question sessions retain `AgentConversationView`: presentation and owner identify the same active Canvas/node, and the owner carries the Question's `threadId`. History, reconnect, Agent turns, tools, lifecycle writes, binding, mode, and change records use that owner scope. Legacy World `nodeRef` sessions and source-reference resolution are removed. Space Preview scenes do not mount source Question conversations; the user enters the source Space to open one. -An authored Question node remains authoritative for persisted agent mode and fixed binding. A new selectable Question snapshots the configured global default unless the caller supplies an explicit binding; existing Questions do not inherit later global or Canvas selection changes. +An authored Question node remains authoritative for persisted agent mode and fixed binding. A new selectable Question snapshots the canonical recent conversational Agent unless the caller supplies an explicit binding; existing Questions do not inherit later conversational, Utility Agent, or Canvas selection changes. ## 5. Groups, tabs, and bounds diff --git a/docs/architecture/question-node.md b/docs/architecture/question-node.md index 8f189a3f1..7fb365d62 100644 --- a/docs/architecture/question-node.md +++ b/docs/architecture/question-node.md @@ -126,7 +126,7 @@ Activating a `conversation` result row ([CanvasSearchResults.tsx](../../apps/web Double-click the node → `openInCompose()` ([QuestionNode.tsx](../../apps/web/src/components/Nodes/question/QuestionNode.tsx)). Creating a question through the toolbar placement flow or the connected-node picker also mints the thread and opens compose immediately. [`questionCompose.ts`](../../apps/web/src/components/Nodes/question/questionCompose.ts) opens the Question's Preview Workspace node tab and directs the input-focus request to that thread. - confirms server-acknowledged creation (or initializes a legacy node's missing thread association), opens the chat panel in **compose mode**, and defaults the built-in Huabu Agent to `operate` -- new Questions snapshot the configured default Agent (external Profile or explicit Built-In Pi) unless a binding is supplied; existing Questions and legacy association repair retain their binding, and the user can switch an editable binding +- new Questions snapshot the most recently selected conversational Agent unless a binding is supplied, falling back to the first selectable external Profile only when no conversational selection has ever been recorded; existing Questions, Utility Agent changes, and legacy association repair retain their binding, and the user can switch an editable binding - user types the question, hits send → first send writes `content` back to the node Toolbar (single action): **Ask** when idle, **View / Watch conversation** once a diff --git a/docs/architecture/sketch-node.md b/docs/architecture/sketch-node.md index e386bd668..f572db694 100644 --- a/docs/architecture/sketch-node.md +++ b/docs/architecture/sketch-node.md @@ -110,7 +110,7 @@ The stroke selection lives in `gesturePreviewStore.sketchStrokeSelection` (`node **Cross-region split / merge (drag).** A **pure** stroke selection (no whole node in the lasso) dropped onto **blank canvas** splits into a brand-new sketch region; dropped onto **another** sketch region it merges into it. On commit [useSketchStrokeMove.ts](../../apps/web/src/hooks/useSketchStrokeMove.ts) hit-tests the drop point in absolute flow (excluding the source regions, topmost wins) and, for a cross-region drop, dispatches the `MOVE_SKETCH_STROKES_TO_REGION` UI intent instead of the in-node translate. [resolveMoveSketchStrokesToRegion.ts](../../apps/web/src/handler/canvasCommand/resolvers/resolveMoveSketchStrokesToRegion.ts) resolves the destination parent frame from the drop point (`resolveFrameAtPoint`) and calls [buildSketchStrokeTransferCommands](../../apps/web/src/components/Nodes/sketch/sketchMerge.ts), which works in **absolute flow** (`getAbsolutePosition`) so transfers across frames stay correct and degrade to the plain in-node math when the parent is unchanged; the source side reuses the extracted pure core `computeEraseCommands` (reflow the remainder, or delete the node when emptied). Splitting **into a frame** shows the same grow-to-fit accept-preview a whole-node drag gets (`computeFrameFit` + `setFrameFitPreviews`); merge / in-place / blank-top-level drops show none. The whole transfer is **one undo entry** — `canvasStore.moveSketchStrokesToRegion` brackets it with `beginNodeDataGesture` / `endNodeDataGesture` (with an empty-op `rollbackGestureSnapshot` guard). Deferred: auto-contact ("bridging") merge, edge rewiring when a source is emptied, and OCR-rerun on split (see the [sketch-region-redesign proposal](../proposals/sketch-region-redesign.md)). -**Toolbar arbitration.** A [StrokeSelectionToolbar](../../apps/web/src/components/Panels/Canvas/FloatingToolbars/StrokeSelectionToolbar.tsx) floats above the stroke selection: on a **pure, single-color** selection it shows color + size controls (reusing [SketchControls](../../apps/web/src/components/Nodes/sketch/SketchControls.tsx), applied only to the selected strokes — the brush preset is untouched). Each color or size tick builds one `MERGE_NODE_DATA` command containing every affected sketch patch, matching the multi-node accent path's atomic update and undo semantics; the size slider's gesture bracket folds all ticks into one undo entry. A **Delete** action (touch only — desktop uses the keyboard) reuses the eraser's `buildEraseCommands` (subset removal → bbox reflow, or node delete when empty). Delete removes the **whole selection** — strokes plus any whole nodes the same lasso caught — as **one undo entry**: the node delete takes its snapshot + intent trace, then the stroke erase folds into that same entry via [commitStrokeCommands](../../apps/web/src/components/Nodes/sketch/sketchMerge.ts) (`foldIntoOpenGesture`), mirroring the mixed stroke-move gesture. **Delete / Backspace** triggers the same combined delete (guarded against text inputs); the canonical keyboard handler in [useCanvasShortcuts.ts](../../apps/web/src/hooks/shortcuts/useCanvasShortcuts.ts) **skips its own node deletion while a stroke selection is active** so the keypress never pushes a second snapshot. To guarantee at most one floating toolbar, the node toolbars (single-select in [NodeWrapper.tsx](../../apps/web/src/components/Nodes/NodeWrapper.tsx) and MultiSelect) hide whenever a stroke selection exists. Pure and mixed Ink selections retain the source count and submit action; an adjacent compact target hint shows `New · ` (or `New · Default Agent` before its name is known) when the Lasso contains no Question/Agent Node, the effective bound Agent name when it contains one valid target, or a blocked state for multiple or invalid targets. New Ink Questions load the configured global default before creation, using Built-In operate or external ask mode; existing targets keep their binding. The target hint is metadata rather than a source and never changes the source count. Rendering a stroke selection to PNG and sending it to the agent is covered in §4.1. Optional submission-time OCR uses only the explicitly selected strokes as transient Agent evidence and does not persist recognized text into the Sketch; eager/background region OCR remains deferred (see [sketch-region-redesign proposal](../proposals/sketch-region-redesign.md) Stage 3). +**Toolbar arbitration.** A [StrokeSelectionToolbar](../../apps/web/src/components/Panels/Canvas/FloatingToolbars/StrokeSelectionToolbar.tsx) floats above the stroke selection: on a **pure, single-color** selection it shows color + size controls (reusing [SketchControls](../../apps/web/src/components/Nodes/sketch/SketchControls.tsx), applied only to the selected strokes — the brush preset is untouched). Each color or size tick builds one `MERGE_NODE_DATA` command containing every affected sketch patch, matching the multi-node accent path's atomic update and undo semantics; the size slider's gesture bracket folds all ticks into one undo entry. A **Delete** action (touch only — desktop uses the keyboard) reuses the eraser's `buildEraseCommands` (subset removal → bbox reflow, or node delete when empty). Delete removes the **whole selection** — strokes plus any whole nodes the same lasso caught — as **one undo entry**: the node delete takes its snapshot + intent trace, then the stroke erase folds into that same entry via [commitStrokeCommands](../../apps/web/src/components/Nodes/sketch/sketchMerge.ts) (`foldIntoOpenGesture`), mirroring the mixed stroke-move gesture. **Delete / Backspace** triggers the same combined delete (guarded against text inputs); the canonical keyboard handler in [useCanvasShortcuts.ts](../../apps/web/src/hooks/shortcuts/useCanvasShortcuts.ts) **skips its own node deletion while a stroke selection is active** so the keypress never pushes a second snapshot. To guarantee at most one floating toolbar, the node toolbars (single-select in [NodeWrapper.tsx](../../apps/web/src/components/Nodes/NodeWrapper.tsx) and MultiSelect) hide whenever a stroke selection exists. Pure and mixed Ink selections retain the source count and submit action; an adjacent compact target hint shows `New · ` (or `New · Recent Agent` before its name is known) when the Lasso contains no Question/Agent Node, the effective bound Agent name when it contains one valid target, or a blocked state for multiple or invalid targets. New Ink Questions load the canonical recent conversational Agent before creation, falling back to the first selectable external Profile only when no selection has ever been recorded and using Built-In operate or external ask mode; existing targets keep their binding. The target hint is metadata rather than a source and never changes the source count. Rendering a stroke selection to PNG and sending it to the agent is covered in §4.1. Optional submission-time OCR uses only the explicitly selected strokes as transient Agent evidence and does not persist recognized text into the Sketch; eager/background region OCR remains deferred (see [sketch-region-redesign proposal](../proposals/sketch-region-redesign.md) Stage 3). An eligible empty-Canvas tap, or an unlocked tap on empty space inside the retained Lasso region, dismisses the complete retained result without creating an undo entry; a locked region gesture still moves it. During Ink Query preparation, a Canvas-scoped transient guard protects the captured selection from dismissal, retained-region movement, and tool-change cleanup; the toolbar keeps its dimensions, disables Send, and replaces the Send icon with the shared Spinner until durable acceptance or a known rejection ends local preparation. An ambiguous pre-acceptance transport result retains its acceptance observer for Chat stream reconnect and Stop reconciliation. A confirmed Stop with no acceptance proves that the turn never started, removes that observer, and restores the Send control while preserving the Lasso and Question for retry. The reservation is scoped to the captured polygon/stroke identity: a newer Lasso immediately restores its normal Send state, but it does not discard the older observer, and stale callbacks from the older turn cannot clear it. Until that observer resolves, the shared turn controller rejects another dispatch to the same Canvas/thread instead of replacing the observer; a submission targeting another thread remains independent. Acceptance removes the toolbar only when its captured Lasso still matches. The accepted turn's running status belongs to the Question Node, and ChatPanel owns Stop rather than morphing the completed Lasso surface into a run controller. Finger direct-selection hit-testing excludes the painted Sketch body and continues to ordinary content below, while React Flow resize and connection controls remain interactive and mouse and pen behavior remains unchanged. diff --git a/docs/architecture/web-architecture.md b/docs/architecture/web-architecture.md index 0bd08fd8e..31fa211ec 100644 --- a/docs/architecture/web-architecture.md +++ b/docs/architecture/web-architecture.md @@ -186,7 +186,7 @@ Space Shortcut retains the `spacePreview` node type and renders the canonical ic ### Settings information architecture -The tabbed Settings modal has three product-owned surfaces. **Agent** presents ordinary external Profile management first, then the Default Agent for new conversations and Huabu utility tasks, conditional Built-In Pi provider/model setup, Agent behavior, and external-Agent runtime controls while preserving each component's existing store and API ownership. **Capabilities** presents Huabu-managed image generation, Web Search, YouTube transcript, and Ink OCR configuration as peer capabilities without an extra “Other Capabilities” grouping; the larger Image Generation form starts collapsed, and the surface copy does not imply external-Agent tool parity. **General** contains application, Canvas, input, update, and deployment preferences that do not configure an Agent or service capability. The hidden Built-In repair deep link remains focused on Pi provider/model setup and returns to the Agent surface after closing. +The tabbed Settings modal has three product-owned surfaces. **Agent** presents ordinary external Profile management first, then the Utility Agent used only for Huabu summaries, titles, labels, keywords, and related auxiliary work, conditional Built-In Pi provider/model setup, Agent behavior, and external-Agent runtime controls while preserving each component's existing store and API ownership. New conversations independently use the most recently selected conversational Agent, falling back to the first selectable Profile only before any conversational selection has been recorded. **Capabilities** presents Huabu-managed image generation, Web Search, YouTube transcript, and Ink OCR configuration as peer capabilities without an extra “Other Capabilities” grouping; the larger Image Generation form starts collapsed, and the surface copy does not imply external-Agent tool parity. **General** contains application, Canvas, input, update, and deployment preferences that do not configure an Agent or service capability. The hidden Built-In repair deep link remains focused on Pi provider/model setup and returns to the Agent surface after closing. Agent Profile management uses `components/Settings/agent-profiles/` and the canonical `acpProfilesStore`. It has ordinary command Profile creation/editing/deletion and no Agent Team templates, Configs or setup lifecycle. The manual editor reads the agentlet-backed catalogue; automatic defaults arrive through the same persisted Profile list used by selectors. Opening an editor focuses the nested Agent view while retaining the existing Profile revision and save contracts. See [Agent Profiles](./agent-profiles.md). diff --git a/packages/shared/src/types/api/agent-defaults.test.ts b/packages/shared/src/types/api/agent-defaults.test.ts index 755d549f4..75aa72065 100644 --- a/packages/shared/src/types/api/agent-defaults.test.ts +++ b/packages/shared/src/types/api/agent-defaults.test.ts @@ -3,7 +3,10 @@ import { describe, expect, it } from 'vitest'; -import { agentDefaultsSchema } from './agent-defaults.js'; +import { + agentDefaultsSchema, + conversationAgentPreferenceSchema, +} from './agent-defaults.js'; describe('Agent defaults contract', () => { it('trims model overrides and allows inheritance', () => { @@ -21,6 +24,28 @@ describe('Agent defaults contract', () => { ); }); + describe('conversation Agent preference contract', () => { + it('accepts an explicit Profile or a never-selected state', () => { + expect( + conversationAgentPreferenceSchema.parse({ profileId: ' profile-a ' }), + ).toEqual({ profileId: 'profile-a' }); + expect( + conversationAgentPreferenceSchema.parse({ profileId: null }), + ).toEqual({ profileId: null }); + }); + + it.each([ + {}, + { profileId: '' }, + { profileId: 1 }, + { profileId: null, functionalModel: '' }, + ])('rejects invalid preference %j', (value) => { + expect(conversationAgentPreferenceSchema.safeParse(value).success).toBe( + false, + ); + }); + }); + it.each(['huabu', ' huabu '])( 'accepts the explicit Built-In selection %j', (profileId) => { diff --git a/packages/shared/src/types/api/agent-defaults.ts b/packages/shared/src/types/api/agent-defaults.ts index e9ebeb9dd..f908759de 100644 --- a/packages/shared/src/types/api/agent-defaults.ts +++ b/packages/shared/src/types/api/agent-defaults.ts @@ -19,3 +19,23 @@ export const agentDefaultsResponseSchema = z.object({ }); export type AgentDefaultsResponse = z.infer; + +export const conversationAgentPreferenceSchema = z + .object({ + profileId: z.string().trim().min(1).max(255).nullable(), + }) + .strict(); + +export type ConversationAgentPreference = z.infer< + typeof conversationAgentPreferenceSchema +>; + +export const conversationAgentPreferenceResponseSchema = z.object({ + preference: conversationAgentPreferenceSchema, + effectiveProfileId: z.string().min(1).max(255).nullable(), + selectionState: z.enum(['unconfigured', 'deleted', 'offline', 'available']), +}); + +export type ConversationAgentPreferenceResponse = z.infer< + typeof conversationAgentPreferenceResponseSchema +>; From c4e6848e3b952ffcd72557774578878995bc5df9 Mon Sep 17 00:00:00 2001 From: Yuqing Yang Date: Thu, 1 Oct 2026 02:52:19 +0000 Subject: [PATCH 11/30] fix(settings): align Agent and capability cards Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Settings/SettingsModal.test.tsx | 5 + .../src/components/Settings/SettingsModal.tsx | 11 +- .../AgentDefaultsSettings.test.tsx | 8 +- .../agent-profiles/AgentDefaultsSettings.tsx | 161 +++++------ .../sections/ImageProviderSettings.test.tsx | 6 +- .../sections/ImageProviderSettings.tsx | 269 ++++++++++-------- apps/web/src/i18n/resources/en/common.json | 1 - apps/web/src/i18n/resources/zh-CN/common.json | 1 - docs/architecture/agent-profiles.md | 2 +- docs/architecture/web-architecture.md | 2 +- 10 files changed, 251 insertions(+), 215 deletions(-) diff --git a/apps/web/src/components/Settings/SettingsModal.test.tsx b/apps/web/src/components/Settings/SettingsModal.test.tsx index 199801a9a..1b52dd2b7 100644 --- a/apps/web/src/components/Settings/SettingsModal.test.tsx +++ b/apps/web/src/components/Settings/SettingsModal.test.tsx @@ -164,6 +164,11 @@ describe('Settings information architecture', () => { }); expect(container.textContent).toContain('settings.capabilitiesDescription'); + expect( + container + .querySelector('[data-testid="capability-sections"]') + ?.classList.contains('space-y-4'), + ).toBe(true); expect( container.querySelector('[data-testid="image-settings"]'), ).not.toBeNull(); diff --git a/apps/web/src/components/Settings/SettingsModal.tsx b/apps/web/src/components/Settings/SettingsModal.tsx index 58c7945d0..0e08c7319 100644 --- a/apps/web/src/components/Settings/SettingsModal.tsx +++ b/apps/web/src/components/Settings/SettingsModal.tsx @@ -307,9 +307,14 @@ export const SettingsModal: React.FC = ({

{t('settings.capabilitiesDescription')}

- - - +
+ + + +
)}
diff --git a/apps/web/src/components/Settings/agent-profiles/AgentDefaultsSettings.test.tsx b/apps/web/src/components/Settings/agent-profiles/AgentDefaultsSettings.test.tsx index 643e706e1..8b2652087 100644 --- a/apps/web/src/components/Settings/agent-profiles/AgentDefaultsSettings.test.tsx +++ b/apps/web/src/components/Settings/agent-profiles/AgentDefaultsSettings.test.tsx @@ -145,11 +145,13 @@ async function editModel(value: string) { } describe('Agent defaults Settings', () => { - it('explains that the default serves new conversations and utility tasks', async () => { + it('places the Utility Agent explanation inside the Profile row card', async () => { await render(); - expect(container.textContent).toContain( - 'settings.agentDefaultsSectionDescription', + const description = [...container.querySelectorAll('p')].find( + (element) => + element.textContent === 'settings.agentDefaultsSectionDescription', ); + expect(description?.closest('.ring-1')).not.toBeNull(); }); it('allows Built-In while the external catalogue is unavailable, retaining the external model', async () => { diff --git a/apps/web/src/components/Settings/agent-profiles/AgentDefaultsSettings.tsx b/apps/web/src/components/Settings/agent-profiles/AgentDefaultsSettings.tsx index ace5500f3..3dbad210c 100644 --- a/apps/web/src/components/Settings/agent-profiles/AgentDefaultsSettings.tsx +++ b/apps/web/src/components/Settings/agent-profiles/AgentDefaultsSettings.tsx @@ -131,97 +131,92 @@ export function AgentDefaultsSettings() { : 'unknown'; return ( - <> -

- {t('settings.agentDefaultsSectionDescription')} -

- - {!draft ? ( -

+ {!draft ? ( +

+ {error ?? t('settings.loadingAgents')} +

+ ) : ( + <> + - {error ?? t('settings.loadingAgents')} -

- ) : ( - <> + void refresh()} - onChange={(profileId) => { - edit({ ...draft, profileId }, true); + { + edit({ ...draft, functionalModel: event.target.value }); + }} + onBlur={() => { + if (error) edit(draft, true); + else debouncedSave.flush(); }} />
- {!isBuiltIn && ( - - { - edit({ ...draft, functionalModel: event.target.value }); - }} - onBlur={() => { - if (error) edit(draft, true); - else debouncedSave.flush(); - }} - /> - - )} -
- {missing ? ( -

- {t('settings.agentDefaultsDeleted')} -

- ) : draft.profileId === null ? ( -

- {t('settings.agentDefaultsUnconfigured')} -

- ) : snapshot?.defaults.profileId === draft.profileId && - snapshot.selectionState === 'offline' ? ( + )} +
+ {missing ? ( +

+ {t('settings.agentDefaultsDeleted')} +

+ ) : draft.profileId === null ? ( +

+ {t('settings.agentDefaultsUnconfigured')} +

+ ) : snapshot?.defaults.profileId === draft.profileId && + snapshot.selectionState === 'offline' ? ( +

+ {t('settings.agentDefaultsOffline')} +

+ ) : null} + {!isBuiltIn && + draft.functionalModel.trim() && + modelCapability !== 'supported' && (

- {t('settings.agentDefaultsOffline')} -

- ) : null} - {!isBuiltIn && - draft.functionalModel.trim() && - modelCapability !== 'supported' && ( -

- {modelCapability === 'unsupported' - ? t('settings.agentDefaultsModelUnsupported') - : t('settings.agentDefaultsModelUnknown')} -

- )} - {(error || profilesError) && ( -

- {error ?? profilesError?.message} + {modelCapability === 'unsupported' + ? t('settings.agentDefaultsModelUnsupported') + : t('settings.agentDefaultsModelUnknown')}

)} - {(saving || saved) && ( -

- {saving - ? t('settings.saving') - : t('settings.agentDefaultsSaved')} -

- )} -
- - )} - - + {(error || profilesError) && ( +

+ {error ?? profilesError?.message} +

+ )} + {(saving || saved) && ( +

+ {saving + ? t('settings.saving') + : t('settings.agentDefaultsSaved')} +

+ )} +
+ + )} +
); } diff --git a/apps/web/src/components/Settings/sections/ImageProviderSettings.test.tsx b/apps/web/src/components/Settings/sections/ImageProviderSettings.test.tsx index 1edf72296..59e5386ec 100644 --- a/apps/web/src/components/Settings/sections/ImageProviderSettings.test.tsx +++ b/apps/web/src/components/Settings/sections/ImageProviderSettings.test.tsx @@ -53,7 +53,11 @@ describe('ImageProviderSettings', () => { const toggle = container.querySelector( 'button[aria-expanded="false"]', ); - expect(toggle?.textContent).toContain('settings.imageGeneration'); + expect(toggle?.getAttribute('aria-label')).toBe('settings.imageGeneration'); + expect(toggle?.closest('section')?.textContent).toContain( + 'settings.imageGeneration', + ); + expect(toggle?.closest('section')?.querySelector('.ring-1')).not.toBeNull(); expect( container.querySelector('[aria-label="settings.endpoint"]'), ).toBeNull(); diff --git a/apps/web/src/components/Settings/sections/ImageProviderSettings.tsx b/apps/web/src/components/Settings/sections/ImageProviderSettings.tsx index b947c94c4..b02f6d02f 100644 --- a/apps/web/src/components/Settings/sections/ImageProviderSettings.tsx +++ b/apps/web/src/components/Settings/sections/ImageProviderSettings.tsx @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +import { ChevronDown } from 'lucide-react'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -11,6 +12,7 @@ import { getImageCapabilities, } from '@huabu/shared'; +import { Button } from '@/components/Common/Button'; import { Select } from '@/components/Common/Select'; import { TextInput } from '@/components/Common/TextInput'; import { ApiKeyRow } from '@/components/Settings/Common/ApiKeyRow'; @@ -46,6 +48,7 @@ const IMAGE_MODEL_FAMILY_OPTIONS = IMAGE_MODEL_FAMILIES.map((f) => ({ */ export const ImageProviderSettings: React.FC = () => { const { t } = useTranslation(); + const [collapsed, setCollapsed] = useState(true); const llmImageConfig = useLLMStore((s) => s.imageConfig); const loadImageConfig = useLLMStore((s) => s.loadImageConfig); const imageError = useLLMStore((s) => s.imageError); @@ -115,135 +118,159 @@ export const ImageProviderSettings: React.FC = () => { ); return ( - - {imageError && ( -

- {imageError} -

- )} - - - saveImage({ provider: v })} + placeholder={t('settings.selectProvider')} + ariaLabel={t('settings.provider')} + className="w-full" + /> + + - - - { - const v = e.target.value; - setImgEndpoint(v); - debouncedSaveImage({ baseUrl: v }); - }} - className="w-full" - /> - - + + + { + const v = e.target.value; + setImgEndpoint(v); + debouncedSaveImage({ baseUrl: v }); + }} + className="w-full" + /> + + - - - { + const next = v as ImageModelFamily; + setImgModelFamily(next); + saveImage({ modelFamily: next }); + }} + /> + + - {t('settings.deployment')}} - description={t('settings.deploymentOptional')} - > - - { - const v = e.target.value; - setImgDeployment(v); - debouncedSaveImage({ model: v }); - }} - className="w-full" - /> - - + {t('settings.deployment')} + } + description={t('settings.deploymentOptional')} + > + + { + const v = e.target.value; + setImgDeployment(v); + debouncedSaveImage({ model: v }); + }} + className="w-full" + /> + + - - - { - const v = e.target.value; - setImgApiVersion(v); - debouncedSaveImage({ apiVersion: v }); - }} - className="w-full" - /> - - + + + { + const v = e.target.value; + setImgApiVersion(v); + debouncedSaveImage({ apiVersion: v }); + }} + className="w-full" + /> + + - - - ({ + value: q, + label: q, + }), + )} + value={imgQuality} + ariaLabel={t('settings.imageQuality')} + className="w-full" + onChange={(v) => { + const next = v as 'low' | 'medium' | 'high' | 'auto'; + setImgQuality(next); + saveImage({ quality: next }); + }} + /> + + - saveImage({ apiKey: key })} - onRemove={() => saveImage({ apiKey: null })} - /> + saveImage({ apiKey: key })} + onRemove={() => saveImage({ apiKey: null })} + /> + + )}
); }; diff --git a/apps/web/src/i18n/resources/en/common.json b/apps/web/src/i18n/resources/en/common.json index 4705316be..9fe8a7524 100644 --- a/apps/web/src/i18n/resources/en/common.json +++ b/apps/web/src/i18n/resources/en/common.json @@ -223,7 +223,6 @@ "builtInPiConfigure": "Configure Built-In Pi providers and models", "structuredLaunchUnavailable": "This Agentlet cannot launch every detected harness with structured configuration. Use a compatible executable or a Custom command.", "agentDefaultsProfile": "Utility Agent Profile", - "agentDefaultsDescription": "Choose a low-latency Agent for Huabu-managed background work without changing the Agent used by conversations.", "agentDefaultsModel": "Functional-task model", "agentDefaultsModelDescription": "Used for summaries, keywords, titles, and image labels when the Agent advertises the model. Image tasks require vision support. Does not change Profile or chat model preferences.", "agentDefaultsInherit": "Inherit the Profile model", diff --git a/apps/web/src/i18n/resources/zh-CN/common.json b/apps/web/src/i18n/resources/zh-CN/common.json index f59625ca8..d85acad83 100644 --- a/apps/web/src/i18n/resources/zh-CN/common.json +++ b/apps/web/src/i18n/resources/zh-CN/common.json @@ -223,7 +223,6 @@ "builtInPiConfigure": "配置 Built-In Pi 提供商和模型", "structuredLaunchUnavailable": "此 Agentlet 无法通过结构化配置启动所有已发现的 harness。请使用兼容的可执行文件或自定义命令。", "agentDefaultsProfile": "Utility Agent 配置", - "agentDefaultsDescription": "为 Huabu 管理的后台任务选择低延迟 Agent,不会改变对话使用的 Agent。", "agentDefaultsModel": "功能任务模型", "agentDefaultsModelDescription": "当 Agent 声明支持所选模型时,用于摘要、关键词、标题和图片标签生成;图片任务需要视觉能力。不修改 Agent 配置或聊天模型偏好。", "agentDefaultsInherit": "继承 Agent 配置的模型", diff --git a/docs/architecture/agent-profiles.md b/docs/architecture/agent-profiles.md index 7301000ae..2e7592431 100644 --- a/docs/architecture/agent-profiles.md +++ b/docs/architecture/agent-profiles.md @@ -73,7 +73,7 @@ Owner-only `GET /api/agent/defaults` and `PUT /api/agent/defaults` expose the Ut When no Utility Agent record exists, provisioning chooses a stable ordered external Profile on a connected Agentlet and saves its identity. With no candidate, setup guides the user to connect an external Agent or explicitly choose Built-In Pi. Existing records, including null/deleted/offline selections, remain authoritative. Old provider credentials do not implicitly enable Pi, and switching utility backends does not clear credentials or models. Reads do not discover agents, initialize settings, or create sessions. Deleting or disconnecting the selected Profile does not choose a replacement. -Settings > Agent is the single Agent configuration surface. It presents external Profile management first, followed by the Utility Agent used only for Huabu-owned auxiliary work, conditional Built-In Pi configuration, Agent behavior, and external-Agent runtime controls without changing their separate persistence owners. Changing the Utility Agent never changes a conversation binding or conversational recency. The external functional-model field is hidden for Built-In; its saved value survives switching. Built-In provider, login, Chat and Utility model controls load and render only for a Built-In Utility Agent or an explicit configuration visit from a Built-In conversation. The conversation's configuration button remains available after binding is locked and does not change Utility Agent settings. Settings > Capabilities separately owns Huabu-managed image generation, OCR, Web Search, and YouTube transcript configuration; these services appear as peer capabilities and do not imply that an external Agent can invoke the corresponding Huabu tools. The larger Image Generation form starts collapsed and expands on demand. +Settings > Agent is the single Agent configuration surface. It presents external Profile management first, followed by the Utility Agent used only for Huabu-owned auxiliary work, conditional Built-In Pi configuration, Agent behavior, and external-Agent runtime controls without changing their separate persistence owners. The Utility Agent explanation sits inside its Profile row rather than above the section. Changing the Utility Agent never changes a conversation binding or conversational recency. The external functional-model field is hidden for Built-In; its saved value survives switching. Built-In provider, login, Chat and Utility model controls load and render only for a Built-In Utility Agent or an explicit configuration visit from a Built-In conversation. The conversation's configuration button remains available after binding is locked and does not change Utility Agent settings. Settings > Capabilities separately owns Huabu-managed image generation, OCR, Web Search, and YouTube transcript configuration; these services appear as peer capabilities with consistent card spacing and do not imply that an external Agent can invoke the corresponding Huabu tools. The larger Image Generation form starts collapsed while retaining its bordered card header and expands on demand. Huabu-owned auxiliary tasks outside an Agent Node's bound thread follow the Utility Agent, including summaries, keywords, content/Frame titles, conversation titles, and image labels. The source conversation's backend does not determine its automatic title backend. Each invocation selects its backend once; changing Utility Agent settings does not reroute in-flight work. Built-In tasks reuse `llmComplete` with the existing `contentMeta`, `frameLabel`, and `imageLabel` roles, preserving Utility/Chat model resolution and vision handling. External tasks use independent ACP Jobs. Thread-owned work and its tools retain the thread binding; explicit internal Skill authoring remains available. Legacy background Memory starts only for a Built-In Utility Agent; external Memory and unified Skill authoring remain separate follow-ups. diff --git a/docs/architecture/web-architecture.md b/docs/architecture/web-architecture.md index 31fa211ec..b1a140d8c 100644 --- a/docs/architecture/web-architecture.md +++ b/docs/architecture/web-architecture.md @@ -186,7 +186,7 @@ Space Shortcut retains the `spacePreview` node type and renders the canonical ic ### Settings information architecture -The tabbed Settings modal has three product-owned surfaces. **Agent** presents ordinary external Profile management first, then the Utility Agent used only for Huabu summaries, titles, labels, keywords, and related auxiliary work, conditional Built-In Pi provider/model setup, Agent behavior, and external-Agent runtime controls while preserving each component's existing store and API ownership. New conversations independently use the most recently selected conversational Agent, falling back to the first selectable Profile only before any conversational selection has been recorded. **Capabilities** presents Huabu-managed image generation, Web Search, YouTube transcript, and Ink OCR configuration as peer capabilities without an extra “Other Capabilities” grouping; the larger Image Generation form starts collapsed, and the surface copy does not imply external-Agent tool parity. **General** contains application, Canvas, input, update, and deployment preferences that do not configure an Agent or service capability. The hidden Built-In repair deep link remains focused on Pi provider/model setup and returns to the Agent surface after closing. +The tabbed Settings modal has three product-owned surfaces. **Agent** presents ordinary external Profile management first, then the Utility Agent used only for Huabu summaries, titles, labels, keywords, and related auxiliary work, conditional Built-In Pi provider/model setup, Agent behavior, and external-Agent runtime controls while preserving each component's existing store and API ownership; the Utility Agent explanation is contained in its Profile row. New conversations independently use the most recently selected conversational Agent, falling back to the first selectable Profile only before any conversational selection has been recorded. **Capabilities** presents Huabu-managed image generation, Web Search, YouTube transcript, and Ink OCR configuration as peer cards with uniform spacing and without an extra “Other Capabilities” grouping; the larger Image Generation form starts collapsed behind an in-card header, and the surface copy does not imply external-Agent tool parity. **General** contains application, Canvas, input, update, and deployment preferences that do not configure an Agent or service capability. The hidden Built-In repair deep link remains focused on Pi provider/model setup and returns to the Agent surface after closing. Agent Profile management uses `components/Settings/agent-profiles/` and the canonical `acpProfilesStore`. It has ordinary command Profile creation/editing/deletion and no Agent Team templates, Configs or setup lifecycle. The manual editor reads the agentlet-backed catalogue; automatic defaults arrive through the same persisted Profile list used by selectors. Opening an editor focuses the nested Agent view while retaining the existing Profile revision and save contracts. See [Agent Profiles](./agent-profiles.md). From 3d909b1a6a47cc516280a3929edfa7e0d46a22fe Mon Sep 17 00:00:00 2001 From: Yuqing Yang Date: Thu, 1 Oct 2026 07:37:37 +0000 Subject: [PATCH 12/30] fix(settings): normalize Agent section spacing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Settings/SettingsModal.test.tsx | 1 + .../src/components/Settings/SettingsModal.tsx | 8 +- .../AgentDefaultsSettings.test.tsx | 15 ++++ .../agent-profiles/AgentDefaultsSettings.tsx | 79 +++++++++++-------- docs/architecture/agent-profiles.md | 2 +- 5 files changed, 67 insertions(+), 38 deletions(-) diff --git a/apps/web/src/components/Settings/SettingsModal.test.tsx b/apps/web/src/components/Settings/SettingsModal.test.tsx index 1b52dd2b7..236f7e074 100644 --- a/apps/web/src/components/Settings/SettingsModal.test.tsx +++ b/apps/web/src/components/Settings/SettingsModal.test.tsx @@ -152,6 +152,7 @@ describe('Settings information architecture', () => { profiles.compareDocumentPosition(defaults) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); + expect(profiles.parentElement?.classList.contains('mb-4')).toBe(true); expect(mocks.init).toHaveBeenCalled(); expect(mocks.llmInit).not.toHaveBeenCalled(); }); diff --git a/apps/web/src/components/Settings/SettingsModal.tsx b/apps/web/src/components/Settings/SettingsModal.tsx index 0e08c7319..3cb1aa820 100644 --- a/apps/web/src/components/Settings/SettingsModal.tsx +++ b/apps/web/src/components/Settings/SettingsModal.tsx @@ -279,9 +279,11 @@ export const SettingsModal: React.FC = ({ ) : ( <> - +
+ +
{externalAgentsNavigation ? null : ( <> diff --git a/apps/web/src/components/Settings/agent-profiles/AgentDefaultsSettings.test.tsx b/apps/web/src/components/Settings/agent-profiles/AgentDefaultsSettings.test.tsx index 8b2652087..5bd6b05aa 100644 --- a/apps/web/src/components/Settings/agent-profiles/AgentDefaultsSettings.test.tsx +++ b/apps/web/src/components/Settings/agent-profiles/AgentDefaultsSettings.test.tsx @@ -154,6 +154,21 @@ describe('Agent defaults Settings', () => { expect(description?.closest('.ring-1')).not.toBeNull(); }); + it('omits the status row when there is no status to show', async () => { + mocks.state.profiles = [ + { + ...mocks.state.profiles[0], + launch: { kind: 'acp-harness', harnessId: 'copilot' }, + }, + ]; + mocks.get.mockResolvedValueOnce({ + ...initial, + defaults: { ...initial.defaults, functionalModel: '' }, + }); + await render(); + expect(container.querySelectorAll('.ring-1 > *')).toHaveLength(2); + }); + it('allows Built-In while the external catalogue is unavailable, retaining the external model', async () => { mocks.state.loaded = false; mocks.state.error = new Error('Registry unavailable'); diff --git a/apps/web/src/components/Settings/agent-profiles/AgentDefaultsSettings.tsx b/apps/web/src/components/Settings/agent-profiles/AgentDefaultsSettings.tsx index 3dbad210c..5058dbd11 100644 --- a/apps/web/src/components/Settings/agent-profiles/AgentDefaultsSettings.tsx +++ b/apps/web/src/components/Settings/agent-profiles/AgentDefaultsSettings.tsx @@ -129,6 +129,15 @@ export function AgentDefaultsSettings() { ?.launch.kind === 'acp-command' ? 'unsupported' : 'unknown'; + const showStatus = + missing || + draft?.profileId === null || + (snapshot?.defaults.profileId === draft?.profileId && + snapshot?.selectionState === 'offline') || + (!isBuiltIn && + Boolean(draft?.functionalModel.trim()) && + modelCapability !== 'supported') || + Boolean(error || profilesError || saving || saved); return ( @@ -178,43 +187,45 @@ export function AgentDefaultsSettings() { /> )} -
- {missing ? ( -

- {t('settings.agentDefaultsDeleted')} -

- ) : draft.profileId === null ? ( -

- {t('settings.agentDefaultsUnconfigured')} -

- ) : snapshot?.defaults.profileId === draft.profileId && - snapshot.selectionState === 'offline' ? ( -

- {t('settings.agentDefaultsOffline')} -

- ) : null} - {!isBuiltIn && - draft.functionalModel.trim() && - modelCapability !== 'supported' && ( + {showStatus && ( +
+ {missing ? ( +

+ {t('settings.agentDefaultsDeleted')} +

+ ) : draft.profileId === null ? ( +

+ {t('settings.agentDefaultsUnconfigured')} +

+ ) : snapshot?.defaults.profileId === draft.profileId && + snapshot.selectionState === 'offline' ? (

- {modelCapability === 'unsupported' - ? t('settings.agentDefaultsModelUnsupported') - : t('settings.agentDefaultsModelUnknown')} + {t('settings.agentDefaultsOffline')} +

+ ) : null} + {!isBuiltIn && + draft.functionalModel.trim() && + modelCapability !== 'supported' && ( +

+ {modelCapability === 'unsupported' + ? t('settings.agentDefaultsModelUnsupported') + : t('settings.agentDefaultsModelUnknown')} +

+ )} + {(error || profilesError) && ( +

+ {error ?? profilesError?.message}

)} - {(error || profilesError) && ( -

- {error ?? profilesError?.message} -

- )} - {(saving || saved) && ( -

- {saving - ? t('settings.saving') - : t('settings.agentDefaultsSaved')} -

- )} -
+ {(saving || saved) && ( +

+ {saving + ? t('settings.saving') + : t('settings.agentDefaultsSaved')} +

+ )} +
+ )} )}
diff --git a/docs/architecture/agent-profiles.md b/docs/architecture/agent-profiles.md index 2e7592431..00901ecdd 100644 --- a/docs/architecture/agent-profiles.md +++ b/docs/architecture/agent-profiles.md @@ -73,7 +73,7 @@ Owner-only `GET /api/agent/defaults` and `PUT /api/agent/defaults` expose the Ut When no Utility Agent record exists, provisioning chooses a stable ordered external Profile on a connected Agentlet and saves its identity. With no candidate, setup guides the user to connect an external Agent or explicitly choose Built-In Pi. Existing records, including null/deleted/offline selections, remain authoritative. Old provider credentials do not implicitly enable Pi, and switching utility backends does not clear credentials or models. Reads do not discover agents, initialize settings, or create sessions. Deleting or disconnecting the selected Profile does not choose a replacement. -Settings > Agent is the single Agent configuration surface. It presents external Profile management first, followed by the Utility Agent used only for Huabu-owned auxiliary work, conditional Built-In Pi configuration, Agent behavior, and external-Agent runtime controls without changing their separate persistence owners. The Utility Agent explanation sits inside its Profile row rather than above the section. Changing the Utility Agent never changes a conversation binding or conversational recency. The external functional-model field is hidden for Built-In; its saved value survives switching. Built-In provider, login, Chat and Utility model controls load and render only for a Built-In Utility Agent or an explicit configuration visit from a Built-In conversation. The conversation's configuration button remains available after binding is locked and does not change Utility Agent settings. Settings > Capabilities separately owns Huabu-managed image generation, OCR, Web Search, and YouTube transcript configuration; these services appear as peer capabilities with consistent card spacing and do not imply that an external Agent can invoke the corresponding Huabu tools. The larger Image Generation form starts collapsed while retaining its bordered card header and expands on demand. +Settings > Agent is the single Agent configuration surface. It presents external Profile management first, followed by the Utility Agent used only for Huabu-owned auxiliary work, conditional Built-In Pi configuration, Agent behavior, and external-Agent runtime controls without changing their separate persistence owners. Top-level Agent modules use consistent section spacing. The Utility Agent explanation sits inside its Profile row rather than above the section, and its status row renders only when it has a warning, error, or save state to display. Changing the Utility Agent never changes a conversation binding or conversational recency. The external functional-model field is hidden for Built-In; its saved value survives switching. Built-In provider, login, Chat and Utility model controls load and render only for a Built-In Utility Agent or an explicit configuration visit from a Built-In conversation. The conversation's configuration button remains available after binding is locked and does not change Utility Agent settings. Settings > Capabilities separately owns Huabu-managed image generation, OCR, Web Search, and YouTube transcript configuration; these services appear as peer capabilities with consistent card spacing and do not imply that an external Agent can invoke the corresponding Huabu tools. The larger Image Generation form starts collapsed while retaining its bordered card header and expands on demand. Huabu-owned auxiliary tasks outside an Agent Node's bound thread follow the Utility Agent, including summaries, keywords, content/Frame titles, conversation titles, and image labels. The source conversation's backend does not determine its automatic title backend. Each invocation selects its backend once; changing Utility Agent settings does not reroute in-flight work. Built-In tasks reuse `llmComplete` with the existing `contentMeta`, `frameLabel`, and `imageLabel` roles, preserving Utility/Chat model resolution and vision handling. External tasks use independent ACP Jobs. Thread-owned work and its tools retain the thread binding; explicit internal Skill authoring remains available. Legacy background Memory starts only for a Built-In Utility Agent; external Memory and unified Skill authoring remain separate follow-ups. From 307da0ef7b9270bb1383ce084af230b88c2ea794 Mon Sep 17 00:00:00 2001 From: Yuqing Yang Date: Thu, 1 Oct 2026 08:02:42 +0000 Subject: [PATCH 13/30] fix(agenetes): reject live agentlet identity collisions Allow remote agentlets to authenticate with the host-owned shared token while preserving disconnected identity reconnection. Reject concurrent duplicate machine identities with actionable guidance instead of replacing the live socket. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- external/agenetes/README.md | 2 +- .../agentlet-gateway/src/gateway.test.ts | 43 ++++++++++++++----- .../packages/agentlet-gateway/src/gateway.ts | 8 ++++ .../packages/agentlet-host/src/daemon-auth.ts | 19 +++----- .../agentlet-host/src/gateway-mount.test.ts | 8 ++-- .../agentlet-host/src/gateway-mount.ts | 5 +-- 6 files changed, 53 insertions(+), 32 deletions(-) diff --git a/external/agenetes/README.md b/external/agenetes/README.md index 1ef2d14b8..34a826355 100644 --- a/external/agenetes/README.md +++ b/external/agenetes/README.md @@ -603,7 +603,7 @@ external/agenetes/packages/ - **`@agenetes/protocol`** — the host application↔Agenetes data/control contracts: the opaque `WorkloadSpec` envelope, shared `AgentSpec`, `AgentSubmission` and canonical `AgentInput`, `AgentStreamEvent`, `AgentTurn`, `ControlMsg` / `ControlAck`, `AgentCapabilities`, `AgentMetadata`, `AgentStateSnapshot { driverState, metadata? }`, and namespace/identity types. Host-agnostic (zod + ACP SDK only). - **`@agenetes/runtime`** — `defineDriver(...)`, the type-erased `MountedAgentDriver`, static heterogeneous `DriverMap`, and live-handle lifecycle owner (`AgentRuntime`): resolve a mounted driver by kind and `get` / `getOrCreate` / `close` a long-lived handle by `threadId`. Depends only on `@agenetes/protocol`. - **`@agenetes/agent-profile`** — the host-agnostic ordinary Profile registry: editable command/options/workspace with immutable wrapper and machine identity, independent frozen snapshots, revision-fenced CRUD, opaque host-owned `customData`, change subscriptions, and schema-versioned atomic storage. All command Profiles are selectable without preparation. `createAgentProfileRegistry({ storageDir, legacyStorageDir?, legacyCommandProfiles? })` initializes the neutral `registry.json` once, importing ordinary commands from legacy Team registry v2/v3 and optional older command records. A present neutral file, including an empty one, is authoritative; legacy files remain unchanged, retired manifest entries and v1 Team deployments are not activated, and malformed commands or conflicting IDs fail explicitly. -- **`@agenetes/agentlet-gateway`** — the durably stateless host-side relay: authenticates daemon/session WebSockets, routes control RPCs and ACP traffic, and owns bounded live reconnect/pre-attach buffers without durable session or event stores. `onAgentletsChanged` emits machine-only connected/disconnected events, including reconnections. `discoverHarnesses(agentletId, params)` requires an explicitly targeted connected daemon advertising discovery v1 and validates every catalogue/result field before returning it. Pending control RPCs are rejected on machine disconnect/replacement rather than resolving against a later connection. +- **`@agenetes/agentlet-gateway`** — the durably stateless host-side relay: authenticates daemon/session WebSockets, routes control RPCs and ACP traffic, and owns bounded live reconnect/pre-attach buffers without durable session or event stores. `onAgentletsChanged` emits machine-only connected/disconnected events, including reconnections. A disconnected machine identity may reconnect, while a second live control connection with the same identity is rejected and must choose a unique identity. `discoverHarnesses(agentletId, params)` requires an explicitly targeted connected daemon advertising discovery v1 and validates every catalogue/result field before returning it. Pending control RPCs are rejected on machine disconnect rather than resolving against a later connection. - **`@agenetes/agentlet-host`** — the ACP transport and Profile composition host: mounts the Agentlet Gateway and optional ordinary registry from `mountAgenetes(..., { profiles: { storageDir, legacyStorageDir?, legacyCommandProfiles? }, ... })`, exposes `getAgentProfileRegistry()`, and supervises the local agentlet daemon. No SecretStore or Team setup/control port is required. ACP-private (not shared base). - **`@agenetes/acp-driver`** — the standard ACP driver and all ACP-specific spec/state/session logic: schemas, handle, client, `session/update → AgentStreamEvent` translation, in-memory session registry, `ensureAcpSession` orchestration, and session-meta handling. It keeps no on-disk store: the handle rehydrates its validated ACP driver state from `recoveryInput` and up-reports full snapshots via `onState`. Hosts may inject generic recipe and runtime-environment resolvers for values fetched immediately before spawn rather than persisted in `WorkloadSpec`. Retired `agentTeam` recipes are explicitly rejected, including mixed command/Team recipes; ordinary command history, bootstrap, cwd, idle suspension, authentication and selection replay remain unchanged. - **`@agenetes/agenetes`** — the top control-plane package: `mountAgenetes(...)` accepts a complete static DriverMap and instance-level stores/policy, constructs the runtime, and returns the `Agenetes` instance (`create` / `get` / `close` plus durable query/log surfaces). It does not pre-mount drivers or own driver factories. diff --git a/external/agenetes/packages/agentlet-gateway/src/gateway.test.ts b/external/agenetes/packages/agentlet-gateway/src/gateway.test.ts index 9934e46b4..ac4ec9847 100644 --- a/external/agenetes/packages/agentlet-gateway/src/gateway.test.ts +++ b/external/agenetes/packages/agentlet-gateway/src/gateway.test.ts @@ -459,7 +459,7 @@ describe('AgentletGateway', () => { }); }); - it('replaces a same-credential control socket and rejects another credential', async () => { + it('rejects a live duplicate identity and permits reconnect after disconnect', async () => { const onReconnection = vi.fn(); const { gateway, url } = await startHarness({ onReconnection }); const first = await connect(url, { @@ -468,18 +468,34 @@ describe('AgentletGateway', () => { token: 'token-a', hello: agentletHello('machine-a'), }); - const firstClosed = new Promise((resolve) => { - first.socket.once('close', resolve); + + const duplicate = await connect(url, { + role: 'agentlet', + queryId: 'machine-a', + token: 'token-a', + hello: agentletHello('machine-a'), }); + expect(duplicate.messages[0]).toMatchObject({ + error: { + code: -32600, + message: + 'Agentlet ID "machine-a" is already connected. Retry with --agentlet-id .', + }, + }); + expect(first.socket.readyState).toBe(WebSocket.OPEN); + expect(onReconnection).not.toHaveBeenCalled(); + expect(gateway.getAgentlet('machine-a')?.status).toBe('connected'); + first.socket.close(); + await waitUntil( + () => gateway.getAgentlet('machine-a')?.status === 'disconnected', + ); await connect(url, { role: 'agentlet', queryId: 'machine-a', token: 'token-a', hello: agentletHello('machine-a'), }); - - await expect(firstClosed).resolves.toBe(1000); expect(onReconnection).toHaveBeenCalledOnce(); expect(gateway.getAgentlet('machine-a')?.status).toBe('connected'); @@ -670,7 +686,7 @@ describe('AgentletGateway', () => { ).resolves.toEqual({ harnesses: [] }); }); - it('emits only machine connection events, including replacement, and unsubscribes', async () => { + it('emits only machine connection events, including reconnect, and unsubscribes', async () => { const { gateway, url } = await startHarness(); const changed = vi.fn(); const unsubscribe = gateway.onAgentletsChanged(changed); @@ -692,19 +708,23 @@ describe('AgentletGateway', () => { gateway.getSession('machine-a', 'session-a')?.status === 'disconnected', ); expect(changed).toHaveBeenCalledTimes(1); + first.socket.close(); + await waitUntil( + () => gateway.getAgentlet('machine-a')?.status === 'disconnected', + ); const replacement = await connect(url, { role: 'agentlet', queryId: 'machine-a', token: 'token-a', hello: agentletHello('machine-a'), }); - await waitUntil(() => first.socket.readyState === WebSocket.CLOSED); expect(changed.mock.calls).toEqual([ [{ agentletId: 'machine-a', status: 'connected' }], + [{ agentletId: 'machine-a', status: 'disconnected' }], [{ agentletId: 'machine-a', status: 'connected' }], ]); replacement.socket.close(); - await waitUntil(() => changed.mock.calls.length === 3); + await waitUntil(() => changed.mock.calls.length === 4); expect(changed).toHaveBeenLastCalledWith({ agentletId: 'machine-a', status: 'disconnected', @@ -716,7 +736,7 @@ describe('AgentletGateway', () => { token: 'token-a', hello: agentletHello('machine-a'), }); - expect(changed).toHaveBeenCalledTimes(3); + expect(changed).toHaveBeenCalledTimes(4); }); it('fails discovery for disconnected and unsupported targets without fallback', async () => { @@ -846,7 +866,7 @@ describe('AgentletGateway', () => { }), ).resolves.toEqual(result); }); - it('rejects stale pending RPCs on replacement and routes new replies correctly', async () => { + it('rejects stale pending RPCs on disconnect and routes replies after reconnect', async () => { const { gateway, url } = await startHarness(); const first = await connect(url, { role: 'agentlet', @@ -865,13 +885,14 @@ describe('AgentletGateway', () => { message.method === ServerMethods.DISCOVER_HARNESSES, ), ); + first.socket.close(); + await rejected; const replacement = await connect(url, { role: 'agentlet', queryId: 'machine-a', token: 'token-a', hello: agentletHello('machine-a'), }); - await rejected; replacement.socket.on('message', (data) => { const message = JSON.parse(data.toString()) as JsonRpcMessage; if ( diff --git a/external/agenetes/packages/agentlet-gateway/src/gateway.ts b/external/agenetes/packages/agentlet-gateway/src/gateway.ts index 103845f73..181d5f6d3 100644 --- a/external/agenetes/packages/agentlet-gateway/src/gateway.ts +++ b/external/agenetes/packages/agentlet-gateway/src/gateway.ts @@ -431,6 +431,14 @@ export class AgentletGateway { const existing = this.agentlets.get(params.agentletId); if (existing) { + if (existing.status === 'connected') { + this.rejectInvalidHello( + ws, + message.id, + `Agentlet ID "${params.agentletId}" is already connected. Retry with --agentlet-id .`, + ); + return; + } this.rejectPendingRequests(params.agentletId); existing.handleReconnect(ws, { agentletProfile: params.agentletProfile, diff --git a/external/agenetes/packages/agentlet-host/src/daemon-auth.ts b/external/agenetes/packages/agentlet-host/src/daemon-auth.ts index b3a134f22..a6dd2619a 100644 --- a/external/agenetes/packages/agentlet-host/src/daemon-auth.ts +++ b/external/agenetes/packages/agentlet-host/src/daemon-auth.ts @@ -1,9 +1,9 @@ /** * Agentlet authentication for the embedded Gateway. * - * One Agenetes host manages exactly one agentlet (forked as a child of - * the server process — see {@link ./daemon-supervisor.ts}). The auth - * model is correspondingly trivial: + * An Agenetes host supervises one local agentlet and may accept additional + * remote agentlets that present the same host-owned credential. The auth + * model is correspondingly simple: * * 1. The host supplies a single `connectionToken` at * {@link ../index.ts mountAgenetes} time — a global, non-ephemeral @@ -41,14 +41,12 @@ import type { /** * In-memory daemon token + handshake validator. * - * Singleton because there is exactly one bridge per server process. + * Singleton because there is exactly one shared credential per host process. * The class shape (rather than module-level state) keeps it cheap to * instantiate fresh per-test. */ class AcpDaemonAuth { private token: string | null = null; - private agentletId: string | null = null; - /** * Set the active token. Called once by `mountAgenetes` with the * host-injected `connectionToken`. @@ -58,8 +56,7 @@ class AcpDaemonAuth { } /** Configure the identity and token accepted for the supervised daemon. */ - configure(agentletId: string, token: string): void { - this.agentletId = agentletId; + configure(_agentletId: string, token: string): void { this.token = token; } @@ -108,17 +105,13 @@ class AcpDaemonAuth { } /** Validate the Gateway identity/token authentication port. */ - validateAgentlet(agentletId: string, token: string): AuthResult { - if (this.agentletId && agentletId !== this.agentletId) { - throw new Error('Invalid supervised agentlet identity'); - } + validateAgentlet(_agentletId: string, token: string): AuthResult { return this.validate(token, {} as AgentletHelloParams); } /** Test/teardown helper — drops the in-memory token. */ close(): void { this.token = null; - this.agentletId = null; } } diff --git a/external/agenetes/packages/agentlet-host/src/gateway-mount.test.ts b/external/agenetes/packages/agentlet-host/src/gateway-mount.test.ts index e857ab955..47b577363 100644 --- a/external/agenetes/packages/agentlet-host/src/gateway-mount.test.ts +++ b/external/agenetes/packages/agentlet-host/src/gateway-mount.test.ts @@ -21,13 +21,13 @@ afterEach(async () => { }); describe('Agentlet Gateway mount', () => { - it('rejects a token presented for another machine identity', () => { + it('accepts the shared token from another machine identity', () => { const auth = getDaemonAuth(); auth.configure('machine-a', 'test-token'); - expect(() => auth.validateAgentlet('machine-b', 'test-token')).toThrow( - 'Invalid supervised agentlet identity', - ); + expect(() => + auth.validateAgentlet('machine-b', 'test-token'), + ).not.toThrow(); }); it('authenticates the supervised identity and closes upgraded sockets', async () => { diff --git a/external/agenetes/packages/agentlet-host/src/gateway-mount.ts b/external/agenetes/packages/agentlet-host/src/gateway-mount.ts index c83287997..ed1a7ee11 100644 --- a/external/agenetes/packages/agentlet-host/src/gateway-mount.ts +++ b/external/agenetes/packages/agentlet-host/src/gateway-mount.ts @@ -28,9 +28,8 @@ export interface MountAgentletGatewayOptions { /** * Override the default authenticator. By default we delegate to * {@link getDaemonAuth}, which only accepts connections carrying the - * host-injected `connectionToken` set at `mountAgenetes` time. There - * is no persistence and no pairing UI: the only legitimate connection - * comes from the agentlet we just forked. + * host-injected `connectionToken` set at `mountAgenetes` time. Credential + * persistence and enrollment policy remain host responsibilities. */ authenticate?: AgentletGatewayOptions['authenticateAgentlet']; } From 8e01c1d06c0f23117e42c2302ab95630ed920bc1 Mon Sep 17 00:00:00 2001 From: Yuqing Yang Date: Thu, 1 Oct 2026 08:11:42 +0000 Subject: [PATCH 14/30] fix(agent): configure shared agentlet connection token Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/server/src/app.ts | 2 + apps/server/src/connection-token.test.ts | 147 ++++++++++++++ apps/server/src/connection-token.ts | 177 +++++++++++++++-- .../agent/acp/connection-token.route.test.ts | 159 +++++++++++++++ .../agent/acp/connection-token.route.ts | 97 ++++++++++ apps/server/src/modules/agent/acp/index.ts | 1 + .../src/security/environment-secret-store.ts | 3 + apps/server/src/security/secret-ids.ts | 1 + apps/server/src/server.ts | 2 + apps/web/src/api/_routes.ts | 2 + apps/web/src/api/acp.ts | 35 ++++ .../ExternalAgentRuntimeSettings.test.tsx | 102 +++++++++- .../sections/ExternalAgentRuntimeSettings.tsx | 183 ++++++++++++++++++ apps/web/src/i18n/resources/en/common.json | 22 +++ apps/web/src/i18n/resources/zh-CN/common.json | 22 +++ apps/web/src/utils/io/clipboard.ts | 3 +- docs/architecture/agent-profiles.md | 4 + docs/architecture/agent-reachback.md | 4 + docs/architecture/credential-storage.md | 6 + docs/architecture/deployment-security.md | 4 +- packages/shared/src/types/api/acp.ts | 41 ++++ 21 files changed, 1003 insertions(+), 14 deletions(-) create mode 100644 apps/server/src/connection-token.test.ts create mode 100644 apps/server/src/modules/agent/acp/connection-token.route.test.ts create mode 100644 apps/server/src/modules/agent/acp/connection-token.route.ts diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 39666968a..24800f015 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -21,6 +21,7 @@ import { acpAgentletRoutes, acpProfilesRoutes, acpThreadsRoutes, + connectionTokenRoutes, externalAgentRuntimeConfigRoutes, getExternalAgentRuntimeConfig, getAgentProfileRegistry, @@ -373,6 +374,7 @@ app.register(acpAgentletRoutes, { prefix: '/api/acp' }); app.register(acpAgentCliRoutes, { prefix: '/api/acp' }); app.register(acpThreadsRoutes, { prefix: '/api/acp' }); app.register(externalAgentRuntimeConfigRoutes, { prefix: '/api/acp' }); +app.register(connectionTokenRoutes, { prefix: '/api/acp' }); app.log.info( '[acp] agentlet Gateway mounted — embedded agentlet will start on server ready', ); diff --git a/apps/server/src/connection-token.test.ts b/apps/server/src/connection-token.test.ts new file mode 100644 index 000000000..3967f9294 --- /dev/null +++ b/apps/server/src/connection-token.test.ts @@ -0,0 +1,147 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + persisted: null as string | null, + writable: true, + setSecret: vi.fn(), + setDaemonToken: vi.fn(), + restart: vi.fn(), + disconnect: vi.fn(), + maxAgents: 7, +})); + +vi.mock('./security/secret-store.js', () => ({ + getPersistedSecret: () => mocks.persisted, + isSecretStoreWritable: () => mocks.writable, + setSecret: mocks.setSecret, +})); + +vi.mock('./modules/agent/acp/runtime-config.js', () => ({ + getExternalAgentRuntimeConfig: () => ({ + idleTimeoutSecs: 600, + maxAgents: mocks.maxAgents, + }), +})); + +vi.mock('@agenetes/agentlet-host', () => ({ + getDaemonAuth: () => ({ setDaemonToken: mocks.setDaemonToken }), + getDaemonSupervisor: () => ({ restart: mocks.restart }), + getAgentletGateway: () => ({ + getAgentlets: () => [{ disconnect: mocks.disconnect }], + }), +})); + +import { + _resetConnectionTokenForTests, + buildAgentletConnectionCommand, + getConnectionToken, + initializeConnectionToken, + setConnectionToken, +} from './connection-token.js'; + +beforeEach(() => { + _resetConnectionTokenForTests(); + mocks.persisted = null; + mocks.writable = true; + mocks.setSecret.mockResolvedValue(undefined); + vi.clearAllMocks(); + delete process.env.HUABU_CONNECTION_TOKEN; +}); + +afterEach(() => { + delete process.env.HUABU_CONNECTION_TOKEN; +}); + +describe('connection token resolution', () => { + it('prefers persisted, environment, then one generated fallback', () => { + mocks.persisted = 'stored-token'; + expect(initializeConnectionToken()).toEqual({ + source: 'stored', + writable: true, + }); + expect(getConnectionToken()).toBe('stored-token'); + + _resetConnectionTokenForTests(); + mocks.persisted = null; + process.env.HUABU_CONNECTION_TOKEN = 'environment-token'; + expect(initializeConnectionToken().source).toBe('environment'); + expect(getConnectionToken()).toBe('environment-token'); + + _resetConnectionTokenForTests(); + delete process.env.HUABU_CONNECTION_TOKEN; + expect(initializeConnectionToken().source).toBe('generated'); + const generated = getConnectionToken(); + expect(generated).toMatch(/^[0-9a-f]{64}$/); + expect(getConnectionToken()).toBe(generated); + }); + + it('persists before switching auth and restarting connected agentlets', async () => { + initializeConnectionToken(); + await setConnectionToken('new-token'); + + expect(mocks.setSecret).toHaveBeenCalledWith( + 'integration:agentlet:connection-token', + 'new-token', + ); + expect(mocks.setDaemonToken).toHaveBeenLastCalledWith('new-token'); + expect(mocks.disconnect).toHaveBeenCalledWith('connection_token_changed'); + expect(mocks.restart).toHaveBeenCalledOnce(); + expect(getConnectionToken()).toBe('new-token'); + }); + + it('keeps the active token when persistence fails', async () => { + process.env.HUABU_CONNECTION_TOKEN = 'old-token'; + initializeConnectionToken(); + mocks.setSecret.mockRejectedValueOnce(new Error('write failed')); + + await expect(setConnectionToken('new-token')).rejects.toThrow( + 'write failed', + ); + expect(getConnectionToken()).toBe('old-token'); + expect(mocks.disconnect).not.toHaveBeenCalled(); + expect(mocks.restart).not.toHaveBeenCalled(); + }); + + it('clears to the environment fallback', async () => { + mocks.persisted = 'stored-token'; + process.env.HUABU_CONNECTION_TOKEN = 'environment-token'; + initializeConnectionToken(); + + await setConnectionToken(null); + + expect(getConnectionToken()).toBe('environment-token'); + expect(mocks.setDaemonToken).toHaveBeenLastCalledWith('environment-token'); + }); +}); + +describe('Agentlet connection command', () => { + it('derives secure and insecure endpoints and reports warnings', () => { + process.env.HUABU_CONNECTION_TOKEN = "token'quoted"; + initializeConnectionToken(); + + expect(buildAgentletConnectionCommand('https://huabu.example.com')).toEqual( + { + command: + "agentlet daemon --server 'wss://huabu.example.com/api/acp/agent' --max-agents 7 --token 'token'\"'\"'quoted'", + warnings: [], + }, + ); + expect(buildAgentletConnectionCommand('http://localhost:3001')).toEqual({ + command: + "agentlet daemon --server 'ws://localhost:3001/api/acp/agent' --max-agents 7 --token 'token'\"'\"'quoted' --allow-insecure", + warnings: ['loopback', 'insecure'], + }); + }); + + it('rejects origins with paths or unsupported protocols', () => { + expect(() => + buildAgentletConnectionCommand('https://example.com/path'), + ).toThrow('Origin must be an HTTP(S) origin without a path'); + expect(() => buildAgentletConnectionCommand('file:///tmp/huabu')).toThrow( + 'Origin must be an HTTP(S) origin without a path', + ); + }); +}); diff --git a/apps/server/src/connection-token.ts b/apps/server/src/connection-token.ts index 2e15c0454..bef40ae48 100644 --- a/apps/server/src/connection-token.ts +++ b/apps/server/src/connection-token.ts @@ -3,6 +3,26 @@ import { randomBytes } from 'node:crypto'; +import { + getAgentletGateway, + getDaemonAuth, + getDaemonSupervisor, +} from '@agenetes/agentlet-host'; + +import { getExternalAgentRuntimeConfig } from './modules/agent/acp/runtime-config.js'; +import { SECRET_IDS } from './security/secret-ids.js'; +import { + getPersistedSecret, + isSecretStoreWritable, + setSecret, +} from './security/secret-store.js'; + +import type { + AgentletConnectionCommandResponse, + ConnectionTokenConfig, + ConnectionTokenSource, +} from '@huabu/shared'; + /** * The global connection token used to authenticate the embedded * agentlet transport (L2 `@agenetes/agentlet-host`) and every agent @@ -13,18 +33,153 @@ import { randomBytes } from 'node:crypto'; * the server process, so agent reachback credentials survive an * agentlet daemon restart. * - * Two runtime layouts, mirroring {@link ./data-dir.ts}: - * ─ `HUABU_CONNECTION_TOKEN` env var — explicit override (e.g. the - * Electron main process can pin a value across restarts). - * ─ Otherwise a fresh 256-bit hex token is minted once per boot and - * cached for the process lifetime. + * The generated fallback is available during module composition. After the + * SecretStore initializes, {@link initializeConnectionToken} activates the + * persisted/environment/generated precedence before the server listens. */ -let cached: string | null = null; +let generatedToken: string | null = null; +let activeToken: string | null = null; +let activeSource: ConnectionTokenSource | null = null; +let mutationQueue = Promise.resolve(); + +function getGeneratedToken(): string { + generatedToken ??= randomBytes(32).toString('hex'); + return generatedToken; +} + +function getEnvironmentToken(): string | null { + return process.env.HUABU_CONNECTION_TOKEN?.trim() || null; +} + +function resolveFallback(): { + token: string; + source: Exclude; +} { + const environment = getEnvironmentToken(); + return environment + ? { token: environment, source: 'environment' } + : { token: getGeneratedToken(), source: 'generated' }; +} + +function activateConnectionToken( + token: string, + source: ConnectionTokenSource, +): void { + activeToken = token; + activeSource = source; + getDaemonAuth().setDaemonToken(token); +} export function getConnectionToken(): string { - if (cached) return cached; - const fromEnv = process.env.HUABU_CONNECTION_TOKEN; - cached = - fromEnv && fromEnv.length > 0 ? fromEnv : randomBytes(32).toString('hex'); - return cached; + if (activeToken) return activeToken; + const fallback = resolveFallback(); + activeToken = fallback.token; + activeSource = fallback.source; + return activeToken; +} + +export function initializeConnectionToken(): ConnectionTokenConfig { + const stored = getPersistedSecret(SECRET_IDS.agentletConnectionToken); + if (stored) activateConnectionToken(stored, 'stored'); + else { + const fallback = resolveFallback(); + activateConnectionToken(fallback.token, fallback.source); + } + return getConnectionTokenConfig(); +} + +export function getConnectionTokenConfig(): ConnectionTokenConfig { + if (!activeSource) getConnectionToken(); + return { + source: activeSource ?? 'generated', + writable: isSecretStoreWritable(), + }; +} + +function disconnectAgentlets(): void { + const gateway = getAgentletGateway(); + for (const connection of gateway?.getAgentlets({ status: 'connected' }) ?? + []) { + connection.disconnect('connection_token_changed'); + } +} + +export function setConnectionToken( + token: string | null, +): Promise { + const mutation = mutationQueue.then(async () => { + await setSecret(SECRET_IDS.agentletConnectionToken, token); + const next = token + ? { token, source: 'stored' as const } + : resolveFallback(); + const changed = next.token !== getConnectionToken(); + activateConnectionToken(next.token, next.source); + if (changed) { + disconnectAgentlets(); + getDaemonSupervisor().restart(); + } + return getConnectionTokenConfig(); + }); + mutationQueue = mutation.then( + () => undefined, + () => undefined, + ); + return mutation; +} + +function quotePosix(value: string): string { + return `'${value.replaceAll("'", "'\"'\"'")}'`; +} + +function isLoopbackHostname(hostname: string): boolean { + return ( + hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname === '[::1]' || + hostname === '::1' + ); +} + +export class InvalidAgentletConnectionOriginError extends Error {} + +export function buildAgentletConnectionCommand( + originValue: string, +): AgentletConnectionCommandResponse { + const origin = new URL(originValue); + if ( + !['http:', 'https:'].includes(origin.protocol) || + origin.username || + origin.password || + origin.pathname !== '/' || + origin.search || + origin.hash + ) { + throw new InvalidAgentletConnectionOriginError( + 'Origin must be an HTTP(S) origin without a path', + ); + } + const insecure = origin.protocol === 'http:'; + const endpoint = `${insecure ? 'ws:' : 'wss:'}//${origin.host}/api/acp/agent`; + const maxAgents = getExternalAgentRuntimeConfig().maxAgents; + const command = [ + 'agentlet daemon', + `--server ${quotePosix(endpoint)}`, + `--max-agents ${maxAgents}`, + `--token ${quotePosix(getConnectionToken())}`, + ...(insecure ? ['--allow-insecure'] : []), + ].join(' '); + return { + command, + warnings: [ + ...(isLoopbackHostname(origin.hostname) ? (['loopback'] as const) : []), + ...(insecure ? (['insecure'] as const) : []), + ], + }; +} + +export function _resetConnectionTokenForTests(): void { + generatedToken = null; + activeToken = null; + activeSource = null; + mutationQueue = Promise.resolve(); } diff --git a/apps/server/src/modules/agent/acp/connection-token.route.test.ts b/apps/server/src/modules/agent/acp/connection-token.route.test.ts new file mode 100644 index 000000000..27d0fcfda --- /dev/null +++ b/apps/server/src/modules/agent/acp/connection-token.route.test.ts @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import connectionTokenRoutes from './connection-token.route.js'; + +const mocks = vi.hoisted(() => ({ + InvalidOriginError: class InvalidOriginError extends Error {}, + buildCommand: vi.fn(), + getConfig: vi.fn(), + isOwner: vi.fn(), + setToken: vi.fn(), +})); + +vi.mock('../../../connection-token.js', () => ({ + InvalidAgentletConnectionOriginError: mocks.InvalidOriginError, + buildAgentletConnectionCommand: mocks.buildCommand, + getConnectionTokenConfig: mocks.getConfig, + setConnectionToken: mocks.setToken, +})); + +vi.mock('../../security/owner.js', () => ({ + isOwnerRequest: mocks.isOwner, +})); + +let app: FastifyInstance | undefined; + +async function setup() { + app = Fastify({ logger: false }); + await app.register(connectionTokenRoutes, { prefix: '/api/acp' }); + return app; +} + +afterEach(async () => { + await app?.close(); + app = undefined; + vi.resetAllMocks(); +}); + +describe('connection token routes', () => { + it('keeps token settings owner-only and never returns token material', async () => { + mocks.isOwner.mockReturnValue(false); + const server = await setup(); + + const response = await server.inject('/api/acp/connection-token'); + + expect(response.statusCode).toBe(403); + expect(mocks.getConfig).not.toHaveBeenCalled(); + expect(response.body).not.toContain('token-value'); + }); + + it('returns only the credential source and writability', async () => { + mocks.isOwner.mockReturnValue(true); + mocks.getConfig.mockReturnValue({ source: 'stored', writable: true }); + const server = await setup(); + + const response = await server.inject('/api/acp/connection-token'); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ source: 'stored', writable: true }); + }); + + it('validates updates and propagates persistence failures', async () => { + mocks.isOwner.mockReturnValue(true); + mocks.setToken.mockRejectedValue(new Error('credential store unavailable')); + const server = await setup(); + + const invalid = await server.inject({ + method: 'PUT', + url: '/api/acp/connection-token', + payload: { token: '' }, + }); + const failed = await server.inject({ + method: 'PUT', + url: '/api/acp/connection-token', + payload: { token: 'replacement' }, + }); + + expect(invalid.statusCode).toBe(400); + expect(failed.statusCode).toBe(500); + expect(mocks.setToken).toHaveBeenCalledOnce(); + expect(mocks.setToken).toHaveBeenCalledWith('replacement'); + }); + + it('returns a no-store command response for a valid browser origin', async () => { + mocks.isOwner.mockReturnValue(true); + mocks.buildCommand.mockReturnValue({ + command: "agentlet daemon --token 'secret'", + warnings: ['loopback'], + }); + const server = await setup(); + + const response = await server.inject({ + method: 'POST', + url: '/api/acp/connection-command', + payload: { origin: 'http://localhost:5173' }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['cache-control']).toBe('no-store'); + expect(response.headers.pragma).toBe('no-cache'); + expect(mocks.buildCommand).toHaveBeenCalledWith('http://localhost:5173'); + }); + + it('rejects malformed and non-origin command inputs', async () => { + mocks.isOwner.mockReturnValue(true); + mocks.buildCommand.mockImplementation(() => { + throw new mocks.InvalidOriginError('invalid origin'); + }); + const server = await setup(); + + const malformed = await server.inject({ + method: 'POST', + url: '/api/acp/connection-command', + payload: { origin: 'not-a-url' }, + }); + const withPath = await server.inject({ + method: 'POST', + url: '/api/acp/connection-command', + payload: { origin: 'https://example.com/path' }, + }); + + expect(malformed.statusCode).toBe(400); + expect(withPath.statusCode).toBe(400); + }); + + it('rejects a browser origin that differs from the request origin', async () => { + mocks.isOwner.mockReturnValue(true); + const server = await setup(); + + const response = await server.inject({ + method: 'POST', + url: '/api/acp/connection-command', + headers: { origin: 'https://huabu.example' }, + payload: { origin: 'https://other.example' }, + }); + + expect(response.statusCode).toBe(400); + expect(mocks.buildCommand).not.toHaveBeenCalled(); + }); + + it('does not disguise command-generation failures as invalid input', async () => { + mocks.isOwner.mockReturnValue(true); + mocks.buildCommand.mockImplementation(() => { + throw new Error('runtime config unavailable'); + }); + const server = await setup(); + + const response = await server.inject({ + method: 'POST', + url: '/api/acp/connection-command', + payload: { origin: 'https://huabu.example' }, + }); + + expect(response.statusCode).toBe(500); + }); +}); diff --git a/apps/server/src/modules/agent/acp/connection-token.route.ts b/apps/server/src/modules/agent/acp/connection-token.route.ts new file mode 100644 index 000000000..0f57b7cdb --- /dev/null +++ b/apps/server/src/modules/agent/acp/connection-token.route.ts @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + agentletConnectionCommandRequestSchema, + connectionTokenUpdateSchema, +} from '@huabu/shared'; + +import { + buildAgentletConnectionCommand, + getConnectionTokenConfig, + InvalidAgentletConnectionOriginError, + setConnectionToken, +} from '../../../connection-token.js'; +import { isOwnerRequest } from '../../security/owner.js'; + +import type { + AgentletConnectionCommandRequest, + AgentletConnectionCommandResponse, + ApiResult, + ConnectionTokenConfig, + ConnectionTokenUpdate, +} from '@huabu/shared'; +import type { FastifyPluginAsync } from 'fastify'; + +const connectionTokenRoutes: FastifyPluginAsync = async (app) => { + app.get<{ Reply: ApiResult }>( + '/connection-token', + async (request, reply) => { + if (!isOwnerRequest(request)) { + return reply.status(403).send({ + message: + 'Forbidden: connection token settings require owner authorization', + }); + } + return getConnectionTokenConfig(); + }, + ); + + app.put<{ + Body: ConnectionTokenUpdate; + Reply: ApiResult; + }>('/connection-token', async (request, reply) => { + if (!isOwnerRequest(request)) { + return reply.status(403).send({ + message: + 'Forbidden: connection token settings require owner authorization', + }); + } + const parsed = connectionTokenUpdateSchema.safeParse(request.body); + if (!parsed.success) { + return reply + .status(400) + .send({ message: parsed.error.issues[0]?.message ?? 'Invalid body' }); + } + return reply.send(await setConnectionToken(parsed.data.token)); + }); + + app.post<{ + Body: AgentletConnectionCommandRequest; + Reply: ApiResult; + }>('/connection-command', async (request, reply) => { + if (!isOwnerRequest(request)) { + return reply.status(403).send({ + message: 'Forbidden: connection command requires owner authorization', + }); + } + const parsed = agentletConnectionCommandRequestSchema.safeParse( + request.body, + ); + if (!parsed.success) { + return reply + .status(400) + .send({ message: parsed.error.issues[0]?.message ?? 'Invalid body' }); + } + const requestOrigin = request.headers.origin; + if (requestOrigin && requestOrigin !== parsed.data.origin) { + return reply + .status(400) + .send({ message: 'Browser origin does not match request origin' }); + } + try { + const response = buildAgentletConnectionCommand(parsed.data.origin); + return reply + .header('Cache-Control', 'no-store') + .header('Pragma', 'no-cache') + .send(response); + } catch (error) { + if (error instanceof InvalidAgentletConnectionOriginError) { + return reply.status(400).send({ message: 'Invalid browser origin' }); + } + throw error; + } + }); +}; + +export default connectionTokenRoutes; diff --git a/apps/server/src/modules/agent/acp/index.ts b/apps/server/src/modules/agent/acp/index.ts index e247ff41d..45b29ac2e 100644 --- a/apps/server/src/modules/agent/acp/index.ts +++ b/apps/server/src/modules/agent/acp/index.ts @@ -17,6 +17,7 @@ export { default as acpAgentCliRoutes } from './agent-cli.route.js'; export { default as acpProfilesRoutes } from './profiles.route.js'; export { default as acpAgentletRoutes } from './daemon.route.js'; export { default as externalAgentRuntimeConfigRoutes } from './runtime-config.route.js'; +export { default as connectionTokenRoutes } from './connection-token.route.js'; export { getExternalAgentRuntimeConfig } from './runtime-config.js'; /** @deprecated Use {@link acpAgentletRoutes} instead. */ export { default as acpDaemonRoutes } from './daemon.route.js'; diff --git a/apps/server/src/security/environment-secret-store.ts b/apps/server/src/security/environment-secret-store.ts index ef8b11305..17a18361b 100644 --- a/apps/server/src/security/environment-secret-store.ts +++ b/apps/server/src/security/environment-secret-store.ts @@ -22,6 +22,9 @@ export class EnvironmentSecretStore implements SecretStore { if (id === SECRET_IDS.rapidApiKey) { return process.env.RAPIDAPI_KEY ?? null; } + if (id === SECRET_IDS.agentletConnectionToken) { + return process.env.HUABU_CONNECTION_TOKEN?.trim() || null; + } if (id === SECRET_IDS.inkOcrApiKey) { return process.env.VISION_KEY?.trim() || null; } diff --git a/apps/server/src/security/secret-ids.ts b/apps/server/src/security/secret-ids.ts index 280eee3a1..2f5f6dc67 100644 --- a/apps/server/src/security/secret-ids.ts +++ b/apps/server/src/security/secret-ids.ts @@ -5,6 +5,7 @@ export const SECRET_IDS = { imageApiKey: 'llm:image:api-key', tavilyApiKey: 'integration:tavily:api-key', rapidApiKey: 'integration:rapidapi:api-key', + agentletConnectionToken: 'integration:agentlet:connection-token', inkOcrApiKey: 'integration:azure-vision:api-key', inkOcrConfig: 'integration:azure-vision:config', copilotOAuth: 'oauth:github-copilot:credentials', diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 2112d7c56..02609afd6 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -5,6 +5,7 @@ import './load-env.js'; import './setup-proxy.js'; import { app } from './app.js'; import { resolveBindHost } from './bind-host.js'; +import { initializeConnectionToken } from './connection-token.js'; import { prewarmOAuthCredentials } from './modules/agent/oauth.js'; import { resolveDeploymentConfig } from './modules/security/deployment-config.js'; import { @@ -53,6 +54,7 @@ async function start(): Promise { } await initializeSecretStore(); + initializeConnectionToken(); await app.listen({ port: PORT, host: HOST }); // When bound to a wildcard address, "localhost" is still the URL a // browser on this machine would use — but log both so operators on a diff --git a/apps/web/src/api/_routes.ts b/apps/web/src/api/_routes.ts index 550a03ffa..1faef52e3 100644 --- a/apps/web/src/api/_routes.ts +++ b/apps/web/src/api/_routes.ts @@ -160,6 +160,8 @@ export const routes = { acpAgentlet: '/acp/agentlet', acpAgentletRestart: '/acp/agentlet/restart', acpRuntimeConfig: '/acp/runtime-config', + acpConnectionToken: '/acp/connection-token', + acpConnectionCommand: '/acp/connection-command', acpThreadCachedMeta: ( threadId: string, canvasId?: string, diff --git a/apps/web/src/api/acp.ts b/apps/web/src/api/acp.ts index d03fc3576..4b56cb1c9 100644 --- a/apps/web/src/api/acp.ts +++ b/apps/web/src/api/acp.ts @@ -18,6 +18,8 @@ * recipes with revision-checked launch and working-directory edits. * - `POST /api/acp/profile-launch-preview` — daemon-built launch preview. * - `GET/POST /api/acp/daemon` — daemon liveness + manual restart. + * - `GET/PUT /api/acp/connection-token` — masked credential configuration. + * - `POST /api/acp/connection-command` — explicit owner-only command reveal. * - `GET /api/acp/threads/:threadId/cached-meta` — cached capabilities. * - thread control POSTs — canonical realization plus per-session knobs. */ @@ -45,6 +47,9 @@ import type { SetAcpSessionModeRequest, SetAcpSessionModeResponse, ExternalAgentRuntimeConfig, + ConnectionTokenConfig, + ConnectionTokenUpdate, + AgentletConnectionCommandResponse, WarmAcpSessionRequest, WarmAcpSessionResponse, } from '@huabu/shared'; @@ -76,6 +81,9 @@ export type { SetAcpSessionModeRequest, SetAcpSessionModeResponse, ExternalAgentRuntimeConfig, + ConnectionTokenConfig, + ConnectionTokenUpdate, + AgentletConnectionCommandResponse, WarmAcpSessionRequest, WarmAcpSessionResponse, } from '@huabu/shared'; @@ -199,6 +207,33 @@ export async function updateExternalAgentRuntimeConfig( }); } +export async function getConnectionTokenConfig(): Promise { + return apiFetch(routes.acpConnectionToken, { + fallbackMessage: 'Failed to read the Agentlet connection token settings', + }); +} + +export async function updateConnectionToken( + update: ConnectionTokenUpdate, +): Promise { + return apiFetch(routes.acpConnectionToken, { + method: 'PUT', + json: update, + fallbackMessage: 'Failed to update the Agentlet connection token', + }); +} + +export async function createAgentletConnectionCommand(): Promise { + return apiFetch( + routes.acpConnectionCommand, + { + method: 'POST', + json: { origin: window.location.origin }, + fallbackMessage: 'Failed to create the Agentlet connection command', + }, + ); +} + /** * Fetch the GET-only capability observation for a thread and its Profile. * This never creates a workload or starts an ACP process. diff --git a/apps/web/src/components/Settings/sections/ExternalAgentRuntimeSettings.test.tsx b/apps/web/src/components/Settings/sections/ExternalAgentRuntimeSettings.test.tsx index 170c1262f..53d57082b 100644 --- a/apps/web/src/components/Settings/sections/ExternalAgentRuntimeSettings.test.tsx +++ b/apps/web/src/components/Settings/sections/ExternalAgentRuntimeSettings.test.tsx @@ -5,8 +5,18 @@ import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const { getRuntimeConfig, updateRuntimeConfig, toast } = vi.hoisted(() => ({ +const { + createConnectionCommand, + getConnectionTokenConfig, + getRuntimeConfig, + updateConnectionToken, + updateRuntimeConfig, + toast, +} = vi.hoisted(() => ({ + createConnectionCommand: vi.fn(), + getConnectionTokenConfig: vi.fn(), getRuntimeConfig: vi.fn(), + updateConnectionToken: vi.fn(), updateRuntimeConfig: vi.fn(), toast: vi.fn(), })); @@ -15,7 +25,10 @@ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }), })); vi.mock('@/api/acp', () => ({ + createAgentletConnectionCommand: createConnectionCommand, + getConnectionTokenConfig, getExternalAgentRuntimeConfig: getRuntimeConfig, + updateConnectionToken, updateExternalAgentRuntimeConfig: updateRuntimeConfig, })); vi.mock('@/components/Common/Toast', () => ({ toast })); @@ -36,6 +49,23 @@ beforeEach(() => { idleTimeoutSecs: 1800, maxAgents: 10, }); + getConnectionTokenConfig.mockResolvedValue({ + source: 'stored', + writable: true, + }); + updateConnectionToken.mockResolvedValue({ + source: 'stored', + writable: true, + }); + createConnectionCommand.mockResolvedValue({ + command: + "agentlet daemon --server 'wss://huabu.example/api/acp/agent' --max-agents 10 --token 'secret'", + warnings: [], + }); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + }); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); @@ -80,4 +110,74 @@ describe('ExternalAgentRuntimeSettings', () => { { tone: 'success' }, ); }); + + it('does not render the active token and saves a replacement', async () => { + await act(async () => { + root.render(); + }); + + expect(container.textContent).not.toContain('secret'); + const tokenInput = container.querySelector( + '#agentlet-connection-token', + ); + if (!tokenInput) throw new Error('Connection token input not found'); + await act(async () => { + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )?.set?.call(tokenInput, 'replacement-token'); + tokenInput.dispatchEvent(new Event('input', { bubbles: true })); + }); + const saveButton = [ + ...(tokenInput.parentElement?.querySelectorAll('button') ?? []), + ].find((button) => button.textContent === 'settings.saveChanges'); + if (!saveButton) throw new Error('Connection token save button not found'); + await act(async () => { + saveButton.click(); + }); + + expect(updateConnectionToken).toHaveBeenCalledWith({ + token: 'replacement-token', + }); + expect(container.textContent).not.toContain('replacement-token'); + }); + + it('copies the generated command directly without rendering it', async () => { + await act(async () => { + root.render(); + }); + + const copyButton = [...container.querySelectorAll('button')].find( + (button) => button.textContent === 'settings.agentletCommandCopy', + ); + if (!copyButton) throw new Error('Copy command button not found'); + await act(async () => { + copyButton.click(); + }); + + expect(createConnectionCommand).toHaveBeenCalledOnce(); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining('wss://huabu.example/api/acp/agent'), + ); + expect(container.textContent).not.toContain('wss://huabu.example'); + expect(toast).toHaveBeenCalledWith('settings.agentletCommandCopied', { + tone: 'success', + }); + }); + + it('clears only the stored override', async () => { + await act(async () => { + root.render(); + }); + + const clearButton = [...container.querySelectorAll('button')].find( + (button) => button.textContent === 'settings.agentletTokenClear', + ); + if (!clearButton) throw new Error('Clear token button not found'); + await act(async () => { + clearButton.click(); + }); + + expect(updateConnectionToken).toHaveBeenCalledWith({ token: null }); + }); }); diff --git a/apps/web/src/components/Settings/sections/ExternalAgentRuntimeSettings.tsx b/apps/web/src/components/Settings/sections/ExternalAgentRuntimeSettings.tsx index 748b46d82..aa0aabfce 100644 --- a/apps/web/src/components/Settings/sections/ExternalAgentRuntimeSettings.tsx +++ b/apps/web/src/components/Settings/sections/ExternalAgentRuntimeSettings.tsx @@ -1,18 +1,26 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +import { Info } from 'lucide-react'; import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { + createAgentletConnectionCommand, + getConnectionTokenConfig, getExternalAgentRuntimeConfig, + updateConnectionToken, updateExternalAgentRuntimeConfig, } from '@/api/acp'; import { Button } from '@/components/Common/Button'; import { Input } from '@/components/Common/Input'; import { Select } from '@/components/Common/Select'; +import { TextInput } from '@/components/Common/TextInput'; import { toast } from '@/components/Common/Toast'; import { SettingRow } from '@/components/Settings/Common/SettingRow'; +import { copyToClipboard } from '@/utils/io/clipboard'; + +import type { ConnectionTokenConfig } from '@huabu/shared'; const IDLE_TIMEOUT_PRESETS = new Set(['0', '300', '600', '1800', '3600']); @@ -25,6 +33,13 @@ export function ExternalAgentRuntimeSettings() { const [customMinutes, setCustomMinutes] = useState('10'); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + const [tokenConfig, setTokenConfig] = useState( + null, + ); + const [tokenInput, setTokenInput] = useState(''); + const [tokenLoading, setTokenLoading] = useState(true); + const [tokenSaving, setTokenSaving] = useState(false); + const [copyingCommand, setCopyingCommand] = useState(false); useEffect(() => { let active = true; @@ -59,6 +74,29 @@ export function ExternalAgentRuntimeSettings() { }; }, [t]); + useEffect(() => { + let active = true; + void getConnectionTokenConfig() + .then((config) => { + if (active) setTokenConfig(config); + }) + .catch((error) => { + if (!active) return; + toast( + error instanceof Error + ? error.message + : t('settings.agentletTokenLoadFailed'), + { tone: 'danger' }, + ); + }) + .finally(() => { + if (active) setTokenLoading(false); + }); + return () => { + active = false; + }; + }, [t]); + const saveIdleTimeout = useCallback( async (nextIdleTimeoutSecs: number) => { setSaving(true); @@ -135,8 +173,153 @@ export function ExternalAgentRuntimeSettings() { parsedCustomMinutes >= 1 && parsedCustomMinutes <= 1440; + const saveConnectionToken = useCallback(async () => { + const token = tokenInput.trim(); + if (!token || !tokenConfig?.writable) return; + setTokenSaving(true); + try { + setTokenConfig(await updateConnectionToken({ token })); + setTokenInput(''); + toast(t('settings.agentletTokenSaved'), { tone: 'success' }); + } catch (error) { + toast( + error instanceof Error + ? error.message + : t('settings.agentletTokenSaveFailed'), + { tone: 'danger' }, + ); + } finally { + setTokenSaving(false); + } + }, [t, tokenConfig?.writable, tokenInput]); + + const clearConnectionToken = useCallback(async () => { + if (!tokenConfig?.writable) return; + setTokenSaving(true); + try { + setTokenConfig(await updateConnectionToken({ token: null })); + setTokenInput(''); + toast(t('settings.agentletTokenCleared'), { tone: 'success' }); + } catch (error) { + toast( + error instanceof Error + ? error.message + : t('settings.agentletTokenSaveFailed'), + { tone: 'danger' }, + ); + } finally { + setTokenSaving(false); + } + }, [t, tokenConfig?.writable]); + + const copyConnectionCommand = useCallback(async () => { + setCopyingCommand(true); + try { + const result = await createAgentletConnectionCommand(); + await copyToClipboard(result.command); + const warningKey = result.warnings.includes('insecure') + ? 'settings.agentletCommandCopiedInsecure' + : result.warnings.includes('loopback') + ? 'settings.agentletCommandCopiedLoopback' + : 'settings.agentletCommandCopied'; + toast(t(warningKey), { + tone: result.warnings.length > 0 ? 'warning' : 'success', + }); + } catch (error) { + toast( + error instanceof Error + ? error.message + : t('settings.agentletCommandCopyFailed'), + { tone: 'danger' }, + ); + } finally { + setCopyingCommand(false); + } + }, [t]); + return ( <> + + {t('settings.agentletConnectionToken')} + + + } + description={ + tokenConfig + ? t('settings.agentletConnectionTokenDescription', { + source: t(`settings.agentletTokenSource.${tokenConfig.source}`), + access: tokenConfig.writable + ? '' + : t('settings.agentletTokenReadOnly'), + }) + : t('settings.agentletConnectionTokenDescriptionLoading') + } + > +
+ setTokenInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') void saveConnectionToken(); + }} + placeholder={t('settings.agentletConnectionTokenPlaceholder')} + aria-label={t('settings.agentletConnectionToken')} + autoComplete="new-password" + maxLength={512} + disabled={ + tokenLoading || tokenSaving || tokenConfig?.writable !== true + } + /> + + {tokenConfig?.source === 'stored' ? ( + + ) : null} + +
+