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 (
+ <>
+
+
+ void check(true)}
+ disabled={checking || requesting}
+ >
+ {checking
+ ? t('settings.canaryChecking')
+ : t('settings.canaryCheck')}
+
+ setConfirming(true)}
+ disabled={checking || requesting || redeployInProgress}
+ >
+ {t('settings.canaryRedeployAction')}
+
+
+
+ {
+ if (!requesting) setConfirming(false);
+ }}
+ title={t('settings.canaryConfirmTitle')}
+ description={t('settings.canaryConfirmDescription')}
+ initialFocusRef={confirmRef}
+ closeOnBackdropClick={!requesting}
+ closeOnEscape={!requesting}
+ footer={
+ <>
+ setConfirming(false)}
+ disabled={requesting}
+ >
+ {t('settings.cancel')}
+
+ void redeploy()}
+ disabled={requesting}
+ >
+ {requesting
+ ? t('settings.canaryStarting')
+ : t('settings.canaryConfirmAction')}
+
+ >
+ }
+ />
+ >
+ );
+}
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}
+ />
+ void saveMaxAgents()}
+ disabled={
+ !maxAgentsValid ||
+ parsedMaxAgents === maxAgents ||
+ idleTimeoutLoading ||
+ idleTimeoutSaving
+ }
+ >
+ {t('settings.saveChanges')}
+
+
+
>
);
};
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')}
+ />
+
+
+ setRecentTurnCount(Number(value))}
+ title={t('settings.recentChatTurns')}
+ ariaLabel={t('settings.recentChatTurns')}
+ />
+
+ >
+ );
+}
diff --git a/apps/web/src/components/Settings/sections/ExternalAgentRuntimeSettings.test.tsx b/apps/web/src/components/Settings/sections/ExternalAgentRuntimeSettings.test.tsx
new file mode 100644
index 000000000..170c1262f
--- /dev/null
+++ b/apps/web/src/components/Settings/sections/ExternalAgentRuntimeSettings.test.tsx
@@ -0,0 +1,83 @@
+// 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 { getRuntimeConfig, updateRuntimeConfig, toast } = vi.hoisted(() => ({
+ getRuntimeConfig: vi.fn(),
+ updateRuntimeConfig: vi.fn(),
+ toast: vi.fn(),
+}));
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+vi.mock('@/api/acp', () => ({
+ getExternalAgentRuntimeConfig: getRuntimeConfig,
+ updateExternalAgentRuntimeConfig: updateRuntimeConfig,
+}));
+vi.mock('@/components/Common/Toast', () => ({ toast }));
+
+import { ExternalAgentRuntimeSettings } from './ExternalAgentRuntimeSettings';
+
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+let root: Root;
+let container: HTMLDivElement;
+
+beforeEach(() => {
+ getRuntimeConfig.mockResolvedValue({
+ idleTimeoutSecs: 600,
+ maxAgents: 10,
+ });
+ updateRuntimeConfig.mockResolvedValue({
+ idleTimeoutSecs: 1800,
+ maxAgents: 10,
+ });
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+});
+
+afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ vi.clearAllMocks();
+});
+
+describe('ExternalAgentRuntimeSettings', () => {
+ it('preserves the process limit when saving the idle timeout', async () => {
+ await act(async () => {
+ root.render( );
+ });
+
+ const idleTimeout = [...container.querySelectorAll('button')].find(
+ (button) => button.textContent?.includes('settings.tenMinutesDefault'),
+ );
+ if (!idleTimeout) throw new Error('Idle-timeout selector not found');
+
+ await act(async () => {
+ idleTimeout.click();
+ });
+
+ const thirtyMinutes = [...document.body.querySelectorAll('button')].find(
+ (button) => button.textContent === 'settings.thirtyMinutes',
+ );
+ if (!thirtyMinutes) throw new Error('Thirty-minute option not found');
+
+ await act(async () => {
+ thirtyMinutes.click();
+ });
+
+ expect(updateRuntimeConfig).toHaveBeenCalledWith({
+ idleTimeoutSecs: 1800,
+ maxAgents: 10,
+ });
+ expect(toast).toHaveBeenCalledWith(
+ 'settings.externalAgentIdleTimeoutSaved',
+ { tone: 'success' },
+ );
+ });
+});
diff --git a/apps/web/src/components/Settings/sections/ExternalAgentRuntimeSettings.tsx b/apps/web/src/components/Settings/sections/ExternalAgentRuntimeSettings.tsx
new file mode 100644
index 000000000..748b46d82
--- /dev/null
+++ b/apps/web/src/components/Settings/sections/ExternalAgentRuntimeSettings.tsx
@@ -0,0 +1,229 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+import { useCallback, useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import {
+ getExternalAgentRuntimeConfig,
+ updateExternalAgentRuntimeConfig,
+} from '@/api/acp';
+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 { SettingRow } from '@/components/Settings/Common/SettingRow';
+
+const IDLE_TIMEOUT_PRESETS = new Set(['0', '300', '600', '1800', '3600']);
+
+export function ExternalAgentRuntimeSettings() {
+ const { t } = useTranslation();
+ 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 [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+
+ 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) setLoading(false);
+ });
+ return () => {
+ active = false;
+ };
+ }, [t]);
+
+ const saveIdleTimeout = useCallback(
+ async (nextIdleTimeoutSecs: number) => {
+ setSaving(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 {
+ setSaving(false);
+ }
+ },
+ [idleTimeoutSecs, maxAgents, t],
+ );
+
+ const parsedMaxAgents = Number(maxAgentsInput);
+ const maxAgentsValid =
+ Number.isSafeInteger(parsedMaxAgents) && parsedMaxAgents >= 1;
+
+ const saveMaxAgents = useCallback(async () => {
+ if (!maxAgentsValid) return;
+ setSaving(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 {
+ setSaving(false);
+ }
+ }, [idleTimeoutSecs, maxAgents, 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 (
+ <>
+
+
+
+ {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')}
+
+ void saveIdleTimeout(parsedCustomMinutes * 60)}
+ disabled={!customMinutesValid || saving}
+ >
+ {t('settings.saveChanges')}
+
+ >
+ ) : null}
+
+
+
+
+ setMaxAgentsInput(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') void saveMaxAgents();
+ }}
+ aria-label={t('settings.externalAgentMaxAgents')}
+ disabled={loading || saving}
+ />
+ void saveMaxAgents()}
+ disabled={
+ !maxAgentsValid ||
+ parsedMaxAgents === maxAgents ||
+ loading ||
+ saving
+ }
+ >
+ {t('settings.saveChanges')}
+
+
+
+ >
+ );
+}
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')}
/>
-
- setRecentTurnCount(Number(value))}
- title={t('settings.recentChatTurns')}
- ariaLabel={t('settings.recentChatTurns')}
- />
-
-
-
-
- {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')}
-
- void saveIdleTimeout(parsedCustomMinutes * 60)}
- disabled={!customMinutesValid || idleTimeoutSaving}
- >
- {t('settings.saveChanges')}
-
- >
- )}
-
-
-
-
- setMaxAgentsInput(event.target.value)}
- onKeyDown={(event) => {
- if (event.key === 'Enter') void saveMaxAgents();
- }}
- aria-label={t('settings.externalAgentMaxAgents')}
- disabled={idleTimeoutLoading || idleTimeoutSaving}
- />
- void saveMaxAgents()}
- disabled={
- !maxAgentsValid ||
- parsedMaxAgents === maxAgents ||
- idleTimeoutLoading ||
- idleTimeoutSaving
- }
- >
- {t('settings.saveChanges')}
-
-
-
>
);
};
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);
- }}
- />
-
- {!isBuiltIn && (
+ {error ?? t('settings.loadingAgents')}
+
+ ) : (
+ <>
- {
- edit({ ...draft, functionalModel: event.target.value });
- }}
- onBlur={() => {
- if (error) edit(draft, true);
- else debouncedSave.flush();
+ 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);
+ }}
+ />
+
+ {!isBuiltIn && (
- 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"
+
+ {t('settings.imageGeneration')}
+ }
+ >
+ setCollapsed((current) => !current)}
+ >
+
-
+
+ {!collapsed && (
+ <>
+ {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 });
- }}
- />
-
-
+
+
+ {
+ 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 });
- }}
- />
-
-
+
+
+ ({
+ 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
+ }
+ />
+ void saveConnectionToken()}
+ disabled={
+ !tokenInput.trim() ||
+ tokenLoading ||
+ tokenSaving ||
+ tokenConfig?.writable !== true
+ }
+ >
+ {t('settings.saveChanges')}
+
+ {tokenConfig?.source === 'stored' ? (
+ void clearConnectionToken()}
+ disabled={tokenSaving || !tokenConfig.writable}
+ >
+ {t('settings.agentletTokenClear')}
+
+ ) : null}
+ void copyConnectionCommand()}
+ disabled={tokenLoading || copyingCommand || !tokenConfig}
+ >
+ {copyingCommand
+ ? t('settings.agentletCommandCopying')
+ : t('settings.agentletCommandCopy')}
+
+
+
{
textarea.style.left = '-1000px';
document.body.appendChild(textarea);
textarea.select();
- document.execCommand('copy');
+ const copied = document.execCommand('copy');
document.body.removeChild(textarea);
+ if (!copied) throw new Error('Clipboard write failed');
};
async function fetchImageAsPng(src: string): Promise {
diff --git a/docs/architecture/agent-profiles.md b/docs/architecture/agent-profiles.md
index 2e7592431..e50220907 100644
--- a/docs/architecture/agent-profiles.md
+++ b/docs/architecture/agent-profiles.md
@@ -23,6 +23,8 @@ Automatic discovery requests workspace preparation. The daemon resolves its own
Huabu subscribes to machine connection events, includes machines already connected at registration, and invalidates stale results on reconnect, disconnect, and shutdown. After each successful response it synchronously checks and creates automatic defaults through the registry. The check and commit contain no asynchronous gap.
+The Agentlet CLI defaults an omitted `--agentlet-id` to the machine hostname, so a copied connection command needs no identity argument for the common personal setup. Different hostnames may share the same Huabu connection token and connect concurrently. If an identity is already online, Gateway rejects the second live connection with guidance to retry using `--agentlet-id ` instead of evicting the first machine; once the prior connection is disconnected, the same identity follows the normal reconnect path.
+
## Profile identity and customization
A Profile has `id`, `alias`, `agentletId`, `workingDirPath`, a launch configuration, optional `metadata.cliId`, opaque `customData`, and configuration/execution revisions. Launch is either `{ kind: 'acp-command', command }` or a structured `{ kind: 'acp-harness', harnessId, options?: { autoApprove? } }`. Every Profile has exactly one wrapper, derived from this launch union: a command Profile uses the Custom command wrapper; a structured Profile uses its `harnessId`. Neither editable metadata nor command-text inspection determines capabilities. Profile ID, target machine, launch kind, and harness identity are immutable; alias, icon, cwd, and supported launch options are editable. Changing machine or wrapper requires a new Profile.
@@ -99,6 +101,8 @@ The Agent Settings surface presents ordinary Profiles, their existing edit/delet
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.
+The same runtime section owns the masked Agentlet connection-token setting and one-click remote connection command. `GET/PUT /api/acp/connection-token` reports the active source and saves or clears the encrypted override without returning plaintext. `POST /api/acp/connection-command` returns the current origin-derived daemon command only to an authorized owner; the Web app copies it directly and shows transport or loopback warnings without rendering the command.
+
## Code entry points
| File or directory | Responsibility |
diff --git a/docs/architecture/agent-reachback.md b/docs/architecture/agent-reachback.md
index 82f8804c0..43868e3bd 100644
--- a/docs/architecture/agent-reachback.md
+++ b/docs/architecture/agent-reachback.md
@@ -73,6 +73,8 @@ The only anonymous exception is `GET /skill` with no Authorization header. It re
The shipped token grants access to the complete RFS surface, including direct reads and writes, and `/capabilities` reports both permissions as enabled. The canvas ID scopes route resolution but is not an independent credential or security boundary.
+The active credential is the same high-privilege token used by the Agentlet control and relay WebSockets. Its precedence is a SecretStore value saved from Settings, then a non-empty `HUABU_CONNECTION_TOKEN`, then one random 256-bit hexadecimal value generated per server boot. A successful Settings mutation persists first, atomically switches RFS and Agentlet authentication in memory, disconnects existing Agentlets, and restarts the supervised daemon; clearing the saved override restores the environment or generated fallback. A failed persistence attempt leaves the active credential and connections unchanged.
+
## File projection
Downloads expose only the public canvas projection: node Markdown sidecars, artifacts, and staged uploads. Private bookkeeping such as memory and history directories is rejected by the path resolver.
@@ -103,6 +105,8 @@ The guide is direct-first: an external agent can discover, query, download, snap
RFS errors use the normal API error body and include a runnable `/skill` recovery command so a caller can reload the current usage contract after a malformed request.
+Settings > Agent > External Agent runtime can copy a complete `agentlet daemon` command for another machine. The owner-only command endpoint derives `ws:` or `wss:` from the current browser origin, includes the configured process limit and active token, and returns `Cache-Control: no-store`; the renderer writes the command directly to the clipboard without displaying it. HTTP origins add `--allow-insecure`, and loopback origins produce a contextual warning rather than being rejected because Windows/WSL, containers, virtual machines, and explicit forwarding can make them reachable.
+
## Interactive View resources
`GET|POST /interactive-views`, `GET /interactive-views/:nodeId`, and `PUT /interactive-views/:nodeId/state` expose Interactive Views as Web Node resources rather than a separate repository. Creation accepts a staged `upload/*.html` renderer, imports it through the canonical Canvas executor, validates that the owner is a durable external Agent thread in the current Canvas namespace, validates the closed state schema, bindings, actions, and initial value, and returns the created Node identity plus a deterministic View revision. The owner identity is `canvasId + ownerThreadId`; it does not require an Agent Node or a fixed Node binding policy. State writes replace the complete value and compare that revision inside the Canvas write mutex.
diff --git a/docs/architecture/credential-storage.md b/docs/architecture/credential-storage.md
index 32d02463b..e7858ca5d 100644
--- a/docs/architecture/credential-storage.md
+++ b/docs/architecture/credential-storage.md
@@ -31,6 +31,12 @@ Remote Server Basic Auth is separate from Huabu's `SecretStore`. Electron accept
Settings API updates for optional capability credentials use an explicit three-state patch contract: omitting a key preserves the persisted value, a non-empty string sets or replaces it, and `null` removes the value stored by Huabu. Removing a persisted key preserves non-secret provider configuration and does not alter deployment-owned environment variables; an environment fallback may therefore keep the capability available at runtime.
+### Agentlet connection token
+
+Settings > Agent > External Agent runtime stores an optional Agentlet connection-token override under `integration:agentlet:connection-token`. The effective value prefers that encrypted SecretStore entry, then non-empty `HUABU_CONNECTION_TOKEN`, then one random 256-bit hexadecimal token generated per server boot. Clearing the entry restores the next fallback and never rewrites deployment environment. Environment-only standalone deployments expose the active source as read-only and reject mutation.
+
+Ordinary reads return only the source (`stored`, `environment`, or `generated`) and SecretStore writability. Plaintext is returned only by the explicit owner-only connection-command action, which places a complete command directly on the clipboard without rendering the token. The response is non-cacheable, but the credential necessarily exists transiently in the HTTP response, renderer memory, clipboard, and the user's shell/process history.
+
### Azure AI Vision handwriting OCR
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.
diff --git a/docs/architecture/deployment-security.md b/docs/architecture/deployment-security.md
index ddf909bcd..de999fc37 100644
--- a/docs/architecture/deployment-security.md
+++ b/docs/architecture/deployment-security.md
@@ -11,7 +11,9 @@ The owner may perform Settings, OAuth, credential, External Agent Profile and ha
- the request's direct TCP peer is loopback;
- the request passed Huabu's configured HTTP Basic Auth gate.
-The connection token is a separate machine credential used by RFS and the embedded Agentlet transport. Its generation and injection are independent of browser owner authentication.
+The connection token is a separate high-privilege machine credential shared by the complete read/write RFS surface and the embedded or remote Agentlet control and relay transports. Its generation and injection are independent of browser owner authentication; possession grants both Agentlet attachment and Space data access.
+
+Owner-only `GET/PUT /api/acp/connection-token` exposes masked source metadata and changes the encrypted override. Owner-only `POST /api/acp/connection-command` is the deliberate reveal boundary: it derives the Agentlet endpoint from the browser origin and returns a complete command with no-store headers for direct clipboard use. Copying makes the token available to the clipboard and later shell/process history. An HTTP-derived command requires `--allow-insecure` and is appropriate only on a trusted network; loopback commands remain available for same-host, Windows/WSL, container, virtual-machine, or forwarded setups and carry a warning.
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.
diff --git a/packages/shared/src/types/api/acp.ts b/packages/shared/src/types/api/acp.ts
index f1d3f0586..17ed9bd35 100644
--- a/packages/shared/src/types/api/acp.ts
+++ b/packages/shared/src/types/api/acp.ts
@@ -534,6 +534,47 @@ export const acpAgentletStatusSchema = agentletStatusSchema;
/** @deprecated Use {@link acpAgentletStatusSchema} instead. */
export const acpDaemonStatusSchema = acpAgentletStatusSchema;
+export const connectionTokenSourceSchema = z.enum([
+ 'stored',
+ 'environment',
+ 'generated',
+]);
+export type ConnectionTokenSource = z.infer;
+
+export const connectionTokenConfigSchema = z.object({
+ source: connectionTokenSourceSchema,
+ writable: z.boolean(),
+});
+export type ConnectionTokenConfig = z.infer;
+
+export const connectionTokenUpdateSchema = z.object({
+ token: z.string().trim().min(1).max(512).nullable(),
+});
+export type ConnectionTokenUpdate = z.infer;
+
+export const agentletConnectionCommandRequestSchema = z.object({
+ origin: z.url().max(2048),
+});
+export type AgentletConnectionCommandRequest = z.infer<
+ typeof agentletConnectionCommandRequestSchema
+>;
+
+export const agentletConnectionCommandWarningSchema = z.enum([
+ 'loopback',
+ 'insecure',
+]);
+export type AgentletConnectionCommandWarning = z.infer<
+ typeof agentletConnectionCommandWarningSchema
+>;
+
+export const agentletConnectionCommandResponseSchema = z.object({
+ command: z.string().min(1),
+ warnings: z.array(agentletConnectionCommandWarningSchema),
+});
+export type AgentletConnectionCommandResponse = z.infer<
+ typeof agentletConnectionCommandResponseSchema
+>;
+
/** Schema mirror of {@link AcpProfilesListResponse}. */
export const acpProfilesListResponseSchema = z.object({
profiles: z.array(agentProfileSchema),
From 4d2c4608ebbed142113344bc7cddf9025f620731 Mon Sep 17 00:00:00 2001
From: Yuqing Yang
Date: Thu, 1 Oct 2026 08:18:53 +0000
Subject: [PATCH 15/30] fix(settings): clarify on-demand capabilities
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../components/Settings/sections/ImageProviderSettings.tsx | 6 +-----
apps/web/src/i18n/resources/en/common.json | 2 +-
apps/web/src/i18n/resources/zh-CN/common.json | 2 +-
docs/architecture/agent-profiles.md | 2 +-
4 files changed, 4 insertions(+), 8 deletions(-)
diff --git a/apps/web/src/components/Settings/sections/ImageProviderSettings.tsx b/apps/web/src/components/Settings/sections/ImageProviderSettings.tsx
index b02f6d02f..4a1567c0b 100644
--- a/apps/web/src/components/Settings/sections/ImageProviderSettings.tsx
+++ b/apps/web/src/components/Settings/sections/ImageProviderSettings.tsx
@@ -119,11 +119,7 @@ export const ImageProviderSettings: React.FC = () => {
return (
- {t('settings.imageGeneration')}
- }
- >
+
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.
+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; all of these Huabu-managed services are configured on demand, 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 582a51b1f549528d91b37ac05ba823ae74828755 Mon Sep 17 00:00:00 2001
From: Yuqing Yang
Date: Thu, 1 Oct 2026 08:42:19 +0000
Subject: [PATCH 16/30] fix(agent): keep recent conversation Agent
browser-local
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
apps/server/src/app.ts | 4 -
.../modules/agent/agent-node.service.test.ts | 48 ++----
.../src/modules/agent/agent-node.service.ts | 20 +--
.../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 --------
.../agent/selectable-agent-profile.test.ts | 35 +---
.../modules/agent/selectable-agent-profile.ts | 11 +-
.../modules/canvas/agent-node-edit.test.ts | 26 +--
.../src/modules/canvas/agent-node-edit.ts | 4 +-
.../src/prompt/external-agent/access-huabu.md | 2 +-
.../src/prompt/external-agent/agents.md | 2 +-
apps/web/src/api/_routes.ts | 1 -
apps/web/src/api/agentDefaults.ts | 20 ---
.../Nodes/question/questionCompose.test.ts | 64 +++----
.../StrokeSelectionToolbar.test.tsx | 67 ++++----
.../StrokeSelectionToolbar.tsx | 3 +-
.../src/components/Panels/ChatPanel/index.tsx | 12 +-
.../PreviewWorkspace.test.tsx | 19 +-
.../src/components/Settings/SettingsModal.tsx | 2 +-
apps/web/src/i18n/resources/en/common.json | 4 +-
apps/web/src/i18n/resources/zh-CN/common.json | 4 +-
apps/web/src/store/acpProfilesStore.test.ts | 101 +++--------
apps/web/src/store/acpProfilesStore.ts | 162 +++++++-----------
.../canvasStore.postCreateEditing.test.ts | 55 +++---
.../src/store/chatStore.sessionScope.test.ts | 32 ++--
apps/web/src/store/chatStore.ts | 4 +-
apps/web/src/store/conversationOwner.test.ts | 11 --
apps/web/src/store/conversationOwner.ts | 12 +-
docs/architecture/agent-profiles.md | 6 +-
docs/architecture/agent-reachback.md | 40 ++---
docs/architecture/api-design.md | 4 +-
docs/architecture/deployment-security.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 ---
40 files changed, 298 insertions(+), 862 deletions(-)
delete mode 100644 apps/server/src/modules/agent/conversation-agent.route.test.ts
delete mode 100644 apps/server/src/modules/agent/conversation-agent.route.ts
delete mode 100644 apps/server/src/modules/agent/conversation-agent.test.ts
delete 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 24800f015..96758aa79 100644
--- a/apps/server/src/app.ts
+++ b/apps/server/src/app.ts
@@ -36,7 +36,6 @@ 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';
@@ -367,9 +366,6 @@ 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-node.service.test.ts b/apps/server/src/modules/agent/agent-node.service.test.ts
index 8136f3f74..7918fd830 100644
--- a/apps/server/src/modules/agent/agent-node.service.test.ts
+++ b/apps/server/src/modules/agent/agent-node.service.test.ts
@@ -39,7 +39,7 @@ function createHarness(options?: {
nodeApplied?: boolean;
edgeApplied?: boolean;
edgeError?: Error;
- defaultProfileId?: string | null;
+ fallbackProfileId?: string | null;
}) {
const execute = vi
.fn()
@@ -50,10 +50,10 @@ function createHarness(options?: {
execute.mockResolvedValueOnce(output(options?.edgeApplied ?? true));
}
const service = new AgentNodeService({
- getDefaultProfileId: () =>
- options?.defaultProfileId === undefined
+ getFallbackProfileId: () =>
+ options?.fallbackProfileId === undefined
? 'profile-a'
- : options.defaultProfileId,
+ : options.fallbackProfileId,
getProfileRegistry: () => ({
getProfile: (profileId) =>
profileId === 'profile-a'
@@ -84,20 +84,6 @@ function createHarness(options?: {
}
describe('AgentNodeService', () => {
- it('creates from an explicit Built-In default without requiring an external Profile', async () => {
- const { service, execute } = createHarness({
- defaultProfileId: 'huabu',
- selectableIds: [],
- });
- const result = await service.create({
- canvasId: 'canvas-a',
- position: { x: 0, y: 0 },
- });
- expect(result.profileId).toBe('huabu');
- expect(
- execute.mock.calls[0][0].commands[0].nodes[0].data.agentBinding,
- ).toEqual({ kind: 'internal' });
- });
it('creates one external Question Node and then its lineage edge', async () => {
const { service, execute } = createHarness();
@@ -283,7 +269,7 @@ describe('AgentNodeService', () => {
);
});
- it('uses the configured default only when no Profile was supplied', async () => {
+ it('uses the first selectable fallback only when no Profile was supplied', async () => {
const { service, execute } = createHarness();
const result = await service.create({
canvasId: 'canvas-a',
@@ -299,20 +285,14 @@ describe('AgentNodeService', () => {
});
});
- it('does not fall back when the default is unconfigured or deleted', async () => {
- for (const defaultProfileId of [null, 'deleted-profile']) {
- const { service, execute } = createHarness({ defaultProfileId });
- await expect(
- service.create({
- canvasId: 'canvas-a',
- position: { x: 1, y: 2 },
- }),
- ).rejects.toMatchObject({
- code: defaultProfileId
- ? 'profile_not_selectable'
- : 'default_profile_unconfigured',
- });
- expect(execute).not.toHaveBeenCalled();
- }
+ it('rejects creation when no selectable fallback exists', async () => {
+ const { service, execute } = createHarness({ fallbackProfileId: null });
+ await expect(
+ service.create({
+ canvasId: 'canvas-a',
+ position: { x: 1, y: 2 },
+ }),
+ ).rejects.toMatchObject({ code: 'default_profile_unconfigured' });
+ expect(execute).not.toHaveBeenCalled();
});
});
diff --git a/apps/server/src/modules/agent/agent-node.service.ts b/apps/server/src/modules/agent/agent-node.service.ts
index 5954fbf09..23ab2cad4 100644
--- a/apps/server/src/modules/agent/agent-node.service.ts
+++ b/apps/server/src/modules/agent/agent-node.service.ts
@@ -18,10 +18,7 @@ import {
parseAgentLaunchOverrides,
} from './agent-launch-overrides.js';
import {
- getEffectiveConversationAgentProfileId,
- rememberConversationAgentProfileId,
-} from './conversation-agent.js';
-import {
+ getFirstSelectableAgentProfileId,
requireSelectableAgentProfile,
SelectableAgentProfileError,
type SelectableAgentProfile,
@@ -92,8 +89,7 @@ interface StoredNode {
interface AgentNodeServiceDependencies {
getProfileRegistry: () => AgentProfileRegistryPort | null;
- getDefaultProfileId?: () => string | null;
- rememberProfileId?: (profileId: string) => void;
+ getFallbackProfileId?: () => string | null;
readCanvasNodes: (canvasId: string) => Promise;
execute: (input: {
canvasId: string;
@@ -112,8 +108,7 @@ async function defaultReadCanvasNodes(
const DEFAULT_DEPENDENCIES: AgentNodeServiceDependencies = {
getProfileRegistry: () => null,
- getDefaultProfileId: getEffectiveConversationAgentProfileId,
- rememberProfileId: rememberConversationAgentProfileId,
+ getFallbackProfileId: getFirstSelectableAgentProfileId,
readCanvasNodes: defaultReadCanvasNodes,
execute: executeOnServer,
};
@@ -223,9 +218,9 @@ export class AgentNodeService {
const profileId =
input.profileId ??
- (this.dependencies.getDefaultProfileId
- ? this.dependencies.getDefaultProfileId()
- : getEffectiveConversationAgentProfileId());
+ (this.dependencies.getFallbackProfileId
+ ? this.dependencies.getFallbackProfileId()
+ : getFirstSelectableAgentProfileId());
if (!profileId) {
throw new AgentNodeCreationError(
'default_profile_unconfigured',
@@ -305,9 +300,6 @@ 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
deleted file mode 100644
index d2fef6159..000000000
--- a/apps/server/src/modules/agent/conversation-agent.route.test.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-// 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
deleted file mode 100644
index e2e784dbb..000000000
--- a/apps/server/src/modules/agent/conversation-agent.route.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-// 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
deleted file mode 100644
index e6c92299a..000000000
--- a/apps/server/src/modules/agent/conversation-agent.test.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-// 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
deleted file mode 100644
index 485ab4905..000000000
--- a/apps/server/src/modules/agent/conversation-agent.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-// 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/selectable-agent-profile.test.ts b/apps/server/src/modules/agent/selectable-agent-profile.test.ts
index 4eda46f96..d2976c00e 100644
--- a/apps/server/src/modules/agent/selectable-agent-profile.test.ts
+++ b/apps/server/src/modules/agent/selectable-agent-profile.test.ts
@@ -24,45 +24,24 @@ describe('listAvailableAgentProfiles', () => {
]);
expect(
- listAvailableAgentProfiles(
- {
- getProfile: (id: string) => profiles.get(id),
- listSelectableProfileIds: () => ['profile-a', 'profile-b'],
- },
- 'profile-b',
- ),
+ listAvailableAgentProfiles({
+ getProfile: (id: string) => profiles.get(id),
+ listSelectableProfileIds: () => ['profile-a', 'profile-b'],
+ }),
).toEqual([
{ id: 'huabu', alias: 'Built-In Pi' },
- { id: 'profile-a', alias: 'Researcher' },
- { id: 'profile-b', alias: 'Builder', default: true },
+ { id: 'profile-a', alias: 'Researcher', default: true },
+ { id: 'profile-b', alias: 'Builder' },
]);
});
it('keeps the Huabu Profile available while the registry is unavailable', () => {
- expect(listAvailableAgentProfiles(null, null)).toEqual([
+ expect(listAvailableAgentProfiles(null)).toEqual([
{ id: 'huabu', alias: 'Built-In Pi' },
]);
});
- it('does not mark another Profile as default when the selected one is missing', () => {
- expect(
- listAvailableAgentProfiles(
- {
- getProfile: () => ({ id: 'other', alias: 'Other' }),
- listSelectableProfileIds: () => ['other'],
- },
- 'deleted',
- ),
- ).toEqual([
- { id: 'huabu', alias: 'Built-In Pi' },
- { id: 'other', alias: 'Other' },
- ]);
- });
-
it('accepts the Huabu Profile without an external registry', () => {
expect(() => requireAvailableAgentProfile('huabu', null)).not.toThrow();
- expect(listAvailableAgentProfiles(null, 'huabu')).toEqual([
- { id: 'huabu', alias: 'Built-In Pi', default: true },
- ]);
});
});
diff --git a/apps/server/src/modules/agent/selectable-agent-profile.ts b/apps/server/src/modules/agent/selectable-agent-profile.ts
index 4d0ba96f1..aabc27e89 100644
--- a/apps/server/src/modules/agent/selectable-agent-profile.ts
+++ b/apps/server/src/modules/agent/selectable-agent-profile.ts
@@ -5,8 +5,6 @@ import { getAgentProfileRegistry } from '@agenetes/agentlet-host';
import { HUABU_AGENT_PROFILE_ID } from '@huabu/shared';
-import { getEffectiveConversationAgentProfileId } from './conversation-agent.js';
-
import type { CustomData } from '@huabu/shared';
export interface SelectableAgentProfile {
@@ -68,14 +66,19 @@ export function requireAvailableAgentProfile(
requireSelectableAgentProfile(profileId, registry);
}
+export function getFirstSelectableAgentProfileId(
+ registry: AgentProfileRegistryPort | null = getAgentProfileRegistry(),
+): string | null {
+ return registry?.listSelectableProfileIds()[0] ?? null;
+}
+
export function listAvailableAgentProfiles(
registry: AgentProfileRegistryPort | null = getAgentProfileRegistry(),
- defaultProfileId: string | null = getEffectiveConversationAgentProfileId(),
): AvailableAgentProfileSummary[] {
+ const defaultProfileId = getFirstSelectableAgentProfileId(registry);
const huabu = {
id: HUABU_AGENT_PROFILE_ID,
alias: 'Built-In Pi',
- ...(defaultProfileId === HUABU_AGENT_PROFILE_ID ? { default: true } : {}),
} as const;
if (!registry) {
return [huabu];
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 898ee2f31..895fc7254 100644
--- a/apps/server/src/modules/canvas/agent-node-edit.test.ts
+++ b/apps/server/src/modules/canvas/agent-node-edit.test.ts
@@ -9,28 +9,23 @@ 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(() => ({
- defaults: vi.fn(),
+ firstProfile: vi.fn(),
profile: vi.fn(),
}));
-vi.mock('../agent/conversation-agent.js', async (importOriginal) => ({
- ...(await importOriginal()),
- getEffectiveConversationAgentProfileId: mocks.defaults,
-}));
-
vi.mock('../agent/selectable-agent-profile.js', async (importOriginal) => ({
...(await importOriginal()),
+ getFirstSelectableAgentProfileId: mocks.firstProfile,
requireSelectableAgentProfile: mocks.profile,
}));
describe('new Agent Node default binding', () => {
beforeEach(() => {
vi.resetAllMocks();
- mocks.defaults.mockReturnValue('external-default');
+ mocks.firstProfile.mockReturnValue('external-default');
mocks.profile.mockReturnValue({
id: 'external-default',
alias: 'External',
@@ -55,21 +50,12 @@ describe('new Agent Node default binding', () => {
])('preserves an explicitly supplied binding: %j', (agentBinding) => {
const data = { agentBinding };
expect(withDefaultAgentBinding(data)).toBe(data);
- expect(mocks.defaults).not.toHaveBeenCalled();
- expect(mocks.profile).not.toHaveBeenCalled();
- });
-
- it('uses an explicit Built-In default without resolving external Profiles', () => {
- mocks.defaults.mockReturnValue('huabu');
- expect(withDefaultAgentBinding({ label: 'New Agent' })).toEqual({
- label: 'New Agent',
- agentBinding: { kind: 'internal' },
- });
+ expect(mocks.firstProfile).not.toHaveBeenCalled();
expect(mocks.profile).not.toHaveBeenCalled();
});
- it('reports an unconfigured default instead of silently choosing internal', () => {
- mocks.defaults.mockReturnValue(null);
+ it('reports that no external Profile is available', () => {
+ mocks.firstProfile.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 e0153f3b6..b4974f385 100644
--- a/apps/server/src/modules/canvas/agent-node-edit.ts
+++ b/apps/server/src/modules/canvas/agent-node-edit.ts
@@ -14,9 +14,9 @@ import { agenetes } from '../agent/agenetes/drivers.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 {
+ getFirstSelectableAgentProfileId,
requireSelectableAgentProfile,
SelectableAgentProfileError,
type SelectableAgentProfile,
@@ -43,7 +43,7 @@ export function withDefaultAgentBinding(
data: Record,
): Record {
if (data.agentBinding) return data;
- const profileId = getEffectiveConversationAgentProfileId();
+ const profileId = getFirstSelectableAgentProfileId();
if (!profileId) {
throw new AgentNodeEditError(
'Connect an external Agent before creating a conversation.',
diff --git a/apps/server/src/prompt/external-agent/access-huabu.md b/apps/server/src/prompt/external-agent/access-huabu.md
index 288834cf6..482028b28 100644
--- a/apps/server/src/prompt/external-agent/access-huabu.md
+++ b/apps/server/src/prompt/external-agent/access-huabu.md
@@ -242,7 +242,7 @@ Use an Agent when open-ended work benefits from interpretation or a durable visi
### 8.1 Create and start an Agent
-Plain text creates a visible Agent with the external Profile selected in Global Settings and immediately submits its first prompt. An unconfigured or deleted default produces an explicit error; no other Profile is substituted:
+Plain text creates a visible Agent with the first selectable external Profile and immediately submits its first prompt. This server-side fallback does not read the Web client's browser-local recent selection:
```bash
SSE="$(curl -fsS -N -H "$AUTH" -H "Content-Type: text/plain" \
diff --git a/apps/server/src/prompt/external-agent/agents.md b/apps/server/src/prompt/external-agent/agents.md
index 144ee08fb..e0ed17d8d 100644
--- a/apps/server/src/prompt/external-agent/agents.md
+++ b/apps/server/src/prompt/external-agent/agents.md
@@ -8,7 +8,7 @@ Load this guide when work should be handled by a visible Agent conversation inst
curl -fsS -H "$AUTH" "$HUABU_RFS_URL/agent/profiles"
```
-The entry marked `default: true` is the external Profile selected in Global Settings. If no entry is marked, configure a default or explicitly choose an available Profile; do not assume the first entry is the default.
+The entry marked `default: true` is the first selectable external Profile used when Agent creation omits `profileId`. If no entry is marked, explicitly choose an available Profile; do not assume Built-In Pi is an implicit fallback.
The `huabu` Profile uses Huabu's configured model provider. Other Profiles use their own configured runtimes and do not depend on that provider.
diff --git a/apps/web/src/api/_routes.ts b/apps/web/src/api/_routes.ts
index 1faef52e3..b14e52c9b 100644
--- a/apps/web/src/api/_routes.ts
+++ b/apps/web/src/api/_routes.ts
@@ -20,7 +20,6 @@ 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 20378b48a..3584f9cab 100644
--- a/apps/web/src/api/agentDefaults.ts
+++ b/apps/web/src/api/agentDefaults.ts
@@ -5,10 +5,6 @@ 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, {
@@ -25,19 +21,3 @@ 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 ff3dcb0b8..759c30e50 100644
--- a/apps/web/src/components/Nodes/question/questionCompose.test.ts
+++ b/apps/web/src/components/Nodes/question/questionCompose.test.ts
@@ -5,10 +5,8 @@ import { assert, beforeEach, describe, expect, it, vi } from 'vitest';
const saveDraft = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const associateNode = vi.hoisted(() => vi.fn());
-const getConversationAgent = vi.hoisted(() => vi.fn());
-vi.mock('@/api/agentDefaults', () => ({
- getConversationAgentPreference: getConversationAgent,
-}));
+const listProfiles = vi.hoisted(() => vi.fn());
+vi.mock('@/api/acp', () => ({ listAcpProfiles: listProfiles }));
vi.mock('@/components/Common/Toast', () => ({ toast: vi.fn() }));
vi.mock('@/api/canvas', async (importOriginal) => ({
...(await importOriginal()),
@@ -54,22 +52,34 @@ const view = {
},
};
+const profileSnapshot = {
+ profiles: [
+ {
+ id: 'global-profile',
+ alias: 'Global Profile',
+ agentletId: 'machine',
+ workingDirPath: '/workspace',
+ launch: { kind: 'acp-command' as const, command: 'agent' },
+ },
+ ],
+ selectableProfileIds: ['global-profile'],
+ agentlet: null,
+ agentDefaults: null,
+};
+
beforeEach(() => {
+ localStorage.clear();
saveDraft.mockClear();
associateNode.mockReset();
- getConversationAgent.mockReset().mockResolvedValue({
- preference: { profileId: 'global-profile' },
- effectiveProfileId: 'global-profile',
- selectionState: 'available',
- });
+ listProfiles.mockReset().mockResolvedValue(profileSnapshot);
useAcpProfilesStore.setState({
loaded: false,
error: null,
profiles: [],
+ selectableProfileIds: [],
agentDefaults: null,
defaultsError: null,
- conversationAgent: null,
- conversationAgentError: null,
+ recentConversationProfileId: null,
});
vi.mocked(toast).mockClear();
useCanvasStore.getState()._setStateNoAutosave({
@@ -186,7 +196,7 @@ describe('Question conversation presentation', () => {
).toEqual({ kind: 'internal' });
});
- it('creates and focuses the global default instead of inheriting the Canvas selection', async () => {
+ it('creates and focuses the browser fallback instead of inheriting the Canvas selection', async () => {
const binding = {
kind: 'external' as const,
profileId: 'profile-1',
@@ -209,7 +219,7 @@ describe('Question conversation presentation', () => {
).toEqual({
kind: 'external',
profileId: 'global-profile',
- alias: 'global-profile',
+ alias: 'Global Profile',
});
expect(addNode).toHaveBeenCalledWith(
expect.objectContaining({
@@ -217,7 +227,7 @@ describe('Question conversation presentation', () => {
agentBinding: {
kind: 'external',
profileId: 'global-profile',
- alias: 'global-profile',
+ alias: 'Global Profile',
},
agentMode: 'ask',
}),
@@ -226,10 +236,10 @@ describe('Question conversation presentation', () => {
});
it('does not create or open a node when defaults are unconfigured', async () => {
- getConversationAgent.mockResolvedValueOnce({
- preference: { profileId: null },
- effectiveProfileId: null,
- selectionState: 'unconfigured',
+ listProfiles.mockResolvedValueOnce({
+ ...profileSnapshot,
+ profiles: [],
+ selectableProfileIds: [],
});
const addNode = vi.fn();
@@ -247,11 +257,7 @@ describe('Question conversation presentation', () => {
});
it('creates a Built-In Question in operate mode without loading external Profiles', async () => {
- getConversationAgent.mockResolvedValueOnce({
- preference: { profileId: 'huabu' },
- effectiveProfileId: 'huabu',
- selectionState: 'available',
- });
+ useAcpProfilesStore.setState({ recentConversationProfileId: 'huabu' });
const addNode = vi.fn().mockReturnValue('question-built-in');
const created = await createQuestionNodeAndCompose({
addNode,
@@ -271,7 +277,7 @@ describe('Question conversation presentation', () => {
it('discards delayed creation after the Canvas changes', async () => {
let resolve!: (value: unknown) => void;
- getConversationAgent.mockReturnValueOnce(
+ listProfiles.mockReturnValueOnce(
new Promise((done) => {
resolve = done;
}),
@@ -283,11 +289,7 @@ describe('Question conversation presentation', () => {
placementPoint: { x: 0, y: 0 },
});
useCanvasStore.setState({ canvasId: 'canvas-2' });
- resolve({
- preference: { profileId: 'global-profile' },
- effectiveProfileId: 'global-profile',
- selectionState: 'available',
- });
+ resolve(profileSnapshot);
expect(await pending).toBeNull();
expect(addNode).not.toHaveBeenCalled();
});
@@ -322,7 +324,7 @@ describe('Question conversation presentation', () => {
{ kind: 'internal' as const },
{ kind: 'external' as const, profileId: 'chosen', alias: 'Chosen Agent' },
])(
- 'preserves the existing node selection %o despite a different global default',
+ 'preserves the existing node selection %o despite a different browser fallback',
(binding) => {
useAcpProfilesStore.setState({
loaded: true,
@@ -353,7 +355,7 @@ describe('Question conversation presentation', () => {
'ask',
);
expect(saveDraft).not.toHaveBeenCalled();
- expect(getConversationAgent).not.toHaveBeenCalled();
+ expect(listProfiles).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 3753aa7cc..5efa68d9a 100644
--- a/apps/web/src/components/Panels/Canvas/FloatingToolbars/StrokeSelectionToolbar.test.tsx
+++ b/apps/web/src/components/Panels/Canvas/FloatingToolbars/StrokeSelectionToolbar.test.tsx
@@ -29,13 +29,11 @@ const mocks = vi.hoisted(() => ({
captureGrounding: vi.fn(),
blobToDataUrl: vi.fn(),
getViewport: vi.fn(),
- getConversationAgent: vi.fn(),
+ listProfiles: vi.fn(),
popoverAnchor: null as unknown,
}));
-vi.mock('@/api/agentDefaults', () => ({
- getConversationAgentPreference: mocks.getConversationAgent,
-}));
+vi.mock('@/api/acp', () => ({ listAcpProfiles: mocks.listProfiles }));
vi.mock('@/components/Common/Toast', () => ({ toast: vi.fn() }));
vi.mock('@xyflow/react', async (original) => ({
@@ -147,21 +145,18 @@ beforeEach(() => {
];
useAcpProfilesStore.setState({
profiles,
+ selectableProfileIds: ['default-profile'],
agentDefaults: { profileId: 'default-profile', functionalModel: '' },
- conversationAgent: {
- preference: { profileId: 'default-profile' },
- effectiveProfileId: 'default-profile',
- selectionState: 'available',
- },
- conversationAgentError: null,
+ recentConversationProfileId: 'default-profile',
loaded: true,
error: null,
defaultsError: null,
});
- mocks.getConversationAgent.mockReset().mockResolvedValue({
- preference: { profileId: 'default-profile' },
- effectiveProfileId: 'default-profile',
- selectionState: 'available',
+ mocks.listProfiles.mockReset().mockResolvedValue({
+ profiles,
+ selectableProfileIds: ['default-profile'],
+ agentlet: null,
+ agentDefaults: null,
});
useGesturePreviewStore.setState({
sketchStrokeSelection: { 'sketch-1': ['stroke-1'] },
@@ -217,11 +212,11 @@ afterEach(() => {
});
describe('StrokeSelectionToolbar Ink submission', () => {
- it('loads and snapshots the default external Profile for a new Ink Question', async () => {
+ it('loads and snapshots the browser fallback for a new Ink Question', async () => {
mocks.dispatch.mockResolvedValueOnce({ status: 'completed' });
const button = await renderToolbar();
await act(async () => button.click());
- expect(mocks.getConversationAgent).toHaveBeenCalledOnce();
+ expect(mocks.listProfiles).toHaveBeenCalledOnce();
expect(mocks.createQuestion).toHaveBeenCalledWith(
expect.objectContaining({
binding: {
@@ -238,10 +233,8 @@ describe('StrokeSelectionToolbar Ink submission', () => {
);
});
- it('keeps the Ink selection and creates nothing when defaults are unavailable', async () => {
- mocks.getConversationAgent.mockRejectedValueOnce(
- new Error('Server unavailable'),
- );
+ it('keeps the Ink selection and creates nothing when Profiles are unavailable', async () => {
+ mocks.listProfiles.mockRejectedValueOnce(new Error('Server unavailable'));
const button = await renderToolbar();
await act(async () => button.click());
expect(mocks.createQuestion).not.toHaveBeenCalled();
@@ -256,12 +249,8 @@ describe('StrokeSelectionToolbar Ink submission', () => {
);
});
- it('restores operate mode for new Ink Questions with a Built-In default', async () => {
- mocks.getConversationAgent.mockResolvedValueOnce({
- preference: { profileId: 'huabu' },
- effectiveProfileId: 'huabu',
- selectionState: 'available',
- });
+ it('restores operate mode for new Ink Questions with a recent Built-In selection', async () => {
+ useAcpProfilesStore.setState({ recentConversationProfileId: 'huabu' });
const button = await renderToolbar();
await act(async () => button.click());
expect(mocks.createQuestion).toHaveBeenCalledWith(
@@ -275,11 +264,12 @@ describe('StrokeSelectionToolbar Ink submission', () => {
);
});
- it('requires a configured default instead of falling back to the internal Agent', async () => {
- mocks.getConversationAgent.mockResolvedValueOnce({
- preference: { profileId: null },
- effectiveProfileId: null,
- selectionState: 'unconfigured',
+ it('requires an external Profile instead of falling back to the internal Agent', async () => {
+ mocks.listProfiles.mockResolvedValueOnce({
+ profiles: [],
+ selectableProfileIds: [],
+ agentlet: null,
+ agentDefaults: null,
});
const button = await renderToolbar();
await act(async () => button.click());
@@ -292,7 +282,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.getConversationAgent.mockImplementationOnce(
+ mocks.listProfiles.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveDefaults = resolve;
@@ -312,9 +302,10 @@ describe('StrokeSelectionToolbar Ink submission', () => {
});
}
resolveDefaults({
- preference: { profileId: 'default-profile' },
- effectiveProfileId: 'default-profile',
- selectionState: 'available',
+ profiles: useAcpProfilesStore.getState().profiles,
+ selectableProfileIds: ['default-profile'],
+ agentlet: null,
+ agentDefaults: null,
});
});
expect(mocks.createQuestion).not.toHaveBeenCalled();
@@ -609,7 +600,7 @@ describe('StrokeSelectionToolbar Ink submission', () => {
expect(mocks.prepare).toHaveBeenCalledWith(
expect.objectContaining({ mode: 'operate' }),
);
- expect(mocks.getConversationAgent).not.toHaveBeenCalled();
+ expect(mocks.listProfiles).not.toHaveBeenCalled();
expect(mocks.createQuestion).not.toHaveBeenCalled();
});
@@ -655,7 +646,7 @@ describe('StrokeSelectionToolbar Ink submission', () => {
}),
}),
);
- expect(mocks.getConversationAgent).not.toHaveBeenCalled();
+ expect(mocks.listProfiles).not.toHaveBeenCalled();
});
it('creates and dispatches at most once for rapid activation', async () => {
@@ -712,7 +703,7 @@ describe('StrokeSelectionToolbar Ink submission', () => {
expect(mocks.createQuestion).toHaveBeenCalledTimes(1);
expect(mocks.dispatch).toHaveBeenCalledTimes(2);
- expect(mocks.getConversationAgent).toHaveBeenCalledTimes(1);
+ expect(mocks.listProfiles).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 d8f7d98c5..af111ac14 100644
--- a/apps/web/src/components/Panels/Canvas/FloatingToolbars/StrokeSelectionToolbar.tsx
+++ b/apps/web/src/components/Panels/Canvas/FloatingToolbars/StrokeSelectionToolbar.tsx
@@ -44,6 +44,7 @@ import { isOutsideCanvasInteraction } from '@/hooks/shortcuts/isEditableTarget';
import { useIsNotMouse } from '@/hooks/useInputMode';
import {
loadDefaultAgentBinding,
+ selectDefaultConversationProfileId,
useAcpProfilesStore,
} from '@/store/acpProfilesStore';
import useCanvasStore from '@/store/canvasStore';
@@ -151,7 +152,7 @@ export const StrokeSelectionToolbar = () => {
);
const agentProfiles = useAcpProfilesStore((state) => state.profiles);
const recentProfileId = useAcpProfilesStore(
- (state) => state.conversationAgent?.effectiveProfileId,
+ selectDefaultConversationProfileId,
);
const currentLassoIdentity = useCallback(
diff --git a/apps/web/src/components/Panels/ChatPanel/index.tsx b/apps/web/src/components/Panels/ChatPanel/index.tsx
index 147581b5f..b75e4b7f7 100644
--- a/apps/web/src/components/Panels/ChatPanel/index.tsx
+++ b/apps/web/src/components/Panels/ChatPanel/index.tsx
@@ -850,17 +850,7 @@ export const ChatPanel = ({
}
}
if (!activeConversationView) {
- try {
- await rememberConversationAgentBinding(choice.binding);
- } catch (error) {
- toast(
- error instanceof Error
- ? error.message
- : 'Failed to save recent Agent selection',
- { tone: 'danger' },
- );
- return;
- }
+ rememberConversationAgentBinding(choice.binding);
}
setAgentBinding(threadId, choice.binding, canvasId || undefined);
setThreadLastAction(threadId, choice.mode);
diff --git a/apps/web/src/components/Panels/PreviewWorkspace/PreviewWorkspace.test.tsx b/apps/web/src/components/Panels/PreviewWorkspace/PreviewWorkspace.test.tsx
index 8100b97cf..760a81bca 100644
--- a/apps/web/src/components/Panels/PreviewWorkspace/PreviewWorkspace.test.tsx
+++ b/apps/web/src/components/Panels/PreviewWorkspace/PreviewWorkspace.test.tsx
@@ -73,19 +73,20 @@ vi.mock('@/api/conversationTitles', () => ({
vi.mock('@/api/acp', async (importOriginal) => ({
...(await importOriginal()),
listAcpProfiles: async () => ({
- profiles: [],
- selectableProfileIds: [],
+ profiles: [
+ {
+ id: 'global-profile',
+ alias: 'Global Profile',
+ agentletId: 'machine',
+ workingDirPath: '/workspace',
+ launch: { kind: 'acp-command', command: 'agent' },
+ },
+ ],
+ selectableProfileIds: ['global-profile'],
agentlet: null,
agentDefaults: { profileId: 'global-profile', functionalModel: '' },
}),
}));
-vi.mock('@/api/agentDefaults', () => ({
- getConversationAgentPreference: async () => ({
- preference: { profileId: 'global-profile' },
- effectiveProfileId: 'global-profile',
- selectionState: 'available',
- }),
-}));
vi.mock('../ChatPanel', () => ({
ChatPanel: ({
diff --git a/apps/web/src/components/Settings/SettingsModal.tsx b/apps/web/src/components/Settings/SettingsModal.tsx
index 3cb1aa820..2de7e1d53 100644
--- a/apps/web/src/components/Settings/SettingsModal.tsx
+++ b/apps/web/src/components/Settings/SettingsModal.tsx
@@ -56,7 +56,7 @@ interface SettingsModalProps {
* pane, so the panel height stays fixed as more settings are added.
*
* Each tab renders the existing self-contained `*Settings` components:
- * - **Agent** — global defaults, Built-In Pi setup, external Profiles, and behavior
+ * - **Agent** — Utility Agent, Built-In Pi setup, external Profiles, and behavior
* - **Capabilities** — Huabu-owned image, search, transcript, and OCR services
* - **General** — application, canvas, input, and update preferences
*
diff --git a/apps/web/src/i18n/resources/en/common.json b/apps/web/src/i18n/resources/en/common.json
index 64c17bec0..86ed9d305 100644
--- a/apps/web/src/i18n/resources/en/common.json
+++ b/apps/web/src/i18n/resources/en/common.json
@@ -217,7 +217,7 @@
"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": "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.",
+ "agentDefaultsSectionDescription": "Used only for Huabu utility tasks such as summaries, titles, labels, and keywords. New conversations in this browser 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",
@@ -1163,8 +1163,6 @@
},
"errors": {
"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 63eb94010..136696567 100644
--- a/apps/web/src/i18n/resources/zh-CN/common.json
+++ b/apps/web/src/i18n/resources/zh-CN/common.json
@@ -217,7 +217,7 @@
"profileEditConflict": "此配置已在其他位置修改。请重新加载配置列表并重新打开编辑器后再保存;您的修改尚未覆盖新版本。",
"profileSaveFailed": "保存配置失败",
"agentDefaultsTitle": "Utility Agent",
- "agentDefaultsSectionDescription": "仅用于摘要、标题、标签和关键词等 Huabu 辅助任务。新对话使用最近选择的对话 Agent。",
+ "agentDefaultsSectionDescription": "仅用于摘要、标题、标签和关键词等 Huabu 辅助任务。此浏览器中的新对话使用最近选择的对话 Agent。",
"builtInPi": "Built-In Pi",
"builtInPiSetup": "使用 Huabu 内的提供商凭据,可能需要配置。",
"builtInPiConfigure": "配置 Built-In Pi 提供商和模型",
@@ -1163,8 +1163,6 @@
},
"errors": {
"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 f0303896b..ec5abeeb4 100644
--- a/apps/web/src/store/acpProfilesStore.test.ts
+++ b/apps/web/src/store/acpProfilesStore.test.ts
@@ -7,31 +7,24 @@ const listProfiles = vi.hoisted(() => 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: api.getDefaults,
updateAgentDefaults: api.updateDefaults,
- getConversationAgentPreference: api.getConversation,
- updateConversationAgentPreference: api.updateConversation,
}));
vi.mock('@/components/Common/Toast', () => ({ toast: api.toast }));
import {
getDefaultAgentBinding,
loadDefaultAgentBinding,
+ RECENT_CONVERSATION_AGENT_STORAGE_KEY,
rememberConversationAgentBinding,
useAcpProfilesStore,
} from './acpProfilesStore';
-import type {
- AgentDefaults,
- AgentDefaultsResponse,
- ConversationAgentPreferenceResponse,
-} from '@huabu/shared';
+import type { AgentDefaults, AgentDefaultsResponse } from '@huabu/shared';
const profile = {
id: 'profile-default',
@@ -47,22 +40,8 @@ const snapshot = {
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(() => {
+ localStorage.clear();
listProfiles.mockReset().mockResolvedValue(snapshot);
api.getDefaults.mockReset().mockResolvedValue({
defaults: snapshot.agentDefaults,
@@ -76,26 +55,20 @@ beforeEach(() => {
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],
+ selectableProfileIds: [profile.id],
agentDefaults: null,
defaultsError: null,
- conversationAgent: null,
- conversationAgentError: null,
+ recentConversationProfileId: null,
});
});
-describe('conversation Agent snapshot', () => {
- it('deduplicates canonical preference loading and resolves the effective Profile', async () => {
+describe('browser-local recent conversation Agent', () => {
+ it('deduplicates Profile refresh and resolves the first selectable Profile', async () => {
const [first, second] = await Promise.all([
loadDefaultAgentBinding(),
loadDefaultAgentBinding(),
@@ -106,26 +79,15 @@ describe('conversation Agent snapshot', () => {
alias: profile.alias,
});
expect(second).toEqual(first);
- expect(api.getConversation).toHaveBeenCalledOnce();
+ expect(listProfiles).toHaveBeenCalledOnce();
expect(api.getDefaults).not.toHaveBeenCalled();
});
- 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',
+ it('falls back to the first selectable Profile when the local cache is stale', async () => {
+ await rememberConversationAgentBinding({
+ kind: 'external',
+ profileId: 'stale-profile',
+ alias: 'Stale',
});
await expect(loadDefaultAgentBinding()).resolves.toMatchObject({
kind: 'external',
@@ -134,7 +96,7 @@ describe('conversation Agent snapshot', () => {
});
it('supports Built-In Pi as an explicit conversational choice', async () => {
- api.getConversation.mockResolvedValueOnce(conversation('huabu'));
+ await rememberConversationAgentBinding({ kind: 'internal' });
await expect(loadDefaultAgentBinding()).resolves.toEqual({
kind: 'internal',
});
@@ -146,9 +108,9 @@ describe('conversation Agent snapshot', () => {
profileId: profile.id,
alias: profile.alias,
});
- expect(api.updateConversation).toHaveBeenCalledWith({
- profileId: profile.id,
- });
+ expect(localStorage.getItem(RECENT_CONVERSATION_AGENT_STORAGE_KEY)).toBe(
+ profile.id,
+ );
expect(getDefaultAgentBinding()).toMatchObject({
profileId: profile.id,
});
@@ -167,28 +129,15 @@ describe('conversation Agent snapshot', () => {
});
});
- it('serializes conversational choices so the last explicit selection wins', async () => {
- let finish!: (response: ConversationAgentPreferenceResponse) => void;
- api.updateConversation.mockImplementationOnce(
- () =>
- new Promise((resolve) => {
- finish = resolve;
- }),
+ it('keeps the last explicit browser selection', () => {
+ useAcpProfilesStore.getState().rememberConversationAgent('first');
+ useAcpProfilesStore.getState().rememberConversationAgent('second');
+ expect(useAcpProfilesStore.getState().recentConversationProfileId).toBe(
+ 'second',
+ );
+ expect(localStorage.getItem(RECENT_CONVERSATION_AGENT_STORAGE_KEY)).toBe(
+ 'second',
);
- const first = useAcpProfilesStore
- .getState()
- .rememberConversationAgent('first');
- const second = useAcpProfilesStore
- .getState()
- .rememberConversationAgent('second');
- await Promise.resolve();
- 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' });
});
});
diff --git a/apps/web/src/store/acpProfilesStore.ts b/apps/web/src/store/acpProfilesStore.ts
index 5b8ee6705..1da23dc0e 100644
--- a/apps/web/src/store/acpProfilesStore.ts
+++ b/apps/web/src/store/acpProfilesStore.ts
@@ -8,7 +8,8 @@
* Why a store and not per-component state? Several surfaces need the
* profile list in lockstep: the Settings editor lets the user CRUD
* profiles, the chat picker uses the same list to label "external"
- * binding options, and new conversations snapshot the global default.
+ * binding options, and new conversations resolve browser-local recency
+ * against the current selectable catalogue.
* Centralising
* the list in a store means any caller can `await
* useAcpProfilesStore.getState().refresh()` and be sure every
@@ -39,13 +40,10 @@
import { create } from 'zustand';
+import { HUABU_AGENT_PROFILE_ID } from '@huabu/shared';
+
import { listAcpProfiles } from '@/api/acp';
-import {
- getAgentDefaults,
- getConversationAgentPreference,
- updateAgentDefaults,
- updateConversationAgentPreference,
-} from '@/api/agentDefaults';
+import { getAgentDefaults, updateAgentDefaults } from '@/api/agentDefaults';
import { toast } from '@/components/Common/Toast';
import { i18n } from '@/i18n';
@@ -54,17 +52,36 @@ 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;
+
+export const RECENT_CONVERSATION_AGENT_STORAGE_KEY =
+ 'huabu.recentConversationAgentProfile.v1';
+
+function readRecentConversationProfileId(): string | null {
+ if (typeof localStorage === 'undefined') return null;
+ try {
+ const profileId = localStorage.getItem(
+ RECENT_CONVERSATION_AGENT_STORAGE_KEY,
+ );
+ return profileId?.trim() || null;
+ } catch {
+ return null;
+ }
+}
+
+function writeRecentConversationProfileId(profileId: string): void {
+ if (typeof localStorage === 'undefined') return;
+ try {
+ localStorage.setItem(RECENT_CONVERSATION_AGENT_STORAGE_KEY, profileId);
+ } catch {
+ // Unavailable browser storage keeps the preference session-local.
+ }
+}
interface AcpProfilesState {
/** Every profile the user has created. Empty until the first fetch. */
@@ -76,8 +93,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;
+ /** Browser-local convenience preference; never persisted by the Server. */
+ recentConversationProfileId: string | null;
/**
* `true` once a fetch has *succeeded* at least once. A failed initial
* fetch leaves this `false` (and {@link profiles} empty), so consumers
@@ -98,10 +115,7 @@ interface AcpProfilesState {
refresh: () => Promise;
loadDefaults: () => Promise;
saveDefaults: (config: AgentDefaults) => Promise;
- loadConversationAgent: () => Promise;
- rememberConversationAgent: (
- profileId: string,
- ) => Promise;
+ rememberConversationAgent: (profileId: string) => void;
}
export const useAcpProfilesStore = create()((set, get) => ({
@@ -110,8 +124,7 @@ export const useAcpProfilesStore = create()((set, get) => ({
agentlet: null,
agentDefaults: null,
defaultsError: null,
- conversationAgent: null,
- conversationAgentError: null,
+ recentConversationProfileId: readRecentConversationProfileId(),
loaded: false,
error: null,
loading: false,
@@ -140,18 +153,9 @@ export const useAcpProfilesStore = create()((set, get) => ({
window.addEventListener('workspace-changed', () => {
set({ error: null });
void get().refresh();
- void get()
- .loadConversationAgent()
- .catch(() => undefined);
});
}
- await Promise.all([
- get().refresh(),
- get()
- .loadConversationAgent()
- .then(() => undefined)
- .catch(() => undefined),
- ]);
+ await get().refresh();
},
loadDefaults: async () => {
await defaultsSaveQueue;
@@ -210,57 +214,9 @@ 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;
+ writeRecentConversationProfileId(profileId);
+ set({ recentConversationProfileId: profileId });
},
refresh: async () => {
await defaultsSaveQueue;
@@ -298,21 +254,32 @@ export const useAcpProfilesStore = create()((set, get) => ({
},
}));
+export function selectDefaultConversationProfileId(
+ state: Pick<
+ AcpProfilesState,
+ 'profiles' | 'selectableProfileIds' | 'recentConversationProfileId'
+ >,
+): string | null {
+ const recentProfileId = state.recentConversationProfileId;
+ if (recentProfileId === HUABU_AGENT_PROFILE_ID) return recentProfileId;
+ if (
+ recentProfileId &&
+ state.selectableProfileIds.includes(recentProfileId) &&
+ state.profiles.some((profile) => profile.id === recentProfileId)
+ ) {
+ return recentProfileId;
+ }
+ return state.selectableProfileIds[0] ?? null;
+}
+
/** Snapshot only the chat identity; functional-model routing is unrelated. */
export function getDefaultAgentBinding(): AgentBinding {
const state = useAcpProfilesStore.getState();
- if (!state.conversationAgent || state.conversationAgentError) {
- throw new Error(i18n.t('errors.conversationAgentUnavailable'));
- }
- const { effectiveProfileId: profileId, selectionState } =
- state.conversationAgent;
- if (!profileId || selectionState === 'unconfigured') {
+ const profileId = selectDefaultConversationProfileId(state);
+ if (!profileId) {
throw new Error(i18n.t('errors.conversationAgentUnconfigured'));
}
- if (selectionState !== 'available') {
- throw new Error(i18n.t('errors.conversationAgentStale'));
- }
- if (profileId === 'huabu') return { kind: 'internal' };
+ if (profileId === HUABU_AGENT_PROFILE_ID) return { kind: 'internal' };
const profile = state.profiles.find((entry) => entry.id === profileId);
return {
kind: 'external',
@@ -321,15 +288,16 @@ export function getDefaultAgentBinding(): AgentBinding {
};
}
-/** User-initiated creation waits for the canonical server snapshot. */
+/** User-initiated creation refreshes the catalogue before applying local recency. */
export async function loadDefaultAgentBinding(): Promise {
- await useAcpProfilesStore.getState().loadConversationAgent();
+ await useAcpProfilesStore.getState().refresh();
+ const { error } = useAcpProfilesStore.getState();
+ if (error) throw error;
return getDefaultAgentBinding();
}
-export async function rememberConversationAgentBinding(
- binding: AgentBinding,
-): Promise {
- const profileId = binding.kind === 'internal' ? 'huabu' : binding.profileId;
- await useAcpProfilesStore.getState().rememberConversationAgent(profileId);
+export function rememberConversationAgentBinding(binding: AgentBinding): void {
+ const profileId =
+ binding.kind === 'internal' ? HUABU_AGENT_PROFILE_ID : binding.profileId;
+ 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 e2616c668..e50bffecf 100644
--- a/apps/web/src/store/canvasStore.postCreateEditing.test.ts
+++ b/apps/web/src/store/canvasStore.postCreateEditing.test.ts
@@ -3,14 +3,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-const getConversationAgent = vi.hoisted(() => vi.fn());
+const listProfiles = vi.hoisted(() => vi.fn());
vi.mock('@/components/Common/Toast', () => ({ toast: vi.fn() }));
-vi.mock('@/api/agentDefaults', () => ({
- getConversationAgentPreference: getConversationAgent,
-}));
+vi.mock('@/api/acp', () => ({ listAcpProfiles: listProfiles }));
import { toast } from '@/components/Common/Toast';
+import { useAcpProfilesStore } from './acpProfilesStore';
import useCanvasStore from './canvasStore';
import { useChatStore } from './chatStore';
import { usePanelStore } from './panelStore';
@@ -30,6 +29,21 @@ import {
const expandedNodeId = () =>
selectActiveNodeId(usePreviewWorkspaceStore.getState());
+const profileSnapshot = {
+ profiles: [
+ {
+ id: 'global-profile',
+ alias: 'Global Profile',
+ agentletId: 'machine',
+ workingDirPath: '/workspace',
+ launch: { kind: 'acp-command' as const, command: 'agent' },
+ },
+ ],
+ selectableProfileIds: ['global-profile'],
+ agentlet: null,
+ agentDefaults: null,
+};
+
function resetStore() {
useCanvasStore.getState()._setStateNoAutosave({
nodes: [],
@@ -48,16 +62,19 @@ function resetStore() {
isRightCollapsed: true,
focusChatInputRequest: null,
});
+ useAcpProfilesStore.setState({
+ profiles: [],
+ selectableProfileIds: [],
+ recentConversationProfileId: null,
+ loaded: false,
+ error: null,
+ });
}
beforeEach(() => {
vi.useFakeTimers();
vi.mocked(toast).mockClear();
- getConversationAgent.mockResolvedValue({
- preference: { profileId: 'global-profile' },
- effectiveProfileId: 'global-profile',
- selectionState: 'available',
- });
+ listProfiles.mockReset().mockResolvedValue(profileSnapshot);
resetStore();
});
@@ -88,7 +105,7 @@ describe('post-create editing', () => {
});
it('focuses the most recently active existing Chat', async () => {
- getConversationAgent.mockClear();
+ listProfiles.mockClear();
const preview = usePreviewWorkspaceStore.getState();
const first = preview.openPreviewTarget({
kind: 'chat',
@@ -114,14 +131,14 @@ describe('post-create editing', () => {
expect(
usePreviewWorkspaceStore.getState().workspace.tabs[second],
).toBeDefined();
- expect(getConversationAgent).not.toHaveBeenCalled();
+ expect(listProfiles).not.toHaveBeenCalled();
});
it('prompts to configure defaults without creating a fallback Chat', async () => {
- getConversationAgent.mockResolvedValueOnce({
- preference: { profileId: null },
- effectiveProfileId: null,
- selectionState: 'unconfigured',
+ listProfiles.mockResolvedValueOnce({
+ ...profileSnapshot,
+ profiles: [],
+ selectableProfileIds: [],
});
const threads = useChatStore.getState().threadsById;
expect(await openChat()).toBe('');
@@ -133,7 +150,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;
- getConversationAgent.mockReturnValueOnce(
+ listProfiles.mockReturnValueOnce(
new Promise((done) => {
resolve = done;
}),
@@ -141,11 +158,7 @@ describe('post-create editing', () => {
const threads = useChatStore.getState().threadsById;
const pending = openNewChat();
openPreviewNode('node-note');
- resolve({
- preference: { profileId: 'global-profile' },
- effectiveProfileId: 'global-profile',
- selectionState: 'available',
- });
+ resolve(profileSnapshot);
expect(await pending).toBe('');
expect(useChatStore.getState().threadsById).toBe(threads);
expect(expandedNodeId()).toBe('node-note');
diff --git a/apps/web/src/store/chatStore.sessionScope.test.ts b/apps/web/src/store/chatStore.sessionScope.test.ts
index 9d36a5a9e..dc3403733 100644
--- a/apps/web/src/store/chatStore.sessionScope.test.ts
+++ b/apps/web/src/store/chatStore.sessionScope.test.ts
@@ -36,17 +36,21 @@ function resetStore() {
useAcpProfilesStore.setState({
loaded: true,
error: null,
- profiles: [],
+ profiles: [
+ {
+ id: EXTERNAL.profileId,
+ alias: EXTERNAL.alias,
+ agentletId: 'machine',
+ workingDirPath: '/workspace',
+ launch: { kind: 'acp-command', command: 'agent' },
+ },
+ ],
+ selectableProfileIds: [EXTERNAL.profileId],
agentDefaults: {
profileId: EXTERNAL.profileId,
functionalModel: 'utility-only',
},
- conversationAgent: {
- preference: { profileId: EXTERNAL.profileId },
- effectiveProfileId: EXTERNAL.profileId,
- selectionState: 'available',
- },
- conversationAgentError: null,
+ recentConversationProfileId: EXTERNAL.profileId,
});
useChatStore.setState({
threadsById: {},
@@ -150,7 +154,7 @@ describe('chatStore thread creation', () => {
expect(selectThreadBinding(useChatStore.getState(), first)).toEqual({
kind: 'external',
profileId: EXTERNAL.profileId,
- alias: EXTERNAL.profileId,
+ alias: EXTERNAL.alias,
});
expect(selectThreadLastAction(useChatStore.getState(), first)).toBe('ask');
});
@@ -170,13 +174,13 @@ describe('chatStore thread creation', () => {
});
});
- it('keeps an existing legacy Canvas identity even with no configured default', () => {
+ it('keeps an existing legacy Canvas identity even with no selectable Profile', () => {
useChatStore.setState({
threadMap: { 'canvas-legacy': 'thread-legacy' },
bindingByThread: { 'thread-legacy': INTERNAL },
});
useAcpProfilesStore.setState({
- conversationAgent: null,
+ recentConversationProfileId: null,
loaded: false,
});
expect(useChatStore.getState().ensureCanvasThread('canvas-legacy')).toBe(
@@ -189,11 +193,9 @@ describe('chatStore thread creation', () => {
it('refuses unconfigured creation without leaving an internal thread', () => {
useAcpProfilesStore.setState({
- conversationAgent: {
- preference: { profileId: null },
- effectiveProfileId: null,
- selectionState: 'unconfigured',
- },
+ profiles: [],
+ selectableProfileIds: [],
+ recentConversationProfileId: null,
});
expect(() => useChatStore.getState().createThread()).toThrow();
expect(() =>
diff --git a/apps/web/src/store/chatStore.ts b/apps/web/src/store/chatStore.ts
index bcf094633..2b34f8b41 100644
--- a/apps/web/src/store/chatStore.ts
+++ b/apps/web/src/store/chatStore.ts
@@ -18,7 +18,7 @@ import type {
/**
* Compatibility binding for existing threads without persisted metadata.
- * New threads must resolve the configured default or supply a binding.
+ * New threads must resolve the browser-local recent selection or supply a binding.
*/
const DEFAULT_BINDING: AgentBinding = { kind: 'internal' };
const DEFAULT_ACTION: AgentMode = 'operate';
@@ -179,7 +179,7 @@ export interface ChatState {
binding?: AgentBinding;
lastAction?: AgentMode;
}) => string;
- /** Reuse legacy identity; new mappings require resolved global defaults. */
+ /** Reuse legacy identity; new mappings require a resolved browser selection. */
ensureCanvasThread: (canvasId: string, binding?: AgentBinding) => string;
/**
* Change a thread's agent binding. Pass `canvasId` to also persist the
diff --git a/apps/web/src/store/conversationOwner.test.ts b/apps/web/src/store/conversationOwner.test.ts
index df2d98279..bb0b0a76f 100644
--- a/apps/web/src/store/conversationOwner.test.ts
+++ b/apps/web/src/store/conversationOwner.test.ts
@@ -13,17 +13,6 @@ 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 4cc415b94..79606ac71 100644
--- a/apps/web/src/store/conversationOwner.ts
+++ b/apps/web/src/store/conversationOwner.ts
@@ -5,7 +5,6 @@ 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';
@@ -204,16 +203,7 @@ 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' },
- );
- }
+ rememberConversationAgentBinding(patch.agentBinding);
});
draftSaves.set(draftKey(view), save);
// Keep a rejected save available to the send guard until an explicit retry.
diff --git a/docs/architecture/agent-profiles.md b/docs/architecture/agent-profiles.md
index 1340ac580..915bb9df1 100644
--- a/docs/architecture/agent-profiles.md
+++ b/docs/architecture/agent-profiles.md
@@ -75,7 +75,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. 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; all of these Huabu-managed services are configured on demand, 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 the browser-local recent conversational Agent. 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; all of these Huabu-managed services are configured on demand, 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.
@@ -87,9 +87,9 @@ External functional text tasks reuse Profile snapshot compilation and Agenetes e
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.
-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.
+The Web client records the most recently explicitly selected conversational Profile in browser `localStorage`; there is no conversation-preference endpoint or server-side preference file. New Web conversations snapshot that local identity after refreshing the Profile catalogue. A missing, deleted, or unavailable cached external identity falls back to the first selectable external Profile without rewriting the cache; an explicit Built-In selection remains local and selectable. Existing conversations and restored nodes keep their own persisted binding. Server-created and RFS Agent Nodes do not consume browser state: callers may supply an explicit Profile, otherwise the Server independently uses the first selectable external Profile without persisting that fallback.
-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.
+Ink submission without an existing Question target refreshes the Profile catalogue and applies the same browser-local recent selection before creating its Question, using internal `operate` or external `ask` mode. It 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.
`GET /api/acp/profiles` reads the canonical persisted list, selectable IDs, and saved `agentDefaults` without detecting harnesses, creating Profiles or starting sessions. Settings refreshes the shared Profile store on mount and after mutations; existing selectors refresh that same list when opened. There is no Web discovery store, selector-time materialization, or discovery polling.
diff --git a/docs/architecture/agent-reachback.md b/docs/architecture/agent-reachback.md
index 43868e3bd..46999e49c 100644
--- a/docs/architecture/agent-reachback.md
+++ b/docs/architecture/agent-reachback.md
@@ -22,24 +22,24 @@ The shipped design record is [`agent-reachback-rfs.md`](../proposals/agent-reach
All endpoints are mounted under `/api/rfs/:canvasId`; `HUABU_RFS_URL` already contains that canvas-scoped base.
-| Endpoint | Responsibility |
-| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
-| `GET /skill` | Return the public bundled root guide; authenticated requests resolve the current root guide and append live Skill Frames. |
-| `GET /skill/:skillId` | Return an authenticated advanced guide: `layout`, `tasks`, or `agents`. |
-| `GET /download/` | Stream a known node, artifact, or staged-upload file. |
-| `POST /upload/` | Stage bytes in the canvas `.upload/` directory without creating a node. |
-| `DELETE /upload/` | Remove one exact staged upload. |
-| `POST /agent` | Create a visible Agent Node and optionally start its first turn. |
-| `POST /agent/:threadId/prompt` | Submit a turn to an existing Agent conversation over SSE. |
-| `POST /agent/:threadId/ink-intent` | Submit a validated report for the matching active external Ink turn. |
-| `GET /agent/profiles` | Return available Agent Profile IDs and aliases, marking the configured external or Built-In default with `default: true` when available. |
-| `POST /task/create` | Create a durable Task and its static Task Note. |
-| `POST /task/:taskId/run/create` | Create a Run, its visible root Agent Node, and start the first turn. |
-| `GET /capabilities` | Report the direct-operation protocol, limits, semantics, and supported operation types. |
-| `GET /capabilities/queries/:type` | Return one query's generated JSON Schema, constraints, result description, and examples. |
-| `GET /capabilities/commands/:type` | Return one command's generated JSON Schema, constraints, result description, and examples. |
-| `POST /query` | Validate and execute one bounded `SpaceQuery`, returning a query-discriminated JSON result. |
-| `POST /execute` | Validate and execute an ordered batch of agent-allowed `CanvasCommand` variants. |
+| Endpoint | Responsibility |
+| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
+| `GET /skill` | Return the public bundled root guide; authenticated requests resolve the current root guide and append live Skill Frames. |
+| `GET /skill/:skillId` | Return an authenticated advanced guide: `layout`, `tasks`, or `agents`. |
+| `GET /download/` | Stream a known node, artifact, or staged-upload file. |
+| `POST /upload/` | Stage bytes in the canvas `.upload/` directory without creating a node. |
+| `DELETE /upload/` | Remove one exact staged upload. |
+| `POST /agent` | Create a visible Agent Node and optionally start its first turn. |
+| `POST /agent/:threadId/prompt` | Submit a turn to an existing Agent conversation over SSE. |
+| `POST /agent/:threadId/ink-intent` | Submit a validated report for the matching active external Ink turn. |
+| `GET /agent/profiles` | Return available Agent Profile IDs and aliases, marking the first selectable external fallback with `default: true` when available. |
+| `POST /task/create` | Create a durable Task and its static Task Note. |
+| `POST /task/:taskId/run/create` | Create a Run, its visible root Agent Node, and start the first turn. |
+| `GET /capabilities` | Report the direct-operation protocol, limits, semantics, and supported operation types. |
+| `GET /capabilities/queries/:type` | Return one query's generated JSON Schema, constraints, result description, and examples. |
+| `GET /capabilities/commands/:type` | Return one command's generated JSON Schema, constraints, result description, and examples. |
+| `POST /query` | Validate and execute one bounded `SpaceQuery`, returning a query-discriminated JSON result. |
+| `POST /execute` | Validate and execute an ordered batch of agent-allowed `CanvasCommand` variants. |
There is no directory-listing endpoint. External agents receive exact node paths in selected-node context or ask the internal agent to discover relevant files.
@@ -87,13 +87,13 @@ 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 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.
+`POST /agent` always creates a visible Agent Node. A plain-text body uses the first selectable external Profile plus an immediate first prompt; the full JSON form optionally selects another available Profile, position, launch options, optional parent thread, and optional prompt. RFS has no access to the Web client's browser-local recent selection. Omitting `profileId` uses the first selectable external Profile without persisting that fallback. `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.
`POST /agent/:threadId/prompt` addresses one existing Agent conversation directly and never creates a Node or changes its Profile. A caller that retained no creation response can query `INSPECT_NODES` for the Agent/Question Node and use its optional `threadId`; non-Question and unbound Question results omit the field. Both immediate creation and later prompts use SSE; creation streams begin with a `created` event carrying `nodeId`, `threadId`, effective `profileId`, parent-connection state, and warnings.
-`GET /agent/profiles` exposes the public available Profile catalogue. It includes `huabu` as the explicit Built-In Pi choice, marks the available saved default with `default: true`, and projects Profiles to stable `id` and `alias` fields without exposing commands, working directories, manifests, setup details, or registry eligibility state. Built-In remains selectable without an external registry; its presence does not prove provider authentication. List order is not a default-selection contract.
+`GET /agent/profiles` exposes the public available Profile catalogue. It includes `huabu` as the explicit Built-In Pi choice, marks the first selectable external fallback with `default: true`, and projects Profiles to stable `id` and `alias` fields without exposing commands, working directories, manifests, setup details, or registry eligibility state. Built-In remains selectable without an external registry; its presence does not prove provider authentication. The `default` marker, not list order alone, communicates the RFS fallback.
## External-agent bootstrap
diff --git a/docs/architecture/api-design.md b/docs/architecture/api-design.md
index 23ffaf116..9766bc16d 100644
--- a/docs/architecture/api-design.md
+++ b/docs/architecture/api-design.md
@@ -144,9 +144,9 @@ 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
+## Utility 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.
+`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. The recent conversational Agent is browser-local UI state and has no HTTP contract.
## RFS Agent discovery
diff --git a/docs/architecture/deployment-security.md b/docs/architecture/deployment-security.md
index de999fc37..593a2fb84 100644
--- a/docs/architecture/deployment-security.md
+++ b/docs/architecture/deployment-security.md
@@ -17,7 +17,7 @@ Owner-only `GET/PUT /api/acp/connection-token` exposes masked source metadata an
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 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.
+The Utility Agent (external Profile or explicit Built-In Pi) and external functional-model preference follow the owner boundary through `GET` and `PUT /api/agent/defaults`. The recent conversational Agent is browser-local UI state, not a Server credential or authorization setting. Neither preference grants new tool permissions or changes native harness approval policy.
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/preview-workspace.md b/docs/architecture/preview-workspace.md
index c70885d07..a06daa283 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. 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.
+`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 browser-local recent conversational Agent unless supplied another binding; a missing, deleted, or unavailable cached external identity uses the first selectable external 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 canonical recent conversational Agent unless the caller supplies an explicit binding; existing Questions do not inherit later conversational, Utility Agent, or Canvas selection changes.
+An authored Question node remains authoritative for persisted agent mode and fixed binding. A new selectable Question snapshots the browser-local recent conversational Agent unless the caller supplies an explicit binding; a missing or stale local external identity falls back to the first selectable external Profile. 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 7fb365d62..e802bf28b 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 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
+- new Questions snapshot the browser-local recent conversational Agent unless a binding is supplied, falling back to the first selectable external Profile when that cache is absent or stale; 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 f572db694..31c023288 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 · 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).
+**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 · ` 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 refresh the Profile catalogue and apply the browser-local recent conversational Agent before creation, falling back to the first selectable external Profile when that cache is absent or stale 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 b1a140d8c..8aaaef87e 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; 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.
+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 browser-local recent conversational Agent, falling back to the first selectable external Profile when that cache is absent or stale. **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).
diff --git a/packages/shared/src/types/api/agent-defaults.test.ts b/packages/shared/src/types/api/agent-defaults.test.ts
index 75aa72065..755d549f4 100644
--- a/packages/shared/src/types/api/agent-defaults.test.ts
+++ b/packages/shared/src/types/api/agent-defaults.test.ts
@@ -3,10 +3,7 @@
import { describe, expect, it } from 'vitest';
-import {
- agentDefaultsSchema,
- conversationAgentPreferenceSchema,
-} from './agent-defaults.js';
+import { agentDefaultsSchema } from './agent-defaults.js';
describe('Agent defaults contract', () => {
it('trims model overrides and allows inheritance', () => {
@@ -24,28 +21,6 @@ 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 f908759de..e9ebeb9dd 100644
--- a/packages/shared/src/types/api/agent-defaults.ts
+++ b/packages/shared/src/types/api/agent-defaults.ts
@@ -19,23 +19,3 @@ 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 5b078018c8f9545762ed9fb616f47820919ec519 Mon Sep 17 00:00:00 2001
From: Yuqing Yang
Date: Fri, 2 Oct 2026 03:21:12 +0000
Subject: [PATCH 17/30] feat(agentlet): persist stable device identity
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../agentlet/packages/local/src/agentlet.ts | 16 +++--
.../packages/local/src/device-identity.ts | 66 +++++++++++++++++++
.../agentlet/packages/local/src/ws-client.ts | 2 +-
.../packages/local/tests/agentlet.test.ts | 50 ++++++++++++--
.../local/tests/daemon-integration.test.ts | 8 ++-
.../packages/protocol/src/messages.ts | 2 +
6 files changed, 132 insertions(+), 12 deletions(-)
create mode 100644 external/agentlet/packages/local/src/device-identity.ts
diff --git a/external/agentlet/packages/local/src/agentlet.ts b/external/agentlet/packages/local/src/agentlet.ts
index 8b2ce5865..813db98a3 100644
--- a/external/agentlet/packages/local/src/agentlet.ts
+++ b/external/agentlet/packages/local/src/agentlet.ts
@@ -1,4 +1,4 @@
-import { hostname, platform } from 'node:os'
+import { arch, hostname, platform } from 'node:os'
import { join, resolve } from 'node:path'
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
import WebSocket from 'ws'
@@ -31,6 +31,7 @@ import {
type SessionProfile,
} from './session-bootstrap.js'
import type { AgentletOptions } from './cli.js'
+import { resolveDeviceIdentity } from './device-identity.js'
interface ManagedAgent {
sessionId: string
@@ -57,9 +58,9 @@ const EARLY_MESSAGE_BUFFER_CAP = 1000
export function resolveAgentletId(
configuredId: string | undefined,
- machineHostname = hostname(),
+ identityPath?: string,
): string {
- return configuredId?.trim() || machineHostname
+ return configuredId?.trim() || resolveDeviceIdentity(identityPath)
}
/**
@@ -74,6 +75,11 @@ export class Agentlet {
private shutdownInProgress = false
private readonly daemonId: string
+ private readonly machine = {
+ hostname: hostname(),
+ platform: platform(),
+ arch: arch(),
+ }
private controlWs: WebSocket | null = null
private readonly agents = new Map()
private pendingSpawns = 0
@@ -201,7 +207,7 @@ export class Agentlet {
private sendDaemonHello(): void {
const agentletProfile: AgentletProfile = {
bridge: { name: 'agentlet', version: PROTOCOL_VERSION },
- machine: { hostname: this.daemonId, platform: platform() },
+ machine: this.machine,
capabilities: {
autoRestart: true,
bufferLimit: this.options.bufferLimit,
@@ -597,7 +603,7 @@ export class Agentlet {
capabilities: { autoRestart, bufferLimit: this.options.bufferLimit },
heartbeatInterval: this.options.heartbeat,
allowInsecure: this.options.allowInsecure,
- machine: { hostname: this.daemonId, platform: platform() },
+ machine: this.machine,
})
managed.ws = agentWs
diff --git a/external/agentlet/packages/local/src/device-identity.ts b/external/agentlet/packages/local/src/device-identity.ts
new file mode 100644
index 000000000..2d2bf1659
--- /dev/null
+++ b/external/agentlet/packages/local/src/device-identity.ts
@@ -0,0 +1,66 @@
+import {
+ closeSync,
+ mkdirSync,
+ openSync,
+ readFileSync,
+ writeFileSync,
+} from 'node:fs'
+import { homedir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { randomUUID } from 'node:crypto'
+
+interface DeviceIdentityFile {
+ version: 1
+ deviceId: string
+}
+
+const UUID_PATTERN =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
+
+export function defaultDeviceIdentityPath(): string {
+ return join(homedir(), '.agentlet', 'device.json')
+}
+
+function parseDeviceIdentity(path: string): string {
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(readFileSync(path, 'utf8'))
+ } catch (error) {
+ throw new Error(
+ `Agentlet device identity is unreadable at ${path}: ${
+ error instanceof Error ? error.message : String(error)
+ }`,
+ )
+ }
+ if (
+ !parsed ||
+ typeof parsed !== 'object' ||
+ (parsed as Partial).version !== 1 ||
+ typeof (parsed as Partial).deviceId !== 'string' ||
+ !UUID_PATTERN.test((parsed as DeviceIdentityFile).deviceId)
+ ) {
+ throw new Error(`Agentlet device identity is invalid at ${path}`)
+ }
+ return (parsed as DeviceIdentityFile).deviceId
+}
+
+export function resolveDeviceIdentity(
+ path = defaultDeviceIdentityPath(),
+): string {
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
+ const deviceId = randomUUID()
+ let descriptor: number | undefined
+ try {
+ descriptor = openSync(path, 'wx', 0o600)
+ writeFileSync(
+ descriptor,
+ `${JSON.stringify({ version: 1, deviceId } satisfies DeviceIdentityFile)}\n`,
+ 'utf8',
+ )
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
+ } finally {
+ if (descriptor !== undefined) closeSync(descriptor)
+ }
+ return parseDeviceIdentity(path)
+}
diff --git a/external/agentlet/packages/local/src/ws-client.ts b/external/agentlet/packages/local/src/ws-client.ts
index 9a2dc2bfc..98c201453 100644
--- a/external/agentlet/packages/local/src/ws-client.ts
+++ b/external/agentlet/packages/local/src/ws-client.ts
@@ -33,7 +33,7 @@ export interface WsClientOptions {
capabilities: { autoRestart: boolean; bufferLimit: number; maxAgents?: number }
heartbeatInterval?: number
allowInsecure?: boolean
- machine?: { hostname: string; platform: string }
+ machine?: { hostname: string; platform: string; arch: string }
}
export interface WsClientEvents {
diff --git a/external/agentlet/packages/local/tests/agentlet.test.ts b/external/agentlet/packages/local/tests/agentlet.test.ts
index 77a354913..74a4d278a 100644
--- a/external/agentlet/packages/local/tests/agentlet.test.ts
+++ b/external/agentlet/packages/local/tests/agentlet.test.ts
@@ -1,4 +1,7 @@
-import { describe, expect, it } from 'vitest'
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
import {
buildAgentProcessEnv,
@@ -7,9 +10,33 @@ import {
import { parseCli } from '../src/cli.js'
describe('agentlet daemon identity', () => {
- it('uses the current machine hostname when no identity is injected', () => {
- expect(resolveAgentletId(undefined, 'machine-a')).toBe('machine-a')
- expect(resolveAgentletId(undefined, 'machine-b')).toBe('machine-b')
+ const directories: string[] = []
+
+ afterEach(() => {
+ for (const directory of directories.splice(0)) {
+ rmSync(directory, { recursive: true, force: true })
+ }
+ })
+
+ function identityPath(): string {
+ const directory = mkdtempSync(join(tmpdir(), 'agentlet-device-'))
+ directories.push(directory)
+ return join(directory, 'device.json')
+ }
+
+ it('creates and reuses one persisted UUID by default', () => {
+ const path = identityPath()
+ const first = resolveAgentletId(undefined, path)
+ const second = resolveAgentletId(undefined, path)
+
+ expect(first).toMatch(
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
+ )
+ expect(second).toBe(first)
+ expect(JSON.parse(readFileSync(path, 'utf8'))).toEqual({
+ version: 1,
+ deviceId: first,
+ })
})
it('accepts an explicit identity from the supervising host', () => {
@@ -31,6 +58,21 @@ describe('agentlet daemon identity', () => {
})
})
+ it('does not read or rewrite the persisted default for an explicit override', () => {
+ const path = identityPath()
+ expect(resolveAgentletId('custom-id', path)).toBe('custom-id')
+ expect(() => readFileSync(path)).toThrow()
+ })
+
+ it('fails instead of rotating a damaged persisted identity', () => {
+ const path = identityPath()
+ writeFileSync(path, '{"version":1,"deviceId":"broken"}')
+
+ expect(() => resolveAgentletId(undefined, path)).toThrow(
+ 'Agentlet device identity is invalid',
+ )
+ })
+
it.each(['0', '-1', '1.5', 'Infinity', '9007199254740992', '10agents'])(
'rejects invalid max-agents value %s',
(maxAgents) => {
diff --git a/external/agentlet/packages/local/tests/daemon-integration.test.ts b/external/agentlet/packages/local/tests/daemon-integration.test.ts
index b19197e07..8a80c99bc 100644
--- a/external/agentlet/packages/local/tests/daemon-integration.test.ts
+++ b/external/agentlet/packages/local/tests/daemon-integration.test.ts
@@ -1,4 +1,5 @@
import { createServer, type Server } from 'node:http'
+import { arch, hostname, platform } from 'node:os'
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { randomUUID } from 'node:crypto'
import { join } from 'node:path'
@@ -137,7 +138,7 @@ describe('agentlet daemon integration', () => {
expect(controlHello).toMatchObject({
agentletId: 'machine-a',
agentletProfile: {
- machine: { hostname: 'machine-a' },
+ machine: { hostname: hostname(), platform: platform(), arch: arch() },
capabilities: { harnessDiscovery: { version: 1 } },
},
})
@@ -163,7 +164,10 @@ describe('agentlet daemon integration', () => {
await waitUntil(() => sessionHello !== undefined)
expect(sessionHello).toMatchObject({
sessionId: 'native-bootstrap',
- sessionProfile: { agentletId: 'machine-a', machine: { hostname: 'machine-a' } },
+ sessionProfile: {
+ agentletId: 'machine-a',
+ machine: { hostname: hostname(), platform: platform(), arch: arch() },
+ },
})
await waitUntil(() => sessionMessages.some(
(message) => 'method' in message && message.method === 'session/update',
diff --git a/external/agentlet/packages/protocol/src/messages.ts b/external/agentlet/packages/protocol/src/messages.ts
index 28b5c7dab..4029320e1 100644
--- a/external/agentlet/packages/protocol/src/messages.ts
+++ b/external/agentlet/packages/protocol/src/messages.ts
@@ -15,6 +15,7 @@ export interface AgentletProfile {
machine?: {
hostname: string
platform: string
+ arch: string
}
/** Agentlet capabilities */
@@ -72,6 +73,7 @@ export interface SessionProfile {
machine?: {
hostname: string
platform: string
+ arch: string
}
/** Agentlet capabilities */
From b6eff770f617d2c9c7aea185d0c0363dd85bc59b Mon Sep 17 00:00:00 2001
From: Yuqing Yang
Date: Fri, 2 Oct 2026 03:22:12 +0000
Subject: [PATCH 18/30] refactor(agenetes): remove supervised placement
identity
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../packages/acp-driver/src/driver.test.ts | 13 ++++++---
.../packages/acp-driver/src/handle.ts | 11 ++++++--
.../packages/acp-driver/src/placement.test.ts | 5 ----
.../acp-driver/src/spawn-orchestrator.ts | 28 ++++---------------
.../packages/agentlet-host/src/daemon-auth.ts | 5 ----
.../agentlet-host/src/daemon-supervisor.ts | 7 -----
.../agentlet-host/src/gateway-mount.test.ts | 4 +--
.../packages/agentlet-host/src/index.ts | 13 +--------
8 files changed, 26 insertions(+), 60 deletions(-)
diff --git a/external/agenetes/packages/acp-driver/src/driver.test.ts b/external/agenetes/packages/acp-driver/src/driver.test.ts
index 337761c08..a31a7d1f0 100644
--- a/external/agenetes/packages/acp-driver/src/driver.test.ts
+++ b/external/agenetes/packages/acp-driver/src/driver.test.ts
@@ -5,7 +5,6 @@
// keyed by `workload.threadId` (I9.3). The driver payload is nested under
// `workload.spec` and validated by the mounted driver.
-import { getSupervisedAgentletId } from '@agenetes/agentlet-host';
import { describe, expect, it } from 'vitest';
import { acpDriverFactory } from './driver.js';
@@ -104,7 +103,7 @@ describe('acpDriverFactory (M5 FACTORY)', () => {
).rejects.toThrow();
});
- it('preserves explicit placement and resolves legacy specs without mutation', () => {
+ it('preserves explicit placement and rejects legacy specs without placement', () => {
const explicit: AcpCreateSpec = {
kind: 'acp',
workloadType: 'Deployment',
@@ -124,7 +123,9 @@ describe('acpDriverFactory (M5 FACTORY)', () => {
};
expect(resolveAcpAgentletId(explicit)).toBe('machine-b');
- expect(resolveAcpAgentletId(legacy)).toBe(getSupervisedAgentletId());
+ expect(() => resolveAcpAgentletId(legacy)).toThrow(
+ 'The workload has no Agentlet placement.',
+ );
expect('agentletId' in legacy.spec).toBe(false);
});
@@ -136,7 +137,10 @@ describe('acpDriverFactory (M5 FACTORY)', () => {
workloadType: 'Deployment',
threadId: 'thr_1',
namespace: { name: 'canvas_1', storage: { root: '/data/c1' } },
- spec: { binding: { alias: 'copilot', profileId: 'prof_1' } },
+ spec: {
+ agentletId: 'machine-a',
+ binding: { alias: 'copilot', profileId: 'prof_1' },
+ },
},
freshContext,
);
@@ -188,6 +192,7 @@ describe('acpDriverFactory (M5 FACTORY)', () => {
threadId: 'thr_2',
namespace: { name: 'canvas_1', storage: { root: '/data/c1' } },
spec: {
+ agentletId: 'machine-a',
binding: { alias: 'claude', profileId: 'prof_2' },
cwd: '/work',
recipe: null,
diff --git a/external/agenetes/packages/acp-driver/src/handle.ts b/external/agenetes/packages/acp-driver/src/handle.ts
index 07b21a611..986fd35fd 100644
--- a/external/agenetes/packages/acp-driver/src/handle.ts
+++ b/external/agenetes/packages/acp-driver/src/handle.ts
@@ -33,7 +33,6 @@
import { randomUUID } from 'node:crypto';
-import { getSupervisedAgentletId } from '@agenetes/agentlet-host';
import { resolveAgentInputs } from '@agenetes/protocol';
import {
HistoryLoadDeniedError,
@@ -236,9 +235,15 @@ export async function resolveAcpRuntimeLaunch(
};
}
-/** Resolve explicit placement or the read-only legacy local fallback. */
+/** Resolve the immutable execution-node placement stored in the workload. */
export function resolveAcpAgentletId(spec: AcpCreateSpec): string {
- return spec.spec.agentletId ?? getSupervisedAgentletId();
+ if (!spec.spec.agentletId) {
+ throw new AcpServiceError(
+ 'placement_unavailable',
+ 'The workload has no Agentlet placement.',
+ );
+ }
+ return spec.spec.agentletId;
}
/** The per-turn context an {@link AcpAgentHandle.run} accepts. */
diff --git a/external/agenetes/packages/acp-driver/src/placement.test.ts b/external/agenetes/packages/acp-driver/src/placement.test.ts
index 00e5d6cc1..9b08f2aca 100644
--- a/external/agenetes/packages/acp-driver/src/placement.test.ts
+++ b/external/agenetes/packages/acp-driver/src/placement.test.ts
@@ -10,11 +10,6 @@ vi.mock('@agenetes/agentlet-host', async (importOriginal) => {
return {
...actual,
getAgentletGateway: () => host.gateway,
- getSupervisedAgentletId: () => 'machine-a',
- getDaemonSupervisor: () => ({
- getStatus: () => ({ online: false }),
- hasGivenUp: () => false,
- }),
};
});
diff --git a/external/agenetes/packages/acp-driver/src/spawn-orchestrator.ts b/external/agenetes/packages/acp-driver/src/spawn-orchestrator.ts
index a3b7a3dee..6571149ba 100644
--- a/external/agenetes/packages/acp-driver/src/spawn-orchestrator.ts
+++ b/external/agenetes/packages/acp-driver/src/spawn-orchestrator.ts
@@ -39,9 +39,7 @@
import {
AgentletRequestError,
- getDaemonSupervisor,
getAgentletGateway,
- getSupervisedAgentletId,
} from '@agenetes/agentlet-host';
import {
harnessLaunchPlanSchema,
@@ -71,10 +69,8 @@ export function isSessionResumeUnavailableError(error: unknown): boolean {
* Gatekeeper systems. Subsequent launches are usually subsecond
* because the OS has the files cached.
*
- * We still short-circuit the wait as soon as the supervisor reports
- * `hasGivenUp()` (agentlet entry missing, repeated crashes, …) so a
- * truly broken install does not make every UI affordance hang for
- * the full window.
+ * Placement is independent from process supervision, so every target
+ * receives the same connection grace period.
*/
const AGENTLET_READY_TIMEOUT_MS = 20_000;
@@ -109,20 +105,15 @@ function readTargetAgentlet(agentletId: string): { agentletId: string } | null {
/**
* Poll {@link readTargetAgentlet} until the target agentlet is online or
* `timeoutMs` elapses. Returns the resolved descriptor or `null` on
- * timeout (or as soon as the supervisor has stopped trying).
+ * timeout.
*/
async function waitForTargetAgentlet(
agentletId: string,
timeoutMs: number,
): Promise<{ agentletId: string } | null> {
const deadline = Date.now() + timeoutMs;
- const supervisor = getDaemonSupervisor();
- const supervisedAgentletId = getSupervisedAgentletId();
let agentlet = readTargetAgentlet(agentletId);
while (!agentlet && Date.now() < deadline) {
- if (agentletId === supervisedAgentletId && supervisor.hasGivenUp()) {
- return null;
- }
await new Promise((r) => setTimeout(r, 100));
agentlet = readTargetAgentlet(agentletId);
}
@@ -178,10 +169,10 @@ async function waitForAgentConnection(
* Cold-start tolerance: we wait up to {@link AGENTLET_READY_TIMEOUT_MS}
* for the agentlet to come online and up to 3 s for the freshly-spawned
* agent to finish its handshake. Only after both windows expire (or the
- * supervisor reports it has given up) do we surface a user-facing error.
+ * target stays unavailable) do we surface a user-facing error.
*
* Throws when:
- * • the supervisor never brings the agentlet online (truly offline),
+ * • the target agentlet does not come online,
* • the agentlet RPC for spawn fails.
*
* Idempotent within a single agentlet's lifetime — repeat calls for
@@ -208,16 +199,9 @@ export async function ensureAgentForThread(
AGENTLET_READY_TIMEOUT_MS,
);
if (!agentlet) {
- const supervisorStatus =
- agentletId === getSupervisedAgentletId()
- ? getDaemonSupervisor().getStatus()
- : null;
- const hint = supervisorStatus?.lastError
- ? ` (${supervisorStatus.lastError})`
- : '';
throw new AcpServiceError(
'placement_unavailable',
- `Target agentlet '${agentletId}' is not connected${hint}.`,
+ `Target agentlet '${agentletId}' is not connected.`,
);
}
diff --git a/external/agenetes/packages/agentlet-host/src/daemon-auth.ts b/external/agenetes/packages/agentlet-host/src/daemon-auth.ts
index a6dd2619a..5d91122f6 100644
--- a/external/agenetes/packages/agentlet-host/src/daemon-auth.ts
+++ b/external/agenetes/packages/agentlet-host/src/daemon-auth.ts
@@ -55,11 +55,6 @@ class AcpDaemonAuth {
this.token = token;
}
- /** Configure the identity and token accepted for the supervised daemon. */
- configure(_agentletId: string, token: string): void {
- this.token = token;
- }
-
/**
* Mint and store a fresh 256-bit hex token. Retained for tests /
* fallback; the production path injects a stable token via
diff --git a/external/agenetes/packages/agentlet-host/src/daemon-supervisor.ts b/external/agenetes/packages/agentlet-host/src/daemon-supervisor.ts
index 324b4c1d7..823042a92 100644
--- a/external/agenetes/packages/agentlet-host/src/daemon-supervisor.ts
+++ b/external/agenetes/packages/agentlet-host/src/daemon-supervisor.ts
@@ -48,7 +48,6 @@
import { fork } from 'node:child_process';
import { existsSync, unlinkSync } from 'node:fs';
-import { hostname } from 'node:os';
import { join } from 'node:path';
import { getDaemonAuth } from './daemon-auth.js';
@@ -230,8 +229,6 @@ export interface AttachOptions {
daemonEntryPath: string;
/** Absolute directory for host-owned persistent state. */
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;
/**
@@ -300,7 +297,6 @@ class DaemonSupervisor {
* {@link attach} time. Used only for legacy-ticket cleanup here.
*/
private dataDir = '';
- private agentletId = '';
private getMaxAgents: (() => number) | undefined;
private hostEnvPrefix: string | undefined;
private hostEnvAllowlist: readonly string[] | undefined;
@@ -314,7 +310,6 @@ class DaemonSupervisor {
this.app = app;
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;
@@ -467,8 +462,6 @@ class DaemonSupervisor {
serverUrl,
'--token',
token,
- '--agentlet-id',
- this.agentletId,
...(maxAgents === undefined ? [] : ['--max-agents', String(maxAgents)]),
'--allow-insecure',
];
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 47b577363..083e15cc4 100644
--- a/external/agenetes/packages/agentlet-host/src/gateway-mount.test.ts
+++ b/external/agenetes/packages/agentlet-host/src/gateway-mount.test.ts
@@ -23,7 +23,7 @@ afterEach(async () => {
describe('Agentlet Gateway mount', () => {
it('accepts the shared token from another machine identity', () => {
const auth = getDaemonAuth();
- auth.configure('machine-a', 'test-token');
+ auth.setDaemonToken('test-token');
expect(() =>
auth.validateAgentlet('machine-b', 'test-token'),
@@ -31,7 +31,7 @@ describe('Agentlet Gateway mount', () => {
});
it('authenticates the supervised identity and closes upgraded sockets', async () => {
- getDaemonAuth().configure('machine-a', 'test-token');
+ getDaemonAuth().setDaemonToken('test-token');
app = Fastify({ logger: false });
const gateway = mountAgentletGateway(app, {});
await app.listen({ host: '127.0.0.1', port: 0 });
diff --git a/external/agenetes/packages/agentlet-host/src/index.ts b/external/agenetes/packages/agentlet-host/src/index.ts
index 52188ba93..f0a3c4d88 100644
--- a/external/agenetes/packages/agentlet-host/src/index.ts
+++ b/external/agenetes/packages/agentlet-host/src/index.ts
@@ -15,8 +15,6 @@
*
*/
-import { hostname } from 'node:os';
-
import { mountAgentProfileRegistry } from './agent-profile-mount.js';
import { getDaemonAuth } from './daemon-auth.js';
import { getDaemonSupervisor } from './daemon-supervisor.js';
@@ -30,13 +28,6 @@ import type {
} from '@agenetes/agentlet-gateway';
import type { FastifyInstance } from 'fastify';
-const supervisedAgentletId = hostname();
-
-/** Machine identity used by Sediment's supervised local daemon. */
-export function getSupervisedAgentletId(): string {
- return supervisedAgentletId;
-}
-
export { getAgentProfileRegistry } from './agent-profile-mount.js';
export {
ACP_UPGRADE_PATH,
@@ -135,8 +126,7 @@ export function mountAgenetes(
app: FastifyInstance,
opts: MountAgenetesOptions,
): AgentletGateway {
- const agentletId = getSupervisedAgentletId();
- getDaemonAuth().configure(agentletId, opts.connectionToken);
+ getDaemonAuth().setDaemonToken(opts.connectionToken);
const gateway = mountAgentletGateway(app, {
authenticate: opts.authenticate,
@@ -149,7 +139,6 @@ export function mountAgenetes(
getDaemonSupervisor().attach(app, {
daemonEntryPath: opts.daemonEntryPath,
dataDir: opts.dataDir,
- agentletId,
getMaxAgents: opts.getMaxAgents,
hostEnvPrefix: opts.hostEnvPrefix,
hostEnvAllowlist: opts.hostEnvAllowlist,
From edefe4c7028e57bdc2c7c6ce3b5d81e174f19a44 Mon Sep 17 00:00:00 2001
From: Yuqing Yang
Date: Fri, 2 Oct 2026 03:31:55 +0000
Subject: [PATCH 19/30] fix(agentlet): publish device identity atomically
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
external/agentlet/README.md | 2 +-
external/agentlet/packages/local/src/cli.ts | 5 +++-
.../packages/local/src/device-identity.ts | 23 +++++++++++++++++--
external/agentlet/spec/protocol.md | 2 +-
4 files changed, 27 insertions(+), 5 deletions(-)
diff --git a/external/agentlet/README.md b/external/agentlet/README.md
index 6a77e8420..8d28f8fb5 100644
--- a/external/agentlet/README.md
+++ b/external/agentlet/README.md
@@ -61,7 +61,7 @@ Important options:
| Option | Meaning |
| --- | --- |
-| `--agentlet-id ` | Stable execution-node identity; defaults to the machine hostname. |
+| `--agentlet-id ` | Exact execution-node identity override; the default is the persistent UUID in `~/.agentlet/device.json`. |
| `--max-agents ` | Maximum number of concurrently managed agent processes. |
| `--buffer-limit ` | Buffer capacity advertised in daemon and session profiles. |
| `--reconnect-max ` | Maximum exponential reconnect delay. |
diff --git a/external/agentlet/packages/local/src/cli.ts b/external/agentlet/packages/local/src/cli.ts
index 7965d0bac..eebfcf2d6 100644
--- a/external/agentlet/packages/local/src/cli.ts
+++ b/external/agentlet/packages/local/src/cli.ts
@@ -43,7 +43,10 @@ export function parseCli(argv: string[]): CliResult {
.option('--reconnect-max ', 'Maximum reconnection backoff in seconds', '300')
.option('--buffer-limit ', 'Max messages buffered during disconnection', '1000')
.option('--max-agents ', 'Maximum concurrent agents', '10')
- .option('--agentlet-id ', 'Machine identity reported to the host (defaults to hostname)')
+ .option(
+ '--agentlet-id ',
+ 'Device identity reported to the host (defaults to the persisted device UUID)',
+ )
.option('--log-level ', 'Logging verbosity: debug, info, warn, error', 'info')
.option('--log-file ', 'Path to write structured log output (JSON lines)')
.option('--heartbeat ', 'WebSocket ping interval in seconds (0 to disable)', '30')
diff --git a/external/agentlet/packages/local/src/device-identity.ts b/external/agentlet/packages/local/src/device-identity.ts
index 2d2bf1659..2a37c097d 100644
--- a/external/agentlet/packages/local/src/device-identity.ts
+++ b/external/agentlet/packages/local/src/device-identity.ts
@@ -1,12 +1,16 @@
import {
closeSync,
+ existsSync,
+ fsyncSync,
+ linkSync,
mkdirSync,
openSync,
readFileSync,
+ unlinkSync,
writeFileSync,
} from 'node:fs'
import { homedir } from 'node:os'
-import { dirname, join } from 'node:path'
+import { basename, dirname, join } from 'node:path'
import { randomUUID } from 'node:crypto'
interface DeviceIdentityFile {
@@ -48,19 +52,34 @@ export function resolveDeviceIdentity(
path = defaultDeviceIdentityPath(),
): string {
mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
+ if (existsSync(path)) return parseDeviceIdentity(path)
+
const deviceId = randomUUID()
+ const temporaryPath = join(
+ dirname(path),
+ `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`,
+ )
let descriptor: number | undefined
try {
- descriptor = openSync(path, 'wx', 0o600)
+ descriptor = openSync(temporaryPath, 'wx', 0o600)
writeFileSync(
descriptor,
`${JSON.stringify({ version: 1, deviceId } satisfies DeviceIdentityFile)}\n`,
'utf8',
)
+ fsyncSync(descriptor)
+ closeSync(descriptor)
+ descriptor = undefined
+ linkSync(temporaryPath, path)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
} finally {
if (descriptor !== undefined) closeSync(descriptor)
+ try {
+ unlinkSync(temporaryPath)
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+ }
}
return parseDeviceIdentity(path)
}
diff --git a/external/agentlet/spec/protocol.md b/external/agentlet/spec/protocol.md
index feb0d053b..77960208e 100644
--- a/external/agentlet/spec/protocol.md
+++ b/external/agentlet/spec/protocol.md
@@ -155,7 +155,7 @@ The daemon uses bounded FIFO buffers for ACP notifications emitted during bootst
## 7. Identity and placement
-The daemon's `agentletId` defaults to the operating-system hostname and can be supplied explicitly with `--agentlet-id`. The same identity appears in the control query, `agentlet/hello`, session query context, and `sessionProfile.agentletId`.
+The daemon's `agentletId` defaults to the persistent UUID in `~/.agentlet/device.json` and can be supplied explicitly with `--agentlet-id`. The identity file is created atomically on first use; an invalid existing file is an error rather than a reason to rotate identity. An explicit override does not rewrite the default identity. The same identity appears in the control query, `agentlet/hello`, session query context, and `sessionProfile.agentletId`; hostname, platform, and architecture are separate informational metadata.
The native ACP `sessionId` is established by session bootstrap and is the routing identity for one session connection. The embedding control plane selects the target `agentletId`; the daemon does not choose workload placement.
From 12acb3d64de5e64b9603648c5d6e777f0f9d3821 Mon Sep 17 00:00:00 2001
From: Yuqing Yang
Date: Fri, 2 Oct 2026 03:31:55 +0000
Subject: [PATCH 20/30] fix(agenetes): isolate supervisor health status
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../agentlet-host/src/daemon-supervisor.ts | 28 ++++---------------
1 file changed, 6 insertions(+), 22 deletions(-)
diff --git a/external/agenetes/packages/agentlet-host/src/daemon-supervisor.ts b/external/agenetes/packages/agentlet-host/src/daemon-supervisor.ts
index 823042a92..6dc6e200e 100644
--- a/external/agenetes/packages/agentlet-host/src/daemon-supervisor.ts
+++ b/external/agenetes/packages/agentlet-host/src/daemon-supervisor.ts
@@ -31,10 +31,10 @@
*
* ### Status reporting
*
- * `getDaemonStatus()` combines the supervisor's view (last error,
- * backoff schedule) with the Gateway's view (is a daemon
- * actually registered right now?). The UI uses the merged snapshot
- * to decide whether to show the amber troubleshooting block.
+ * `getDaemonStatus()` reports only the supervised child lifecycle.
+ * Connected Agentlet devices are projected separately by the host,
+ * because the child owns its device identity and the supervisor must
+ * not infer it from an arbitrary Gateway connection.
*
* ### Entry resolution
*
@@ -51,7 +51,6 @@ import { existsSync, unlinkSync } from 'node:fs';
import { join } from 'node:path';
import { getDaemonAuth } from './daemon-auth.js';
-import { getAgentletGateway } from './gateway-mount.js';
import type { AgentletStatus } from '@agenetes/protocol';
import type { FastifyInstance } from 'fastify';
@@ -404,24 +403,9 @@ class DaemonSupervisor {
getDaemonAuth().close();
}
- /**
- * Merge the supervisor's view with the Gateway's daemon
- * registry to produce the wire snapshot consumed by the UI.
- */
+ /** Project the supervised child lifecycle for the UI health surface. */
getStatus(): AgentletStatus {
- const gateway = getAgentletGateway();
- const live = gateway?.getAgentlets({ status: 'connected' }) ?? [];
- const agentlet = live[0];
-
- if (agentlet) {
- return {
- online: true,
- agentletId: agentlet.agentletId,
- hostname: agentlet.agentletProfile?.machine?.hostname,
- platform: agentlet.agentletProfile?.machine?.platform,
- connectedAt: agentlet.connectedAt.toISOString(),
- };
- }
+ if (this.state.child && !this.state.child.killed) return { online: true };
const status: AgentletStatus = { online: false };
if (this.state.lastError) status.lastError = this.state.lastError;
From 371021a95ca0a9924b05f396ecb1f1f1fec82a73 Mon Sep 17 00:00:00 2001
From: Yuqing Yang
Date: Fri, 2 Oct 2026 03:33:03 +0000
Subject: [PATCH 21/30] feat(agent): manage connected Agentlet devices
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
apps/server/src/app.ts | 15 +--
.../modules/agent/acp/agent-cli.route.test.ts | 16 ++--
.../src/modules/agent/acp/agent-cli.route.ts | 29 +++---
.../src/modules/agent/acp/daemon-auth.test.ts | 2 +-
apps/server/src/modules/agent/acp/index.ts | 1 -
.../acp/legacy-profile-migration.test.ts | 75 ---------------
.../agent/acp/legacy-profile-migration.ts | 34 -------
.../modules/agent/acp/profile-store.test.ts | 74 ---------------
.../src/modules/agent/acp/profile-store.ts | 64 -------------
.../modules/agent/acp/profiles.route.test.ts | 41 +++++++-
.../src/modules/agent/acp/profiles.route.ts | 75 +++++++++++++--
apps/server/src/modules/agent/acp/service.ts | 28 +++---
.../agent/acp/service.workload-spec.test.ts | 11 ++-
.../acp/threads.route.cached-meta.test.ts | 12 +--
.../modules/agent/acp/threads.route.test.ts | 4 -
.../src/modules/agent/acp/threads.route.ts | 9 +-
.../agent/selectable-agent-profile.test.ts | 36 ++++++-
.../modules/agent/selectable-agent-profile.ts | 32 ++++++-
apps/web/src/api/_routes.ts | 8 +-
apps/web/src/api/acp.test.ts | 8 +-
apps/web/src/api/acp.ts | 11 ++-
.../AgentProfileEditor.test.tsx | 37 +++++++-
.../agent-profiles/AgentProfileEditor.tsx | 12 ++-
.../agent-profiles/CommandProfileForm.tsx | 53 +++++++++--
.../ExternalAgentsSettings.test.tsx | 16 +++-
.../agent-profiles/ExternalAgentsSettings.tsx | 95 ++++++++++++++++---
.../agent-profiles/useDetectedClis.test.tsx | 23 +++--
.../agent-profiles/useDetectedClis.ts | 21 ++--
apps/web/src/i18n/resources/en/common.json | 6 ++
apps/web/src/i18n/resources/zh-CN/common.json | 6 ++
apps/web/src/store/acpProfilesStore.test.ts | 2 +
apps/web/src/store/acpProfilesStore.ts | 10 +-
docs/architecture/agent-profiles.md | 17 ++--
docs/architecture/api-design.md | 2 +
...agenetes-agentlet-gateway-consolidation.md | 70 +++++++-------
packages/shared/src/types/api/acp.ts | 28 +++++-
.../src/types/api/agent-profile.test.ts | 13 +++
.../shared/src/types/api/agent-profile.ts | 11 ++-
38 files changed, 569 insertions(+), 438 deletions(-)
delete mode 100644 apps/server/src/modules/agent/acp/legacy-profile-migration.test.ts
delete mode 100644 apps/server/src/modules/agent/acp/legacy-profile-migration.ts
delete mode 100644 apps/server/src/modules/agent/acp/profile-store.test.ts
delete mode 100644 apps/server/src/modules/agent/acp/profile-store.ts
diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts
index 96758aa79..231aaeffe 100644
--- a/apps/server/src/app.ts
+++ b/apps/server/src/app.ts
@@ -1,7 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
-import { existsSync, unlinkSync } from 'node:fs';
+import { unlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
@@ -25,13 +25,10 @@ import {
externalAgentRuntimeConfigRoutes,
getExternalAgentRuntimeConfig,
getAgentProfileRegistry,
- getSupervisedAgentletId,
installAcpProfileCachePort,
mountAgenetes,
resolveDaemonEntry,
} from './modules/agent/acp/index.js';
-import { buildLegacyCommandProfiles } from './modules/agent/acp/legacy-profile-migration.js';
-import { listProfiles as listLegacyAcpProfiles } from './modules/agent/acp/profile-store.js';
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';
@@ -317,15 +314,7 @@ const agentletGateway = mountAgenetes(app, {
profiles: {
storageDir: join(getDataDir(), 'agent-profiles'),
legacyStorageDir: join(getDataDir(), 'agent-team'),
- legacyCommandProfiles: existsSync(
- join(getDataDir(), 'agent-profiles', 'registry.json'),
- )
- ? []
- : buildLegacyCommandProfiles(
- listLegacyAcpProfiles(),
- getSupervisedAgentletId(),
- process.cwd(),
- ),
+ legacyCommandProfiles: [],
},
});
let unregisterHarnessDiscovery: (() => void) | undefined;
diff --git a/apps/server/src/modules/agent/acp/agent-cli.route.test.ts b/apps/server/src/modules/agent/acp/agent-cli.route.test.ts
index a02f85b31..a8b3aae5c 100644
--- a/apps/server/src/modules/agent/acp/agent-cli.route.test.ts
+++ b/apps/server/src/modules/agent/acp/agent-cli.route.test.ts
@@ -12,8 +12,10 @@ import type { FastifyInstance } from 'fastify';
const mocks = vi.hoisted(() => ({ getProfile: vi.fn(), discover: vi.fn() }));
vi.mock('@agenetes/agentlet-host', () => ({
getAgentProfileRegistry: () => ({ getProfile: mocks.getProfile }),
- getSupervisedAgentletId: () => 'supervised',
- getAgentletGateway: () => ({ discoverHarnesses: mocks.discover }),
+ getAgentletGateway: () => ({
+ getAgentlet: () => ({ status: 'connected' }),
+ discoverHarnesses: mocks.discover,
+ }),
}));
let app: FastifyInstance | undefined;
@@ -52,7 +54,7 @@ describe('ACP agent CLI route', () => {
const response = await app.inject({
method: 'GET',
- url: '/api/acp/agent-cli',
+ url: '/api/acp/agent-cli?agentletId=machine-a',
});
expect(response.statusCode).toBe(200);
@@ -72,7 +74,7 @@ describe('ACP agent CLI route', () => {
const response = await app.inject({
method: 'GET',
- url: '/api/acp/agent-cli',
+ url: '/api/acp/agent-cli?agentletId=machine-a',
remoteAddress: '192.0.2.10',
});
@@ -92,7 +94,7 @@ describe('ACP agent CLI route', () => {
const response = await app.inject({
method: 'GET',
- url: '/api/acp/agent-cli',
+ url: '/api/acp/agent-cli?agentletId=machine-a',
remoteAddress: '192.0.2.10',
});
@@ -108,7 +110,9 @@ describe('ACP agent CLI route', () => {
),
{ prefix: '/api/acp' },
);
- const response = await app.inject('/api/acp/agent-cli');
+ const response = await app.inject(
+ '/api/acp/agent-cli?agentletId=machine-a',
+ );
expect(response.statusCode).toBe(503);
expect(response.json().code).toBe('harness_discovery_unavailable');
expect(response.json()).not.toHaveProperty('agents');
diff --git a/apps/server/src/modules/agent/acp/agent-cli.route.ts b/apps/server/src/modules/agent/acp/agent-cli.route.ts
index 116e1915a..24d053434 100644
--- a/apps/server/src/modules/agent/acp/agent-cli.route.ts
+++ b/apps/server/src/modules/agent/acp/agent-cli.route.ts
@@ -17,7 +17,6 @@
import {
getAgentletGateway,
- getSupervisedAgentletId,
getAgentProfileRegistry,
} from '@agenetes/agentlet-host';
import {
@@ -36,19 +35,25 @@ import type {
} from '@huabu/shared';
import type { FastifyPluginAsync } from 'fastify';
-async function detectAgentClis(profileId?: string): Promise {
+async function detectAgentClis(target: {
+ profileId?: string;
+ agentletId?: string;
+}): Promise {
const gateway = getAgentletGateway();
if (!gateway) throw new Error('Agentlet Gateway is not ready');
- const profile = profileId
- ? getAgentProfileRegistry()?.getProfile(profileId)
+ const profile = target.profileId
+ ? getAgentProfileRegistry()?.getProfile(target.profileId)
: undefined;
- if (profileId && !profile) throw new Error('Agent Profile is unavailable');
- const result = await gateway.discoverHarnesses(
- profile?.agentletId ?? getSupervisedAgentletId(),
- {
- prepareWorkspaces: false,
- },
- );
+ if (target.profileId && !profile)
+ throw new Error('Agent Profile is unavailable');
+ const agentletId = profile?.agentletId ?? target.agentletId;
+ if (!agentletId) throw new Error('Agentlet target is required');
+ const connection = gateway.getAgentlet(agentletId);
+ if (connection?.status !== 'connected')
+ throw new Error('Agentlet is not connected');
+ const result = await gateway.discoverHarnesses(agentletId, {
+ prepareWorkspaces: false,
+ });
if (result.harnesses.some((entry) => entry.id === CUSTOM_COMMAND_WRAPPER_ID))
return result.harnesses;
return [
@@ -96,7 +101,7 @@ export function createAcpAgentCliRoutes(
message: 'Agent Profile is unavailable',
});
}
- return { agents: await detect(parsed.data.profileId) };
+ return { agents: await detect(parsed.data) };
} catch (error) {
request.log.warn(
{ err: error },
diff --git a/apps/server/src/modules/agent/acp/daemon-auth.test.ts b/apps/server/src/modules/agent/acp/daemon-auth.test.ts
index 7fad62a5c..975015cea 100644
--- a/apps/server/src/modules/agent/acp/daemon-auth.test.ts
+++ b/apps/server/src/modules/agent/acp/daemon-auth.test.ts
@@ -32,7 +32,7 @@ function makeAgentletHello(): AgentletHelloParams {
agentletId: 'test:agentlet',
agentletProfile: {
bridge: { name: 'agentlet', version: '1.0.0' },
- machine: { hostname: 'test', platform: 'linux' },
+ machine: { hostname: 'test', platform: 'linux', arch: 'x64' },
capabilities: { autoRestart: true, bufferLimit: 1000 },
},
};
diff --git a/apps/server/src/modules/agent/acp/index.ts b/apps/server/src/modules/agent/acp/index.ts
index 45b29ac2e..e2d0430e9 100644
--- a/apps/server/src/modules/agent/acp/index.ts
+++ b/apps/server/src/modules/agent/acp/index.ts
@@ -4,7 +4,6 @@
export {
mountAgenetes,
getAgentProfileRegistry,
- getSupervisedAgentletId,
ACP_UPGRADE_PATH,
} from '@agenetes/agentlet-host';
export type {
diff --git a/apps/server/src/modules/agent/acp/legacy-profile-migration.test.ts b/apps/server/src/modules/agent/acp/legacy-profile-migration.test.ts
deleted file mode 100644
index 2b8afabb5..000000000
--- a/apps/server/src/modules/agent/acp/legacy-profile-migration.test.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT license.
-
-import { describe, expect, it } from 'vitest';
-
-import { buildLegacyCommandProfiles } from './legacy-profile-migration.js';
-
-import type { AcpAgentProfile } from '@huabu/shared';
-
-function makeProfile(
- overrides: Partial = {},
-): AcpAgentProfile {
- return {
- id: 'legacy-profile',
- displayName: 'Legacy Profile',
- cliId: 'copilot',
- command: 'copilot --acp',
- cwd: '/workspace',
- autoRestart: true,
- createdAt: 1,
- updatedAt: 1,
- ...overrides,
- };
-}
-
-describe('buildLegacyCommandProfiles', () => {
- it('preserves ordinary profile identity and launch fields', () => {
- expect(
- buildLegacyCommandProfiles(
- [makeProfile()],
- 'local-agentlet',
- '/server-cwd',
- ),
- ).toEqual([
- {
- id: 'legacy-profile',
- alias: 'Legacy Profile',
- agentletId: 'local-agentlet',
- command: 'copilot --acp',
- workingDirPath: '/workspace',
- metadata: { cliId: 'copilot' },
- },
- ]);
- });
-
- it('uses the inherited host directory when an old profile omitted cwd', () => {
- const [profile] = buildLegacyCommandProfiles(
- [makeProfile({ cwd: undefined })],
- 'local-agentlet',
- '/server-cwd',
- );
-
- expect(profile?.workingDirPath).toBe('/server-cwd');
- });
-
- it('leaves legacy Agent Team records unmigrated', () => {
- expect(
- buildLegacyCommandProfiles(
- [makeProfile({ id: 'team', cliId: 'agent-team' })],
- 'local-agentlet',
- '/server-cwd',
- ),
- ).toEqual([]);
- });
-
- it('fails explicitly for an ordinary record without a command', () => {
- expect(() =>
- buildLegacyCommandProfiles(
- [makeProfile({ command: undefined })],
- 'local-agentlet',
- '/server-cwd',
- ),
- ).toThrow("Legacy command Profile 'legacy-profile' has no command");
- });
-});
diff --git a/apps/server/src/modules/agent/acp/legacy-profile-migration.ts b/apps/server/src/modules/agent/acp/legacy-profile-migration.ts
deleted file mode 100644
index 474278013..000000000
--- a/apps/server/src/modules/agent/acp/legacy-profile-migration.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT license.
-
-import type { CreateAcpCommandProfileInput } from '@agenetes/agent-profile';
-import type { AcpAgentProfile } from '@huabu/shared';
-
-/**
- * Convert spawnable legacy ACP profiles into unified command Profiles.
- *
- * Older records may omit `cwd`; the old launcher then inherited the host
- * process directory, so migration makes that implicit behavior explicit.
- */
-export function buildLegacyCommandProfiles(
- profiles: AcpAgentProfile[],
- agentletId: string,
- defaultWorkingDir: string,
-): CreateAcpCommandProfileInput[] {
- return profiles.flatMap((profile) => {
- if (profile.cliId === 'agent-team') return [];
- if (!profile.command?.trim()) {
- throw new Error(`Legacy command Profile '${profile.id}' has no command`);
- }
- return [
- {
- id: profile.id,
- alias: profile.displayName,
- agentletId,
- command: profile.command,
- workingDirPath: profile.cwd ?? defaultWorkingDir,
- metadata: { cliId: profile.cliId },
- },
- ];
- });
-}
diff --git a/apps/server/src/modules/agent/acp/profile-store.test.ts b/apps/server/src/modules/agent/acp/profile-store.test.ts
deleted file mode 100644
index cc70c2b81..000000000
--- a/apps/server/src/modules/agent/acp/profile-store.test.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT license.
-
-import { mkdtempSync, 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 { listProfiles } from './profile-store.js';
-
-let directory: string;
-let previousDataDir: string | undefined;
-
-const commandProfile = {
- id: 'command',
- displayName: 'Copilot',
- cliId: 'copilot',
- command: 'copilot --acp',
- cwd: '/workspace',
- autoRestart: true,
- createdAt: 1,
- updatedAt: 1,
-};
-
-beforeEach(() => {
- directory = mkdtempSync(join(tmpdir(), 'huabu-legacy-profiles-'));
- previousDataDir = process.env.HUABU_DATA_DIR;
- process.env.HUABU_DATA_DIR = directory;
-});
-
-afterEach(() => {
- if (previousDataDir === undefined) delete process.env.HUABU_DATA_DIR;
- else process.env.HUABU_DATA_DIR = previousDataDir;
- rmSync(directory, { recursive: true, force: true });
-});
-
-describe('read-only legacy Profile import source', () => {
- it('returns no records for an absent file', () => {
- expect(listProfiles()).toEqual([]);
- });
-
- it('reads ordinary Profiles without rewriting retired Team data', () => {
- const file = join(directory, 'agent-profiles.json');
- const original = JSON.stringify({
- schemaVersion: 1,
- profiles: [
- commandProfile,
- {
- id: 'retired-team',
- cliId: 'agent-team',
- agentTeam: { agentDir: '/team' },
- },
- { ...commandProfile, id: 'team-disguised-as-command', agentTeam: {} },
- ],
- });
- writeFileSync(file, original);
- expect(listProfiles()).toEqual([commandProfile]);
- expect(readFileSync(file, 'utf8')).toBe(original);
- });
-
- it.each([
- '{"profiles": []}',
- '{"schemaVersion": 999, "profiles": []}',
- '{"schemaVersion": 1, "profiles": [{}]}',
- '{invalid',
- ])(
- 'rejects corrupt or unsupported data rather than silently losing it',
- (text) => {
- writeFileSync(join(directory, 'agent-profiles.json'), text);
- expect(() => listProfiles()).toThrow();
- },
- );
-});
diff --git a/apps/server/src/modules/agent/acp/profile-store.ts b/apps/server/src/modules/agent/acp/profile-store.ts
deleted file mode 100644
index 21437bc56..000000000
--- a/apps/server/src/modules/agent/acp/profile-store.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT license.
-
-import { readFileSync } from 'node:fs';
-import { join } from 'node:path';
-
-import { acpAgentProfileSchema } from '@huabu/shared';
-
-import { getDataDir } from '../../../data-dir.js';
-import { logger } from '../../../utils/logger.js';
-
-import type { AcpAgentProfile } from '@huabu/shared';
-
-/** Read the pre-registry command data once during migration; never rewrite it. */
-export function listProfiles(): AcpAgentProfile[] {
- let text: string;
- try {
- text = readFileSync(join(getDataDir(), 'agent-profiles.json'), 'utf8');
- } catch (error) {
- if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
- return [];
- }
- throw error;
- }
- const file: unknown = JSON.parse(text);
- if (
- !file ||
- typeof file !== 'object' ||
- !('schemaVersion' in file) ||
- file.schemaVersion !== 1 ||
- !('profiles' in file) ||
- !Array.isArray(file.profiles)
- ) {
- throw new Error('Unsupported legacy command Profile file');
- }
-
- const profiles: AcpAgentProfile[] = [];
- let retired = 0;
- for (const raw of file.profiles) {
- if (
- raw &&
- typeof raw === 'object' &&
- (raw.cliId === 'agent-team' ||
- Object.prototype.hasOwnProperty.call(raw, 'agentTeam'))
- ) {
- retired += 1;
- continue;
- }
- const parsed = acpAgentProfileSchema.safeParse(raw);
- if (!parsed.success) {
- throw new Error(
- `Invalid legacy command Profile: ${parsed.error.message}`,
- );
- }
- profiles.push(parsed.data);
- }
- if (retired) {
- logger.warn(
- { retired },
- '[acp] legacy Agent Team records retained as data only',
- );
- }
- return profiles.sort((left, right) => left.createdAt - right.createdAt);
-}
diff --git a/apps/server/src/modules/agent/acp/profiles.route.test.ts b/apps/server/src/modules/agent/acp/profiles.route.test.ts
index bd206adb8..dab86c012 100644
--- a/apps/server/src/modules/agent/acp/profiles.route.test.ts
+++ b/apps/server/src/modules/agent/acp/profiles.route.test.ts
@@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => ({
initializeDefaults: vi.fn(),
discoverHarnesses: vi.fn(),
buildHarnessLaunch: vi.fn(),
+ connectedIds: new Set(['machine-a', 'remote-machine']),
}));
vi.mock('../agent-defaults.js', () => ({
@@ -32,10 +33,23 @@ vi.mock('@agenetes/agentlet-host', () => ({
getDaemonSupervisor: () => ({
getStatus: () => ({ online: true, restartAttempt: 0 }),
}),
- getSupervisedAgentletId: () => 'machine-a',
getAgentletGateway: () => ({
discoverHarnesses: mocks.discoverHarnesses,
buildHarnessLaunch: mocks.buildHarnessLaunch,
+ getAgentlets: () =>
+ [...mocks.connectedIds].map((agentletId) => ({
+ agentletId,
+ status: 'connected',
+ connectedAt: new Date('2026-01-01T00:00:00.000Z'),
+ agentletProfile: {
+ bridge: { name: 'agentlet', version: '1.0.0' },
+ machine: {
+ hostname: `${agentletId}-host`,
+ platform: 'linux',
+ arch: 'x64',
+ },
+ },
+ })),
}),
}));
@@ -92,7 +106,12 @@ describe('ordinary Profile catalog routes', () => {
const response = await server.inject({
method: 'POST',
url: '/api/acp/profiles',
- payload: { alias: 'Typed', workingDirPath: '/work', launch },
+ payload: {
+ alias: 'Typed',
+ agentletId: 'machine-a',
+ workingDirPath: '/work',
+ launch,
+ },
});
expect(response.statusCode).toBe(200);
expect(mocks.discoverHarnesses).toHaveBeenCalledWith('machine-a', {
@@ -122,6 +141,7 @@ describe('ordinary Profile catalog routes', () => {
url: '/api/acp/profiles',
payload: {
alias: 'Typed',
+ agentletId: 'machine-a',
workingDirPath: '/work',
launch: { kind: 'acp-harness', harnessId: 'copilot' },
},
@@ -139,6 +159,7 @@ describe('ordinary Profile catalog routes', () => {
url: '/api/acp/profiles',
payload: {
alias: 'Copilot',
+ agentletId: 'machine-a',
workingDirPath: '/work/project',
launch: commandProfile.launch,
metadata: { cliId: 'copilot' },
@@ -166,6 +187,16 @@ describe('ordinary Profile catalog routes', () => {
expect(response.json()).toMatchObject({
profiles: [commandProfile],
selectableProfileIds: ['command-1'],
+ connectedDevices: [
+ expect.objectContaining({
+ agentletId: 'machine-a',
+ profileCount: 1,
+ }),
+ expect.objectContaining({
+ agentletId: 'remote-machine',
+ profileCount: 0,
+ }),
+ ],
});
expect(mocks.registry.createProfile).not.toHaveBeenCalled();
expect(mocks.initializeDefaults).not.toHaveBeenCalled();
@@ -179,6 +210,7 @@ describe('ordinary Profile catalog routes', () => {
url: '/api/acp/profiles',
payload: {
alias: 'Forged',
+ agentletId: 'machine-a',
workingDirPath: '/work',
launch: commandProfile.launch,
customData: { discoveredAgent: source },
@@ -419,7 +451,10 @@ describe('ordinary Profile catalog routes', () => {
const response = await server.inject({
method: 'POST',
url: '/api/acp/profile-launch-preview',
- payload: { launch: commandProfile.launch },
+ payload: {
+ agentletId: 'machine-a',
+ launch: commandProfile.launch,
+ },
});
expect(response.statusCode).toBe(503);
expect(response.json().code).toBe('harness_preview_unavailable');
diff --git a/apps/server/src/modules/agent/acp/profiles.route.ts b/apps/server/src/modules/agent/acp/profiles.route.ts
index cf06c373d..9e4fe6d3d 100644
--- a/apps/server/src/modules/agent/acp/profiles.route.ts
+++ b/apps/server/src/modules/agent/acp/profiles.route.ts
@@ -29,7 +29,6 @@ import {
getAgentProfileRegistry,
getAgentletGateway,
getDaemonSupervisor,
- getSupervisedAgentletId,
} from '@agenetes/agentlet-host';
import {
@@ -50,6 +49,7 @@ import {
initializeAgentDefaults,
} from '../agent-defaults.js';
+import type { AgentletConnection } from '@agenetes/agentlet-host';
import type {
AcpProfileMutationResponse,
AcpProfilesListResponse,
@@ -68,6 +68,16 @@ function denyRemote(request: FastifyRequest, reply: FastifyReply): boolean {
return true;
}
+function getConnectedAgentlets(): AgentletConnection[] {
+ return getAgentletGateway()?.getAgentlets({ status: 'connected' }) ?? [];
+}
+
+function isAgentletConnected(agentletId: string): boolean {
+ return getConnectedAgentlets().some(
+ (connection) => connection.agentletId === agentletId,
+ );
+}
+
async function validateHarnessLaunch(
launch: AgentProfileView['launch'],
agentletId: string,
@@ -146,10 +156,16 @@ const acpProfilesRoutes: FastifyPluginAsync = async (app) => {
try {
const gateway = getAgentletGateway();
if (!gateway) throw new Error('Agentlet Gateway is not ready');
- return await gateway.buildHarnessLaunch(
- profile?.agentletId ?? getSupervisedAgentletId(),
- { launch: parsed.data.launch },
- );
+ const agentletId = profile?.agentletId ?? parsed.data.agentletId;
+ if (!agentletId || !isAgentletConnected(agentletId)) {
+ return reply.status(409).send({
+ code: 'agentlet_unavailable',
+ message: 'The selected Agentlet is not connected.',
+ });
+ }
+ return await gateway.buildHarnessLaunch(agentletId, {
+ launch: parsed.data.launch,
+ });
} catch (error) {
request.log.warn({ err: error }, 'Profile launch preview failed');
return reply.status(503).send({
@@ -172,9 +188,46 @@ const acpProfilesRoutes: FastifyPluginAsync = async (app) => {
code: 'profile_registry_unavailable',
});
}
+ const profiles = registry.listProfiles();
+ const connected = getConnectedAgentlets();
+ const connectedIds = new Set(
+ connected.map((connection) => connection.agentletId),
+ );
+ const profileCounts = new Map();
+ for (const profile of profiles) {
+ profileCounts.set(
+ profile.agentletId,
+ (profileCounts.get(profile.agentletId) ?? 0) + 1,
+ );
+ }
+ const connectedDevices = connected
+ .map((connection) => ({
+ agentletId: connection.agentletId,
+ ...(connection.agentletProfile?.machine?.hostname
+ ? { hostname: connection.agentletProfile.machine.hostname }
+ : {}),
+ ...(connection.agentletProfile?.machine?.platform
+ ? { platform: connection.agentletProfile.machine.platform }
+ : {}),
+ ...(connection.agentletProfile?.machine?.arch
+ ? { arch: connection.agentletProfile.machine.arch }
+ : {}),
+ version: connection.agentletProfile?.bridge.version ?? 'unknown',
+ connectedAt: connection.connectedAt.toISOString(),
+ profileCount: profileCounts.get(connection.agentletId) ?? 0,
+ }))
+ .sort(
+ (left, right) =>
+ (left.hostname ?? left.agentletId).localeCompare(
+ right.hostname ?? right.agentletId,
+ ) || left.agentletId.localeCompare(right.agentletId),
+ );
return {
- profiles: registry.listProfiles(),
- selectableProfileIds: registry.listSelectableProfileIds(),
+ profiles,
+ selectableProfileIds: profiles
+ .filter((profile) => connectedIds.has(profile.agentletId))
+ .map((profile) => profile.id),
+ connectedDevices,
agentlet: getDaemonSupervisor().getStatus(),
agentDefaults: getAgentDefaults(),
};
@@ -206,7 +259,13 @@ const acpProfilesRoutes: FastifyPluginAsync = async (app) => {
code: 'profile_registry_unavailable',
});
}
- const agentletId = getSupervisedAgentletId();
+ const agentletId = parsed.data.agentletId;
+ if (!isAgentletConnected(agentletId)) {
+ return reply.status(409).send({
+ code: 'agentlet_unavailable',
+ message: 'The selected Agentlet is not connected.',
+ });
+ }
const launch = parsed.data.launch;
if (!(await validateHarnessLaunch(launch, agentletId, request, reply)))
return;
diff --git a/apps/server/src/modules/agent/acp/service.ts b/apps/server/src/modules/agent/acp/service.ts
index 24d5fae3f..62a6d0194 100644
--- a/apps/server/src/modules/agent/acp/service.ts
+++ b/apps/server/src/modules/agent/acp/service.ts
@@ -22,10 +22,8 @@
import { randomUUID } from 'node:crypto';
-import {
- getAgentProfileRegistry,
- getSupervisedAgentletId,
-} from '@agenetes/agentlet-host';
+import { AcpServiceError } from '@agenetes/acp-driver';
+import { getAgentProfileRegistry } from '@agenetes/agentlet-host';
import { renderExternalAgentInputs } from './preprocessor.js';
import { getProfileSessionPreferences } from './profile-session-preferences.js';
@@ -183,18 +181,18 @@ export function buildAcpWorkloadSpec(
const { binding, threadId } = opts;
const canvasId = opts.canvasId ?? '';
const profile = resolveProfileSnapshot(binding.profileId);
- let agentletId: string;
- let cwd: string | undefined;
- let recipe: AcpBindingRecipe | null;
- if (profile) {
- agentletId = profile.agentletId;
- cwd = profile.workingDirPath;
- recipe = recipeFromProfileSnapshot(profile, binding.alias);
- } else {
- agentletId = getSupervisedAgentletId();
- cwd = opts.cwd;
- recipe = resolveBindingRecipe(binding.profileId);
+ if (!profile) {
+ throw new AcpServiceError(
+ 'profile_missing',
+ `Agent Profile '${binding.profileId}' is unavailable.`,
+ );
}
+ const agentletId = profile.agentletId;
+ let cwd: string | undefined = profile.workingDirPath;
+ let recipe: AcpBindingRecipe | null = recipeFromProfileSnapshot(
+ profile,
+ binding.alias,
+ );
const workingDirPath = opts.launchOverrides?.workingDirPath;
cwd = workingDirPath ?? cwd;
diff --git a/apps/server/src/modules/agent/acp/service.workload-spec.test.ts b/apps/server/src/modules/agent/acp/service.workload-spec.test.ts
index 732e764eb..0434f5611 100644
--- a/apps/server/src/modules/agent/acp/service.workload-spec.test.ts
+++ b/apps/server/src/modules/agent/acp/service.workload-spec.test.ts
@@ -20,7 +20,6 @@ vi.mock('@agenetes/agentlet-host', () => ({
getAgentProfileRegistry: () => ({
getProfile: () => mocks.profile,
}),
- getSupervisedAgentletId: () => 'supervised-agentlet',
}));
vi.mock('../agenetes/drivers.js', () => ({
@@ -137,4 +136,14 @@ describe('buildAcpWorkloadSpec', () => {
'Node constraints',
]);
});
+
+ it('rejects a missing Profile instead of inventing a placement', () => {
+ expect(() =>
+ buildAcpWorkloadSpec({
+ binding: { profileId: 'missing', alias: 'Missing' },
+ threadId: 'thread-a',
+ canvasId: 'canvas-a',
+ }),
+ ).toThrow("Agent Profile 'missing' is unavailable.");
+ });
});
diff --git a/apps/server/src/modules/agent/acp/threads.route.cached-meta.test.ts b/apps/server/src/modules/agent/acp/threads.route.cached-meta.test.ts
index c8fdf0d8a..66dfbc803 100644
--- a/apps/server/src/modules/agent/acp/threads.route.cached-meta.test.ts
+++ b/apps/server/src/modules/agent/acp/threads.route.cached-meta.test.ts
@@ -29,10 +29,6 @@ vi.mock('@agenetes/acp-driver', () => ({
},
}));
-vi.mock('@agenetes/agentlet-host', () => ({
- getSupervisedAgentletId: () => 'agentlet-supervised',
-}));
-
vi.mock('./external-agent-realization.js', () => ({
externalAgentRealization: { realize: vi.fn(), ensureSession: vi.fn() },
realizationHttpError: () => ({
@@ -139,7 +135,7 @@ describe('ACP cached-meta across awaited persistence', () => {
});
});
- it('falls back to the supervised agentlet for a thread with no record', async () => {
+ it('does not invent a placement for a thread with no record', async () => {
mocks.live.set('agentlet-supervised\u0000thread-1', {
availableCommands: [],
commandsUpdatedAt: 3,
@@ -160,11 +156,7 @@ describe('ACP cached-meta across awaited persistence', () => {
url: CACHED_META_URL,
});
- expect(response.json()).toMatchObject({
- source: 'thread',
- commandsUpdatedAt: 3,
- sessionMeta: { updatedAt: 4 },
- });
+ expect(response.json()).toMatchObject({ source: 'none' });
});
it('answers a dormant thread from the metadata its record kept', async () => {
diff --git a/apps/server/src/modules/agent/acp/threads.route.test.ts b/apps/server/src/modules/agent/acp/threads.route.test.ts
index 0a1f91c1b..4f9549346 100644
--- a/apps/server/src/modules/agent/acp/threads.route.test.ts
+++ b/apps/server/src/modules/agent/acp/threads.route.test.ts
@@ -19,10 +19,6 @@ vi.mock('@agenetes/acp-driver', () => ({
acpSessionRegistry: { get: () => mocks.live },
}));
-vi.mock('@agenetes/agentlet-host', () => ({
- getSupervisedAgentletId: () => 'agentlet-1',
-}));
-
vi.mock('./external-agent-realization.js', () => ({
externalAgentRealization: {
realize: mocks.realize,
diff --git a/apps/server/src/modules/agent/acp/threads.route.ts b/apps/server/src/modules/agent/acp/threads.route.ts
index 58947fc60..df778ecb0 100644
--- a/apps/server/src/modules/agent/acp/threads.route.ts
+++ b/apps/server/src/modules/agent/acp/threads.route.ts
@@ -2,7 +2,6 @@
// Licensed under the MIT license.
import { acpSessionRegistry } from '@agenetes/acp-driver';
-import { getSupervisedAgentletId } from '@agenetes/agentlet-host';
import {
acpPermissionDecisionSchema,
@@ -140,7 +139,7 @@ export async function awaitSchemaQuiescence(
async function resolveThreadAgentletId(
threadId: string,
canvasId?: string,
-): Promise {
+): Promise {
if (canvasId) {
const record = await agenetes.record(
canvasAcpNamespace(canvasId),
@@ -155,7 +154,7 @@ async function resolveThreadAgentletId(
return (driverSpec as { agentletId: string }).agentletId;
}
}
- return getSupervisedAgentletId();
+ return undefined;
}
/**
@@ -285,7 +284,9 @@ const acpThreadsRoutes: FastifyPluginAsync = async (app) => {
}
const { canvasId, profileId } = parsed.data;
const agentletId = await resolveThreadAgentletId(threadId, canvasId);
- const live = acpSessionRegistry.get(agentletId, threadId);
+ const live = agentletId
+ ? acpSessionRegistry.get(agentletId, threadId)
+ : undefined;
if (live) {
return {
source: 'thread',
diff --git a/apps/server/src/modules/agent/selectable-agent-profile.test.ts b/apps/server/src/modules/agent/selectable-agent-profile.test.ts
index d2976c00e..0f649404f 100644
--- a/apps/server/src/modules/agent/selectable-agent-profile.test.ts
+++ b/apps/server/src/modules/agent/selectable-agent-profile.test.ts
@@ -1,13 +1,34 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
-import { describe, expect, it } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const host = vi.hoisted(() => ({
+ profiles: [] as Array<{ id: string; alias: string; agentletId: string }>,
+ connectedIds: [] as string[],
+}));
+
+vi.mock('@agenetes/agentlet-host', () => ({
+ getAgentProfileRegistry: () => ({
+ getProfile: (profileId: string) =>
+ host.profiles.find((profile) => profile.id === profileId),
+ listProfiles: () => host.profiles,
+ }),
+ getAgentletGateway: () => ({
+ getAgentlets: () => host.connectedIds.map((agentletId) => ({ agentletId })),
+ }),
+}));
import {
listAvailableAgentProfiles,
requireAvailableAgentProfile,
} from './selectable-agent-profile.js';
+afterEach(() => {
+ host.profiles = [];
+ host.connectedIds = [];
+});
+
describe('listAvailableAgentProfiles', () => {
it('prepends Huabu and projects available Profile identities', () => {
const profiles = new Map([
@@ -41,6 +62,19 @@ describe('listAvailableAgentProfiles', () => {
]);
});
+ it('projects only Profiles on currently connected Agentlets by default', () => {
+ host.profiles = [
+ { id: 'online', alias: 'Online', agentletId: 'device-a' },
+ { id: 'offline', alias: 'Offline', agentletId: 'device-b' },
+ ];
+ host.connectedIds = ['device-a'];
+
+ expect(listAvailableAgentProfiles()).toEqual([
+ { id: 'huabu', alias: 'Built-In Pi' },
+ { id: 'online', alias: 'Online', default: true },
+ ]);
+ });
+
it('accepts the Huabu Profile without an external registry', () => {
expect(() => requireAvailableAgentProfile('huabu', null)).not.toThrow();
});
diff --git a/apps/server/src/modules/agent/selectable-agent-profile.ts b/apps/server/src/modules/agent/selectable-agent-profile.ts
index aabc27e89..8b69df24e 100644
--- a/apps/server/src/modules/agent/selectable-agent-profile.ts
+++ b/apps/server/src/modules/agent/selectable-agent-profile.ts
@@ -1,7 +1,10 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
-import { getAgentProfileRegistry } from '@agenetes/agentlet-host';
+import {
+ getAgentProfileRegistry,
+ getAgentletGateway,
+} from '@agenetes/agentlet-host';
import { HUABU_AGENT_PROFILE_ID } from '@huabu/shared';
@@ -18,6 +21,25 @@ interface AgentProfileRegistryPort {
listSelectableProfileIds(): string[];
}
+function getConnectedProfileRegistry(): AgentProfileRegistryPort | null {
+ const registry = getAgentProfileRegistry();
+ if (!registry) return null;
+ return {
+ getProfile: (profileId) => registry.getProfile(profileId),
+ listSelectableProfileIds: () => {
+ const connectedIds = new Set(
+ (getAgentletGateway()?.getAgentlets({ status: 'connected' }) ?? []).map(
+ (connection) => connection.agentletId,
+ ),
+ );
+ return registry
+ .listProfiles()
+ .filter((profile) => connectedIds.has(profile.agentletId))
+ .map((profile) => profile.id);
+ },
+ };
+}
+
export interface AvailableAgentProfileSummary {
id: string;
alias: string;
@@ -36,7 +58,7 @@ export class SelectableAgentProfileError extends Error {
export function requireSelectableAgentProfile(
profileId: string,
- registry: AgentProfileRegistryPort | null = getAgentProfileRegistry(),
+ registry: AgentProfileRegistryPort | null = getConnectedProfileRegistry(),
): SelectableAgentProfile {
if (!registry) {
throw new SelectableAgentProfileError(
@@ -60,20 +82,20 @@ export function requireSelectableAgentProfile(
export function requireAvailableAgentProfile(
profileId: string,
- registry: AgentProfileRegistryPort | null = getAgentProfileRegistry(),
+ registry: AgentProfileRegistryPort | null = getConnectedProfileRegistry(),
): void {
if (profileId === HUABU_AGENT_PROFILE_ID) return;
requireSelectableAgentProfile(profileId, registry);
}
export function getFirstSelectableAgentProfileId(
- registry: AgentProfileRegistryPort | null = getAgentProfileRegistry(),
+ registry: AgentProfileRegistryPort | null = getConnectedProfileRegistry(),
): string | null {
return registry?.listSelectableProfileIds()[0] ?? null;
}
export function listAvailableAgentProfiles(
- registry: AgentProfileRegistryPort | null = getAgentProfileRegistry(),
+ registry: AgentProfileRegistryPort | null = getConnectedProfileRegistry(),
): AvailableAgentProfileSummary[] {
const defaultProfileId = getFirstSelectableAgentProfileId(registry);
const huabu = {
diff --git a/apps/web/src/api/_routes.ts b/apps/web/src/api/_routes.ts
index b14e52c9b..2cabc6501 100644
--- a/apps/web/src/api/_routes.ts
+++ b/apps/web/src/api/_routes.ts
@@ -149,8 +149,12 @@ export const routes = {
agentChangeReviewConfig: '/agent-change-review/config',
// ── ACP (external agent bridge) ───────────────────────────────────
- acpAgentCli: (profileId?: string) =>
- `/acp/agent-cli${profileId ? `?profileId=${enc(profileId)}` : ''}`,
+ acpAgentCli: (target: { profileId?: string; agentletId?: string }) => {
+ const params = target.profileId
+ ? `profileId=${enc(target.profileId)}`
+ : `agentletId=${enc(target.agentletId ?? '')}`;
+ return `/acp/agent-cli?${params}`;
+ },
// Profiles (loopback-only) — user-managed spawn recipes.
acpProfiles: '/acp/profiles',
acpProfileLaunchPreview: '/acp/profile-launch-preview',
diff --git a/apps/web/src/api/acp.test.ts b/apps/web/src/api/acp.test.ts
index 26697c41d..a4841ea6d 100644
--- a/apps/web/src/api/acp.test.ts
+++ b/apps/web/src/api/acp.test.ts
@@ -18,19 +18,19 @@ describe('ACP Profile editing API', () => {
it('routes discovery to the saved Profile and encodes its identity', async () => {
const fetch = vi.fn().mockResolvedValue(new Response('{"agents":[]}'));
vi.stubGlobal('fetch', fetch);
- await listAcpAgentClis('remote/#1');
+ await listAcpAgentClis({ profileId: 'remote/#1' });
expect(fetch).toHaveBeenCalledWith(
expect.stringMatching(/\/api\/acp\/agent-cli\?profileId=remote%2F%231$/),
expect.any(Object),
);
});
- it('uses supervised-daemon discovery for creation', async () => {
+ it('routes creation discovery to the selected Agentlet', async () => {
const fetch = vi.fn().mockResolvedValue(new Response('{"agents":[]}'));
vi.stubGlobal('fetch', fetch);
- await listAcpAgentClis();
+ await listAcpAgentClis({ agentletId: 'device/#1' });
expect(fetch).toHaveBeenCalledWith(
- expect.stringMatching(/\/api\/acp\/agent-cli$/),
+ expect.stringMatching(/\/api\/acp\/agent-cli\?agentletId=device%2F%231$/),
expect.any(Object),
);
});
diff --git a/apps/web/src/api/acp.ts b/apps/web/src/api/acp.ts
index 4b56cb1c9..f8965f44f 100644
--- a/apps/web/src/api/acp.ts
+++ b/apps/web/src/api/acp.ts
@@ -29,6 +29,7 @@ import { routes } from './_routes';
import type {
AcpAgentCliListResponse,
+ AcpAgentCliQuery,
AcpAgentletStatus,
AcpAgentletStatusResponse,
AcpPermissionDecisionRequest,
@@ -55,6 +56,7 @@ import type {
} from '@huabu/shared';
export type {
+ AcpAgentCliQuery,
AcpAgentCliInfo,
AcpAgentCliListResponse,
AcpAgentProfile,
@@ -84,6 +86,7 @@ export type {
ConnectionTokenConfig,
ConnectionTokenUpdate,
AgentletConnectionCommandResponse,
+ ConnectedAgentletDevice,
WarmAcpSessionRequest,
WarmAcpSessionResponse,
} from '@huabu/shared';
@@ -91,12 +94,12 @@ export type {
// ── Agent CLI detection ──────────────────────────────────────────────
/**
- * Read the supervised daemon's catalogue, or the saved Profile's target daemon.
+ * Read the explicitly selected Agentlet's catalogue.
*/
export async function listAcpAgentClis(
- profileId?: string,
+ target: AcpAgentCliQuery,
): Promise {
- return apiFetch(routes.acpAgentCli(profileId), {
+ return apiFetch(routes.acpAgentCli(target), {
fallbackMessage: 'Failed to detect installed agent CLIs',
});
}
@@ -111,7 +114,7 @@ export async function listAcpProfiles(): Promise {
}
/**
- * Create a command Profile on the local agentlet. The server allocates its id.
+ * Create a command Profile on its explicitly selected Agentlet.
*/
export async function createAcpProfile(
payload: CreateAcpProfileBody,
diff --git a/apps/web/src/components/Settings/agent-profiles/AgentProfileEditor.test.tsx b/apps/web/src/components/Settings/agent-profiles/AgentProfileEditor.test.tsx
index 0140a3aaf..fc388e428 100644
--- a/apps/web/src/components/Settings/agent-profiles/AgentProfileEditor.test.tsx
+++ b/apps/web/src/components/Settings/agent-profiles/AgentProfileEditor.test.tsx
@@ -62,12 +62,18 @@ vi.mock('@/components/Common/Select', () => ({
value,
options,
onChange,
+ ariaLabel,
}: {
value: string;
options: { value: string; label: string; disabled?: boolean }[];
onChange: (value: string) => void;
+ ariaLabel?: string;
}) => (
- onChange(event.target.value)}>
+ onChange(event.target.value)}
+ >
Loading
{options.map((option) => (
,
@@ -188,7 +207,9 @@ async function settlePreview() {
});
}
function chooseCustom() {
- const select = container?.querySelector('select');
+ const select = container?.querySelector(
+ 'select[aria-label="settings.agent"]',
+ );
act(() => {
if (select) select.value = 'custom';
select?.dispatchEvent(new Event('change', { bubbles: true }));
@@ -268,7 +289,9 @@ describe('AgentProfileEditor', () => {
it('lists known wrappers with unsupported choices disabled and one Custom option', () => {
renderEditor();
- const select = container?.querySelector('select');
+ const select = container?.querySelector(
+ 'select[aria-label="settings.agent"]',
+ );
expect(select?.value).toBe('copilot');
expect(
[...(select?.options ?? [])].filter(
@@ -293,6 +316,7 @@ describe('AgentProfileEditor', () => {
expect(saveButton()?.disabled).toBe(true);
await settlePreview();
expect(api.preview).toHaveBeenCalledWith({
+ agentletId: 'device-1',
launch: {
kind: 'acp-harness',
harnessId: 'copilot',
@@ -308,6 +332,7 @@ describe('AgentProfileEditor', () => {
await act(async () => saveButton()?.click());
expect(api.create).toHaveBeenCalledWith({
alias: 'GitHub Copilot (project)',
+ agentletId: 'device-1',
workingDirPath: 'C:\\work\\project',
launch: {
kind: 'acp-harness',
@@ -382,7 +407,11 @@ describe('AgentProfileEditor', () => {
undefined,
agents.map((agent) => ({ ...agent, launchPreviewVersion: undefined })),
);
- expect(container?.querySelector('select')?.value).toBe('custom');
+ expect(
+ container?.querySelector(
+ 'select[aria-label="settings.agent"]',
+ )?.value,
+ ).toBe('custom');
expect(container?.textContent).toContain(
'settings.structuredLaunchUnavailable',
);
diff --git a/apps/web/src/components/Settings/agent-profiles/AgentProfileEditor.tsx b/apps/web/src/components/Settings/agent-profiles/AgentProfileEditor.tsx
index 9a7abcdb1..6ffbc3816 100644
--- a/apps/web/src/components/Settings/agent-profiles/AgentProfileEditor.tsx
+++ b/apps/web/src/components/Settings/agent-profiles/AgentProfileEditor.tsx
@@ -3,11 +3,18 @@
import { CommandProfileForm } from './CommandProfileForm';
-import type { AcpAgentCliInfo, AgentProfileView } from '@huabu/shared';
+import type {
+ AcpAgentCliInfo,
+ AgentProfileView,
+ ConnectedAgentletDevice,
+} from '@huabu/shared';
type AgentProfileEditorProps = {
detectedClis: AcpAgentCliInfo[];
detectionLoaded: boolean;
+ connectedDevices: ConnectedAgentletDevice[];
+ agentletId: string;
+ onAgentletChange: (agentletId: string) => void;
onClose: () => void;
onSaved: () => Promise;
} & ({ mode: 'create' } | { mode: 'edit-command'; profile: AgentProfileView });
@@ -23,6 +30,9 @@ export function AgentProfileEditor(props: AgentProfileEditorProps) {
editing={props.mode === 'create' ? null : props.profile}
detectedClis={props.detectedClis}
detectionLoaded={props.detectionLoaded}
+ connectedDevices={props.connectedDevices}
+ agentletId={props.agentletId}
+ onAgentletChange={props.onAgentletChange}
onClose={props.onClose}
onSaved={props.onSaved}
/>
diff --git a/apps/web/src/components/Settings/agent-profiles/CommandProfileForm.tsx b/apps/web/src/components/Settings/agent-profiles/CommandProfileForm.tsx
index 7955e606f..752e2d8f2 100644
--- a/apps/web/src/components/Settings/agent-profiles/CommandProfileForm.tsx
+++ b/apps/web/src/components/Settings/agent-profiles/CommandProfileForm.tsx
@@ -25,12 +25,19 @@ import { ProfileEditActions } from './ProfileEditActions';
import { ReadOnlyField } from './ReadOnlyField';
import { useProfileLaunchPreview } from './useProfileLaunchPreview';
-import type { AcpAgentCliInfo, AgentProfileView } from '@huabu/shared';
+import type {
+ AcpAgentCliInfo,
+ AgentProfileView,
+ ConnectedAgentletDevice,
+} from '@huabu/shared';
interface CommandProfileFormProps {
editing: AgentProfileView | null;
detectedClis: AcpAgentCliInfo[];
detectionLoaded: boolean;
+ connectedDevices: ConnectedAgentletDevice[];
+ agentletId: string;
+ onAgentletChange: (agentletId: string) => void;
onClose: () => void;
onSaved: () => Promise;
}
@@ -73,6 +80,9 @@ export function CommandProfileForm({
editing,
detectedClis,
detectionLoaded,
+ connectedDevices,
+ agentletId,
+ onAgentletChange,
onClose,
onSaved,
}: CommandProfileFormProps) {
@@ -156,7 +166,10 @@ export function CommandProfileForm({
};
const preview = useProfileLaunchPreview(
!custom && structuredSupported
- ? { launch, ...(editing ? { profileId: editing.id } : {}) }
+ ? {
+ launch,
+ ...(editing ? { profileId: editing.id } : { agentletId }),
+ }
: null,
);
const executionChanged = launchChanged || cwdChanged;
@@ -169,7 +182,12 @@ export function CommandProfileForm({
!preview.plan ||
!!preview.error ||
(launchChanged && !approvalSupported && !!editing)));
- const saveDisabled = saving || !cliId || invalidExecution;
+ const saveDisabled =
+ saving ||
+ !cliId ||
+ !agentletId ||
+ (!editing && !connectedDevices.length) ||
+ invalidExecution;
const knownControlsDisabled =
saving || !structuredSupported || !!preview.error;
const options = [
@@ -198,6 +216,7 @@ export function CommandProfileForm({
} else {
await createAcpProfile({
alias: displayName.trim() || defaultName,
+ agentletId,
workingDirPath: cwd.trim(),
launch,
metadata: { cliId },
@@ -226,6 +245,27 @@ export function CommandProfileForm({
return (
+
+
+ {editing ? (
+
+ ) : (
+ ({
+ value: device.agentletId,
+ label: device.hostname ?? device.agentletId,
+ description: device.agentletId,
+ }))}
+ placeholder={t('settings.noConnectedDevices')}
+ ariaLabel={t('settings.profileMachine')}
+ disabled={saving || connectedDevices.length === 0}
+ className="w-full"
+ />
+ )}
+
+
{editing ? (
@@ -246,13 +286,6 @@ export function CommandProfileForm({
)}
- {editing ? (
-
-
-
-
-
- ) : null}
{custom ? (
{
apiMocks.list.mockImplementation(async () => ({
profiles: [...profiles],
selectableProfileIds: [],
+ connectedDevices: [
+ {
+ agentletId: 'local',
+ hostname: 'Local machine',
+ platform: 'linux',
+ arch: 'x64',
+ version: '1.0.0',
+ connectedAt: '2026-01-01T00:00:00.000Z',
+ profileCount: 1,
+ },
+ ],
agentlet: null,
}));
useAcpProfilesStore.setState({
profiles: [],
selectableProfileIds: [],
+ connectedDevices: [],
agentlet: null,
loaded: true,
loading: false,
@@ -222,7 +234,9 @@ describe('ExternalAgentsSettings', () => {
});
await renderSettings();
await click('settings.editProfile');
- expect(apiMocks.detection).toHaveBeenLastCalledWith(true, profile.id);
+ expect(apiMocks.detection).toHaveBeenLastCalledWith(true, {
+ profileId: profile.id,
+ });
input('input[aria-label="settings.displayName"]', 'Renamed');
await click('settings.saveChanges');
diff --git a/apps/web/src/components/Settings/agent-profiles/ExternalAgentsSettings.tsx b/apps/web/src/components/Settings/agent-profiles/ExternalAgentsSettings.tsx
index 92f7ca0af..3ff6d1f34 100644
--- a/apps/web/src/components/Settings/agent-profiles/ExternalAgentsSettings.tsx
+++ b/apps/web/src/components/Settings/agent-profiles/ExternalAgentsSettings.tsx
@@ -30,7 +30,7 @@ import type { AgentIconValue } from '@/components/Common/AgentIcon';
import type { AgentProfileView } from '@huabu/shared';
type EditorState =
- | { kind: 'create' }
+ | { kind: 'create'; agentletId: string }
| { kind: 'edit-command'; profile: AgentProfileView };
interface PendingDelete {
@@ -52,6 +52,12 @@ export function ExternalAgentsSettings({
}: ExternalAgentsSettingsProps) {
const { t } = useTranslation();
const profiles = useAcpProfilesStore((state) => state.profiles);
+ const connectedDevices = useAcpProfilesStore(
+ (state) => state.connectedDevices,
+ );
+ const selectableProfileIds = useAcpProfilesStore(
+ (state) => state.selectableProfileIds,
+ );
const loading = useAcpProfilesStore((state) => state.loading);
const error = useAcpProfilesStore((state) => state.error);
const agentlet = useAcpProfilesStore((state) => state.agentlet);
@@ -178,12 +184,20 @@ export function ExternalAgentsSettings({
[],
);
- const needsCliNames = profiles.some(
- (profile) => profile.launch.kind === 'acp-harness',
+ const catalogueProfile = profiles.find(
+ (profile) =>
+ profile.launch.kind === 'acp-harness' &&
+ selectableProfileIds.includes(profile.id),
);
const { detectedClis, loaded: detectionLoaded } = useDetectedClis(
- needsCliNames || editor !== null,
- editor?.kind === 'edit-command' ? editor.profile.id : undefined,
+ editor !== null || catalogueProfile !== undefined,
+ editor?.kind === 'edit-command'
+ ? { profileId: editor.profile.id }
+ : editor?.kind === 'create'
+ ? { agentletId: editor.agentletId }
+ : catalogueProfile
+ ? { profileId: catalogueProfile.id }
+ : { agentletId: '' },
);
const saveIcon = useCallback(
@@ -214,15 +228,18 @@ export function ExternalAgentsSettings({
profile.launch.kind === 'acp-harness'
? profile.launch.harnessId
: 'custom';
- if (!cliId || cliId === 'custom') {
- return [
- t('settings.agentCustomBadge'),
- profile.launch.kind === 'acp-command'
- ? profile.launch.command
- : profile.launch.harnessId,
- ].join(' · ');
- }
- return detectedClis.find((cli) => cli.id === cliId)?.displayName ?? cliId;
+ const description =
+ !cliId || cliId === 'custom'
+ ? [
+ t('settings.agentCustomBadge'),
+ profile.launch.kind === 'acp-command'
+ ? profile.launch.command
+ : profile.launch.harnessId,
+ ].join(' · ')
+ : (detectedClis.find((cli) => cli.id === cliId)?.displayName ?? cliId);
+ return selectableProfileIds.includes(profile.id)
+ ? description
+ : `${description} · ${t('settings.agentUnavailable')}`;
};
const handleRestart = useCallback(async () => {
@@ -281,6 +298,19 @@ export function ExternalAgentsSettings({
})}
detectedClis={detectedClis}
detectionLoaded={detectionLoaded}
+ connectedDevices={connectedDevices}
+ agentletId={
+ editor.kind === 'create'
+ ? editor.agentletId
+ : editor.profile.agentletId
+ }
+ onAgentletChange={(agentletId) =>
+ setEditor((current) =>
+ current?.kind === 'create'
+ ? { ...current, agentletId }
+ : current,
+ )
+ }
onClose={closeEditor}
onSaved={refresh}
/>
@@ -290,6 +320,36 @@ export function ExternalAgentsSettings({
) : (
+
+ {connectedDevices.length === 0 ? (
+
+
+
+ ) : (
+ connectedDevices.map((device) => (
+
+
+ {device.version}
+
+
+ ))
+ )}
+
{loading ? (
@@ -359,7 +419,12 @@ export function ExternalAgentsSettings({
size="sm"
ref={restoreTriggerFocus('create')}
data-editor-trigger="create"
- onClick={() => openEditor({ kind: 'create' }, 'create')}
+ onClick={() => {
+ const agentletId = connectedDevices[0]?.agentletId;
+ if (agentletId)
+ openEditor({ kind: 'create', agentletId }, 'create');
+ }}
+ disabled={connectedDevices.length === 0}
>
{t('settings.addAgent')}
diff --git a/apps/web/src/components/Settings/agent-profiles/useDetectedClis.test.tsx b/apps/web/src/components/Settings/agent-profiles/useDetectedClis.test.tsx
index 91a73aa06..b58d816b8 100644
--- a/apps/web/src/components/Settings/agent-profiles/useDetectedClis.test.tsx
+++ b/apps/web/src/components/Settings/agent-profiles/useDetectedClis.test.tsx
@@ -40,7 +40,10 @@ function Harness({
enabled: boolean;
profileId?: string;
}) {
- const { detectedClis, loaded } = useDetectedClis(enabled, profileId);
+ const { detectedClis, loaded } = useDetectedClis(
+ enabled,
+ profileId ? { profileId } : { agentletId: 'local-device' },
+ );
return (
{loaded ? 'loaded' : 'idle'}:{detectedClis.length}
@@ -166,14 +169,12 @@ describe('useDetectedClis', () => {
);
await act(async () => resolveOld?.({ agents: [detectedAgent] }));
- expect(apiMocks.listAgentClis).toHaveBeenNthCalledWith(
- 1,
- 'machine-a-profile',
- );
- expect(apiMocks.listAgentClis).toHaveBeenNthCalledWith(
- 2,
- 'machine-b-profile',
- );
+ expect(apiMocks.listAgentClis).toHaveBeenNthCalledWith(1, {
+ profileId: 'machine-a-profile',
+ });
+ expect(apiMocks.listAgentClis).toHaveBeenNthCalledWith(2, {
+ profileId: 'machine-b-profile',
+ });
expect(container.textContent).toBe('loaded:0');
});
@@ -190,7 +191,9 @@ describe('useDetectedClis', () => {
root?.render( ),
);
expect(container.textContent).toBe('loaded:0');
- expect(apiMocks.listAgentClis).toHaveBeenLastCalledWith('remote-profile');
+ expect(apiMocks.listAgentClis).toHaveBeenLastCalledWith({
+ profileId: 'remote-profile',
+ });
expect(apiMocks.toast).toHaveBeenCalledWith('Target offline', {
tone: 'danger',
});
diff --git a/apps/web/src/components/Settings/agent-profiles/useDetectedClis.ts b/apps/web/src/components/Settings/agent-profiles/useDetectedClis.ts
index 1d4074aef..89c82667e 100644
--- a/apps/web/src/components/Settings/agent-profiles/useDetectedClis.ts
+++ b/apps/web/src/components/Settings/agent-profiles/useDetectedClis.ts
@@ -7,41 +7,42 @@ import { useTranslation } from 'react-i18next';
import { listAcpAgentClis } from '@/api/acp';
import { toast } from '@/components/Common/Toast';
-import type { AcpAgentCliInfo } from '@huabu/shared';
+import type { AcpAgentCliInfo, AcpAgentCliQuery } from '@huabu/shared';
/** Reads the daemon's catalogue for Settings without a browser discovery cache. */
export function useDetectedClis(
enabled = true,
- profileId?: string,
+ target: AcpAgentCliQuery,
): {
detectedClis: AcpAgentCliInfo[];
loaded: boolean;
} {
const { t } = useTranslation();
const [snapshot, setSnapshot] = useState<{
- profileId?: string;
+ key: string;
detectedClis: AcpAgentCliInfo[];
loaded: boolean;
- }>({ profileId, detectedClis: [], loaded: false });
+ }>({ key: JSON.stringify(target), detectedClis: [], loaded: false });
+ const key = JSON.stringify(target);
useEffect(() => {
if (!enabled) return;
let generation = 0;
const load = async () => {
const current = ++generation;
- setSnapshot({ profileId, loaded: false, detectedClis: [] });
+ setSnapshot({ key, loaded: false, detectedClis: [] });
try {
- const response = await listAcpAgentClis(profileId);
+ const response = await listAcpAgentClis(target);
if (current === generation) {
setSnapshot({
- profileId,
+ key,
loaded: true,
detectedClis: response.agents,
});
}
} catch (error) {
if (current === generation) {
- setSnapshot({ profileId, loaded: true, detectedClis: [] });
+ setSnapshot({ key, loaded: true, detectedClis: [] });
toast(
error instanceof Error
? error.message
@@ -58,9 +59,9 @@ export function useDetectedClis(
generation++;
window.removeEventListener('workspace-changed', handler);
};
- }, [enabled, profileId, t]);
+ }, [enabled, key, t]);
- return snapshot.profileId === profileId
+ return snapshot.key === key
? { detectedClis: snapshot.detectedClis, loaded: snapshot.loaded }
: { detectedClis: [], loaded: false };
}
diff --git a/apps/web/src/i18n/resources/en/common.json b/apps/web/src/i18n/resources/en/common.json
index 86ed9d305..074a8ffad 100644
--- a/apps/web/src/i18n/resources/en/common.json
+++ b/apps/web/src/i18n/resources/en/common.json
@@ -206,6 +206,12 @@
"profileCreated": "Profile created",
"profileUpdated": "Profile updated",
"profileMachine": "Machine (fixed)",
+ "connectedDevices": "Connected devices",
+ "noConnectedDevices": "No Agentlet devices connected",
+ "noConnectedDevicesDescription": "Connect an Agentlet before creating a Profile.",
+ "deviceProfileCount_one": "{{count}} Profile",
+ "deviceProfileCount_other": "{{count}} Profiles",
+ "agentUnavailable": "Device unavailable",
"profileLaunchPreviewHint": "Read-only launch plan built by the wrapper on the target machine. Previewing does not start an agent; environment values are not displayed.",
"profilePreviewLoading": "Preparing launch preview…",
"profilePreviewUnavailable": "Launch preview unavailable.",
diff --git a/apps/web/src/i18n/resources/zh-CN/common.json b/apps/web/src/i18n/resources/zh-CN/common.json
index 136696567..be029bcc0 100644
--- a/apps/web/src/i18n/resources/zh-CN/common.json
+++ b/apps/web/src/i18n/resources/zh-CN/common.json
@@ -206,6 +206,12 @@
"profileCreated": "配置已创建",
"profileUpdated": "配置已更新",
"profileMachine": "机器(不可更改)",
+ "connectedDevices": "已连接设备",
+ "noConnectedDevices": "没有已连接的 Agentlet 设备",
+ "noConnectedDevicesDescription": "请先连接 Agentlet,再创建配置。",
+ "deviceProfileCount_one": "{{count}} 个配置",
+ "deviceProfileCount_other": "{{count}} 个配置",
+ "agentUnavailable": "设备不可用",
"profileLaunchPreviewHint": "由目标机器上的封装器生成的只读启动计划。预览不会启动智能体,也不会显示环境变量的值。",
"profilePreviewLoading": "正在生成启动预览…",
"profilePreviewUnavailable": "启动预览不可用。",
diff --git a/apps/web/src/store/acpProfilesStore.test.ts b/apps/web/src/store/acpProfilesStore.test.ts
index ec5abeeb4..49932ec3e 100644
--- a/apps/web/src/store/acpProfilesStore.test.ts
+++ b/apps/web/src/store/acpProfilesStore.test.ts
@@ -36,6 +36,7 @@ const profile = {
const snapshot = {
profiles: [profile],
selectableProfileIds: [profile.id],
+ connectedDevices: [],
agentlet: null,
agentDefaults: { profileId: 'huabu', functionalModel: 'utility-only' },
};
@@ -61,6 +62,7 @@ beforeEach(() => {
error: null,
profiles: [profile],
selectableProfileIds: [profile.id],
+ connectedDevices: [],
agentDefaults: null,
defaultsError: null,
recentConversationProfileId: null,
diff --git a/apps/web/src/store/acpProfilesStore.ts b/apps/web/src/store/acpProfilesStore.ts
index 1da23dc0e..694e16c27 100644
--- a/apps/web/src/store/acpProfilesStore.ts
+++ b/apps/web/src/store/acpProfilesStore.ts
@@ -47,7 +47,11 @@ import { getAgentDefaults, updateAgentDefaults } from '@/api/agentDefaults';
import { toast } from '@/components/Common/Toast';
import { i18n } from '@/i18n';
-import type { AcpAgentletStatus, AgentProfileView } from '@/api/acp';
+import type {
+ AcpAgentletStatus,
+ AgentProfileView,
+ ConnectedAgentletDevice,
+} from '@/api/acp';
import type {
AgentBinding,
AgentDefaults,
@@ -88,6 +92,8 @@ interface AcpProfilesState {
profiles: AgentProfileView[];
/** Profile ids that are currently runtime-ready and safe to select. */
selectableProfileIds: string[];
+ /** Active Agentlet control connections, sorted by the server. */
+ connectedDevices: ConnectedAgentletDevice[];
/** Latest agentlet snapshot. `null` until the first fetch resolves. */
agentlet: AcpAgentletStatus | null;
/** Absent on older servers; never infer a default from list ordering. */
@@ -121,6 +127,7 @@ interface AcpProfilesState {
export const useAcpProfilesStore = create()((set, get) => ({
profiles: [],
selectableProfileIds: [],
+ connectedDevices: [],
agentlet: null,
agentDefaults: null,
defaultsError: null,
@@ -229,6 +236,7 @@ export const useAcpProfilesStore = create()((set, get) => ({
set({
profiles: res.profiles,
selectableProfileIds: res.selectableProfileIds,
+ connectedDevices: res.connectedDevices,
agentlet: res.agentlet,
...(revision === defaultsRevision
? { agentDefaults: res.agentDefaults ?? null, defaultsError: null }
diff --git a/docs/architecture/agent-profiles.md b/docs/architecture/agent-profiles.md
index 915bb9df1..04c7fd28f 100644
--- a/docs/architecture/agent-profiles.md
+++ b/docs/architecture/agent-profiles.md
@@ -23,7 +23,9 @@ Automatic discovery requests workspace preparation. The daemon resolves its own
Huabu subscribes to machine connection events, includes machines already connected at registration, and invalidates stale results on reconnect, disconnect, and shutdown. After each successful response it synchronously checks and creates automatic defaults through the registry. The check and commit contain no asynchronous gap.
-The Agentlet CLI defaults an omitted `--agentlet-id` to the machine hostname, so a copied connection command needs no identity argument for the common personal setup. Different hostnames may share the same Huabu connection token and connect concurrently. If an identity is already online, Gateway rejects the second live connection with guidance to retry using `--agentlet-id ` instead of evicting the first machine; once the prior connection is disconnected, the same identity follows the normal reconnect path.
+The Agentlet CLI owns its default device identity. On first start it atomically creates `~/.agentlet/device.json` containing `{ "version": 1, "deviceId": "" }`; later starts reuse that UUID, so identity survives daemon, Huabu, OS-session, binary, upgrade, and worktree restarts while the home directory remains. A malformed or unreadable identity file fails explicitly instead of rotating identity. `--agentlet-id` remains an advanced exact override and does not rewrite the persisted UUID. Hello metadata reports the real hostname, platform, and architecture separately for display; none of those fields participates in identity. Windows and WSL naturally receive distinct identities because they use separate home directories.
+
+Multiple Agentlets may share one Huabu connection token and connect concurrently. If an identity is already online, Gateway rejects the second live connection with guidance to retry using `--agentlet-id ` instead of evicting the first device; once the prior connection is disconnected, the same identity follows the normal reconnect path. The embedded supervisor is special only as process-lifecycle infrastructure: it resolves, starts, restarts, stops, and diagnoses its child, but it neither assigns that Agentlet's identity nor acts as a placement default.
## Profile identity and customization
@@ -63,7 +65,7 @@ Deleting an automatic Profile is ordinary deletion. A later discovery may create
## Persistence and retired Team data
-The generic registry writes `/agent-profiles/registry.json` atomically. On first initialization only, it imports ordinary command Profiles from the former `agent-team/registry.json` and, when applicable, the older `agent-profiles.json` source. IDs, directories, metadata and custom data are preserved. Initialization is persisted even for an empty list; an existing new registry is authoritative, so a deleted Profile cannot reappear through repeated migration. Invalid command data and conflicting identities fail explicitly.
+The generic registry writes `/agent-profiles/registry.json` atomically. On first initialization only, it imports ordinary command Profiles from the former `agent-team/registry.json`, whose records already carry device placement. IDs, directories, metadata and custom data are preserved. Huabu does not import the older `agent-profiles.json` format because those records have no device identity and assigning them to an arbitrary connected or supervised Agentlet would break placement continuity. Initialization is persisted even for an empty list; an existing new registry is authoritative, so a deleted Profile cannot reappear through repeated migration. Invalid command data and conflicting identities fail explicitly.
Old Team registry/config/setup-log files and conversation records are not deleted or rewritten by migration. Manifest Profiles are not imported as active Profiles or converted to executable commands. Agentlet and Agenetes reject retired Team launch recipes instead of silently treating them as ordinary commands.
@@ -91,13 +93,15 @@ The Web client records the most recently explicitly selected conversational Prof
Ink submission without an existing Question target refreshes the Profile catalogue and applies the same browser-local recent selection before creating its Question, using internal `operate` or external `ask` mode. It 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.
-`GET /api/acp/profiles` reads the canonical persisted list, selectable IDs, and saved `agentDefaults` without detecting harnesses, creating Profiles or starting sessions. Settings refreshes the shared Profile store on mount and after mutations; existing selectors refresh that same list when opened. There is no Web discovery store, selector-time materialization, or discovery polling.
+`GET /api/acp/profiles` reads the canonical persisted list, active connected devices, connectivity-filtered selectable IDs, supervised-child health, and saved `agentDefaults` without detecting harnesses, creating Profiles or starting sessions. The active device projection is sorted deterministically and contains the Agentlet UUID, display metadata, Agentlet version, connection time, and associated Profile count; it is not persisted and contains no offline history or last-seen state. A Profile is selectable only while its exact `agentletId` has an active control connection. Settings refreshes the shared Profile store on mount and after mutations; existing selectors refresh that same list when opened. Unavailable Profiles remain visible and editable in Settings but cannot be selected for runtime work. There is no Web discovery store, selector-time materialization, or discovery polling.
+
+`GET /api/acp/agent-cli` requires exactly one explicit target: `agentletId` for creation or `profileId` for editing. It includes launch support and capability observations without preparing workspaces or creating Profiles. Older successful catalogues receive the canonical Custom descriptor; failed detection remains an explicit error, not a misleading successful catalogue. Custom command configuration and display-only edits remain available without successful detection. Manual creation requires an active device and an explicit `workingDirPath`; only automatic defaults get a daemon-prepared directory.
-`GET /api/acp/agent-cli` adapts the supervised agentlet's read-only discovery response; optional `profileId` instead targets the saved Profile's machine. It includes launch support and capability observations without preparing workspaces or creating Profiles. Older successful catalogues receive the canonical Custom descriptor; failed detection remains an explicit error, not a misleading successful catalogue. Custom command configuration and display-only edits remain available without successful detection. Manual creation requires an explicit `workingDirPath`; only automatic defaults get a daemon-prepared directory.
+Owner-only `POST /api/acp/profile-launch-preview` requires `{ launch, agentletId }` for creation or `{ launch, profileId }` for editing and returns the explicitly targeted Agentlet's validated `exec`/`shell` plan. It never spawns an Agent or prepares a workspace. Unsupported or disconnected target previews fail explicitly. `POST /api/acp/profiles` requires an active `agentletId` and persists that immutable placement; Settings initializes the form with the first deterministically sorted active device only as an unsaved UI convenience. 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 Agentlet.
-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 active Connected Devices first, followed by ordinary Profiles, their existing edit/delete actions, and the supervised-child health banner alongside Utility Agent and backend-specific configuration. The create editor requires a device selection before harness selection; editing displays the immutable device identity read-only. 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.
-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.
+Workload placement is always explicit. New workloads snapshot the Profile's `agentletId`; existing snapshots continue to use their stored placement. A missing Profile or a legacy workload without placement is treated as invalid data and fails clearly. Runtime code never falls back to the supervised child, hostname, or first connected device.
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.
@@ -108,6 +112,7 @@ The same runtime section owns the masked Agentlet connection-token setting and o
| File or directory | Responsibility |
| ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| [`local/src/harnesses/`](../../external/agentlet/packages/local/src/harnesses/) | Canonical catalogue, local detector and default workspaces |
+| [`local/src/device-identity.ts`](../../external/agentlet/packages/local/src/device-identity.ts) | Persistent UUID device identity |
| [`protocol/src/messages.ts`](../../external/agentlet/packages/protocol/src/messages.ts) | Harness discovery wire contract |
| [`agentlet-gateway/src/gateway.ts`](../../external/agenetes/packages/agentlet-gateway/src/gateway.ts) | Machine events and validated discovery routing |
| [`packages/agent-profile/`](../../external/agenetes/packages/agent-profile/) | Generic Profile registry, persistence and snapshots |
diff --git a/docs/architecture/api-design.md b/docs/architecture/api-design.md
index 9766bc16d..515f2fd38 100644
--- a/docs/architecture/api-design.md
+++ b/docs/architecture/api-design.md
@@ -144,6 +144,8 @@ 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.
+`GET /api/acp/profiles` returns persisted Profiles, active connected devices, connectivity-filtered `selectableProfileIds`, supervised-child health, and optional Agent defaults. `POST /api/acp/profiles` requires an explicit active `agentletId`. `GET /api/acp/agent-cli` and `POST /api/acp/profile-launch-preview` require exactly one explicit target: `agentletId` while creating or `profileId` while editing. The server validates every target against the live Gateway and never substitutes the supervised child or first connected device.
+
## Utility 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. The recent conversational Agent is browser-local UI state and has no HTTP contract.
diff --git a/docs/proposals/agenetes-agentlet-gateway-consolidation.md b/docs/proposals/agenetes-agentlet-gateway-consolidation.md
index a40fe8a04..4145da2ad 100644
--- a/docs/proposals/agenetes-agentlet-gateway-consolidation.md
+++ b/docs/proposals/agenetes-agentlet-gateway-consolidation.md
@@ -2,7 +2,7 @@
> Absorb the host-side agentlet relay into Agenetes, retire the standalone agentlet-server control plane, and make Agenetes the sole owner of durable workload and conversation state.
>
-> Status: **Shipped** · Last updated: 2026-07-14
+> Status: **Shipped** · Last updated: 2026-10-02
---
@@ -85,25 +85,25 @@ Every daemon-directed operation carries an explicit stable `agentletId`. The gat
Connection registries and ACP session caches are scoped by agentlet identity. A control connection disconnect or replacement affects only control operations for that daemon; existing session connections remain usable and are invalidated only when their own sockets disconnect. No event for one daemon invalidates another daemon's connections or caches.
-In the first version, `agentletId` is the daemon's machine name. For Huabu's supervised local daemon, the host reads `os.hostname()` at startup and passes the same value to the Gateway and the daemon's `--agentlet-id` option. No additional local identity file is introduced.
+Agentlet owns its default `agentletId` as a persistent UUID in `~/.agentlet/device.json`. The supervised host does not inject an identity; it controls only child-process lifecycle. `--agentlet-id` remains an exact advanced override that does not rewrite the persisted UUID.
-For backward compatibility, persisted ACP WorkloadSpecs created before placement was introduced may omit `agentletId`. The ACP composition resolves only those legacy specs to the host-injected supervised local daemon ID. This is a read-time compatibility rule: it does not rewrite the durable WorkloadSpec and never selects from the set of currently connected daemons.
+Persisted ACP WorkloadSpecs without `agentletId` fail explicitly. The composition never substitutes the supervised child, hostname, or first connected Agentlet.
-Every newly compiled ACP WorkloadSpec includes an explicit `agentletId`. A missing ID is therefore accepted only when reading a legacy record, not when creating a new workload.
+Every newly compiled ACP WorkloadSpec includes the immutable `agentletId` from its selected Profile.
-Changing the machine name, including changing the supervised host's OS hostname, creates a new execution node identity. Existing roots, deployments, and explicit workload bindings remain attached to the old identity and become unavailable rather than migrating implicitly.
+Changing the machine hostname does not change device identity. Replacing or deleting the home directory creates a new device identity; existing roots, deployments, Profiles, and explicit workload bindings remain attached to the old UUID and become unavailable rather than migrating implicitly.
-Huabu's supervised local daemon uses the existing process-lifetime connection token generated by the host and injected into its child process. The host and daemon restart together, so this token does not require persistence.
+Huabu's supervised local daemon and manually launched remote daemons use the same active host connection token. Token persistence and rotation are host concerns and remain independent from device identity.
-Future remote daemons each use their own long-lived token. The operator manually configures the same `{ machineName, token }` pair on the remote daemon and the Agenetes host.
+The operator supplies the active token to each remote daemon; every daemon independently reports its persistent UUID.
-The gateway authenticates both the daemon control WebSocket and every session WebSocket independently through a host-injected `authenticateAgentlet(machineName, token)` port. A credential may claim only its configured machine name, and the claimed name must match `sessionProfile.agentletId` on a session hello. A session connection does not require its daemon's control socket to be online.
+The gateway authenticates both the daemon control WebSocket and every session WebSocket independently through a host-injected `authenticateAgentlet(agentletId, token)` port. Huabu's shared connection token authorizes any Agentlet identity; the claimed control identity must match `sessionProfile.agentletId` on a session hello. A session connection does not require its daemon's control socket to be online.
The gateway does not persist credentials. The supervised local token remains host-process state; Huabu stores future remote daemon tokens through its SecretStore-backed host adapter.
Remote token changes and revocation are startup-time operations in the first version that supports remote configuration. After changing configuration on both sides, the operator restarts the Gateway host and the corresponding daemon. There is no runtime rotation notification or connection-eviction API in this migration.
-Registering a live duplicate machine name with the configured credential replaces the old control socket as a reconnect without closing that machine's session sockets. A duplicate machine name using a different credential is rejected.
+Registering a live duplicate Agentlet identity is rejected instead of evicting the existing control socket. Once the prior control connection disconnects, the same identity follows the normal reconnect path without closing that device's session sockets.
### 6.3 Connection topology and session identity
@@ -116,7 +116,7 @@ The gateway keys a session connection by `(agentletId, nativeAcpSessionId)`. Nat
Agenetes `ThreadIdentity` remains a durable control-plane identity and is not part of the session WebSocket identity. Agenetes owns the binding from a thread to its placed daemon and native ACP session.
-Both connection types use the credential bound to the claimed daemon machine name, but their connection lifecycles remain independent.
+Both connection types use the shared host credential and the same claimed Agentlet identity, but their connection lifecycles remain independent.
### 6.4 Preserve current reconnect and buffer behavior
@@ -190,7 +190,7 @@ The migration is feasible with a contained blast radius:
The primary implementation risks are:
1. Losing bootstrap `session/update` notifications when EventStore is removed. The bounded inbound pre-attach buffer is a required migration mechanism.
-2. Accidentally falling back to the first connected daemon. New ACP specs and every spawn/cache path require explicit placement; only legacy persisted specs may use the host-injected supervised local daemon ID.
+2. Accidentally falling back to an implicit daemon. ACP specs and every spawn/cache path require explicit placement; legacy persisted specs without placement fail clearly.
3. Cascading a control disconnect or reconnect into otherwise healthy session connections. Control and session registries must be keyed by daemon but lifecycled independently.
4. Breaking the repository between package moves. The Gateway must be added and adopted before the agentlet server package is deleted.
@@ -246,14 +246,14 @@ Changes under `external/agentlet/` are always committed separately from Agenetes
### G2 — Make daemon machine identity explicit
-| Item | Detail |
-| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Status | ✅ Complete. |
-| Scope | `external/agentlet/packages/local/` and the corresponding Agentlet protocol documentation. |
-| Work | Use the daemon's machine name as `agentletId` on control and session hello, accept an explicit `--agentlet-id` from the supervising host, keep the existing token and daemon reconnect behavior, and remove direct bridge mode. G3 derives Huabu's supervised local ID from `os.hostname()` and injects the same value into both sides. |
-| Dependency | G0; may proceed in parallel with G1. |
-| Validation | Control and session connections report the same machine name; changing the supervised host's hostname produces a new execution-node identity; existing daemon tests pass. |
-| Commit | Agentlet-subtree-only commit. |
+| Item | Detail |
+| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Status | ✅ Complete. |
+| Scope | `external/agentlet/packages/local/` and the corresponding Agentlet protocol documentation. |
+| Work | Use one Agentlet-owned persistent UUID as `agentletId` on control and session hello, retain an exact `--agentlet-id` override, keep token and reconnect behavior independent from identity, and remove direct bridge mode. |
+| Dependency | G0; may proceed in parallel with G1. |
+| Validation | Control and session connections report the same UUID; restart reuses the identity; malformed identity files fail explicitly; hostname remains display metadata; existing daemon tests pass. |
+| Commit | Agentlet-subtree-only commit. |
### G3 — Mount Gateway through the existing host package
@@ -268,14 +268,14 @@ Changes under `external/agentlet/` are always committed separately from Agenetes
### G4 — Persist explicit ACP placement in the driver spec
-| Item | Detail |
-| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Status | ✅ Complete. |
-| Scope | `external/agenetes/packages/acp-driver/` plus Agenetes protocol binding definitions. |
-| Work | Add explicit `agentletId` placement to the ACP driver-specific create spec/binding recipe. Keep the persisted read shape backward-compatible with legacy records that omit it, resolving only those records to the host-injected supervised local daemon ID without durable migration or write-back. Because Agenetes already persists the complete WorkloadSpec in ThreadRecord, no separate generic thread-store placement field is added. Re-key live ACP caches by `(agentletId, threadId)` and remove every `getAgentlets()[0]` selection. |
-| Dependency | G1 and G3. |
-| Validation | New persisted ACP WorkloadSpecs retain explicit placement across restart; a legacy spec without placement resolves to the configured supervised local daemon without being rewritten; reconnecting daemon B does not invalidate daemon A sessions; missing target daemon produces a structured placement error. |
-| Commit | Agenetes-only commit. |
+| Item | Detail |
+| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Status | ✅ Complete. |
+| Scope | `external/agenetes/packages/acp-driver/` plus Agenetes protocol binding definitions. |
+| Work | Add explicit `agentletId` placement to the ACP driver-specific create spec/binding recipe. Keep the persisted read shape capable of parsing legacy records but fail realization when placement is absent. Because Agenetes already persists the complete WorkloadSpec in ThreadRecord, no separate generic thread-store placement field is added. Re-key live ACP caches by `(agentletId, threadId)` and remove every `getAgentlets()[0]` selection. |
+| Dependency | G1 and G3. |
+| Validation | New persisted ACP WorkloadSpecs retain explicit placement across restart; a legacy spec without placement resolves to the configured supervised local daemon without being rewritten; reconnecting daemon B does not invalidate daemon A sessions; missing target daemon produces a structured placement error. |
+| Commit | Agenetes-only commit. |
### G5 — Remove ACP driver reads from agentlet stores
@@ -290,14 +290,14 @@ Changes under `external/agentlet/` are always committed separately from Agenetes
### G6 — Switch Huabu to the Gateway
-| Item | Detail |
-| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Status | ✅ Complete. |
-| Scope | Server mount wiring, package dependencies, build scripts, daemon status projection, and current External Agent composition. |
-| Work | Register the supervised local daemon's machine name/token through the host authentication adapter, supply its `agentletId` when compiling ACP WorkloadSpecs, mount the Gateway, retain the existing single-agentlet status projection and restart endpoints, and remove runtime calls to `@agentlet/server`. Remote daemon configuration UI remains outside this migration. |
-| Dependency | G3–G5. |
-| Validation | Existing External Agent flows work against the supervised local daemon; server restarts recover through Agenetes; no application code imports `@agentlet/server`. |
-| Commit | Huabu-only commit; must not touch `external/agentlet/`. |
+| Item | Detail |
+| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Status | ✅ Complete. |
+| Scope | Server mount wiring, package dependencies, build scripts, daemon status projection, and current External Agent composition. |
+| Work | Register the shared connection token through the host authentication adapter, compile ACP WorkloadSpecs from explicit Profile placement, mount the Gateway, retain supervisor lifecycle status and restart endpoints without treating the child as an identity authority, and remove runtime calls to `@agentlet/server`. |
+| Dependency | G3–G5. |
+| Validation | Existing External Agent flows work against the supervised local daemon; server restarts recover through Agenetes; no application code imports `@agentlet/server`. |
+| Commit | Huabu-only commit; must not touch `external/agentlet/`. |
### G7 — Remove standalone agentlet-server
diff --git a/packages/shared/src/types/api/acp.ts b/packages/shared/src/types/api/acp.ts
index 17ed9bd35..46b6bf1a0 100644
--- a/packages/shared/src/types/api/acp.ts
+++ b/packages/shared/src/types/api/acp.ts
@@ -163,8 +163,18 @@ export const acpAgentCliInfoSchema = z.object({
export type AcpAgentCliInfo = z.infer;
export const acpAgentCliQuerySchema = z
- .object({ profileId: z.string().min(1).optional() })
- .strict();
+ .object({
+ profileId: z.string().min(1).optional(),
+ agentletId: z.string().min(1).optional(),
+ })
+ .strict()
+ .refine(
+ (value) =>
+ Number(value.profileId !== undefined) +
+ Number(value.agentletId !== undefined) ===
+ 1,
+ { message: 'Exactly one Profile or Agentlet target is required' },
+ );
export type AcpAgentCliQuery = z.infer;
/** Response body for `GET /api/acp/agent-cli`, including unavailable entries. */
@@ -576,9 +586,23 @@ export type AgentletConnectionCommandResponse = z.infer<
>;
/** Schema mirror of {@link AcpProfilesListResponse}. */
+export const connectedAgentletDeviceSchema = z.object({
+ agentletId: z.string().min(1),
+ hostname: z.string().min(1).optional(),
+ platform: z.string().min(1).optional(),
+ arch: z.string().min(1).optional(),
+ version: z.string().min(1),
+ connectedAt: z.iso.datetime(),
+ profileCount: z.number().int().nonnegative(),
+});
+export type ConnectedAgentletDevice = z.infer<
+ typeof connectedAgentletDeviceSchema
+>;
+
export const acpProfilesListResponseSchema = z.object({
profiles: z.array(agentProfileSchema),
selectableProfileIds: z.array(z.string().min(1)),
+ connectedDevices: z.array(connectedAgentletDeviceSchema),
agentlet: acpAgentletStatusSchema,
agentDefaults: agentDefaultsSchema.optional(),
});
diff --git a/packages/shared/src/types/api/agent-profile.test.ts b/packages/shared/src/types/api/agent-profile.test.ts
index 6d13d2498..cbdb8675d 100644
--- a/packages/shared/src/types/api/agent-profile.test.ts
+++ b/packages/shared/src/types/api/agent-profile.test.ts
@@ -9,6 +9,7 @@ import {
agentProfileParamsSchema,
agentProfileSchema,
createAcpCommandProfileBodySchema,
+ createAcpProfileBodySchema,
createAgentProfileBodySchema,
patchAgentProfileBodySchema,
} from './agent-profile.js';
@@ -40,6 +41,18 @@ describe('ordinary command Profile contracts', () => {
).toBe(false);
});
+ it('requires explicit Agentlet placement for the ACP create API', () => {
+ expect(createAcpProfileBodySchema.safeParse(commandBody).success).toBe(
+ false,
+ );
+ expect(
+ createAcpProfileBodySchema.safeParse({
+ ...commandBody,
+ agentletId: 'device-1',
+ }).success,
+ ).toBe(true);
+ });
+
it.each(['/work/project', 'C:\\work\\project', '\\\\host\\share'])(
'accepts an absolute working directory: %s',
(workingDirPath) => {
diff --git a/packages/shared/src/types/api/agent-profile.ts b/packages/shared/src/types/api/agent-profile.ts
index c483fd415..717227f23 100644
--- a/packages/shared/src/types/api/agent-profile.ts
+++ b/packages/shared/src/types/api/agent-profile.ts
@@ -133,7 +133,6 @@ export type CreateAcpCommandProfileBody = z.infer<
export const createAcpProfileBodySchema = agentProfileSchema.omit({
id: true,
- agentletId: true,
revision: true,
executionRevision: true,
});
@@ -160,9 +159,17 @@ export type PatchAgentProfileBody = z.infer;
export const acpProfileLaunchPreviewBodySchema = z
.object({
profileId: trimmedString(255).optional(),
+ agentletId: trimmedString(255).optional(),
launch: agentProfileSchema.shape.launch,
})
- .strict();
+ .strict()
+ .refine(
+ (value) =>
+ Number(value.profileId !== undefined) +
+ Number(value.agentletId !== undefined) ===
+ 1,
+ { message: 'Exactly one Profile or Agentlet target is required' },
+ );
export type AcpProfileLaunchPreviewBody = z.infer<
typeof acpProfileLaunchPreviewBodySchema
>;
From 3c074d6beb85a31e4ccd118220140ee4c39ebbe9 Mon Sep 17 00:00:00 2001
From: Yuqing Yang
Date: Sun, 4 Oct 2026 03:20:02 +0000
Subject: [PATCH 22/30] fix(agent): resolve hostname-era Agentlet placement
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../modules/agent/acp/agent-cli.route.test.ts | 4 +-
.../src/modules/agent/acp/agent-cli.route.ts | 9 ++-
.../agent/acp/external-agent-realization.ts | 5 +-
.../modules/agent/acp/profiles.route.test.ts | 35 +++++++++++
.../src/modules/agent/acp/profiles.route.ts | 24 ++++----
.../acp/threads.route.cached-meta.test.ts | 30 ++++++++++
.../src/modules/agent/acp/threads.route.ts | 4 +-
.../agent/agent-defaults.route.test.ts | 3 +-
.../src/modules/agent/agent-defaults.route.ts | 5 +-
.../src/modules/agent/agent-defaults.test.ts | 20 +++++--
.../src/modules/agent/agent-defaults.ts | 8 +--
.../agent/selectable-agent-profile.test.ts | 22 ++++++-
.../modules/agent/selectable-agent-profile.ts | 16 ++---
docs/architecture/agent-profiles.md | 2 +
.../packages/acp-driver/src/handle.ts | 18 +++++-
.../packages/acp-driver/src/placement.test.ts | 56 ++++++++++++++++++
.../src/session-self-repair.test.ts | 1 +
.../acp-driver/src/spawn-orchestrator.ts | 9 ++-
.../src/agentlet-resolution.test.ts | 59 +++++++++++++++++++
.../agentlet-host/src/agentlet-resolution.ts | 38 ++++++++++++
.../packages/agentlet-host/src/index.ts | 4 ++
21 files changed, 320 insertions(+), 52 deletions(-)
create mode 100644 external/agenetes/packages/agentlet-host/src/agentlet-resolution.test.ts
create mode 100644 external/agenetes/packages/agentlet-host/src/agentlet-resolution.ts
diff --git a/apps/server/src/modules/agent/acp/agent-cli.route.test.ts b/apps/server/src/modules/agent/acp/agent-cli.route.test.ts
index a8b3aae5c..194dd8d08 100644
--- a/apps/server/src/modules/agent/acp/agent-cli.route.test.ts
+++ b/apps/server/src/modules/agent/acp/agent-cli.route.test.ts
@@ -16,6 +16,8 @@ vi.mock('@agenetes/agentlet-host', () => ({
getAgentlet: () => ({ status: 'connected' }),
discoverHarnesses: mocks.discover,
}),
+ resolveConnectedAgentletId: (target: string) =>
+ target === 'legacy-host' ? 'remote-machine' : target,
}));
let app: FastifyInstance | undefined;
@@ -119,7 +121,7 @@ describe('ACP agent CLI route', () => {
});
it('queries the edited Profile machine and projects Custom for older catalogues', async () => {
- mocks.getProfile.mockReturnValue({ agentletId: 'remote-machine' });
+ mocks.getProfile.mockReturnValue({ agentletId: 'legacy-host' });
mocks.discover.mockResolvedValue({ harnesses: [] });
app = Fastify({ logger: false });
await app.register(createAcpAgentCliRoutes(), { prefix: '/api/acp' });
diff --git a/apps/server/src/modules/agent/acp/agent-cli.route.ts b/apps/server/src/modules/agent/acp/agent-cli.route.ts
index 24d053434..a173f3737 100644
--- a/apps/server/src/modules/agent/acp/agent-cli.route.ts
+++ b/apps/server/src/modules/agent/acp/agent-cli.route.ts
@@ -18,6 +18,7 @@
import {
getAgentletGateway,
getAgentProfileRegistry,
+ resolveConnectedAgentletId,
} from '@agenetes/agentlet-host';
import {
CUSTOM_COMMAND_CAPABILITIES,
@@ -46,8 +47,12 @@ async function detectAgentClis(target: {
: undefined;
if (target.profileId && !profile)
throw new Error('Agent Profile is unavailable');
- const agentletId = profile?.agentletId ?? target.agentletId;
- if (!agentletId) throw new Error('Agentlet target is required');
+ const requestedAgentletId = profile?.agentletId ?? target.agentletId;
+ if (!requestedAgentletId) throw new Error('Agentlet target is required');
+ const agentletId = profile
+ ? resolveConnectedAgentletId(requestedAgentletId)
+ : requestedAgentletId;
+ if (!agentletId) throw new Error('Agentlet is not connected');
const connection = gateway.getAgentlet(agentletId);
if (connection?.status !== 'connected')
throw new Error('Agentlet is not connected');
diff --git a/apps/server/src/modules/agent/acp/external-agent-realization.ts b/apps/server/src/modules/agent/acp/external-agent-realization.ts
index 563117060..f10d864f8 100644
--- a/apps/server/src/modules/agent/acp/external-agent-realization.ts
+++ b/apps/server/src/modules/agent/acp/external-agent-realization.ts
@@ -6,6 +6,7 @@ import {
ensureAcpSession,
resolveAcpAgentletId,
} from '@agenetes/acp-driver';
+import { resolveConnectedAgentletId } from '@agenetes/agentlet-host';
import { canvasAcpNamespace } from '../../workspace/paths.js';
import {
@@ -123,8 +124,10 @@ async function ensureSessionFromCanonicalSpec(
? { ...resolvedEnvironment, ...spec.spec.env }
: undefined;
const record = await agenetes.record(spec.namespace, spec.threadId);
+ const requestedAgentletId = resolveAcpAgentletId(spec);
return ensureAcpSession({
- agentletId: resolveAcpAgentletId(spec),
+ agentletId:
+ resolveConnectedAgentletId(requestedAgentletId) ?? requestedAgentletId,
threadId: spec.threadId,
binding: spec.spec.binding,
namespace: spec.namespace,
diff --git a/apps/server/src/modules/agent/acp/profiles.route.test.ts b/apps/server/src/modules/agent/acp/profiles.route.test.ts
index dab86c012..5f9583fbb 100644
--- a/apps/server/src/modules/agent/acp/profiles.route.test.ts
+++ b/apps/server/src/modules/agent/acp/profiles.route.test.ts
@@ -51,6 +51,13 @@ vi.mock('@agenetes/agentlet-host', () => ({
},
})),
}),
+ resolveConnectedAgentletId: (target: string) => {
+ if (mocks.connectedIds.has(target)) return target;
+ const matches = [...mocks.connectedIds].filter(
+ (agentletId) => `${agentletId}-host` === target,
+ );
+ return matches.length === 1 ? matches[0] : undefined;
+ },
}));
vi.mock('./profile-schema-cache.js', () => ({
@@ -198,11 +205,39 @@ describe('ordinary Profile catalog routes', () => {
}),
],
});
+
expect(mocks.registry.createProfile).not.toHaveBeenCalled();
expect(mocks.initializeDefaults).not.toHaveBeenCalled();
expect(mocks.discoverHarnesses).not.toHaveBeenCalled();
});
+ it('maps one hostname-era Profile to the unique connected device', async () => {
+ const legacyProfile = {
+ ...commandProfile,
+ id: 'legacy-command',
+ agentletId: 'machine-a-host',
+ };
+ mocks.registry.listProfiles.mockReturnValue([legacyProfile]);
+ const server = await setup();
+ const response = await server.inject('/api/acp/profiles');
+
+ expect(response.statusCode).toBe(200);
+ expect(response.json()).toMatchObject({
+ profiles: [legacyProfile],
+ selectableProfileIds: ['legacy-command'],
+ connectedDevices: [
+ expect.objectContaining({
+ agentletId: 'machine-a',
+ profileCount: 1,
+ }),
+ expect.objectContaining({
+ agentletId: 'remote-machine',
+ profileCount: 0,
+ }),
+ ],
+ });
+ });
+
it('rejects caller-created automatic provenance', async () => {
const server = await setup();
const response = await server.inject({
diff --git a/apps/server/src/modules/agent/acp/profiles.route.ts b/apps/server/src/modules/agent/acp/profiles.route.ts
index 9e4fe6d3d..54d15d85d 100644
--- a/apps/server/src/modules/agent/acp/profiles.route.ts
+++ b/apps/server/src/modules/agent/acp/profiles.route.ts
@@ -29,6 +29,7 @@ import {
getAgentProfileRegistry,
getAgentletGateway,
getDaemonSupervisor,
+ resolveConnectedAgentletId,
} from '@agenetes/agentlet-host';
import {
@@ -88,7 +89,10 @@ async function validateHarnessLaunch(
try {
const gateway = getAgentletGateway();
if (!gateway) throw new Error('Agentlet Gateway is not ready');
- const result = await gateway.discoverHarnesses(agentletId, {
+ const resolvedAgentletId = resolveConnectedAgentletId(agentletId);
+ if (!resolvedAgentletId)
+ throw new Error('The selected Agentlet is not connected');
+ const result = await gateway.discoverHarnesses(resolvedAgentletId, {
prepareWorkspaces: false,
});
const harness = result.harnesses.find(
@@ -117,7 +121,7 @@ async function validateHarnessLaunch(
});
return false;
}
- await gateway.buildHarnessLaunch(agentletId, { launch });
+ await gateway.buildHarnessLaunch(resolvedAgentletId, { launch });
return true;
} catch (error) {
request.log.warn(
@@ -156,7 +160,9 @@ const acpProfilesRoutes: FastifyPluginAsync = async (app) => {
try {
const gateway = getAgentletGateway();
if (!gateway) throw new Error('Agentlet Gateway is not ready');
- const agentletId = profile?.agentletId ?? parsed.data.agentletId;
+ const agentletId = profile
+ ? resolveConnectedAgentletId(profile.agentletId)
+ : parsed.data.agentletId;
if (!agentletId || !isAgentletConnected(agentletId)) {
return reply.status(409).send({
code: 'agentlet_unavailable',
@@ -190,15 +196,11 @@ const acpProfilesRoutes: FastifyPluginAsync = async (app) => {
}
const profiles = registry.listProfiles();
const connected = getConnectedAgentlets();
- const connectedIds = new Set(
- connected.map((connection) => connection.agentletId),
- );
const profileCounts = new Map();
for (const profile of profiles) {
- profileCounts.set(
- profile.agentletId,
- (profileCounts.get(profile.agentletId) ?? 0) + 1,
- );
+ const agentletId = resolveConnectedAgentletId(profile.agentletId);
+ if (!agentletId) continue;
+ profileCounts.set(agentletId, (profileCounts.get(agentletId) ?? 0) + 1);
}
const connectedDevices = connected
.map((connection) => ({
@@ -225,7 +227,7 @@ const acpProfilesRoutes: FastifyPluginAsync = async (app) => {
return {
profiles,
selectableProfileIds: profiles
- .filter((profile) => connectedIds.has(profile.agentletId))
+ .filter((profile) => resolveConnectedAgentletId(profile.agentletId))
.map((profile) => profile.id),
connectedDevices,
agentlet: getDaemonSupervisor().getStatus(),
diff --git a/apps/server/src/modules/agent/acp/threads.route.cached-meta.test.ts b/apps/server/src/modules/agent/acp/threads.route.cached-meta.test.ts
index 66dfbc803..9c2b3a8ea 100644
--- a/apps/server/src/modules/agent/acp/threads.route.cached-meta.test.ts
+++ b/apps/server/src/modules/agent/acp/threads.route.cached-meta.test.ts
@@ -29,6 +29,11 @@ vi.mock('@agenetes/acp-driver', () => ({
},
}));
+vi.mock('@agenetes/agentlet-host', () => ({
+ resolveConnectedAgentletId: (agentletId: string) =>
+ agentletId === 'legacy-host' ? 'device-uuid' : agentletId,
+}));
+
vi.mock('./external-agent-realization.js', () => ({
externalAgentRealization: { realize: vi.fn(), ensureSession: vi.fn() },
realizationHttpError: () => ({
@@ -115,6 +120,7 @@ describe('ACP cached-meta across awaited persistence', () => {
usage: null,
metaUpdatedAt: 32,
});
+
const server = await createApp();
const response = await server.inject({
@@ -135,6 +141,30 @@ describe('ACP cached-meta across awaited persistence', () => {
});
});
+ it('finds a live session through unique hostname-era placement resolution', async () => {
+ mocks.record = externalRecord('legacy-host');
+ mocks.live.set('device-uuid\u0000thread-1', {
+ availableCommands: [{ name: 'review', description: 'Review changes' }],
+ commandsUpdatedAt: 5,
+ availableModes: [],
+ currentModeId: null,
+ availableModels: [],
+ currentModelId: null,
+ configOptions: [],
+ selections: {},
+ sessionInfo: null,
+ usage: null,
+ metaUpdatedAt: 6,
+ });
+ const response = await (await createApp()).inject(CACHED_META_URL);
+
+ expect(response.statusCode).toBe(200);
+ expect(response.json()).toMatchObject({
+ source: 'thread',
+ availableCommands: [{ name: 'review' }],
+ });
+ });
+
it('does not invent a placement for a thread with no record', async () => {
mocks.live.set('agentlet-supervised\u0000thread-1', {
availableCommands: [],
diff --git a/apps/server/src/modules/agent/acp/threads.route.ts b/apps/server/src/modules/agent/acp/threads.route.ts
index df778ecb0..1950b9cbe 100644
--- a/apps/server/src/modules/agent/acp/threads.route.ts
+++ b/apps/server/src/modules/agent/acp/threads.route.ts
@@ -2,6 +2,7 @@
// Licensed under the MIT license.
import { acpSessionRegistry } from '@agenetes/acp-driver';
+import { resolveConnectedAgentletId } from '@agenetes/agentlet-host';
import {
acpPermissionDecisionSchema,
@@ -151,7 +152,8 @@ async function resolveThreadAgentletId(
typeof driverSpec === 'object' &&
typeof (driverSpec as { agentletId?: unknown }).agentletId === 'string'
) {
- return (driverSpec as { agentletId: string }).agentletId;
+ const agentletId = (driverSpec as { agentletId: string }).agentletId;
+ return resolveConnectedAgentletId(agentletId) ?? agentletId;
}
}
return undefined;
diff --git a/apps/server/src/modules/agent/agent-defaults.route.test.ts b/apps/server/src/modules/agent/agent-defaults.route.test.ts
index 7a98899d1..7832a8512 100644
--- a/apps/server/src/modules/agent/agent-defaults.route.test.ts
+++ b/apps/server/src/modules/agent/agent-defaults.route.test.ts
@@ -26,7 +26,8 @@ vi.mock('./agent-defaults.js', () => ({
vi.mock('@agenetes/agentlet-host', () => ({
getAgentProfileRegistry: () =>
mocks.registryReady ? { getProfile: mocks.getProfile } : null,
- getAgentletGateway: () => ({ getAgentlet: mocks.getAgentlet }),
+ resolveConnectedAgentletId: (target: string) =>
+ mocks.getAgentlet(target)?.status === 'connected' ? target : undefined,
}));
vi.mock('./acp/profile-schema-cache.js', () => ({
getProfileSchemaCache: mocks.getCache,
diff --git a/apps/server/src/modules/agent/agent-defaults.route.ts b/apps/server/src/modules/agent/agent-defaults.route.ts
index 49d537e35..9412298bd 100644
--- a/apps/server/src/modules/agent/agent-defaults.route.ts
+++ b/apps/server/src/modules/agent/agent-defaults.route.ts
@@ -3,7 +3,7 @@
import {
getAgentProfileRegistry,
- getAgentletGateway,
+ resolveConnectedAgentletId,
} from '@agenetes/agentlet-host';
import {
@@ -60,8 +60,7 @@ function projectDefaults(defaults: AgentDefaults): AgentDefaultsResponse {
? 'offline'
: !profile
? 'deleted'
- : getAgentletGateway()?.getAgentlet(profile.agentletId)?.status ===
- 'connected'
+ : resolveConnectedAgentletId(profile.agentletId)
? 'available'
: 'offline',
// Missing observations and editable CLI labels do not prove lack of support.
diff --git a/apps/server/src/modules/agent/agent-defaults.test.ts b/apps/server/src/modules/agent/agent-defaults.test.ts
index f900a4d99..0610c7cda 100644
--- a/apps/server/src/modules/agent/agent-defaults.test.ts
+++ b/apps/server/src/modules/agent/agent-defaults.test.ts
@@ -12,11 +12,8 @@ import {
import type { AgentDefaults, AgentProfileView } from '@huabu/shared';
vi.mock('@agenetes/agentlet-host', () => ({
- getAgentletGateway: () => ({
- getAgentlet: (id: string) => ({
- status: id === 'connected' ? 'connected' : 'disconnected',
- }),
- }),
+ resolveConnectedAgentletId: (id: string) =>
+ id === 'connected' || id === 'legacy-host' ? 'connected' : undefined,
}));
function profile(
@@ -59,6 +56,19 @@ describe('installation Agent defaults', () => {
}
});
+ it('offers a uniquely resolved hostname-era candidate to initialization', () => {
+ const initialize = vi
+ .spyOn(AgentDefaultsService.prototype, 'initializeAgentDefaults')
+ .mockReturnValue({ profileId: null, functionalModel: '' });
+ try {
+ const legacy = profile('legacy', 'legacy-host');
+ initializeAgentDefaults([legacy]);
+ expect(initialize).toHaveBeenCalledWith([legacy]);
+ } finally {
+ initialize.mockRestore();
+ }
+ });
+
it('requires an explicit external default instead of falling back to Huabu', () => {
const { service, storage } = setup();
expect(() => service.requireDefaultAgentProfileId()).toThrow(
diff --git a/apps/server/src/modules/agent/agent-defaults.ts b/apps/server/src/modules/agent/agent-defaults.ts
index 3d5c3af62..0accc5686 100644
--- a/apps/server/src/modules/agent/agent-defaults.ts
+++ b/apps/server/src/modules/agent/agent-defaults.ts
@@ -4,7 +4,7 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
-import { getAgentletGateway } from '@agenetes/agentlet-host';
+import { resolveConnectedAgentletId } from '@agenetes/agentlet-host';
import { agentDefaultsSchema } from '@huabu/shared';
@@ -134,9 +134,7 @@ export const initializeAgentDefaults = (
profiles: readonly AgentProfileView[],
): AgentDefaults =>
service.initializeAgentDefaults(
- profiles.filter(
- (profile) =>
- getAgentletGateway()?.getAgentlet(profile.agentletId)?.status ===
- 'connected',
+ profiles.filter((profile) =>
+ resolveConnectedAgentletId(profile.agentletId),
),
);
diff --git a/apps/server/src/modules/agent/selectable-agent-profile.test.ts b/apps/server/src/modules/agent/selectable-agent-profile.test.ts
index 0f649404f..0ce944a7c 100644
--- a/apps/server/src/modules/agent/selectable-agent-profile.test.ts
+++ b/apps/server/src/modules/agent/selectable-agent-profile.test.ts
@@ -14,9 +14,13 @@ vi.mock('@agenetes/agentlet-host', () => ({
host.profiles.find((profile) => profile.id === profileId),
listProfiles: () => host.profiles,
}),
- getAgentletGateway: () => ({
- getAgentlets: () => host.connectedIds.map((agentletId) => ({ agentletId })),
- }),
+ resolveConnectedAgentletId: (target: string) => {
+ if (host.connectedIds.includes(target)) return target;
+ const matches = host.connectedIds.filter(
+ (agentletId) => `${agentletId}-host` === target,
+ );
+ return matches.length === 1 ? matches[0] : undefined;
+ },
}));
import {
@@ -75,6 +79,18 @@ describe('listAvailableAgentProfiles', () => {
]);
});
+ it('projects a hostname-era Profile when exactly one device reports that hostname', () => {
+ host.profiles = [
+ { id: 'legacy', alias: 'Legacy', agentletId: 'device-a-host' },
+ ];
+ host.connectedIds = ['device-a'];
+
+ expect(listAvailableAgentProfiles()).toEqual([
+ { id: 'huabu', alias: 'Built-In Pi' },
+ { id: 'legacy', alias: 'Legacy', default: true },
+ ]);
+ });
+
it('accepts the Huabu Profile without an external registry', () => {
expect(() => requireAvailableAgentProfile('huabu', null)).not.toThrow();
});
diff --git a/apps/server/src/modules/agent/selectable-agent-profile.ts b/apps/server/src/modules/agent/selectable-agent-profile.ts
index 8b69df24e..a67e1d315 100644
--- a/apps/server/src/modules/agent/selectable-agent-profile.ts
+++ b/apps/server/src/modules/agent/selectable-agent-profile.ts
@@ -3,7 +3,7 @@
import {
getAgentProfileRegistry,
- getAgentletGateway,
+ resolveConnectedAgentletId,
} from '@agenetes/agentlet-host';
import { HUABU_AGENT_PROFILE_ID } from '@huabu/shared';
@@ -26,17 +26,11 @@ function getConnectedProfileRegistry(): AgentProfileRegistryPort | null {
if (!registry) return null;
return {
getProfile: (profileId) => registry.getProfile(profileId),
- listSelectableProfileIds: () => {
- const connectedIds = new Set(
- (getAgentletGateway()?.getAgentlets({ status: 'connected' }) ?? []).map(
- (connection) => connection.agentletId,
- ),
- );
- return registry
+ listSelectableProfileIds: () =>
+ registry
.listProfiles()
- .filter((profile) => connectedIds.has(profile.agentletId))
- .map((profile) => profile.id);
- },
+ .filter((profile) => resolveConnectedAgentletId(profile.agentletId))
+ .map((profile) => profile.id),
};
}
diff --git a/docs/architecture/agent-profiles.md b/docs/architecture/agent-profiles.md
index 04c7fd28f..f86b24f18 100644
--- a/docs/architecture/agent-profiles.md
+++ b/docs/architecture/agent-profiles.md
@@ -25,6 +25,8 @@ Huabu subscribes to machine connection events, includes machines already connect
The Agentlet CLI owns its default device identity. On first start it atomically creates `~/.agentlet/device.json` containing `{ "version": 1, "deviceId": "" }`; later starts reuse that UUID, so identity survives daemon, Huabu, OS-session, binary, upgrade, and worktree restarts while the home directory remains. A malformed or unreadable identity file fails explicitly instead of rotating identity. `--agentlet-id` remains an advanced exact override and does not rewrite the persisted UUID. Hello metadata reports the real hostname, platform, and architecture separately for display; none of those fields participates in identity. Windows and WSL naturally receive distinct identities because they use separate home directories.
+Hostname-era Profiles and frozen workload records remain compatible without rewriting persisted data. Live placement resolution first requires an exact connected `agentletId`; when none exists, it treats the stored target as a legacy hostname and accepts it only when exactly one connected Agentlet reports that hostname. The resolved UUID is used for availability, device projections, Profile operations, and ACP session routing. Zero or multiple hostname matches remain unavailable, so Windows/WSL collisions never route to an arbitrary device.
+
Multiple Agentlets may share one Huabu connection token and connect concurrently. If an identity is already online, Gateway rejects the second live connection with guidance to retry using `--agentlet-id ` instead of evicting the first device; once the prior connection is disconnected, the same identity follows the normal reconnect path. The embedded supervisor is special only as process-lifecycle infrastructure: it resolves, starts, restarts, stops, and diagnoses its child, but it neither assigns that Agentlet's identity nor acts as a placement default.
## Profile identity and customization
diff --git a/external/agenetes/packages/acp-driver/src/handle.ts b/external/agenetes/packages/acp-driver/src/handle.ts
index 986fd35fd..78a21e7f9 100644
--- a/external/agenetes/packages/acp-driver/src/handle.ts
+++ b/external/agenetes/packages/acp-driver/src/handle.ts
@@ -33,6 +33,7 @@
import { randomUUID } from 'node:crypto';
+import { resolveConnectedAgentletId } from '@agenetes/agentlet-host';
import { resolveAgentInputs } from '@agenetes/protocol';
import {
HistoryLoadDeniedError,
@@ -328,15 +329,25 @@ export class AcpAgentHandle<
getIdleTimeoutSecs: () => 600,
},
) {
- this.agentletId = resolveAcpAgentletId(spec);
+ this.requestedAgentletId = resolveAcpAgentletId(spec);
+ this.agentletId =
+ resolveConnectedAgentletId(this.requestedAgentletId) ??
+ this.requestedAgentletId;
// Jobs may share a durable thread or have none. Their live sessions must not.
this.sessionThreadId =
spec.workloadType === 'Job' ? `acp-job-${randomUUID()}` : spec.threadId;
}
- private readonly agentletId: string;
+ private readonly requestedAgentletId: string;
+ private agentletId: string;
private readonly sessionThreadId: string;
+ private resolveAgentletId(): string {
+ this.agentletId =
+ resolveConnectedAgentletId(this.requestedAgentletId) ?? this.agentletId;
+ return this.agentletId;
+ }
+
private async authorizeHistoryLoad(
mode: 'recover' | 'fork',
turns: readonly AgentTurn[],
@@ -425,8 +436,9 @@ export class AcpAgentHandle<
this.spec.spec,
this.runtimePolicy,
);
+ const agentletId = this.resolveAgentletId();
return ensureAcpSession({
- agentletId: this.agentletId,
+ agentletId,
threadId: this.sessionThreadId,
workloadType: this.spec.workloadType,
binding: this.spec.spec.binding,
diff --git a/external/agenetes/packages/acp-driver/src/placement.test.ts b/external/agenetes/packages/acp-driver/src/placement.test.ts
index 9b08f2aca..974d6df16 100644
--- a/external/agenetes/packages/acp-driver/src/placement.test.ts
+++ b/external/agenetes/packages/acp-driver/src/placement.test.ts
@@ -10,6 +10,28 @@ vi.mock('@agenetes/agentlet-host', async (importOriginal) => {
return {
...actual,
getAgentletGateway: () => host.gateway,
+ resolveConnectedAgentletId: (target: string) => {
+ const gateway = host.gateway as
+ | {
+ getAgentlet?: (
+ agentletId: string,
+ ) => { agentletId?: string; status?: string } | undefined;
+ getAgentlets?: () => Array<{
+ agentletId: string;
+ status: string;
+ agentletProfile?: { machine?: { hostname?: string } };
+ }>;
+ }
+ | undefined;
+ const exact = gateway?.getAgentlet?.(target);
+ if (exact?.status === 'connected') return exact.agentletId ?? target;
+ const matches = (gateway?.getAgentlets?.() ?? []).filter(
+ (connection) =>
+ connection.status === 'connected' &&
+ connection.agentletProfile?.machine?.hostname === target,
+ );
+ return matches.length === 1 ? matches[0]?.agentletId : undefined;
+ },
};
});
@@ -37,6 +59,40 @@ afterEach(() => {
});
describe('explicit ACP placement', () => {
+ it('routes a hostname-era workload to its unique connected device identity', async () => {
+ const sessions = new Map();
+ const spawnOnAgentlet = vi.fn(
+ async (agentletId: string, params: { appId: string }) => {
+ const sessionId = `${agentletId}-${params.appId}`;
+ sessions.set(JSON.stringify([agentletId, sessionId]), {
+ status: 'connected',
+ });
+ return { sessionId, pid: 101 };
+ },
+ );
+ host.gateway = {
+ getAgentlet: () => undefined,
+ getAgentlets: () => [
+ {
+ agentletId: 'device-uuid',
+ status: 'connected',
+ agentletProfile: { machine: { hostname: 'legacy-host' } },
+ },
+ ],
+ getSession: (agentletId: string, sessionId: string) =>
+ sessions.get(JSON.stringify([agentletId, sessionId])),
+ spawnOnAgentlet,
+ };
+
+ await expect(
+ ensureAgentForThread('legacy-host', 'legacy-thread', recipe),
+ ).resolves.toMatchObject({ agentletId: 'device-uuid' });
+ expect(spawnOnAgentlet).toHaveBeenCalledWith(
+ 'device-uuid',
+ expect.any(Object),
+ );
+ });
+
it('forwards a persisted structured plan and retains it when reusing the live process', async () => {
const launch = {
kind: 'acp-harness' as const,
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 6a47ec5c7..eadb42ec0 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
@@ -21,6 +21,7 @@ const clients = vi.hoisted(() => ({
vi.mock('@agenetes/agentlet-host', () => ({
getAgentletGateway: () => host.gateway,
+ resolveConnectedAgentletId: (agentletId: string) => agentletId,
}));
vi.mock('./spawn-orchestrator.js', () => orchestrator);
diff --git a/external/agenetes/packages/acp-driver/src/spawn-orchestrator.ts b/external/agenetes/packages/acp-driver/src/spawn-orchestrator.ts
index 6571149ba..b29a32123 100644
--- a/external/agenetes/packages/acp-driver/src/spawn-orchestrator.ts
+++ b/external/agenetes/packages/acp-driver/src/spawn-orchestrator.ts
@@ -40,6 +40,7 @@
import {
AgentletRequestError,
getAgentletGateway,
+ resolveConnectedAgentletId,
} from '@agenetes/agentlet-host';
import {
harnessLaunchPlanSchema,
@@ -96,10 +97,8 @@ function agentletThreadKey(agentletId: string, threadId: string): string {
/** Resolve one explicitly targeted execution node. */
function readTargetAgentlet(agentletId: string): { agentletId: string } | null {
- const gateway = getAgentletGateway();
- if (!gateway) return null;
- const agentlet = gateway.getAgentlet(agentletId);
- return agentlet?.status === 'connected' ? { agentletId } : null;
+ const resolved = resolveConnectedAgentletId(agentletId);
+ return resolved ? { agentletId: resolved } : null;
}
/**
@@ -205,7 +204,7 @@ export async function ensureAgentForThread(
);
}
- const cacheKey = agentletThreadKey(agentletId, threadId);
+ const cacheKey = agentletThreadKey(agentlet.agentletId, threadId);
const cached = threadToAgent.get(cacheKey);
if (cached) {
const gateway = getAgentletGateway();
diff --git a/external/agenetes/packages/agentlet-host/src/agentlet-resolution.test.ts b/external/agenetes/packages/agentlet-host/src/agentlet-resolution.test.ts
new file mode 100644
index 000000000..7c29230bb
--- /dev/null
+++ b/external/agenetes/packages/agentlet-host/src/agentlet-resolution.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from 'vitest';
+
+import { resolveConnectedAgentletIdFromConnections } from './agentlet-resolution.js';
+
+import type { AgentletConnection } from '@agenetes/agentlet-gateway';
+
+function connection(
+ agentletId: string,
+ hostname: string,
+ status: 'connected' | 'disconnected' = 'connected',
+): AgentletConnection {
+ return {
+ agentletId,
+ status,
+ connectedAt: new Date(),
+ agentletProfile: {
+ bridge: { name: 'agentlet', version: '1.0.0' },
+ machine: { hostname, platform: 'linux', arch: 'x64' },
+ capabilities: { autoRestart: true, bufferLimit: 1000 },
+ },
+ } as AgentletConnection;
+}
+
+describe('resolveConnectedAgentletIdFromConnections', () => {
+ it('prefers an exact connected identity over hostname compatibility', () => {
+ expect(
+ resolveConnectedAgentletIdFromConnections('device-a', [
+ connection('device-a', 'other-host'),
+ connection('device-b', 'device-a'),
+ ]),
+ ).toBe('device-a');
+ });
+
+ it('resolves one connected legacy hostname to its current identity', () => {
+ expect(
+ resolveConnectedAgentletIdFromConnections('legacy-host', [
+ connection('device-a', 'legacy-host'),
+ ]),
+ ).toBe('device-a');
+ });
+
+ it('does not resolve absent or ambiguous legacy hostnames', () => {
+ const devices = [
+ connection('device-a', 'shared-host'),
+ connection('device-b', 'shared-host'),
+ connection('device-c', 'offline-host', 'disconnected'),
+ ];
+
+ expect(
+ resolveConnectedAgentletIdFromConnections('missing-host', devices),
+ ).toBeUndefined();
+ expect(
+ resolveConnectedAgentletIdFromConnections('shared-host', devices),
+ ).toBeUndefined();
+ expect(
+ resolveConnectedAgentletIdFromConnections('offline-host', devices),
+ ).toBeUndefined();
+ });
+});
diff --git a/external/agenetes/packages/agentlet-host/src/agentlet-resolution.ts b/external/agenetes/packages/agentlet-host/src/agentlet-resolution.ts
new file mode 100644
index 000000000..3c3af80b7
--- /dev/null
+++ b/external/agenetes/packages/agentlet-host/src/agentlet-resolution.ts
@@ -0,0 +1,38 @@
+import { getAgentletGateway } from './gateway-mount.js';
+
+import type { AgentletConnection } from '@agenetes/agentlet-gateway';
+
+type ConnectedAgentlet = Pick<
+ AgentletConnection,
+ 'agentletId' | 'agentletProfile' | 'status'
+>;
+
+export function resolveConnectedAgentletIdFromConnections(
+ target: string,
+ connections: readonly ConnectedAgentlet[],
+): string | undefined {
+ const connected = connections.filter(
+ (connection) => connection.status === 'connected',
+ );
+ const exact = connected.find(
+ (connection) => connection.agentletId === target,
+ );
+ if (exact) return exact.agentletId;
+
+ const hostnameMatches = connected.filter(
+ (connection) =>
+ connection.agentletProfile?.machine?.hostname === target,
+ );
+ return hostnameMatches.length === 1
+ ? hostnameMatches[0]?.agentletId
+ : undefined;
+}
+
+export function resolveConnectedAgentletId(
+ target: string,
+): string | undefined {
+ return resolveConnectedAgentletIdFromConnections(
+ target,
+ getAgentletGateway()?.getAgentlets({ status: 'connected' }) ?? [],
+ );
+}
diff --git a/external/agenetes/packages/agentlet-host/src/index.ts b/external/agenetes/packages/agentlet-host/src/index.ts
index f0a3c4d88..ac27e0371 100644
--- a/external/agenetes/packages/agentlet-host/src/index.ts
+++ b/external/agenetes/packages/agentlet-host/src/index.ts
@@ -29,6 +29,10 @@ import type {
import type { FastifyInstance } from 'fastify';
export { getAgentProfileRegistry } from './agent-profile-mount.js';
+export {
+ resolveConnectedAgentletId,
+ resolveConnectedAgentletIdFromConnections,
+} from './agentlet-resolution.js';
export {
ACP_UPGRADE_PATH,
getAgentletGateway,
From 94d81e91b41dec4b14fb9b8d71c12be721ffea30 Mon Sep 17 00:00:00 2001
From: Yuqing Yang
Date: Sun, 4 Oct 2026 03:38:25 +0000
Subject: [PATCH 23/30] fix(agent): use readable Agentlet device labels
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../acp/harness-profile-discovery.test.ts | 17 ++++-
.../agent/acp/harness-profile-discovery.ts | 20 +++++-
.../modules/agent/acp/profiles.route.test.ts | 1 +
.../src/modules/agent/acp/profiles.route.ts | 4 ++
.../agent/agentlet-device-display.test.ts | 28 ++++++++
.../modules/agent/agentlet-device-display.ts | 19 ++++++
.../AgentProfileEditor.test.tsx | 64 +++++++++++++++----
.../agent-profiles/CommandProfileForm.tsx | 22 ++++++-
.../ExternalAgentsSettings.test.tsx | 2 +
.../agent-profiles/ExternalAgentsSettings.tsx | 3 +-
docs/architecture/agent-profiles.md | 2 +
packages/shared/src/types/api/acp.ts | 1 +
12 files changed, 162 insertions(+), 21 deletions(-)
create mode 100644 apps/server/src/modules/agent/agentlet-device-display.test.ts
create mode 100644 apps/server/src/modules/agent/agentlet-device-display.ts
diff --git a/apps/server/src/modules/agent/acp/harness-profile-discovery.test.ts b/apps/server/src/modules/agent/acp/harness-profile-discovery.test.ts
index e948a7029..3f3f935ef 100644
--- a/apps/server/src/modules/agent/acp/harness-profile-discovery.test.ts
+++ b/apps/server/src/modules/agent/acp/harness-profile-discovery.test.ts
@@ -63,7 +63,17 @@ function setup(initialMachines = ['machine-a']) {
}),
};
const gateway = {
- getAgentlets: () => initialMachines.map((agentletId) => ({ agentletId })),
+ getAgentlets: () =>
+ initialMachines.map((agentletId) => ({
+ agentletId,
+ agentletProfile: {
+ machine: {
+ hostname: agentletId,
+ platform: 'linux',
+ arch: 'x64',
+ },
+ },
+ })),
onAgentletsChanged: (handler: (event: Event) => void) => {
listener = handler;
return vi.fn();
@@ -116,6 +126,7 @@ describe('automatic ordinary Profile provisioning', () => {
},
);
expect(context.profiles[0]).toMatchObject({
+ alias: 'GitHub Copilot (machine-a: linux x64)',
agentletId: 'machine-a',
workingDirPath: '/home/user/.agentlet/workspace/copilot',
launch: { kind: 'acp-command', command: 'copilot --acp' },
@@ -277,7 +288,7 @@ describe('automatic ordinary Profile provisioning', () => {
const registry = createAgentProfileRegistry({ storageDir });
const legacy = registry.createProfile({
launchKind: 'acp-command',
- alias: 'GitHub Copilot (machine-a)',
+ alias: 'GitHub Copilot (machine-a: linux x64)',
agentletId: 'machine-a',
workingDirPath: '/custom/work',
command: 'copilot --acp --model old-model',
@@ -328,7 +339,7 @@ describe('automatic ordinary Profile provisioning', () => {
.listProfiles()
.find((profile) => profile.launch.kind === 'acp-harness');
expect(typed).toMatchObject({
- alias: 'GitHub Copilot (machine-a) [copilot]',
+ alias: 'GitHub Copilot (machine-a: linux x64) [copilot]',
workingDirPath: observation.harnesses[0].workingDirPath,
launch: {
kind: 'acp-harness',
diff --git a/apps/server/src/modules/agent/acp/harness-profile-discovery.ts b/apps/server/src/modules/agent/acp/harness-profile-discovery.ts
index 3271b5c20..12c2133d8 100644
--- a/apps/server/src/modules/agent/acp/harness-profile-discovery.ts
+++ b/apps/server/src/modules/agent/acp/harness-profile-discovery.ts
@@ -3,6 +3,8 @@
import { CUSTOM_COMMAND_WRAPPER_ID } from '@agentlet/protocol';
+import { formatAgentletDeviceDisplayName } from '../agentlet-device-display.js';
+
import type {
AgentProfile,
AgentProfileRegistry,
@@ -76,7 +78,16 @@ export function mergeProfileCustomData(
}
interface DiscoveryGateway {
- getAgentlets(filter: { status: 'connected' }): Array<{ agentletId: string }>;
+ getAgentlets(filter: { status: 'connected' }): Array<{
+ agentletId: string;
+ agentletProfile?: {
+ machine?: {
+ hostname?: string;
+ platform?: string;
+ arch?: string;
+ };
+ };
+ }>;
onAgentletsChanged(
handler: (event: {
agentletId: string;
@@ -149,7 +160,12 @@ export function registerHarnessProfileDiscovery({
);
});
if (existing) continue;
- const defaultAlias = `${harness.displayName} (${agentletId})`;
+ const connection = gateway
+ .getAgentlets({ status: 'connected' })
+ .find((candidate) => candidate.agentletId === agentletId);
+ const defaultAlias = `${harness.displayName} (${formatAgentletDeviceDisplayName(
+ connection?.agentletProfile?.machine,
+ )})`;
const common = {
agentletId,
alias:
diff --git a/apps/server/src/modules/agent/acp/profiles.route.test.ts b/apps/server/src/modules/agent/acp/profiles.route.test.ts
index 5f9583fbb..ed5032b9a 100644
--- a/apps/server/src/modules/agent/acp/profiles.route.test.ts
+++ b/apps/server/src/modules/agent/acp/profiles.route.test.ts
@@ -197,6 +197,7 @@ describe('ordinary Profile catalog routes', () => {
connectedDevices: [
expect.objectContaining({
agentletId: 'machine-a',
+ displayName: 'machine-a-host: linux x64',
profileCount: 1,
}),
expect.objectContaining({
diff --git a/apps/server/src/modules/agent/acp/profiles.route.ts b/apps/server/src/modules/agent/acp/profiles.route.ts
index 54d15d85d..0386a0a76 100644
--- a/apps/server/src/modules/agent/acp/profiles.route.ts
+++ b/apps/server/src/modules/agent/acp/profiles.route.ts
@@ -49,6 +49,7 @@ import {
getAgentDefaults,
initializeAgentDefaults,
} from '../agent-defaults.js';
+import { formatAgentletDeviceDisplayName } from '../agentlet-device-display.js';
import type { AgentletConnection } from '@agenetes/agentlet-host';
import type {
@@ -205,6 +206,9 @@ const acpProfilesRoutes: FastifyPluginAsync = async (app) => {
const connectedDevices = connected
.map((connection) => ({
agentletId: connection.agentletId,
+ displayName: formatAgentletDeviceDisplayName(
+ connection.agentletProfile?.machine,
+ ),
...(connection.agentletProfile?.machine?.hostname
? { hostname: connection.agentletProfile.machine.hostname }
: {}),
diff --git a/apps/server/src/modules/agent/agentlet-device-display.test.ts b/apps/server/src/modules/agent/agentlet-device-display.test.ts
new file mode 100644
index 000000000..a1ed05980
--- /dev/null
+++ b/apps/server/src/modules/agent/agentlet-device-display.test.ts
@@ -0,0 +1,28 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+import { describe, expect, it } from 'vitest';
+
+import { formatAgentletDeviceDisplayName } from './agentlet-device-display.js';
+
+describe('formatAgentletDeviceDisplayName', () => {
+ it('combines hostname and environment without exposing device identity', () => {
+ expect(
+ formatAgentletDeviceDisplayName({
+ hostname: 'huabu',
+ platform: 'linux',
+ arch: 'x64',
+ }),
+ ).toBe('huabu: linux x64');
+ });
+
+ it('omits missing metadata and retains a non-identity fallback', () => {
+ expect(formatAgentletDeviceDisplayName({ hostname: 'huabu' })).toBe(
+ 'huabu',
+ );
+ expect(
+ formatAgentletDeviceDisplayName({ platform: 'linux', arch: 'arm64' }),
+ ).toBe('Agentlet: linux arm64');
+ expect(formatAgentletDeviceDisplayName()).toBe('Agentlet');
+ });
+});
diff --git a/apps/server/src/modules/agent/agentlet-device-display.ts b/apps/server/src/modules/agent/agentlet-device-display.ts
new file mode 100644
index 000000000..e67aa270a
--- /dev/null
+++ b/apps/server/src/modules/agent/agentlet-device-display.ts
@@ -0,0 +1,19 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+interface AgentletMachineDisplayMetadata {
+ hostname?: string;
+ platform?: string;
+ arch?: string;
+}
+
+export function formatAgentletDeviceDisplayName(
+ machine?: AgentletMachineDisplayMetadata,
+): string {
+ const hostname = machine?.hostname?.trim() || 'Agentlet';
+ const environment = [machine?.platform, machine?.arch]
+ .map((value) => value?.trim())
+ .filter(Boolean)
+ .join(' ');
+ return environment ? `${hostname}: ${environment}` : hostname;
+}
diff --git a/apps/web/src/components/Settings/agent-profiles/AgentProfileEditor.test.tsx b/apps/web/src/components/Settings/agent-profiles/AgentProfileEditor.test.tsx
index fc388e428..9df45744c 100644
--- a/apps/web/src/components/Settings/agent-profiles/AgentProfileEditor.test.tsx
+++ b/apps/web/src/components/Settings/agent-profiles/AgentProfileEditor.test.tsx
@@ -13,6 +13,7 @@ import type {
AcpAgentCliInfo,
AcpProfileLaunchPreviewResponse,
AgentProfileView,
+ ConnectedAgentletDevice,
} from '@huabu/shared';
declare global {
@@ -65,7 +66,12 @@ vi.mock('@/components/Common/Select', () => ({
ariaLabel,
}: {
value: string;
- options: { value: string; label: string; disabled?: boolean }[];
+ options: {
+ value: string;
+ label: string;
+ description?: string;
+ disabled?: boolean;
+ }[];
onChange: (value: string) => void;
ariaLabel?: string;
}) => (
@@ -82,6 +88,7 @@ vi.mock('@/components/Common/Select', () => ({
disabled={option.disabled}
>
{option.label}
+ {option.description ? ` (${option.description})` : ''}
))}
@@ -148,6 +155,18 @@ function renderEditor(
editing?: AgentProfileView,
clis = agents,
loaded = true,
+ connectedDevices: ConnectedAgentletDevice[] = [
+ {
+ agentletId: editing?.agentletId ?? 'device-1',
+ displayName: 'Test device: linux x64',
+ hostname: 'Test device',
+ platform: 'linux',
+ arch: 'x64',
+ version: '1.0.0',
+ connectedAt: '2026-01-01T00:00:00.000Z',
+ profileCount: 0,
+ },
+ ],
) {
if (!container) {
container = document.createElement('div');
@@ -162,17 +181,7 @@ function renderEditor(
: ({ mode: 'create' } as const))}
detectedClis={clis}
detectionLoaded={loaded}
- connectedDevices={[
- {
- agentletId: editing?.agentletId ?? 'device-1',
- hostname: 'Test device',
- platform: 'linux',
- arch: 'x64',
- version: '1.0.0',
- connectedAt: '2026-01-01T00:00:00.000Z',
- profileCount: 0,
- },
- ]}
+ connectedDevices={connectedDevices}
agentletId={editing?.agentletId ?? 'device-1'}
onAgentletChange={vi.fn()}
onClose={onClose}
@@ -256,9 +265,40 @@ describe('AgentProfileEditor', () => {
alias: 'Renamed',
customData: legacy.customData,
});
+
expect(api.preview).not.toHaveBeenCalled();
});
+ it('renders human-readable device labels while retaining UUID identity details', () => {
+ renderEditor();
+ const machine = container?.querySelector(
+ 'select[aria-label="settings.profileMachine"]',
+ );
+ expect(machine?.selectedOptions[0]?.textContent).toContain(
+ 'Test device: linux x64',
+ );
+ expect(machine?.selectedOptions[0]?.textContent).toContain('device-1');
+ });
+
+ it('maps a hostname-era Profile to the unique connected device label', () => {
+ renderEditor({ ...legacy, agentletId: 'legacy-host' }, agents, true, [
+ {
+ agentletId: 'device-uuid',
+ displayName: 'legacy-host: linux x64',
+ hostname: 'legacy-host',
+ platform: 'linux',
+ arch: 'x64',
+ version: '1.0.0',
+ connectedAt: '2026-01-01T00:00:00.000Z',
+ profileCount: 1,
+ },
+ ]);
+
+ expect(container?.textContent).toContain(
+ 'legacy-host: linux x64 (device-uuid)',
+ );
+ });
+
it('edits custom command and cwd without changing wrapper, machine, metadata, or custom data', async () => {
renderEditor({ ...legacy, revision: 9 });
input('settings.launchCommand', 'new-agent --custom');
diff --git a/apps/web/src/components/Settings/agent-profiles/CommandProfileForm.tsx b/apps/web/src/components/Settings/agent-profiles/CommandProfileForm.tsx
index 752e2d8f2..107be07be 100644
--- a/apps/web/src/components/Settings/agent-profiles/CommandProfileForm.tsx
+++ b/apps/web/src/components/Settings/agent-profiles/CommandProfileForm.tsx
@@ -75,6 +75,18 @@ function displayNameFor(agent: string, cwd: string) {
return folder ? `${agent} (${folder})` : agent;
}
+function resolveConnectedDevice(
+ target: string,
+ devices: ConnectedAgentletDevice[],
+): ConnectedAgentletDevice | undefined {
+ const exact = devices.find((device) => device.agentletId === target);
+ if (exact) return exact;
+ const hostnameMatches = devices.filter(
+ (device) => device.hostname === target,
+ );
+ return hostnameMatches.length === 1 ? hostnameMatches[0] : undefined;
+}
+
/** One capability-driven form for both structured and raw-command Profiles. */
export function CommandProfileForm({
editing,
@@ -200,6 +212,12 @@ export function CommandProfileForm({
})),
{ value: 'custom', label: t('settings.customCommand') },
];
+ const editingDevice = editing
+ ? resolveConnectedDevice(editing.agentletId, connectedDevices)
+ : undefined;
+ const editingDeviceLabel = editingDevice
+ ? `${editingDevice.displayName} (${editingDevice.agentletId})`
+ : `${t('settings.agentUnavailable')} (${editing?.agentletId ?? ''})`;
async function save() {
if (saveDisabled) return;
@@ -248,14 +266,14 @@ export function CommandProfileForm({
{editing ? (
-
+
) : (
({
value: device.agentletId,
- label: device.hostname ?? device.agentletId,
+ label: device.displayName,
description: device.agentletId,
}))}
placeholder={t('settings.noConnectedDevices')}
diff --git a/apps/web/src/components/Settings/agent-profiles/ExternalAgentsSettings.test.tsx b/apps/web/src/components/Settings/agent-profiles/ExternalAgentsSettings.test.tsx
index 4a63c9ef1..de4cc8b51 100644
--- a/apps/web/src/components/Settings/agent-profiles/ExternalAgentsSettings.test.tsx
+++ b/apps/web/src/components/Settings/agent-profiles/ExternalAgentsSettings.test.tsx
@@ -144,6 +144,7 @@ beforeEach(() => {
connectedDevices: [
{
agentletId: 'local',
+ displayName: 'Local machine: linux x64',
hostname: 'Local machine',
platform: 'linux',
arch: 'x64',
@@ -189,6 +190,7 @@ describe('ExternalAgentsSettings', () => {
it('refreshes the singleton on every mount and lists all Profiles, including unavailable ones', async () => {
await renderSettings();
expect(container?.textContent).toContain('Reviewer');
+ expect(container?.textContent).toContain('Local machine: linux x64');
expect(useAcpProfilesStore.getState().profiles).toEqual([profile]);
profiles = [
profile,
diff --git a/apps/web/src/components/Settings/agent-profiles/ExternalAgentsSettings.tsx b/apps/web/src/components/Settings/agent-profiles/ExternalAgentsSettings.tsx
index 3ff6d1f34..b3a1baa78 100644
--- a/apps/web/src/components/Settings/agent-profiles/ExternalAgentsSettings.tsx
+++ b/apps/web/src/components/Settings/agent-profiles/ExternalAgentsSettings.tsx
@@ -332,9 +332,8 @@ export function ExternalAgentsSettings({
connectedDevices.map((device) => (
` instead of evicting the first device; once the prior connection is disconnected, the same identity follows the normal reconnect path. The embedded supervisor is special only as process-lifecycle infrastructure: it resolves, starts, restarts, stops, and diagnoses its child, but it neither assigns that Agentlet's identity nor acts as a placement default.
+Device-facing UI uses one human-readable `hostname: platform arch` display name, such as `huabu: linux x64`. UUID-backed `agentletId` remains a secondary identity detail for disambiguation and routing; it is not part of automatic Profile aliases or the primary device label. New automatic aliases include the device display name, while existing and user-customized aliases are never rewritten.
+
## Profile identity and customization
A Profile has `id`, `alias`, `agentletId`, `workingDirPath`, a launch configuration, optional `metadata.cliId`, opaque `customData`, and configuration/execution revisions. Launch is either `{ kind: 'acp-command', command }` or a structured `{ kind: 'acp-harness', harnessId, options?: { autoApprove? } }`. Every Profile has exactly one wrapper, derived from this launch union: a command Profile uses the Custom command wrapper; a structured Profile uses its `harnessId`. Neither editable metadata nor command-text inspection determines capabilities. Profile ID, target machine, launch kind, and harness identity are immutable; alias, icon, cwd, and supported launch options are editable. Changing machine or wrapper requires a new Profile.
diff --git a/packages/shared/src/types/api/acp.ts b/packages/shared/src/types/api/acp.ts
index 46b6bf1a0..10befbe49 100644
--- a/packages/shared/src/types/api/acp.ts
+++ b/packages/shared/src/types/api/acp.ts
@@ -588,6 +588,7 @@ export type AgentletConnectionCommandResponse = z.infer<
/** Schema mirror of {@link AcpProfilesListResponse}. */
export const connectedAgentletDeviceSchema = z.object({
agentletId: z.string().min(1),
+ displayName: z.string().min(1),
hostname: z.string().min(1).optional(),
platform: z.string().min(1).optional(),
arch: z.string().min(1).optional(),
From 00b6aae871fe4336c9f8a244900ac58caaf83550 Mon Sep 17 00:00:00 2001
From: Yuqing Yang
Date: Sun, 4 Oct 2026 04:28:28 +0000
Subject: [PATCH 24/30] fix(agent): expire stale Agentlet connections
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../ExternalAgentsSettings.test.tsx | 28 ++++++++++++
.../agent-profiles/ExternalAgentsSettings.tsx | 11 +++++
apps/web/src/store/acpProfilesStore.ts | 19 +++++---
docs/architecture/agent-profiles.md | 2 +
.../agentlet-gateway/src/gateway.test.ts | 45 +++++++++++++++++++
.../packages/agentlet-gateway/src/gateway.ts | 27 +++++++++++
.../packages/agentlet-gateway/src/types.ts | 1 +
7 files changed, 126 insertions(+), 7 deletions(-)
diff --git a/apps/web/src/components/Settings/agent-profiles/ExternalAgentsSettings.test.tsx b/apps/web/src/components/Settings/agent-profiles/ExternalAgentsSettings.test.tsx
index de4cc8b51..24b9d1811 100644
--- a/apps/web/src/components/Settings/agent-profiles/ExternalAgentsSettings.test.tsx
+++ b/apps/web/src/components/Settings/agent-profiles/ExternalAgentsSettings.test.tsx
@@ -178,6 +178,7 @@ beforeEach(() => {
});
afterEach(() => {
+ vi.useRealTimers();
act(() => root?.unmount());
container?.remove();
root = undefined;
@@ -187,6 +188,33 @@ afterEach(() => {
});
describe('ExternalAgentsSettings', () => {
+ it('refreshes Agent settings every five seconds only while visible and mounted', async () => {
+ vi.useFakeTimers();
+ let visibility: DocumentVisibilityState = 'visible';
+ vi.spyOn(document, 'visibilityState', 'get').mockImplementation(
+ () => visibility,
+ );
+ await renderSettings();
+ expect(apiMocks.list).toHaveBeenCalledOnce();
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(5_000);
+ });
+ expect(apiMocks.list).toHaveBeenCalledTimes(2);
+
+ visibility = 'hidden';
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(5_000);
+ });
+ expect(apiMocks.list).toHaveBeenCalledTimes(2);
+
+ act(() => root?.unmount());
+ root = undefined;
+ visibility = 'visible';
+ await vi.advanceTimersByTimeAsync(5_000);
+ expect(apiMocks.list).toHaveBeenCalledTimes(2);
+ });
+
it('refreshes the singleton on every mount and lists all Profiles, including unavailable ones', async () => {
await renderSettings();
expect(container?.textContent).toContain('Reviewer');
diff --git a/apps/web/src/components/Settings/agent-profiles/ExternalAgentsSettings.tsx b/apps/web/src/components/Settings/agent-profiles/ExternalAgentsSettings.tsx
index b3a1baa78..94504ca01 100644
--- a/apps/web/src/components/Settings/agent-profiles/ExternalAgentsSettings.tsx
+++ b/apps/web/src/components/Settings/agent-profiles/ExternalAgentsSettings.tsx
@@ -33,6 +33,8 @@ type EditorState =
| { kind: 'create'; agentletId: string }
| { kind: 'edit-command'; profile: AgentProfileView };
+const PROFILE_REFRESH_INTERVAL_MS = 5_000;
+
interface PendingDelete {
id: string;
alias: string;
@@ -82,6 +84,15 @@ export function ExternalAgentsSettings({
void refresh();
}, [init, refresh]);
+ useEffect(() => {
+ const timer = window.setInterval(() => {
+ if (document.visibilityState === 'visible') {
+ void refresh({ background: true });
+ }
+ }, PROFILE_REFRESH_INTERVAL_MS);
+ return () => window.clearInterval(timer);
+ }, [refresh]);
+
useEffect(() => {
if (error) toast(error.message, { tone: 'danger' });
}, [error]);
diff --git a/apps/web/src/store/acpProfilesStore.ts b/apps/web/src/store/acpProfilesStore.ts
index 694e16c27..06f12bbf6 100644
--- a/apps/web/src/store/acpProfilesStore.ts
+++ b/apps/web/src/store/acpProfilesStore.ts
@@ -118,7 +118,7 @@ interface AcpProfilesState {
/** Idempotent first-load helper called by the hook on mount. */
init: () => Promise;
/** Force a fresh GET. Safe to call concurrently. */
- refresh: () => Promise;
+ refresh: (options?: { background?: boolean }) => Promise;
loadDefaults: () => Promise;
saveDefaults: (config: AgentDefaults) => Promise;
rememberConversationAgent: (profileId: string) => void;
@@ -225,12 +225,13 @@ export const useAcpProfilesStore = create()((set, get) => ({
writeRecentConversationProfileId(profileId);
set({ recentConversationProfileId: profileId });
},
- refresh: async () => {
+ refresh: async (options) => {
await defaultsSaveQueue;
if (inFlightRefresh) return inFlightRefresh;
+ const background = options?.background === true;
const revision = defaultsRevision;
const request = (async () => {
- set({ loading: true });
+ if (!background) set({ loading: true });
try {
const res = await listAcpProfiles();
set({
@@ -248,10 +249,14 @@ export const useAcpProfilesStore = create()((set, get) => ({
} catch (err) {
// Leave the previous snapshot in place so transient errors
// don't make the picker flicker between "available" and empty.
- set({
- error: err instanceof Error ? err : new Error(String(err)),
- loading: false,
- });
+ set(
+ background
+ ? { loading: false }
+ : {
+ error: err instanceof Error ? err : new Error(String(err)),
+ loading: false,
+ },
+ );
}
})();
inFlightRefresh = request;
diff --git a/docs/architecture/agent-profiles.md b/docs/architecture/agent-profiles.md
index a7b30ce35..48f379639 100644
--- a/docs/architecture/agent-profiles.md
+++ b/docs/architecture/agent-profiles.md
@@ -31,6 +31,8 @@ Multiple Agentlets may share one Huabu connection token and connect concurrently
Device-facing UI uses one human-readable `hostname: platform arch` display name, such as `huabu: linux x64`. UUID-backed `agentletId` remains a secondary identity detail for disambiguation and routing; it is not part of automatic Profile aliases or the primary device label. New automatic aliases include the device display name, while existing and user-customized aliases are never rewritten.
+Gateway sends a WebSocket heartbeat every 15 seconds and terminates connections that do not answer before the next heartbeat. While Agent Settings is mounted and the browser page is visible, its Profile and connected-device snapshot refreshes every 5 seconds; this refresh is scoped to Agent settings and does not reload Space or Workspace data. Disconnected connection records remain available for reconnect but are excluded from connected-device and selectable-Profile projections.
+
## Profile identity and customization
A Profile has `id`, `alias`, `agentletId`, `workingDirPath`, a launch configuration, optional `metadata.cliId`, opaque `customData`, and configuration/execution revisions. Launch is either `{ kind: 'acp-command', command }` or a structured `{ kind: 'acp-harness', harnessId, options?: { autoApprove? } }`. Every Profile has exactly one wrapper, derived from this launch union: a command Profile uses the Custom command wrapper; a structured Profile uses its `harnessId`. Neither editable metadata nor command-text inspection determines capabilities. Profile ID, target machine, launch kind, and harness identity are immutable; alias, icon, cwd, and supported launch options are editable. Changing machine or wrapper requires a new Profile.
diff --git a/external/agenetes/packages/agentlet-gateway/src/gateway.test.ts b/external/agenetes/packages/agentlet-gateway/src/gateway.test.ts
index ac4ec9847..bd4bb90bd 100644
--- a/external/agenetes/packages/agentlet-gateway/src/gateway.test.ts
+++ b/external/agenetes/packages/agentlet-gateway/src/gateway.test.ts
@@ -739,6 +739,44 @@ describe('AgentletGateway', () => {
expect(changed).toHaveBeenCalledTimes(4);
});
+ it('disconnects an Agentlet that stops answering heartbeats', async () => {
+ const { gateway, url } = await startHarness({ heartbeatInterval: 20 });
+ const changed = vi.fn();
+ gateway.onAgentletsChanged(changed);
+ const client = await connect(url, {
+ role: 'agentlet',
+ queryId: 'machine-a',
+ token: 'token-a',
+ hello: agentletHello('machine-a'),
+ });
+
+ client.socket.pause();
+ await waitUntil(
+ () => gateway.getAgentlet('machine-a')?.status === 'disconnected',
+ );
+ client.socket.resume();
+
+ expect(changed).toHaveBeenLastCalledWith({
+ agentletId: 'machine-a',
+ status: 'disconnected',
+ });
+ expect(gateway.getAgentlets({ status: 'connected' })).toEqual([]);
+ });
+
+ it('keeps an Agentlet connected while it answers heartbeats', async () => {
+ const { gateway, url } = await startHarness({ heartbeatInterval: 20 });
+ await connect(url, {
+ role: 'agentlet',
+ queryId: 'machine-a',
+ token: 'token-a',
+ hello: agentletHello('machine-a'),
+ });
+
+ await new Promise((resolve) => setTimeout(resolve, 80));
+
+ expect(gateway.getAgentlet('machine-a')?.status).toBe('connected');
+ });
+
it('fails discovery for disconnected and unsupported targets without fallback', async () => {
const { gateway, url } = await startHarness();
await expect(
@@ -976,5 +1014,12 @@ describe('AgentletGateway', () => {
inboundPreAttachBufferLimit: 0,
}),
).toThrow('inboundPreAttachBufferLimit must be a positive integer');
+ expect(
+ () =>
+ new AgentletGateway({
+ authenticateAgentlet: () => ({}),
+ heartbeatInterval: 0,
+ }),
+ ).toThrow('heartbeatInterval must be a positive integer');
});
});
diff --git a/external/agenetes/packages/agentlet-gateway/src/gateway.ts b/external/agenetes/packages/agentlet-gateway/src/gateway.ts
index 181d5f6d3..7df5ef10a 100644
--- a/external/agenetes/packages/agentlet-gateway/src/gateway.ts
+++ b/external/agenetes/packages/agentlet-gateway/src/gateway.ts
@@ -42,6 +42,7 @@ import type { Duplex } from 'node:stream';
const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;
const DEFAULT_CONTROL_REQUEST_TIMEOUT_MS = 60_000;
const DEFAULT_SPAWN_REQUEST_TIMEOUT_MS = 240_000;
+const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000;
const DEFAULT_OUTBOUND_BUFFER_LIMIT = 100;
const DEFAULT_INBOUND_PRE_ATTACH_BUFFER_LIMIT = 1_000;
@@ -82,6 +83,9 @@ export class AgentletGateway {
private readonly handshakeTimeout: number;
private readonly controlRequestTimeout: number;
private readonly spawnRequestTimeout: number;
+ private readonly heartbeatInterval: number;
+ private readonly heartbeatTimer: NodeJS.Timeout;
+ private readonly heartbeatAlive = new WeakMap();
private readonly outboundBufferLimit: number;
private readonly inboundPreAttachBufferLimit: number;
private readonly logger: AgentletGatewayLogger;
@@ -96,17 +100,25 @@ export class AgentletGateway {
options.controlRequestTimeout ?? DEFAULT_CONTROL_REQUEST_TIMEOUT_MS;
this.spawnRequestTimeout =
options.spawnRequestTimeout ?? DEFAULT_SPAWN_REQUEST_TIMEOUT_MS;
+ this.heartbeatInterval =
+ options.heartbeatInterval ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
this.outboundBufferLimit =
options.outboundBufferLimit ?? DEFAULT_OUTBOUND_BUFFER_LIMIT;
this.inboundPreAttachBufferLimit =
options.inboundPreAttachBufferLimit ??
DEFAULT_INBOUND_PRE_ATTACH_BUFFER_LIMIT;
this.logger = options.logger ?? noopLogger;
+ this.assertPositiveInteger(this.heartbeatInterval, 'heartbeatInterval');
this.assertPositiveInteger(this.outboundBufferLimit, 'outboundBufferLimit');
this.assertPositiveInteger(
this.inboundPreAttachBufferLimit,
'inboundPreAttachBufferLimit',
);
+ this.heartbeatTimer = setInterval(
+ () => this.checkConnectionHeartbeats(),
+ this.heartbeatInterval,
+ );
+ this.heartbeatTimer.unref();
}
get connectionCount(): number {
@@ -282,6 +294,7 @@ export class AgentletGateway {
close(): void {
if (this.closed) return;
this.closed = true;
+ clearInterval(this.heartbeatTimer);
for (const connection of this.allConnections()) {
connection.disconnect('server_shutting_down');
}
@@ -305,6 +318,8 @@ export class AgentletGateway {
private onWebSocket(ws: WebSocket, request: IncomingMessage): void {
let handshakeComplete = false;
+ this.heartbeatAlive.set(ws, true);
+ ws.on('pong', () => this.heartbeatAlive.set(ws, true));
const url = new URL(request.url ?? '', 'http://localhost');
const token = url.searchParams.get('token') ?? '';
const queryRole = url.searchParams.get('role');
@@ -396,6 +411,18 @@ export class AgentletGateway {
});
}
+ private checkConnectionHeartbeats(): void {
+ for (const ws of this.wss.clients) {
+ if (ws.readyState !== WebSocket.OPEN) continue;
+ if (this.heartbeatAlive.get(ws) === false) {
+ ws.terminate();
+ continue;
+ }
+ this.heartbeatAlive.set(ws, false);
+ ws.ping();
+ }
+ }
+
private async handleAgentletHello(
ws: WebSocket,
token: string,
diff --git a/external/agenetes/packages/agentlet-gateway/src/types.ts b/external/agenetes/packages/agentlet-gateway/src/types.ts
index 3634550fc..02d725d00 100644
--- a/external/agenetes/packages/agentlet-gateway/src/types.ts
+++ b/external/agenetes/packages/agentlet-gateway/src/types.ts
@@ -28,6 +28,7 @@ export interface AgentletGatewayOptions {
handshakeTimeout?: number;
controlRequestTimeout?: number;
spawnRequestTimeout?: number;
+ heartbeatInterval?: number;
outboundBufferLimit?: number;
inboundPreAttachBufferLimit?: number;
logger?: AgentletGatewayLogger;
From abc04946718d8e52297df0e401c0454cc1cc2859 Mon Sep 17 00:00:00 2001
From: Yuqing Yang
Date: Sun, 4 Oct 2026 15:42:38 +0000
Subject: [PATCH 25/30] style(agent): format Agentlet resolver
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../packages/agentlet-host/src/agentlet-resolution.ts | 7 ++-----
1 file changed, 2 insertions(+), 5 deletions(-)
diff --git a/external/agenetes/packages/agentlet-host/src/agentlet-resolution.ts b/external/agenetes/packages/agentlet-host/src/agentlet-resolution.ts
index 3c3af80b7..4c04041cc 100644
--- a/external/agenetes/packages/agentlet-host/src/agentlet-resolution.ts
+++ b/external/agenetes/packages/agentlet-host/src/agentlet-resolution.ts
@@ -20,17 +20,14 @@ export function resolveConnectedAgentletIdFromConnections(
if (exact) return exact.agentletId;
const hostnameMatches = connected.filter(
- (connection) =>
- connection.agentletProfile?.machine?.hostname === target,
+ (connection) => connection.agentletProfile?.machine?.hostname === target,
);
return hostnameMatches.length === 1
? hostnameMatches[0]?.agentletId
: undefined;
}
-export function resolveConnectedAgentletId(
- target: string,
-): string | undefined {
+export function resolveConnectedAgentletId(target: string): string | undefined {
return resolveConnectedAgentletIdFromConnections(
target,
getAgentletGateway()?.getAgentlets({ status: 'connected' }) ?? [],
From fd44f48a3de686d343596e45ce45f570134c15af Mon Sep 17 00:00:00 2001
From: Yuqing Yang
Date: Sun, 4 Oct 2026 16:26:58 +0000
Subject: [PATCH 26/30] Default Move shortcut option off
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Panels/Canvas/MoveSelectionPanel.tsx | 2 +-
.../Canvas/MoveSelectionPopover.test.tsx | 22 ++++++++++++-------
docs/architecture/space-preview.md | 2 +-
3 files changed, 16 insertions(+), 10 deletions(-)
diff --git a/apps/web/src/components/Panels/Canvas/MoveSelectionPanel.tsx b/apps/web/src/components/Panels/Canvas/MoveSelectionPanel.tsx
index 5606e68fd..484a20d72 100644
--- a/apps/web/src/components/Panels/Canvas/MoveSelectionPanel.tsx
+++ b/apps/web/src/components/Panels/Canvas/MoveSelectionPanel.tsx
@@ -52,7 +52,7 @@ export function MoveSelectionPanel({
const nameRef = useRef(null);
const [selectedDestination, setSelectedDestination] = useState('');
const [newSpaceTitle, setNewSpaceTitle] = useState('');
- const [createSourcePreview, setCreateSourcePreview] = useState(true);
+ const [createSourcePreview, setCreateSourcePreview] = useState(false);
const creatingNewSpace = selectedDestination === NEW_SPACE_DESTINATION;
const destinationCanvasId = options.some(
(option) => option.value === selectedDestination,
diff --git a/apps/web/src/components/Panels/Canvas/MoveSelectionPopover.test.tsx b/apps/web/src/components/Panels/Canvas/MoveSelectionPopover.test.tsx
index 688f7317c..4d9801cd4 100644
--- a/apps/web/src/components/Panels/Canvas/MoveSelectionPopover.test.tsx
+++ b/apps/web/src/components/Panels/Canvas/MoveSelectionPopover.test.tsx
@@ -287,8 +287,9 @@ describe('MoveSelectionPopover', () => {
const trigger = await openPanel();
const checkbox =
document.querySelector('[type="checkbox"]');
- act(() => checkbox?.click());
expect(checkbox?.checked).toBe(false);
+ act(() => checkbox?.click());
+ expect(checkbox?.checked).toBe(true);
act(() =>
document.body.dispatchEvent(
new PointerEvent('pointerdown', { bubbles: true }),
@@ -300,7 +301,7 @@ describe('MoveSelectionPopover', () => {
);
expect(
document.querySelector('[type="checkbox"]')?.checked,
- ).toBe(true);
+ ).toBe(false);
});
it('locks submission and keeps the captured selection while the move is pending', async () => {
@@ -331,7 +332,7 @@ describe('MoveSelectionPopover', () => {
'source',
expect.objectContaining({
selectedNodeIds: ['node-selected'],
- createSourcePreview: true,
+ createSourcePreview: false,
}),
);
await act(async () => finish?.(moveResult));
@@ -423,7 +424,7 @@ describe('MoveSelectionPopover', () => {
);
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
expect(document.querySelector('[type="checkbox"]')).toBe(checkbox);
- expect(checkbox?.checked).toBe(false);
+ expect(checkbox?.checked).toBe(true);
});
it.each(Object.entries(knownErrors))(
@@ -661,7 +662,7 @@ describe('MoveSelectionPopover', () => {
expect(moveCanvasSelection).toHaveBeenCalledWith('source', {
selectedNodeIds: ['node-selected'],
destination: { kind: 'new', title: 'New destination' },
- createSourcePreview: true,
+ createSourcePreview: false,
expectedSourceVersion: 3,
});
expect(useWorkspaceStore.getState().spaceTitles).toEqual(
@@ -708,6 +709,11 @@ describe('MoveSelectionPopover', () => {
document.querySelectorAll('[role="option"]'),
).find((button) => button.textContent === 'Destination');
act(() => destination?.click());
+ act(() =>
+ document
+ .querySelector('input[type="checkbox"]')
+ ?.click(),
+ );
const move = Array.from(document.querySelectorAll('button')).find(
(button) => button.textContent === 'moveSelection.confirm',
);
@@ -721,7 +727,7 @@ describe('MoveSelectionPopover', () => {
});
});
- it('defaults the source Preview checkbox on and allows disabling it', async () => {
+ it('defaults the source Preview checkbox off and allows enabling it', async () => {
listCanvases.mockResolvedValue({
canvases: [{ canvasId: 'destination', title: 'Destination' }],
});
@@ -747,8 +753,8 @@ describe('MoveSelectionPopover', () => {
const checkbox = document.querySelector(
'input[type="checkbox"]',
);
- expect(checkbox?.checked).toBe(true);
- act(() => checkbox?.click());
expect(checkbox?.checked).toBe(false);
+ act(() => checkbox?.click());
+ expect(checkbox?.checked).toBe(true);
});
});
diff --git a/docs/architecture/space-preview.md b/docs/architecture/space-preview.md
index ff03fb6e3..9359c812d 100644
--- a/docs/architecture/space-preview.md
+++ b/docs/architecture/space-preview.md
@@ -59,7 +59,7 @@ Gesture-driven zoom-through remains deferred; viewport zoom, responsive layout,
Ordinary Spaces expose Add Space Shortcut from the Canvas toolbar's Add Content dropdown. World omits this action because its shortcut membership is server-managed.
-Moving content between Spaces can also create an ordinary source-owned `spacePreview` breadcrumb when the default-enabled Move option remains selected. It occupies the moved set's former absolute top-left and uses compact automatic sizing, independent of the moved content's footprint. It is created in the same source executor batch that deletes the moved roots, and a later move to the same target creates another breadcrumb rather than reusing one at a different historical location. Disabling the option leaves no breadcrumb and does not change boundary-edge removal or compensation.
+Moving content between Spaces can also create an ordinary source-owned `spacePreview` breadcrumb when the user explicitly enables the default-off Move option. The choice resets to off each time the Move panel opens. When enabled, the shortcut occupies the moved set's former absolute top-left and uses compact automatic sizing, independent of the moved content's footprint. It is created in the same source executor batch that deletes the moved roots, and a later move to the same target creates another breadcrumb rather than reusing one at a different historical location. Leaving the option disabled creates no breadcrumb and does not change boundary-edge removal or compensation.
## Development design comparison
From 864de33649f865d5129a2a1e454a843803cc5fe0 Mon Sep 17 00:00:00 2001
From: Yuqing Yang
Date: Sun, 4 Oct 2026 16:26:58 +0000
Subject: [PATCH 27/30] Make Canary redeploy branch configurable
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.env.example | 9 +-
README.md | 2 +-
.../security/canary-redeploy.route.test.ts | 37 +-
.../modules/security/canary-redeploy.route.ts | 120 ++++--
.../modules/security/canary-redeploy.test.ts | 111 ++++-
.../src/modules/security/canary-redeploy.ts | 386 ++++++++++++++----
apps/web/src/api/_routes.ts | 1 +
apps/web/src/api/deployment.ts | 19 +-
.../Settings/CanaryRedeploySettings.test.tsx | 37 +-
.../Settings/CanaryRedeploySettings.tsx | 93 ++++-
apps/web/src/i18n/resources/en/common.json | 24 +-
apps/web/src/i18n/resources/zh-CN/common.json | 24 +-
docs/architecture/api-design.md | 4 +
docs/architecture/canary-deployment.md | 42 +-
docs/architecture/deployment-security.md | 2 +
.../shared/src/types/api/deployment.test.ts | 58 +++
packages/shared/src/types/api/deployment.ts | 25 +-
scripts/canary-redeploy-runner.mjs | 30 +-
scripts/start-huabu.sh | 18 +-
19 files changed, 854 insertions(+), 188 deletions(-)
create mode 100644 packages/shared/src/types/api/deployment.test.ts
diff --git a/.env.example b/.env.example
index 92d807d82..562580ab6 100644
--- a/.env.example
+++ b/.env.example
@@ -77,10 +77,11 @@
# ── 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.
+# Settings controls that persist an exact branch from fixed remote origin,
+# defaulting to alpha when unconfigured, and can run
+# `scripts/start-huabu.sh --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
# Non-interactive redeployment waits up to 300 seconds by default.
# HUABU_CANARY_READINESS_TIMEOUT_SECONDS=300
diff --git a/README.md b/README.md
index fedbb5232..18d54db9d 100644
--- a/README.md
+++ b/README.md
@@ -94,7 +94,7 @@ 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).
+For a personal Alpha Canary started from a repository checkout, set `HUABU_CANARY_REDEPLOY_ENABLED=1` before `pnpm start:web`. The authenticated owner can configure an exact branch from fixed remote `origin` in Settings, compare it with the running commit, and invoke the checked-in `scripts/start-huabu.sh --non-interactive` redeployment instead of connecting through SSH. An empty configuration uses `alpha`. 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)
diff --git a/apps/server/src/modules/security/canary-redeploy.route.test.ts b/apps/server/src/modules/security/canary-redeploy.route.test.ts
index 2f9d445ea..8910b6d7f 100644
--- a/apps/server/src/modules/security/canary-redeploy.route.test.ts
+++ b/apps/server/src/modules/security/canary-redeploy.route.test.ts
@@ -1,6 +1,10 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
import Fastify from 'fastify';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
@@ -10,10 +14,14 @@ import type { FastifyInstance } from 'fastify';
describe('Canary redeployment routes', () => {
let app: FastifyInstance;
+ let dataDir: string;
const originalEnabled = process.env.HUABU_CANARY_REDEPLOY_ENABLED;
+ const originalDataDir = process.env.HUABU_DATA_DIR;
beforeEach(async () => {
delete process.env.HUABU_CANARY_REDEPLOY_ENABLED;
+ dataDir = mkdtempSync(join(tmpdir(), 'huabu-canary-route-'));
+ process.env.HUABU_DATA_DIR = dataDir;
app = Fastify({ logger: false });
await app.register(canaryRedeployRoutes, {
prefix: '/api/deployment/canary',
@@ -27,6 +35,12 @@ describe('Canary redeployment routes', () => {
} else {
process.env.HUABU_CANARY_REDEPLOY_ENABLED = originalEnabled;
}
+ if (originalDataDir === undefined) {
+ delete process.env.HUABU_DATA_DIR;
+ } else {
+ process.env.HUABU_DATA_DIR = originalDataDir;
+ }
+ rmSync(dataDir, { recursive: true, force: true });
});
it('lets the local owner inspect a disabled capability', async () => {
@@ -39,6 +53,7 @@ describe('Canary redeployment routes', () => {
available: false,
reason: 'disabled',
branch: 'alpha',
+ configuredBranch: null,
});
});
@@ -63,7 +78,27 @@ describe('Canary redeployment routes', () => {
const unavailable = await app.inject({
method: 'POST',
url: '/api/deployment/canary/redeploy',
- payload: {},
+ payload: { expectedBranch: 'alpha' },
+ });
+ expect(unavailable.statusCode).toBe(503);
+ expect(unavailable.json()).toMatchObject({
+ code: 'canary_redeploy_unavailable',
+ });
+ });
+
+ it('validates branch configuration before capability checks', async () => {
+ const malformed = await app.inject({
+ method: 'PUT',
+ url: '/api/deployment/canary/config',
+ payload: { branch: '' },
+ });
+ expect(malformed.statusCode).toBe(400);
+ expect(malformed.json()).toMatchObject({ code: 'validation_failed' });
+
+ const unavailable = await app.inject({
+ method: 'PUT',
+ url: '/api/deployment/canary/config',
+ payload: { branch: 'x/alpha' },
});
expect(unavailable.statusCode).toBe(503);
expect(unavailable.json()).toMatchObject({
diff --git a/apps/server/src/modules/security/canary-redeploy.route.ts b/apps/server/src/modules/security/canary-redeploy.route.ts
index 4442cded5..f08214e2d 100644
--- a/apps/server/src/modules/security/canary-redeploy.route.ts
+++ b/apps/server/src/modules/security/canary-redeploy.route.ts
@@ -2,45 +2,109 @@
// Licensed under the MIT license.
import {
+ canaryCheckRequestSchema,
+ canaryRedeployConfigUpdateSchema,
canaryRedeployRequestSchema,
type ApiResult,
+ type CanaryCheckRequest,
+ type CanaryRedeployConfigUpdate,
type CanaryRedeployRequest,
type CanaryRedeployStatusResponse,
} from '@huabu/shared';
import {
+ CanaryRedeployError,
checkCanaryRemote,
getCanaryRedeployStatus,
requestCanaryRedeploy,
+ setCanaryRedeployConfig,
} from './canary-redeploy.js';
import { isOwnerRequest } from './owner.js';
-import type { FastifyPluginAsync } from 'fastify';
+import type { FastifyPluginAsync, FastifyReply } from 'fastify';
+
+function sendCanaryError(
+ reply: FastifyReply,
+ error: unknown,
+ fallback: string,
+) {
+ if (error instanceof CanaryRedeployError) {
+ const status =
+ error.code === 'operation_in_progress' || error.code === 'stale_branch'
+ ? 409
+ : error.code === 'branch_invalid'
+ ? 400
+ : error.code === 'branch_unavailable'
+ ? 422
+ : error.code === 'config_invalid'
+ ? 500
+ : 503;
+ return reply.status(status).send({
+ message: error.message,
+ code: `canary_${error.code}`,
+ });
+ }
+ return reply.status(500).send({
+ message: fallback,
+ code: 'canary_internal_error',
+ });
+}
const canaryRedeployRoutes: FastifyPluginAsync = async (app) => {
+ app.addHook('preHandler', async (request, reply) => {
+ if (!isOwnerRequest(request)) {
+ return reply.status(403).send({
+ message: 'Forbidden: Canary redeployment requires owner authorization',
+ });
+ }
+ });
+
app.get<{ Reply: ApiResult }>(
'/',
async (request, reply) => {
- if (!isOwnerRequest(request)) {
- return reply.status(403).send({
- message:
- 'Forbidden: Canary redeployment requires owner authorization',
- });
+ try {
+ return await getCanaryRedeployStatus();
+ } catch (error) {
+ request.log.error({ err: error }, 'Unable to read Canary status');
+ return sendCanaryError(
+ reply,
+ error,
+ 'Unable to load Canary redeployment status',
+ );
}
- return getCanaryRedeployStatus();
},
);
- app.post<{
- Body: CanaryRedeployRequest;
+ app.put<{
+ Body: CanaryRedeployConfigUpdate;
Reply: ApiResult;
- }>('/check', async (request, reply) => {
- if (!isOwnerRequest(request)) {
- return reply.status(403).send({
- message: 'Forbidden: Canary redeployment requires owner authorization',
+ }>('/config', async (request, reply) => {
+ const parsed = canaryRedeployConfigUpdateSchema.safeParse(request.body);
+ if (!parsed.success) {
+ return reply.status(400).send({
+ message:
+ parsed.error.issues[0]?.message ??
+ 'Invalid Canary branch configuration',
+ code: 'validation_failed',
});
}
- const parsed = canaryRedeployRequestSchema.safeParse(request.body);
+ try {
+ return await setCanaryRedeployConfig(parsed.data);
+ } catch (error) {
+ request.log.warn({ err: error }, 'Unable to save Canary branch');
+ return sendCanaryError(
+ reply,
+ error,
+ 'Unable to save Canary branch configuration',
+ );
+ }
+ });
+
+ app.post<{
+ Body: CanaryCheckRequest;
+ Reply: ApiResult;
+ }>('/check', async (request, reply) => {
+ const parsed = canaryCheckRequestSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
message:
@@ -52,10 +116,7 @@ const canaryRedeployRoutes: FastifyPluginAsync = async (app) => {
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',
- });
+ return sendCanaryError(reply, error, 'Unable to check Canary branch');
}
});
@@ -63,11 +124,6 @@ const canaryRedeployRoutes: FastifyPluginAsync = async (app) => {
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({
@@ -77,21 +133,15 @@ const canaryRedeployRoutes: FastifyPluginAsync = async (app) => {
});
}
try {
- const status = await requestCanaryRedeploy();
+ const status = await requestCanaryRedeploy(parsed.data.expectedBranch);
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',
- });
+ return sendCanaryError(
+ reply,
+ error,
+ 'Canary redeployment is unavailable',
+ );
}
});
};
diff --git a/apps/server/src/modules/security/canary-redeploy.test.ts b/apps/server/src/modules/security/canary-redeploy.test.ts
index e1ef186a5..009f43e84 100644
--- a/apps/server/src/modules/security/canary-redeploy.test.ts
+++ b/apps/server/src/modules/security/canary-redeploy.test.ts
@@ -18,8 +18,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
checkCanaryRemote,
getCanaryRedeployStatus,
+ requestCanaryRedeploy,
resetCanaryRedeployStateForTest,
resolveCanaryCapability,
+ setCanaryRedeployConfig,
writeCanaryRedeployResult,
} from './canary-redeploy.js';
@@ -104,15 +106,89 @@ describe('Canary redeployment service', () => {
expect(status).toMatchObject({
available: true,
branch: 'alpha',
+ configuredBranch: null,
updateAvailable: false,
runningSha: status.remoteSha,
});
expect(status.checkedAt).toEqual(expect.any(Number));
});
+ it('persists and checks one exact nested origin branch', async () => {
+ execFileSync('git', ['branch', 'x/alpha'], { cwd: root });
+ execFileSync('git', ['push', 'origin', 'x/alpha'], { cwd: root });
+
+ await expect(
+ setCanaryRedeployConfig({ branch: 'x/alpha' }),
+ ).resolves.toMatchObject({
+ branch: 'x/alpha',
+ configuredBranch: 'x/alpha',
+ updateAvailable: false,
+ });
+ await expect(checkCanaryRemote()).resolves.toMatchObject({
+ branch: 'x/alpha',
+ configuredBranch: 'x/alpha',
+ });
+
+ expect(
+ JSON.parse(
+ readFileSync(join(dataDir, 'canary-redeploy-config.json'), 'utf8'),
+ ),
+ ).toEqual({ version: 1, branch: 'x/alpha' });
+ });
+
+ it('uses alpha only for an explicit reset and rejects invalid or unavailable refs', async () => {
+ await expect(
+ setCanaryRedeployConfig({ branch: '-bad' }),
+ ).rejects.toMatchObject({ code: 'branch_invalid' });
+ await expect(
+ setCanaryRedeployConfig({ branch: 'HEAD' }),
+ ).rejects.toMatchObject({ code: 'branch_invalid' });
+ await expect(
+ setCanaryRedeployConfig({ branch: 'missing' }),
+ ).rejects.toMatchObject({ code: 'branch_unavailable' });
+ await expect(
+ setCanaryRedeployConfig({ branch: null }),
+ ).resolves.toMatchObject({
+ branch: 'alpha',
+ configuredBranch: null,
+ });
+ });
+
+ it('fails explicitly for malformed persisted configuration', async () => {
+ writeFileSync(
+ join(dataDir, 'canary-redeploy-config.json'),
+ '{"version":1,"branch":"bad..branch"}',
+ );
+ await expect(getCanaryRedeployStatus()).rejects.toMatchObject({
+ code: 'config_invalid',
+ });
+ });
+
+ it('rejects stale confirmations and configuration changes during a persistent redeploy', async () => {
+ await expect(requestCanaryRedeploy('x/alpha')).rejects.toMatchObject({
+ code: 'stale_branch',
+ });
+
+ writeFileSync(
+ join(dataDir, 'canary-redeploy-status.json'),
+ JSON.stringify({
+ state: 'running',
+ branch: 'alpha',
+ startedAt: 10,
+ runnerPid: process.pid,
+ }),
+ );
+ await expect(
+ setCanaryRedeployConfig({ branch: null }),
+ ).rejects.toMatchObject({
+ code: 'operation_in_progress',
+ });
+ });
+
it('persists only the bounded redeployment result contract', async () => {
await writeCanaryRedeployResult({
state: 'failed',
+ branch: 'x/alpha',
startedAt: 10,
completedAt: 20,
exitCode: 1,
@@ -122,6 +198,7 @@ describe('Canary redeployment service', () => {
await expect(getCanaryRedeployStatus()).resolves.toMatchObject({
redeploy: {
state: 'failed',
+ branch: 'x/alpha',
exitCode: 1,
},
});
@@ -143,7 +220,7 @@ describe('Canary redeployment service', () => {
);
const result = spawnSync(
process.execPath,
- [runner, hook, statusPath, logPath, '100'],
+ [runner, hook, statusPath, logPath, '100', 'x/alpha'],
{ encoding: 'utf8' },
);
@@ -151,10 +228,42 @@ describe('Canary redeployment service', () => {
const status = readFileSync(statusPath, 'utf8');
expect(JSON.parse(status)).toMatchObject({
state: 'failed',
+ branch: 'x/alpha',
startedAt: 100,
exitCode: 7,
});
expect(status).not.toContain('private-output');
+ expect(readFileSync(logPath, 'utf8')).toContain('Redeploying x/alpha');
expect(readFileSync(logPath, 'utf8')).toContain('private-output');
});
+
+ it('rejects malformed runner branch arguments before executing the hook', () => {
+ const marker = join(dataDir, 'unexpected-hook-run');
+ const hook = join(root, 'marker-hook.sh');
+ writeFileSync(hook, `#!/usr/bin/env bash\ntouch '${marker}'\n`);
+ chmodSync(hook, 0o755);
+ const runner = join(
+ process.cwd(),
+ '..',
+ '..',
+ 'scripts',
+ 'canary-redeploy-runner.mjs',
+ );
+
+ const result = spawnSync(
+ process.execPath,
+ [
+ runner,
+ hook,
+ join(dataDir, 'invalid-status.json'),
+ join(dataDir, 'invalid.log'),
+ '100',
+ '-bad',
+ ],
+ { encoding: 'utf8' },
+ );
+
+ expect(result.status).toBe(2);
+ expect(() => readFileSync(marker)).toThrow();
+ });
});
diff --git a/apps/server/src/modules/security/canary-redeploy.ts b/apps/server/src/modules/security/canary-redeploy.ts
index d66e47deb..df2fbbdfa 100644
--- a/apps/server/src/modules/security/canary-redeploy.ts
+++ b/apps/server/src/modules/security/canary-redeploy.ts
@@ -1,26 +1,43 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
-import { execFile, spawn } from 'node:child_process';
+import { execFile, execFileSync, 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 { z } from 'zod';
+
import {
+ canaryBranchSchema,
canaryRedeployResultSchema,
canaryRedeployStatusResponseSchema,
+ type CanaryBranch,
+ type CanaryRedeployConfigUpdate,
type CanaryRedeployResult,
type CanaryRedeployStatusResponse,
} from '@huabu/shared';
import { getDataDir } from '../../data-dir.js';
+import { atomicWriteJson, readJsonStrict } from '../../utils/fs.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;
+const DEFAULT_BRANCH = 'alpha';
+
+const configRecordSchema = z
+ .object({
+ version: z.literal(1),
+ branch: canaryBranchSchema.nullable(),
+ })
+ .strict();
+
+const legacyRedeployResultSchema = canaryRedeployResultSchema.omit({
+ branch: true,
+});
interface CanaryCapability {
available: boolean;
@@ -36,12 +53,32 @@ interface StoredRedeployResult {
runnerPid: number | null;
}
-let cachedRemote:
- | { remoteSha: string; checkedAt: number }
- | { remoteSha: null; checkedAt: number }
- | null = null;
+type CanaryOperation = 'check' | 'configure' | 'redeploy';
+
+let cachedRemote: {
+ branch: CanaryBranch;
+ remoteSha: string;
+ checkedAt: number;
+} | null = null;
+let activeOperation: CanaryOperation | null = null;
let redeployRequestInFlight = false;
+export class CanaryRedeployError extends Error {
+ constructor(
+ readonly code:
+ | 'branch_invalid'
+ | 'branch_unavailable'
+ | 'config_invalid'
+ | 'operation_in_progress'
+ | 'redeploy_unavailable'
+ | 'stale_branch',
+ message: string,
+ ) {
+ super(message);
+ this.name = 'CanaryRedeployError';
+ }
+}
+
function enabled(env: NodeJS.ProcessEnv): boolean {
return env.HUABU_CANARY_REDEPLOY_ENABLED === '1';
}
@@ -113,6 +150,10 @@ export function resolveCanaryCapability(
};
}
+export function canaryConfigPath(): string {
+ return join(getDataDir(), 'canary-redeploy-config.json');
+}
+
export function canaryStatusPath(): string {
return join(getDataDir(), 'canary-redeploy-status.json');
}
@@ -121,6 +162,43 @@ export function canaryLogPath(): string {
return join(getDataDir(), 'logs', 'canary-redeploy.log');
}
+function readCanaryConfig(): {
+ branch: CanaryBranch;
+ configuredBranch: CanaryBranch | null;
+} {
+ let stored: unknown;
+ try {
+ stored = readJsonStrict(canaryConfigPath());
+ } catch {
+ throw new CanaryRedeployError(
+ 'config_invalid',
+ 'Stored Canary branch configuration is unreadable',
+ );
+ }
+ if (stored === null) {
+ return { branch: DEFAULT_BRANCH, configuredBranch: null };
+ }
+ const parsed = configRecordSchema.safeParse(stored);
+ if (!parsed.success) {
+ throw new CanaryRedeployError(
+ 'config_invalid',
+ 'Stored Canary branch configuration is invalid',
+ );
+ }
+ try {
+ validateBranchFormat(parsed.data.branch ?? DEFAULT_BRANCH);
+ } catch {
+ throw new CanaryRedeployError(
+ 'config_invalid',
+ 'Stored Canary branch configuration is invalid',
+ );
+ }
+ return {
+ branch: parsed.data.branch ?? DEFAULT_BRANCH,
+ configuredBranch: parsed.data.branch,
+ };
+}
+
async function readRedeployResult(): Promise {
let parsed: unknown;
try {
@@ -130,9 +208,16 @@ async function readRedeployResult(): Promise {
if (code === 'ENOENT') return null;
throw error;
}
- const result = canaryRedeployResultSchema.safeParse(parsed);
- if (!result.success) {
- throw new Error('Canary redeployment status is invalid');
+ const current = canaryRedeployResultSchema.safeParse(parsed);
+ let result: CanaryRedeployResult;
+ if (current.success) {
+ result = current.data;
+ } else {
+ const legacy = legacyRedeployResultSchema.safeParse(parsed);
+ if (!legacy.success) {
+ throw new Error('Canary redeployment status is invalid');
+ }
+ result = { ...legacy.data, branch: DEFAULT_BRANCH };
}
const runnerPid =
parsed &&
@@ -142,7 +227,7 @@ async function readRedeployResult(): Promise {
Number(parsed.runnerPid) > 0
? Number(parsed.runnerPid)
: null;
- return { result: result.data, runnerPid };
+ return { result, runnerPid };
}
function processIsRunning(pid: number): boolean {
@@ -154,6 +239,108 @@ function processIsRunning(pid: number): boolean {
}
}
+async function redeployIsActive(): Promise {
+ const stored = await readRedeployResult();
+ if (
+ stored?.result.state === 'succeeded' ||
+ stored?.result.state === 'failed'
+ ) {
+ redeployRequestInFlight = false;
+ return false;
+ }
+ const persistentRunnerActive = Boolean(
+ stored?.result.state === 'running' &&
+ stored.runnerPid !== null &&
+ processIsRunning(stored.runnerPid),
+ );
+ return (
+ persistentRunnerActive ||
+ (redeployRequestInFlight &&
+ (stored?.result.state === 'requested' ||
+ stored?.result.state === 'running'))
+ );
+}
+
+async function beginOperation(operation: CanaryOperation): Promise {
+ if (activeOperation) {
+ throw new CanaryRedeployError(
+ 'operation_in_progress',
+ 'Another Canary operation is already in progress',
+ );
+ }
+ activeOperation = operation;
+ try {
+ if (await redeployIsActive()) {
+ throw new CanaryRedeployError(
+ 'operation_in_progress',
+ 'Canary redeployment is already in progress',
+ );
+ }
+ } catch (error) {
+ activeOperation = null;
+ throw error;
+ }
+}
+
+function validateBranchFormat(branch: CanaryBranch): void {
+ if (branch.startsWith('-')) {
+ throw new CanaryRedeployError(
+ 'branch_invalid',
+ `Invalid Canary branch: ${branch}`,
+ );
+ }
+ try {
+ execFileSync('git', ['check-ref-format', '--branch', branch], {
+ stdio: 'ignore',
+ timeout: 5_000,
+ });
+ execFileSync('git', ['check-ref-format', `refs/heads/${branch}`], {
+ stdio: 'ignore',
+ timeout: 5_000,
+ });
+ } catch {
+ throw new CanaryRedeployError(
+ 'branch_invalid',
+ `Invalid Canary branch: ${branch}`,
+ );
+ }
+}
+
+async function resolveRemoteBranch(
+ repoRoot: string,
+ branch: CanaryBranch,
+): Promise {
+ validateBranchFormat(branch);
+ const fullRef = `refs/heads/${branch}`;
+ let stdout: string;
+ try {
+ ({ stdout } = await execFileAsync(
+ 'git',
+ ['ls-remote', '--exit-code', '--refs', 'origin', fullRef],
+ {
+ cwd: repoRoot,
+ encoding: 'utf8',
+ timeout: 10_000,
+ maxBuffer: 64 * 1024,
+ },
+ ));
+ } catch {
+ throw new CanaryRedeployError(
+ 'branch_unavailable',
+ `Unable to resolve origin/${branch}`,
+ );
+ }
+ const fields = stdout.trim().split(/\s+/);
+ const remoteSha = validSha(fields[0]);
+ if (!remoteSha || fields[1] !== fullRef || fields.length !== 2) {
+ throw new CanaryRedeployError(
+ 'branch_unavailable',
+ `Unable to resolve origin/${branch}`,
+ );
+ }
+ return remoteSha;
+}
+
export async function writeCanaryRedeployResult(
result: CanaryRedeployResult,
): Promise {
@@ -170,6 +357,7 @@ export async function writeCanaryRedeployResult(
export async function getCanaryRedeployStatus(): Promise {
const capability = resolveCanaryCapability();
+ const config = readCanaryConfig();
const storedRedeploy = await readRedeployResult();
let redeploy = storedRedeploy?.result ?? null;
if (
@@ -180,6 +368,7 @@ export async function getCanaryRedeployStatus(): Promise {
+ const capability = resolveCanaryCapability();
+ if (!capability.available || !capability.repoRoot) {
+ throw new CanaryRedeployError(
+ 'redeploy_unavailable',
+ 'Canary redeployment is unavailable',
+ );
+ }
+ await beginOperation('configure');
+ try {
+ const branch = update.branch ?? DEFAULT_BRANCH;
+ const remoteSha = await resolveRemoteBranch(capability.repoRoot, branch);
+ atomicWriteJson(canaryConfigPath(), {
+ version: 1,
+ branch: update.branch,
+ });
+ cachedRemote = { branch, remoteSha, checkedAt: Date.now() };
+ return await getCanaryRedeployStatus();
+ } finally {
+ activeOperation = null;
+ }
+}
+
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');
+ await beginOperation('check');
+ try {
+ const { branch } = readCanaryConfig();
+ const remoteSha = await resolveRemoteBranch(capability.repoRoot, branch);
+ cachedRemote = { branch, remoteSha, checkedAt: Date.now() };
+ return await getCanaryRedeployStatus();
+ } finally {
+ activeOperation = null;
}
- cachedRemote = { remoteSha, checkedAt: Date.now() };
- return getCanaryRedeployStatus();
}
-export async function requestCanaryRedeploy(): Promise {
+export async function requestCanaryRedeploy(
+ expectedBranch: CanaryBranch,
+): Promise {
const capability = resolveCanaryCapability();
if (
!capability.available ||
@@ -238,59 +447,76 @@ export async function requestCanaryRedeploy(): Promise {
- 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',
+ await beginOperation('redeploy');
+ let branch: CanaryBranch | null = null;
+ let startedAt: number | null = null;
+ try {
+ branch = readCanaryConfig().branch;
+ if (expectedBranch !== branch) {
+ throw new CanaryRedeployError(
+ 'stale_branch',
+ `Canary branch changed from ${expectedBranch} to ${branch}; review and confirm again`,
);
+ }
+ const remoteSha = await resolveRemoteBranch(capability.repoRoot, branch);
+ cachedRemote = { branch, remoteSha, checkedAt: Date.now() };
+ await access(capability.scriptPath, constants.X_OK);
+ startedAt = Date.now();
+ await writeCanaryRedeployResult({
+ state: 'requested',
+ branch,
+ startedAt,
});
- });
- child.unref();
- redeployRequestInFlight = true;
- return getCanaryRedeployStatus();
+
+ const child = spawn(
+ process.execPath,
+ [
+ capability.runnerPath,
+ capability.scriptPath,
+ canaryStatusPath(),
+ canaryLogPath(),
+ String(startedAt),
+ branch,
+ ],
+ {
+ 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',
+ branch: branch ?? DEFAULT_BRANCH,
+ startedAt: startedAt ?? Date.now(),
+ 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 await getCanaryRedeployStatus();
+ } finally {
+ activeOperation = null;
+ }
}
export function resetCanaryRedeployStateForTest(): void {
cachedRemote = null;
+ activeOperation = null;
redeployRequestInFlight = false;
}
diff --git a/apps/web/src/api/_routes.ts b/apps/web/src/api/_routes.ts
index 2cabc6501..52d205e4b 100644
--- a/apps/web/src/api/_routes.ts
+++ b/apps/web/src/api/_routes.ts
@@ -17,6 +17,7 @@ export const routes = {
// ── Deployment ────────────────────────────────────────────────────
deploymentReadiness: '/deployment/readiness',
canaryRedeployStatus: '/deployment/canary',
+ canaryRedeployConfig: '/deployment/canary/config',
canaryRedeployCheck: '/deployment/canary/check',
canaryRedeploy: '/deployment/canary/redeploy',
agentDefaults: '/agent/defaults',
diff --git a/apps/web/src/api/deployment.ts b/apps/web/src/api/deployment.ts
index d279386d1..e3c02f2da 100644
--- a/apps/web/src/api/deployment.ts
+++ b/apps/web/src/api/deployment.ts
@@ -5,6 +5,7 @@ import { apiFetch } from './_client';
import { routes } from './_routes';
import type {
+ CanaryRedeployConfigUpdate,
CanaryRedeployStatusResponse,
DeploymentReadinessResponse,
} from '@huabu/shared';
@@ -25,14 +26,26 @@ export function checkCanaryRedeploy(): Promise {
return apiFetch(routes.canaryRedeployCheck, {
method: 'POST',
json: {},
- fallbackMessage: 'Failed to check origin/alpha',
+ fallbackMessage: 'Failed to check the Canary branch',
});
}
-export function requestCanaryRedeploy(): Promise {
+export function updateCanaryRedeployConfig(
+ update: CanaryRedeployConfigUpdate,
+): Promise {
+ return apiFetch(routes.canaryRedeployConfig, {
+ method: 'PUT',
+ json: update,
+ fallbackMessage: 'Failed to save the Canary branch',
+ });
+}
+
+export function requestCanaryRedeploy(
+ expectedBranch: string,
+): Promise {
return apiFetch(routes.canaryRedeploy, {
method: 'POST',
- json: {},
+ json: { expectedBranch },
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
index 2e4346328..8b852f61a 100644
--- a/apps/web/src/components/Settings/CanaryRedeploySettings.test.tsx
+++ b/apps/web/src/components/Settings/CanaryRedeploySettings.test.tsx
@@ -15,6 +15,7 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
const mocks = vi.hoisted(() => ({
getStatus: vi.fn(),
check: vi.fn(),
+ save: vi.fn(),
redeploy: vi.fn(),
toast: vi.fn(),
t: (key: string) => key,
@@ -23,6 +24,7 @@ const mocks = vi.hoisted(() => ({
vi.mock('@/api/deployment', () => ({
getCanaryRedeployStatus: mocks.getStatus,
checkCanaryRedeploy: mocks.check,
+ updateCanaryRedeployConfig: mocks.save,
requestCanaryRedeploy: mocks.redeploy,
}));
vi.mock('@/components/Common/Toast', () => ({ toast: mocks.toast }));
@@ -40,6 +42,7 @@ const availableStatus: CanaryRedeployStatusResponse = {
available: true,
reason: 'available',
branch: 'alpha',
+ configuredBranch: null,
runningSha: 'a'.repeat(40),
remoteSha: 'b'.repeat(40),
updateAvailable: true,
@@ -56,9 +59,14 @@ beforeEach(() => {
root = createRoot(container);
mocks.getStatus.mockResolvedValue(availableStatus);
mocks.check.mockResolvedValue(availableStatus);
+ mocks.save.mockResolvedValue({
+ ...availableStatus,
+ branch: 'x/alpha',
+ configuredBranch: 'x/alpha',
+ });
mocks.redeploy.mockResolvedValue({
...availableStatus,
- redeploy: { state: 'requested', startedAt: 2 },
+ redeploy: { state: 'requested', branch: 'alpha', startedAt: 2 },
});
});
@@ -103,9 +111,36 @@ describe('CanaryRedeploySettings', () => {
});
expect(mocks.redeploy).toHaveBeenCalledOnce();
+ expect(mocks.redeploy).toHaveBeenCalledWith('alpha');
expect(mocks.toast).toHaveBeenCalledWith('settings.canaryRedeployStarted', {
tone: 'info',
duration: 10_000,
});
});
+
+ it('persists a configured branch and uses an empty value for the alpha default', async () => {
+ await renderSettings();
+ const input = container.querySelector('input');
+ expect(input?.placeholder).toBe('alpha');
+ expect(input?.value).toBe('');
+
+ act(() => {
+ const setValue = Object.getOwnPropertyDescriptor(
+ HTMLInputElement.prototype,
+ 'value',
+ )?.set;
+ setValue?.call(input, 'x/alpha');
+ input?.dispatchEvent(new Event('input', { bubbles: true }));
+ });
+ await act(async () => button('settings.canaryBranchSave').click());
+
+ expect(mocks.save).toHaveBeenCalledWith({ branch: 'x/alpha' });
+ expect(input?.value).toBe('x/alpha');
+
+ act(() => button('settings.canaryRedeployAction').click());
+ await act(async () => {
+ button('settings.canaryConfirmAction').click();
+ });
+ expect(mocks.redeploy).toHaveBeenCalledWith('x/alpha');
+ });
});
diff --git a/apps/web/src/components/Settings/CanaryRedeploySettings.tsx b/apps/web/src/components/Settings/CanaryRedeploySettings.tsx
index 4474d893a..22c37f33f 100644
--- a/apps/web/src/components/Settings/CanaryRedeploySettings.tsx
+++ b/apps/web/src/components/Settings/CanaryRedeploySettings.tsx
@@ -1,16 +1,18 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
-import { useCallback, useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useId, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
checkCanaryRedeploy,
getCanaryRedeployStatus,
requestCanaryRedeploy,
+ updateCanaryRedeployConfig,
} from '@/api/deployment';
import { Button } from '@/components/Common/Button';
import { Modal } from '@/components/Common/Modal';
+import { TextInput } from '@/components/Common/TextInput';
import { toast } from '@/components/Common/Toast';
import { SettingRow } from '@/components/Settings/Common/SettingRow';
@@ -22,11 +24,14 @@ function shortSha(sha: string | null): string {
export function CanaryRedeploySettings() {
const { t } = useTranslation();
+ const branchInputId = useId();
const [status, setStatus] = useState(
null,
);
+ const [branchDraft, setBranchDraft] = useState('');
const [loading, setLoading] = useState(true);
const [checking, setChecking] = useState(false);
+ const [saving, setSaving] = useState(false);
const [requesting, setRequesting] = useState(false);
const [confirming, setConfirming] = useState(false);
const confirmRef = useRef(null);
@@ -40,8 +45,8 @@ export function CanaryRedeploySettings() {
if (showToast) {
toast(
next.updateAvailable
- ? t('settings.canaryUpdateAvailable')
- : t('settings.canaryUpToDate'),
+ ? t('settings.canaryUpdateAvailable', { branch: next.branch })
+ : t('settings.canaryUpToDate', { branch: next.branch }),
{ tone: next.updateAvailable ? 'info' : 'success' },
);
}
@@ -65,6 +70,7 @@ export function CanaryRedeploySettings() {
.then((initial) => {
if (!active) return;
setStatus(initial);
+ setBranchDraft(initial.configuredBranch ?? '');
if (initial.available) void check(false);
})
.catch((error: unknown) => {
@@ -85,13 +91,37 @@ export function CanaryRedeploySettings() {
};
}, [check, t]);
+ const saveBranch = useCallback(async () => {
+ setSaving(true);
+ try {
+ const next = await updateCanaryRedeployConfig({
+ branch: branchDraft.trim() || null,
+ });
+ setStatus(next);
+ setBranchDraft(next.configuredBranch ?? '');
+ toast(t('settings.canaryBranchSaved', { branch: next.branch }), {
+ tone: 'success',
+ });
+ } catch (error) {
+ toast(
+ error instanceof Error
+ ? error.message
+ : t('settings.canaryBranchSaveFailed'),
+ { tone: 'danger' },
+ );
+ } finally {
+ setSaving(false);
+ }
+ }, [branchDraft, t]);
+
const redeploy = useCallback(async () => {
+ if (!status) return;
setRequesting(true);
try {
- const next = await requestCanaryRedeploy();
+ const next = await requestCanaryRedeploy(status.branch);
setStatus(next);
setConfirming(false);
- toast(t('settings.canaryRedeployStarted'), {
+ toast(t('settings.canaryRedeployStarted', { branch: next.branch }), {
tone: 'info',
duration: 10_000,
});
@@ -105,7 +135,7 @@ export function CanaryRedeploySettings() {
} finally {
setRequesting(false);
}
- }, [t]);
+ }, [status, t]);
if (loading || !status?.available) return null;
@@ -115,9 +145,12 @@ export function CanaryRedeploySettings() {
const redeployInProgress =
status.redeploy?.state === 'requested' ||
status.redeploy?.state === 'running';
+ const busy = checking || saving || requesting || redeployInProgress;
const description = t('settings.canaryDescription', {
+ branch: status.branch,
running: shortSha(status.runningSha),
remote: shortSha(status.remoteSha),
+ redeployBranch: status.redeploy?.branch ?? status.branch,
outcome,
});
@@ -133,7 +166,7 @@ export function CanaryRedeploySettings() {
tone="neutral"
size="sm"
onClick={() => void check(true)}
- disabled={checking || requesting}
+ disabled={busy}
>
{checking
? t('settings.canaryChecking')
@@ -144,9 +177,41 @@ export function CanaryRedeploySettings() {
tone="warning"
size="sm"
onClick={() => setConfirming(true)}
- disabled={checking || requesting || redeployInProgress}
+ disabled={busy}
+ >
+ {t('settings.canaryRedeployAction', { branch: status.branch })}
+
+
+
+
+
+ setBranchDraft(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter' && !busy) void saveBranch();
+ }}
+ />
+ void saveBranch()}
+ disabled={busy}
>
- {t('settings.canaryRedeployAction')}
+ {saving
+ ? t('settings.canaryBranchSaving')
+ : t('settings.canaryBranchSave')}
@@ -155,8 +220,10 @@ export function CanaryRedeploySettings() {
onClose={() => {
if (!requesting) setConfirming(false);
}}
- title={t('settings.canaryConfirmTitle')}
- description={t('settings.canaryConfirmDescription')}
+ title={t('settings.canaryConfirmTitle', { branch: status.branch })}
+ description={t('settings.canaryConfirmDescription', {
+ branch: status.branch,
+ })}
initialFocusRef={confirmRef}
closeOnBackdropClick={!requesting}
closeOnEscape={!requesting}
@@ -181,7 +248,9 @@ export function CanaryRedeploySettings() {
>
{requesting
? t('settings.canaryStarting')
- : t('settings.canaryConfirmAction')}
+ : t('settings.canaryConfirmAction', {
+ branch: status.branch,
+ })}
>
}
diff --git a/apps/web/src/i18n/resources/en/common.json b/apps/web/src/i18n/resources/en/common.json
index 074a8ffad..1c5422cb7 100644
--- a/apps/web/src/i18n/resources/en/common.json
+++ b/apps/web/src/i18n/resources/en/common.json
@@ -376,19 +376,25 @@
"credentialStoreReadOnly": "Credential storage is read-only. Set HUABU_SECRET_KEY and restart Huabu to save credentials or use OAuth.",
"remoteTransportUnverified": "This remote connection uses operator-managed transport. Use HTTPS or a trusted private network.",
"canaryRedeploy": "Alpha Canary redeployment",
- "canaryDescription": "Running {{running}} · origin/alpha {{remote}} · Last result: {{outcome}}",
+ "canaryDescription": "Running {{running}} · origin/{{branch}} {{remote}} · Last result for {{redeployBranch}}: {{outcome}}",
"canaryCheck": "Check for update",
"canaryChecking": "Checking…",
- "canaryUpdateAvailable": "A newer Alpha revision is available",
- "canaryUpToDate": "This Canary matches origin/alpha",
- "canaryCheckFailed": "Unable to check origin/alpha",
+ "canaryUpdateAvailable": "A newer {{branch}} revision is available",
+ "canaryUpToDate": "This Canary matches origin/{{branch}}",
+ "canaryCheckFailed": "Unable to check the Canary branch",
"canaryStatusFailed": "Unable to load Canary redeployment status",
- "canaryRedeployAction": "Redeploy Alpha",
- "canaryConfirmTitle": "Redeploy the Alpha Canary?",
- "canaryConfirmDescription": "Huabu will run the repository redeployment script. This page may disconnect, and a failed deployment may require SSH repair.",
- "canaryConfirmAction": "Redeploy and restart",
+ "canaryBranch": "Canary branch",
+ "canaryBranchDescription": "Leave empty to use alpha. The branch must exist on origin.",
+ "canaryBranchSave": "Save",
+ "canaryBranchSaving": "Saving…",
+ "canaryBranchSaved": "Canary branch set to {{branch}}",
+ "canaryBranchSaveFailed": "Unable to save the Canary branch",
+ "canaryRedeployAction": "Redeploy {{branch}}",
+ "canaryConfirmTitle": "Redeploy {{branch}} to the Alpha Canary?",
+ "canaryConfirmDescription": "Huabu will run the repository redeployment script for origin/{{branch}}. This page may disconnect, and a failed deployment may require SSH repair.",
+ "canaryConfirmAction": "Redeploy {{branch}} and restart",
"canaryStarting": "Starting…",
- "canaryRedeployStarted": "Redeployment started. Huabu may disconnect while it restarts.",
+ "canaryRedeployStarted": "Redeployment of {{branch}} started. Huabu may disconnect while it restarts.",
"canaryRedeployFailed": "Unable to start Canary redeployment",
"canaryNeverRedeployed": "not run",
"canaryState_requested": "requested",
diff --git a/apps/web/src/i18n/resources/zh-CN/common.json b/apps/web/src/i18n/resources/zh-CN/common.json
index be029bcc0..2471c988a 100644
--- a/apps/web/src/i18n/resources/zh-CN/common.json
+++ b/apps/web/src/i18n/resources/zh-CN/common.json
@@ -376,19 +376,25 @@
"credentialStoreReadOnly": "凭据存储为只读。请设置 HUABU_SECRET_KEY 并重启 Huabu,以保存凭据或使用 OAuth。",
"remoteTransportUnverified": "此远程连接使用运维方管理的传输。请使用 HTTPS 或可信私有网络。",
"canaryRedeploy": "Alpha Canary 重新部署",
- "canaryDescription": "运行版本 {{running}} · origin/alpha {{remote}} · 最近结果:{{outcome}}",
+ "canaryDescription": "运行版本 {{running}} · origin/{{branch}} {{remote}} · {{redeployBranch}} 最近结果:{{outcome}}",
"canaryCheck": "检查更新",
"canaryChecking": "检查中…",
- "canaryUpdateAvailable": "存在更新的 Alpha 版本",
- "canaryUpToDate": "当前 Canary 与 origin/alpha 一致",
- "canaryCheckFailed": "无法检查 origin/alpha",
+ "canaryUpdateAvailable": "存在更新的 {{branch}} 版本",
+ "canaryUpToDate": "当前 Canary 与 origin/{{branch}} 一致",
+ "canaryCheckFailed": "无法检查 Canary 分支",
"canaryStatusFailed": "无法加载 Canary 重新部署状态",
- "canaryRedeployAction": "重新部署 Alpha",
- "canaryConfirmTitle": "重新部署 Alpha Canary?",
- "canaryConfirmDescription": "Huabu 将运行仓库内的重新部署脚本。页面可能断开连接,部署失败后可能需要通过 SSH 手工修复。",
- "canaryConfirmAction": "重新部署并重启",
+ "canaryBranch": "Canary 分支",
+ "canaryBranchDescription": "留空时使用 alpha。该分支必须存在于 origin。",
+ "canaryBranchSave": "保存",
+ "canaryBranchSaving": "保存中…",
+ "canaryBranchSaved": "Canary 分支已设为 {{branch}}",
+ "canaryBranchSaveFailed": "无法保存 Canary 分支",
+ "canaryRedeployAction": "重新部署 {{branch}}",
+ "canaryConfirmTitle": "将 {{branch}} 重新部署到 Alpha Canary?",
+ "canaryConfirmDescription": "Huabu 将针对 origin/{{branch}} 运行仓库内的重新部署脚本。页面可能断开连接,部署失败后可能需要通过 SSH 手工修复。",
+ "canaryConfirmAction": "重新部署 {{branch}} 并重启",
"canaryStarting": "正在启动…",
- "canaryRedeployStarted": "已启动重新部署,Huabu 重启期间可能断开连接。",
+ "canaryRedeployStarted": "已开始重新部署 {{branch}},Huabu 重启期间可能断开连接。",
"canaryRedeployFailed": "无法启动 Canary 重新部署",
"canaryNeverRedeployed": "尚未运行",
"canaryState_requested": "已请求",
diff --git a/docs/architecture/api-design.md b/docs/architecture/api-design.md
index 515f2fd38..b24f12ceb 100644
--- a/docs/architecture/api-design.md
+++ b/docs/architecture/api-design.md
@@ -126,6 +126,10 @@ Question `conversationTitleSource` is also server-owned and excluded from ordina
`POST /api/canvas/:canvasId/move-selection` validates its params and body with the shared Move schemas. Errors expose only a bounded `MOVE_*` code and fixed English message. `MOVE_AGENT_CLOSE_FAILED`, `MOVE_AGENT_REHOME_FAILED`, and unexpected `MOVE_FAILED` return HTTP 500; eligibility/history and destination conflicts retain their existing 4xx behavior. Agenetes `rehome_unknown_outcome` and failed compensation become HTTP 500 `MOVE_OUTCOME_UNKNOWN` before cleanup decisions. No raw cause, namespace, thread identity, artifact name, or arbitrary exception message is serialized. Server diagnostics retain the operation phase, allowlisted upstream error code, failure category, and compensation/cleanup outcome; original causes remain internal and are not dumped into logs. Web Move presentation localizes the code and uses a safe generic fallback rather than displaying unknown error messages.
+## Canary branch configuration
+
+`GET /api/deployment/canary` returns the effective bounded `branch`, nullable `configuredBranch`, branch-bound remote check state, and a redeployment result carrying its own captured branch. `PUT /api/deployment/canary/config` accepts `{ branch: string | null }`, where null clears the override and resolves to `alpha`; the Server validates the full Git ref and exact fixed-`origin` availability before persisting. `POST /api/deployment/canary/check` retains a strict empty body. `POST /api/deployment/canary/redeploy` accepts `{ expectedBranch }` only as a freshness guard and rejects a mismatch rather than treating the request as a target selector. All bodies use the canonical schemas in `deployment.ts`, all routes remain owner-only, and operation conflicts are explicit HTTP 409 responses.
+
## Conversation titles
[`conversation-title.ts`](../../packages/shared/src/types/api/conversation-title.ts) defines the shared schemas and inferred types for `ConversationTitle { title, source }`, batch queries, and manual renames. `POST /api/agent/threads/titles/query` validates `{ canvasId, threadIds }` (at most 100 thread IDs; an empty batch is valid) and returns `{ titles }` keyed by thread ID. `PUT /api/agent/threads/:threadId/title?canvasId=...` validates params, query, and a trimmed non-empty `{ title }` of at most 120 characters, returning the effective title or `404 thread_not_found` when no writable Question or durable thread exists.
diff --git a/docs/architecture/canary-deployment.md b/docs/architecture/canary-deployment.md
index 1f15fe613..bb1654fcd 100644
--- a/docs/architecture/canary-deployment.md
+++ b/docs/architecture/canary-deployment.md
@@ -1,10 +1,10 @@
# Alpha Canary Deployment
-> Personal-development deployment workflow for the long-lived `alpha` branch. This is not the stable release or promotion path.
+> Personal-development deployment workflow whose source branch defaults to `alpha`. 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.
+`main` is the stable branch. `alpha` is the rolling integration branch and the compatibility-default source for the personal Canary. An owner may configure another branch, such as `x/alpha`, for one development deployment without changing the repository's issue-branch or promotion policy. Issue branches still 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.
@@ -12,40 +12,44 @@ Canary use is additional end-to-end evidence only. It does not replace pull-requ
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.
+Setting `HUABU_CANARY_REDEPLOY_ENABLED=1` enables an owner-only Settings surface. The selected branch is persisted as an application-global versioned record under `HUABU_DATA_DIR`; a missing record or explicit cleared value resolves to `alpha`. Malformed stored configuration fails explicitly rather than silently using the default.
-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:
+Opening Settings compares the captured startup commit with the exact configured remote ref using shell-free `git ls-remote --exit-code --refs origin refs/heads/`. Branch syntax is checked as a bounded full Git branch ref and option-like leading `-` values are rejected. A configured ref that is invalid or unavailable fails explicitly and never falls back to `alpha`. The result identifies a different branch head; it does not independently attest CI status.
+
+The owner confirms the effective branch displayed by Settings. The request carries that branch only as a freshness guard; the Server rejects it if it no longer matches persisted configuration, so the request cannot independently select a deployment target. The Server launches a detached runner with the fixed executable and bounded arguments:
```text
-/scripts/start-huabu.sh alpha --non-interactive
+/scripts/start-huabu.sh --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.
+The runner persists the captured branch with `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.
+
+Only one check, configuration write, or redeployment admission may run at a time. A configuration write is rejected while a check or persisted runner is active, concurrent operations receive an explicit conflict, and an admitted redeployment cannot be retargeted. Persisted result status retains its captured branch so a later configuration can never make an older outcome appear to belong to another branch.
## 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`.
+`scripts/start-huabu.sh` derives the repository root from its own tracked path, so the checkout may live anywhere. Direct operator and Settings use both accept exactly one validated branch argument.
-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 requires a clean checkout, stops listeners on ports 3001–3005, removes the previous `app` tmux session, fetches the exact `refs/heads/` from fixed remote `origin` into its matching remote-tracking ref, checks out or creates the matching local branch, and fast-forwards it without rewriting divergent work. It then 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.
## 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.
+Status, branch configuration, 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 configuration or 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.
+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. The validated effective branch occupies one fixed argument slot; browser input cannot alter the executable, remote, repository path, option set, or argument count. 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. |
+| 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 branch, status, configuration, and action wire contracts. |
+| [`apps/server/src/modules/security/canary-redeploy.ts`](../../apps/server/src/modules/security/canary-redeploy.ts) | Configuration persistence, exact remote checks, concurrency, status, and runner launch. |
+| [`apps/server/src/modules/security/canary-redeploy.route.ts`](../../apps/server/src/modules/security/canary-redeploy.route.ts) | Owner-only status, branch configuration, check, and redeploy endpoints. |
+| [`apps/web/src/components/Settings/CanaryRedeploySettings.tsx`](../../apps/web/src/components/Settings/CanaryRedeploySettings.tsx) | Settings branch editor, status, check action, and branch-bound confirmed redeploy action. |
diff --git a/docs/architecture/deployment-security.md b/docs/architecture/deployment-security.md
index 593a2fb84..47d955b04 100644
--- a/docs/architecture/deployment-security.md
+++ b/docs/architecture/deployment-security.md
@@ -19,6 +19,8 @@ The global Agent Change Review configuration follows the same owner boundary. `G
The Utility Agent (external Profile or explicit Built-In Pi) and external functional-model preference follow the owner boundary through `GET` and `PUT /api/agent/defaults`. The recent conversational Agent is browser-local UI state, not a Server credential or authorization setting. Neither preference grants new tool permissions or changes native harness approval policy.
+The optional personal Canary helper follows the same owner boundary for status, branch configuration, remote checks, and redeployment. Its persisted branch is a bounded Git ref name, not an executable input surface: the Server checks only the exact `refs/heads/` on fixed remote `origin`, launches one repository-owned runner without a shell, and supplies only the validated branch in a fixed argument position. Invalid, unavailable, stale, and concurrently changed values fail explicitly without falling back to `alpha`; see [Alpha Canary deployment](./canary-deployment.md).
+
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.
Prompt debugging is a separate local retention surface: when enabled, it writes the assembled prompt, including OCR evidence subject to the diagnostic's text truncation. When `HUABU_DEBUG_PROMPT` is unset, it defaults to enabled outside production and disabled in production; an explicit value overrides that default. Set `HUABU_DEBUG_PROMPT=off` to disable these additional prompt logs. This does not disable normal conversation persistence or remove previously written data. Operators are responsible for the persisted conversation, local debug artifacts, and the configured Azure resource's data-processing and retention policy.
diff --git a/packages/shared/src/types/api/deployment.test.ts b/packages/shared/src/types/api/deployment.test.ts
new file mode 100644
index 000000000..a9bac9e35
--- /dev/null
+++ b/packages/shared/src/types/api/deployment.test.ts
@@ -0,0 +1,58 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+import { describe, expect, it } from 'vitest';
+
+import {
+ canaryRedeployConfigUpdateSchema,
+ canaryRedeployRequestSchema,
+ canaryRedeployStatusResponseSchema,
+} from './deployment.js';
+
+describe('Canary deployment contracts', () => {
+ it('accepts a nested branch and an explicit default reset', () => {
+ expect(
+ canaryRedeployConfigUpdateSchema.parse({ branch: 'x/alpha' }),
+ ).toEqual({ branch: 'x/alpha' });
+ expect(canaryRedeployConfigUpdateSchema.parse({ branch: null })).toEqual({
+ branch: null,
+ });
+ });
+
+ it('requires a bounded expected branch for redeployment', () => {
+ expect(
+ canaryRedeployRequestSchema.parse({ expectedBranch: 'x/alpha' }),
+ ).toEqual({ expectedBranch: 'x/alpha' });
+ expect(canaryRedeployRequestSchema.safeParse({}).success).toBe(false);
+ expect(
+ canaryRedeployRequestSchema.safeParse({
+ expectedBranch: 'x'.repeat(256),
+ }).success,
+ ).toBe(false);
+ });
+
+ it('keeps current and historical branch identities in status', () => {
+ expect(
+ canaryRedeployStatusResponseSchema.parse({
+ available: true,
+ reason: 'available',
+ branch: 'x/alpha',
+ configuredBranch: 'x/alpha',
+ runningSha: null,
+ remoteSha: null,
+ updateAvailable: null,
+ checkedAt: null,
+ redeploy: {
+ state: 'succeeded',
+ branch: 'alpha',
+ startedAt: 1,
+ completedAt: 2,
+ exitCode: 0,
+ },
+ }),
+ ).toMatchObject({
+ branch: 'x/alpha',
+ redeploy: { branch: 'alpha' },
+ });
+ });
+});
diff --git a/packages/shared/src/types/api/deployment.ts b/packages/shared/src/types/api/deployment.ts
index 7be0e18ac..4bf9194c6 100644
--- a/packages/shared/src/types/api/deployment.ts
+++ b/packages/shared/src/types/api/deployment.ts
@@ -45,8 +45,12 @@ export const canaryRedeployStateSchema = z.enum([
]);
export type CanaryRedeployState = z.infer;
+export const canaryBranchSchema = z.string().trim().min(1).max(255);
+export type CanaryBranch = z.infer;
+
export const canaryRedeployResultSchema = z.object({
state: canaryRedeployStateSchema,
+ branch: canaryBranchSchema,
startedAt: z.number().int().nonnegative(),
completedAt: z.number().int().nonnegative().optional(),
exitCode: z.number().int().optional(),
@@ -62,7 +66,8 @@ export const canaryRedeployStatusResponseSchema = z.object({
'repository-unavailable',
'script-unavailable',
]),
- branch: z.literal('alpha'),
+ branch: canaryBranchSchema,
+ configuredBranch: canaryBranchSchema.nullable(),
runningSha: z
.string()
.regex(/^[0-9a-f]{40}$/)
@@ -79,5 +84,21 @@ export type CanaryRedeployStatusResponse = z.infer<
typeof canaryRedeployStatusResponseSchema
>;
-export const canaryRedeployRequestSchema = z.object({}).strict();
+export const canaryCheckRequestSchema = z.object({}).strict();
+export type CanaryCheckRequest = z.infer;
+
+export const canaryRedeployConfigUpdateSchema = z
+ .object({
+ branch: canaryBranchSchema.nullable(),
+ })
+ .strict();
+export type CanaryRedeployConfigUpdate = z.infer<
+ typeof canaryRedeployConfigUpdateSchema
+>;
+
+export const canaryRedeployRequestSchema = z
+ .object({
+ expectedBranch: canaryBranchSchema,
+ })
+ .strict();
export type CanaryRedeployRequest = z.infer;
diff --git a/scripts/canary-redeploy-runner.mjs b/scripts/canary-redeploy-runner.mjs
index 9818381a3..42a61877a 100755
--- a/scripts/canary-redeploy-runner.mjs
+++ b/scripts/canary-redeploy-runner.mjs
@@ -5,18 +5,30 @@
import { createWriteStream } from 'node:fs';
import { mkdir, rename, writeFile } from 'node:fs/promises';
import path from 'node:path';
-import { spawn } from 'node:child_process';
+import { spawn, spawnSync } from 'node:child_process';
-const [scriptPath, statusPath, logPath, startedAtValue] = process.argv.slice(2);
+const [scriptPath, statusPath, logPath, startedAtValue, branch] =
+ process.argv.slice(2);
const startedAt = Number(startedAtValue);
const RESPONSE_GRACE_MS = 1500;
+const branchIsValid =
+ typeof branch === 'string' &&
+ branch.length <= 255 &&
+ !branch.startsWith('-') &&
+ spawnSync('git', ['check-ref-format', '--branch', branch], {
+ stdio: 'ignore',
+ }).status === 0 &&
+ spawnSync('git', ['check-ref-format', `refs/heads/${branch}`], {
+ stdio: 'ignore',
+ }).status === 0;
if (
!scriptPath ||
!statusPath ||
!logPath ||
!Number.isSafeInteger(startedAt) ||
- startedAt < 0
+ startedAt < 0 ||
+ !branchIsValid
) {
process.exitCode = 2;
} else {
@@ -32,14 +44,19 @@ if (
await rename(temporaryPath, statusPath);
}
- await writeStatus({ state: 'running', startedAt, runnerPid: process.pid });
+ await writeStatus({
+ state: 'running',
+ branch,
+ startedAt,
+ runnerPid: process.pid,
+ });
const log = createWriteStream(logPath, { flags: 'a', mode: 0o600 });
- log.write(`\n[${new Date().toISOString()}] Redeploying alpha\n`);
+ log.write(`\n[${new Date().toISOString()}] Redeploying ${branch}\n`);
await new Promise((resolveDelay) =>
setTimeout(resolveDelay, RESPONSE_GRACE_MS),
);
- const child = spawn(scriptPath, ['alpha', '--non-interactive'], {
+ const child = spawn(scriptPath, [branch, '--non-interactive'], {
stdio: ['ignore', 'pipe', 'pipe'],
env: process.env,
});
@@ -69,6 +86,7 @@ if (
const succeeded = result.exitCode === 0;
const status = {
state: succeeded ? 'succeeded' : 'failed',
+ branch,
startedAt,
completedAt: Date.now(),
exitCode: result.exitCode,
diff --git a/scripts/start-huabu.sh b/scripts/start-huabu.sh
index e764fa0b5..7fe5e7f9c 100755
--- a/scripts/start-huabu.sh
+++ b/scripts/start-huabu.sh
@@ -41,9 +41,10 @@ main() {
echo "HUABU_CANARY_READINESS_TIMEOUT_SECONDS must be a positive integer." >&2
return 2
fi
- if [[ ! "$branch_name" =~ ^[A-Za-z0-9._/-]+$ ]] ||
+ if (( ${#branch_name} > 255 )) ||
[[ "$branch_name" == -* ]] ||
- [[ "$branch_name" == *..* ]]; then
+ ! git check-ref-format --branch "$branch_name" >/dev/null 2>&1 ||
+ ! git check-ref-format "refs/heads/$branch_name" >/dev/null 2>&1; then
echo "Invalid branch name: $branch_name" >&2
return 2
fi
@@ -117,9 +118,16 @@ main() {
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"
+ local branch_ref="refs/heads/$branch_name"
+ local remote_ref="refs/remotes/origin/$branch_name"
+ echo "==> Updating $branch_name from origin in $huabu_dir"
+ git -C "$huabu_dir" fetch --no-tags origin "$branch_ref:$remote_ref"
+ if git -C "$huabu_dir" show-ref --verify --quiet "$branch_ref"; then
+ git -C "$huabu_dir" checkout "$branch_name"
+ git -C "$huabu_dir" merge --ff-only "$remote_ref"
+ else
+ git -C "$huabu_dir" checkout -b "$branch_name" --track "$remote_ref"
+ fi
echo "==> Installing dependencies"
pnpm --dir "$huabu_dir" install --frozen-lockfile
From fdee20d7d05848a4bba7bec9a903d7282cbf02e9 Mon Sep 17 00:00:00 2001
From: Yuqing Yang
Date: Mon, 5 Oct 2026 02:03:04 +0000
Subject: [PATCH 28/30] feat: expose Agent Node cwd override
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../WorkingDirectoryOverride.test.tsx | 135 ++++++++++++++++++
.../ChatPanel/WorkingDirectoryOverride.tsx | 105 ++++++++++++++
.../src/components/Panels/ChatPanel/index.tsx | 69 +++++++--
apps/web/src/i18n/resources/en/common.json | 5 +
apps/web/src/i18n/resources/zh-CN/common.json | 5 +
apps/web/src/store/conversationOwner.test.ts | 56 ++++++++
apps/web/src/store/conversationOwner.ts | 33 ++++-
docs/architecture/agent-profiles.md | 2 +-
docs/architecture/question-node.md | 35 ++---
9 files changed, 415 insertions(+), 30 deletions(-)
create mode 100644 apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.test.tsx
create mode 100644 apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.tsx
diff --git a/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.test.tsx b/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.test.tsx
new file mode 100644
index 000000000..73ec530b3
--- /dev/null
+++ b/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.test.tsx
@@ -0,0 +1,135 @@
+// 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 { i18n } from '@/i18n';
+
+import { WorkingDirectoryOverride } from './WorkingDirectoryOverride';
+
+let container: HTMLDivElement;
+let root: Root;
+
+async function render(
+ props: React.ComponentProps,
+) {
+ await act(async () => root.render( ));
+}
+
+beforeEach(async () => {
+ await i18n.changeLanguage('en');
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+});
+
+afterEach(async () => {
+ await act(async () => root.unmount());
+ container.remove();
+});
+
+describe('WorkingDirectoryOverride', () => {
+ it('shows Profile inheritance and updates it without persisting a copy', async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined);
+ await render({
+ profileWorkingDirPath: '/profiles/first',
+ editable: true,
+ saving: false,
+ onSave,
+ });
+
+ const input = container.querySelector('input')!;
+ expect(input.placeholder).toBe('/profiles/first');
+ expect(container.textContent).toContain(
+ 'Inheriting Profile directory: /profiles/first',
+ );
+
+ await render({
+ profileWorkingDirPath: '/profiles/second',
+ editable: true,
+ saving: false,
+ onSave,
+ });
+ expect(container.querySelector('input')?.value).toBe('');
+ expect(container.querySelector('input')?.placeholder).toBe(
+ '/profiles/second',
+ );
+ expect(onSave).not.toHaveBeenCalled();
+ });
+
+ it('preserves an explicit override across Profile changes and clears to null', async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined);
+ await render({
+ profileWorkingDirPath: '/profiles/first',
+ workingDirPath: '/node/work',
+ editable: true,
+ saving: false,
+ onSave,
+ });
+ await render({
+ profileWorkingDirPath: '/profiles/second',
+ workingDirPath: '/node/work',
+ editable: true,
+ saving: false,
+ onSave,
+ });
+ const input = container.querySelector('input')!;
+ expect(input.value).toBe('/node/work');
+
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(
+ HTMLInputElement.prototype,
+ 'value',
+ )!.set!.call(input, '');
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ input.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
+ });
+ expect(onSave).toHaveBeenCalledWith(null);
+ });
+
+ it('keeps a locked explicit override visible but hides locked inheritance', async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined);
+ await render({
+ profileWorkingDirPath: '/profiles/default',
+ workingDirPath: '/node/work',
+ editable: false,
+ saving: false,
+ onSave,
+ });
+ expect(container.textContent).toContain('/node/work');
+ expect(container.querySelector('input')).toBeNull();
+
+ await render({
+ profileWorkingDirPath: '/profiles/default',
+ editable: false,
+ saving: false,
+ onSave,
+ });
+ expect(container.textContent).toBe('');
+ });
+
+ it('surfaces server validation errors without replacing the draft', async () => {
+ const onSave = vi.fn().mockRejectedValue(new Error('Must be absolute'));
+ await render({
+ profileWorkingDirPath: '/profiles/default',
+ editable: true,
+ saving: false,
+ onSave,
+ });
+ const input = container.querySelector('input')!;
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(
+ HTMLInputElement.prototype,
+ 'value',
+ )!.set!.call(input, 'relative/path');
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ input.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
+ });
+ expect(container.querySelector('[role="alert"]')?.textContent).toBe(
+ 'Must be absolute',
+ );
+ expect(input.value).toBe('relative/path');
+ });
+});
diff --git a/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.tsx b/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.tsx
new file mode 100644
index 000000000..32c25bdd8
--- /dev/null
+++ b/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.tsx
@@ -0,0 +1,105 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+import { useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { TextInput } from '@/components/Common/TextInput';
+
+interface WorkingDirectoryOverrideProps {
+ profileWorkingDirPath: string;
+ workingDirPath?: string;
+ editable: boolean;
+ saving: boolean;
+ onSave: (workingDirPath: string | null) => Promise;
+}
+
+export function WorkingDirectoryOverride({
+ profileWorkingDirPath,
+ workingDirPath,
+ editable,
+ saving,
+ onSave,
+}: WorkingDirectoryOverrideProps) {
+ const { t } = useTranslation();
+ const [draft, setDraft] = useState(workingDirPath ?? '');
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ setDraft(workingDirPath ?? '');
+ setError(null);
+ }, [workingDirPath]);
+
+ if (!editable && !workingDirPath) return null;
+
+ const commit = async () => {
+ const trimmed = draft.trim();
+ if (trimmed === (workingDirPath ?? '')) return;
+ setError(null);
+ try {
+ await onSave(trimmed || null);
+ } catch (saveError) {
+ setError(
+ saveError instanceof Error
+ ? saveError.message
+ : t('chat.workingDirectoryOverrideSaveFailed'),
+ );
+ }
+ };
+
+ if (!editable) {
+ return (
+
+
+ {t('chat.workingDirectoryOverride')}
+
+
+ {workingDirPath}
+
+
+ {t('chat.workingDirectoryNodeOnlyLocked')}
+
+
+ );
+ }
+
+ return (
+
+
+ {t('chat.workingDirectoryOverride')}
+
+
setDraft(event.target.value)}
+ onBlur={() => void commit()}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ event.currentTarget.blur();
+ } else if (event.key === 'Escape') {
+ setDraft(workingDirPath ?? '');
+ setError(null);
+ event.currentTarget.blur();
+ }
+ }}
+ placeholder={profileWorkingDirPath}
+ aria-label={t('chat.workingDirectoryOverride')}
+ mono
+ disabled={saving}
+ className="w-full"
+ />
+
+ {draft.trim()
+ ? t('chat.workingDirectoryNodeOnly')
+ : t('chat.workingDirectoryInherited', {
+ path: profileWorkingDirPath,
+ })}
+
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/Panels/ChatPanel/index.tsx b/apps/web/src/components/Panels/ChatPanel/index.tsx
index b75e4b7f7..ea01cfa19 100644
--- a/apps/web/src/components/Panels/ChatPanel/index.tsx
+++ b/apps/web/src/components/Panels/ChatPanel/index.tsx
@@ -50,6 +50,7 @@ import {
acknowledgeConversationResult,
awaitConversationDraft,
saveConversationDraft,
+ saveConversationWorkingDirectoryOverride,
resolveConversationAgentBinding,
resolveConversationOwnerSource,
} from '@/store/conversationOwner';
@@ -78,6 +79,7 @@ import { ChangeReviewCard } from './ChangeReviewCard';
import { parseSlashInvocations } from './parseSlashInvocations';
import { saveChatAsQuestion } from './saveChatAsQuestion';
import { ThreadChatInput } from './ThreadChatInput';
+import { WorkingDirectoryOverride } from './WorkingDirectoryOverride';
import { useAgentStream } from '../../../hooks/useAgentStream';
import { useChatHistory } from '../../../hooks/useChatHistory';
import { MessageList } from '../../Messages/MessageList';
@@ -169,6 +171,7 @@ export const ChatPanel = ({
conversationOwnerSource?.agentBindingPolicy === 'fixed' ||
conversationOwnerSource?.bindingState === 'bound';
const [savingAgentDraft, setSavingAgentDraft] = useState(false);
+ const [savingWorkingDirectory, setSavingWorkingDirectory] = useState(false);
const activelyViewingOwner = useActivelyViewingQuestionNode(
activeConversationView?.presentationAnchor.nodeId ?? '',
);
@@ -811,13 +814,40 @@ export const ChatPanel = ({
// read-only. Picking an agent rebinds the *current* (empty) thread in
// place; it never mints a new thread.
const threadHasUserMessage = messages.some((m) => m.role === 'user');
- const agentSelectorEditable =
+ const preparationEditable =
!viewingQuestionBindingIsFixed &&
(activeConversationView
? conversationOwnerSource?.bindingState !== 'bound'
: !threadHasUserMessage) &&
!savingAgentDraft &&
!isLoading;
+ const agentSelectorEditable = preparationEditable && !savingWorkingDirectory;
+ const selectedExternalProfile =
+ agentBinding.kind === 'external'
+ ? acpProfiles.find((profile) => profile.id === agentBinding.profileId)
+ : undefined;
+ const workingDirectoryOverride =
+ conversationOwnerSource?.agentLaunchOverrides?.workingDirPath;
+ const showWorkingDirectoryOverride =
+ !!activeConversationView &&
+ ((!!selectedExternalProfile && preparationEditable) ||
+ !!workingDirectoryOverride);
+ const handleSaveWorkingDirectory = useCallback(
+ async (workingDirPath: string | null) => {
+ if (!activeConversationView) return;
+ setSavingWorkingDirectory(true);
+ try {
+ await saveConversationWorkingDirectoryOverride(
+ activeConversationView,
+ workingDirPath,
+ );
+ onCommit?.();
+ } finally {
+ setSavingWorkingDirectory(false);
+ }
+ },
+ [activeConversationView, onCommit],
+ );
const handleSelectAgent = useCallback(
async (choice: AgentChoice) => {
// Agent binding is immutable once a turn starts (1 thread = 1 binding).
@@ -1060,16 +1090,33 @@ export const ChatPanel = ({
slashLoading={slashLoading}
onSlashMenuIntent={refreshSlashCommands}
agentSelectorSlot={
-
+
+
+ {showWorkingDirectoryOverride && (
+
+ )}
+
}
acpSelectorsSlot={
agentBinding.kind === 'external' ? (
diff --git a/apps/web/src/i18n/resources/en/common.json b/apps/web/src/i18n/resources/en/common.json
index 074a8ffad..49b2b27ff 100644
--- a/apps/web/src/i18n/resources/en/common.json
+++ b/apps/web/src/i18n/resources/en/common.json
@@ -765,6 +765,11 @@
}
},
"chat": {
+ "workingDirectoryOverride": "Working directory override",
+ "workingDirectoryInherited": "Inheriting Profile directory: {{path}}",
+ "workingDirectoryNodeOnly": "Applies only to this Agent Node.",
+ "workingDirectoryNodeOnlyLocked": "Node-only override captured for this execution.",
+ "workingDirectoryOverrideSaveFailed": "Failed to save working directory override",
"showEarlierTurns": "Show {{count}} earlier turns",
"loadingEarlierTurns": "Loading earlier turns…",
"backToBottom": "Back to bottom",
diff --git a/apps/web/src/i18n/resources/zh-CN/common.json b/apps/web/src/i18n/resources/zh-CN/common.json
index be029bcc0..d6f4e0aaa 100644
--- a/apps/web/src/i18n/resources/zh-CN/common.json
+++ b/apps/web/src/i18n/resources/zh-CN/common.json
@@ -765,6 +765,11 @@
}
},
"chat": {
+ "workingDirectoryOverride": "工作目录覆盖",
+ "workingDirectoryInherited": "继承 Profile 目录:{{path}}",
+ "workingDirectoryNodeOnly": "仅应用于此 Agent 节点。",
+ "workingDirectoryNodeOnlyLocked": "此执行已固定使用该节点专属覆盖。",
+ "workingDirectoryOverrideSaveFailed": "保存工作目录覆盖失败",
"showEarlierTurns": "显示更早的 {{count}} 个轮次",
"loadingEarlierTurns": "正在加载更早的轮次…",
"backToBottom": "回到底部",
diff --git a/apps/web/src/store/conversationOwner.test.ts b/apps/web/src/store/conversationOwner.test.ts
index bb0b0a76f..53a899fed 100644
--- a/apps/web/src/store/conversationOwner.test.ts
+++ b/apps/web/src/store/conversationOwner.test.ts
@@ -27,6 +27,7 @@ import {
shouldComposeConversationOwner,
validateConversationView,
saveConversationDraft,
+ saveConversationWorkingDirectoryOverride,
awaitConversationDraft,
acknowledgeConversationResult,
} from './conversationOwner';
@@ -324,6 +325,61 @@ describe('conversation owner routing', () => {
).toEqual({ kind: 'internal' });
});
+ it('persists only an explicit Node cwd and clears it back to inheritance', async () => {
+ useCanvasStore.getState()._setStateNoAutosave({
+ nodes: [
+ {
+ id: 'node-source',
+ type: 'question',
+ position: { x: 0, y: 0 },
+ data: {
+ type: 'question',
+ threadId: 'thread-source',
+ content: '',
+ agentLaunchOverrides: {
+ additionalInitialPreamble: 'Keep this instruction.',
+ },
+ },
+ },
+ ],
+ });
+
+ await saveConversationWorkingDirectoryOverride(
+ ownerView,
+ ' /work/project ',
+ );
+ expect(
+ postCanvasExecute.mock.calls[0][1].commands[0].patches[0].patch,
+ ).toEqual({
+ agentLaunchOverrides: {
+ additionalInitialPreamble: 'Keep this instruction.',
+ workingDirPath: '/work/project',
+ },
+ });
+
+ useCanvasStore.getState()._setStateNoAutosave({
+ nodes: [
+ {
+ id: 'node-source',
+ type: 'question',
+ position: { x: 0, y: 0 },
+ data: {
+ type: 'question',
+ threadId: 'thread-source',
+ content: '',
+ agentLaunchOverrides: {
+ workingDirPath: '/work/project',
+ },
+ },
+ },
+ ],
+ });
+ await saveConversationWorkingDirectoryOverride(ownerView, null);
+ expect(
+ postCanvasExecute.mock.calls[1][1].commands[0].patches[0].patch,
+ ).toEqual({ agentLaunchOverrides: null });
+ });
+
it('omits server-owned fields regardless of binding policy', () => {
expect(
filterClientOwnedQuestionPatch(
diff --git a/apps/web/src/store/conversationOwner.ts b/apps/web/src/store/conversationOwner.ts
index 79606ac71..d8f88cc93 100644
--- a/apps/web/src/store/conversationOwner.ts
+++ b/apps/web/src/store/conversationOwner.ts
@@ -8,7 +8,11 @@ import { acknowledgeAgentNodeResult, postCanvasExecute } from '@/api/canvas';
import { rememberConversationAgentBinding } from '@/store/acpProfilesStore';
import useCanvasStore, { awaitQuestionCreation } from '@/store/canvasStore';
-import type { AgentBinding, AgentConversationView } from '@huabu/shared';
+import type {
+ AgentBinding,
+ AgentConversationView,
+ AgentLaunchOverrides,
+} from '@huabu/shared';
import type { Delta } from '@huabu/shared/canvas-engine';
import type { Node } from '@xyflow/react';
@@ -22,6 +26,7 @@ export type ConversationOwnerSource = {
agentBinding?: AgentBinding;
agentBindingPolicy?: 'selectable' | 'fixed';
bindingState?: 'editing' | 'bound';
+ agentLaunchOverrides?: AgentLaunchOverrides;
invocationToken?: string;
pendingInkIntentLabel?: boolean;
content?: unknown;
@@ -211,6 +216,32 @@ export function saveConversationDraft(
return save;
}
+export async function saveConversationWorkingDirectoryOverride(
+ view: AgentConversationView,
+ workingDirPath: string | null,
+): Promise {
+ const state = useCanvasStore.getState();
+ const source = resolveConversationOwnerSource(
+ state.canvasId,
+ state.nodes,
+ view,
+ );
+ if (!source) {
+ throw new ConversationIntegrityError(
+ 'Conversation owner no longer matches the active Agent node',
+ );
+ }
+
+ const nextOverrides = { ...source.agentLaunchOverrides };
+ if (workingDirPath === null) delete nextOverrides.workingDirPath;
+ else nextOverrides.workingDirPath = workingDirPath.trim();
+
+ await patchConversationOwnerNode(view, {
+ agentLaunchOverrides:
+ Object.keys(nextOverrides).length > 0 ? nextOverrides : null,
+ });
+}
+
export async function awaitConversationDraft(
view: AgentConversationView,
): Promise {
diff --git a/docs/architecture/agent-profiles.md b/docs/architecture/agent-profiles.md
index 48f379639..6fa5c14cd 100644
--- a/docs/architecture/agent-profiles.md
+++ b/docs/architecture/agent-profiles.md
@@ -49,7 +49,7 @@ The same capability-driven form handles creation and editing, including automati
After a successful structured ACP bootstrap, the driver persists the resolved `harnessLaunchPlan` in driver state and reuses it during recovery. The Profile recipe remains immutable and independent of later edits or deletion. The plan freezes launch arguments and launch-specific environment, not installed binary versions, inherited runtime environment, or a pre-bootstrap failure.
-The editable Profile is a template, not an execution. A conversation freezes its recipe, cwd, instructions, preferences, and `profileExecutionRevision` at first realization (first prompt or first mode/model/config control), not when its tab or Agent Node is created. Unrealized conversations use the latest template; realized executions and resumed/restarted processes keep their persisted snapshot. Node-specific cwd overrides still take precedence. A successful non-no-op patch advances `revision`; launch/cwd changes also advance `executionRevision`. Missing legacy revisions mean zero without rewriting the record. HTTP edits require `expectedRevision`; stale saves fail with `409 profile_conflict`, including changes that race asynchronous daemon validation.
+The editable Profile is a template, not an execution. A conversation freezes its recipe, cwd, instructions, preferences, and `profileExecutionRevision` at first realization (first prompt or first mode/model/config control), not when its tab or Agent Node is created. Unrealized conversations use the latest template; realized executions and resumed/restarted processes keep their persisted snapshot. An editable external Agent Node may persist a Node-specific cwd override in `agentLaunchOverrides.workingDirPath`; an absent override inherits the selected Profile without copying its value, while an explicit override survives pre-realization Profile changes and takes precedence only for that Node. A successful non-no-op Profile patch advances `revision`; launch/cwd changes also advance `executionRevision`. Missing legacy revisions mean zero without rewriting the record. HTTP edits require `expectedRevision`; stale saves fail with `409 profile_conflict`, including changes that race asynchronous daemon validation.
Runtime-relevant edits invalidate the Profile schema cache. Cache warm-starts and metadata writes are fenced against the frozen execution revision, so an old thread cannot repopulate or consume the new template's cache. Successful model/thought-level choices may update known-harness Profile preferences only from a matching execution revision; Custom keeps such choices session-local. Existing thread metadata remains authoritative for its live controls regardless of Profile edits.
diff --git a/docs/architecture/question-node.md b/docs/architecture/question-node.md
index e802bf28b..88dcfae85 100644
--- a/docs/architecture/question-node.md
+++ b/docs/architecture/question-node.md
@@ -40,23 +40,23 @@ User-facing node terminology is **Agent Node** in English and **Agent 节点** i
`QuestionNodeData` ([node.ts](../../packages/shared/src/types/canvas/node.ts)):
-| Field | Persisted | Notes |
-| ------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------ |
-| `content` | sidecar | The question text; stored in `nodes/.md` body like text/note (`TEXT_BEARING_NODE_TYPES`), stripped from the structure PUT |
-| `status` | ✅ | Optional sparse status: absent means `idle`; non-default values are `running` / `done` / `error` |
-| `threadId` | ✅ | Owns one chat thread; minted on first compose |
-| `conversationTitleSource` | ✅ | Server-owned naming provenance (`user` / `generated` / `acp` / `fallback` / null); the title value remains the canonical `label` |
-| `agentBinding` | ✅ | Acknowledged preparation draft; driver/Profile identity becomes immutable after canonical execution binding |
-| `agentBindingPolicy` | ✅ | Optional `selectable` / `fixed`; absent means selectable, while service-created Agent Nodes use fixed before first send |
-| `agentIcon` | ✅ | External Agent's bind-time avatar fallback; current Profile icon wins while that Profile still exists |
-| `agentLaunchOverrides` | ✅ | Optional bounded cwd and additional-initial-preamble overrides for a service-created external Agent Node |
-| `agentMode` | ✅ | `operate` (default) / `ask` for the internal agent |
-| `errorMessage` | ✅ | Set on `status === 'error'` |
-| `viewed` | ✅ | Drives unread terminal-state attention on the Agent avatar |
-| `bindingState` | ✅ | Server-owned `editing` / `bound`; Bound acknowledges a validated canonical Agenetes record and never demotes |
-| `invocationToken` | ✅ | Server-owned current or last admitted prompt identity; fences terminal writes and viewed acknowledgements |
-| `responseSummary` | reserved | Teaser field; not yet written by the runner |
-| `pendingInkIntentLabel` | ✅ | Marks only a newly created Ink Question whose placeholder may be replaced by the first structured inferred intent |
+| Field | Persisted | Notes |
+| ------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
+| `content` | sidecar | The question text; stored in `nodes/.md` body like text/note (`TEXT_BEARING_NODE_TYPES`), stripped from the structure PUT |
+| `status` | ✅ | Optional sparse status: absent means `idle`; non-default values are `running` / `done` / `error` |
+| `threadId` | ✅ | Owns one chat thread; minted on first compose |
+| `conversationTitleSource` | ✅ | Server-owned naming provenance (`user` / `generated` / `acp` / `fallback` / null); the title value remains the canonical `label` |
+| `agentBinding` | ✅ | Acknowledged preparation draft; driver/Profile identity becomes immutable after canonical execution binding |
+| `agentBindingPolicy` | ✅ | Optional `selectable` / `fixed`; absent means selectable, while service-created Agent Nodes use fixed before first send |
+| `agentIcon` | ✅ | External Agent's bind-time avatar fallback; current Profile icon wins while that Profile still exists |
+| `agentLaunchOverrides` | ✅ | Optional bounded cwd and additional-initial-preamble overrides for an external Agent Node; user-created Nodes may edit cwd before binding |
+| `agentMode` | ✅ | `operate` (default) / `ask` for the internal agent |
+| `errorMessage` | ✅ | Set on `status === 'error'` |
+| `viewed` | ✅ | Drives unread terminal-state attention on the Agent avatar |
+| `bindingState` | ✅ | Server-owned `editing` / `bound`; Bound acknowledges a validated canonical Agenetes record and never demotes |
+| `invocationToken` | ✅ | Server-owned current or last admitted prompt identity; fences terminal writes and viewed acknowledgements |
+| `responseSummary` | reserved | Teaser field; not yet written by the runner |
+| `pendingInkIntentLabel` | ✅ | Marks only a newly created Ink Question whose placeholder may be replaced by the first structured inferred intent |
Not persisted: the server invocation phase and cancellation controller, plus the browser's stream controller and request feedback. The complete Node is a read model, not a writable snapshot: ordinary Canvas PUT, commands, and undo omit or preserve the server-owned fields and cannot replace the thread association.
Question nodes are content nodes: preprocessing delegates their `content` to `ConversationTitleService.initializeQuestion()` rather than running a separate `generate_label` stage. The profile has no `persist_source`, so Questions do **not** enter the knowledge base. They remain visible to agents (`type: 'question'` in `get_space_outline`). See [node-preprocessing.md](./node-preprocessing.md) for the profile and option gates.
@@ -77,6 +77,7 @@ Created like any node via `CREATE_NODES` ([resolveAddNodes.ts](../../apps/web/sr
- **Idle** → double-click opens compose (§5).
- An idle node with `agentBindingPolicy: fixed` opens compose with its persisted binding and a read-only Agent selector. Ordinary Editing nodes retain the picker; Bound nodes cannot switch execution identity even when a first control created no messages.
+- An Editing Node bound to an external Profile exposes an optional Node-specific working-directory override below the Agent selector. Empty means inherit the selected Profile without persisting a copy; an explicit value survives Profile changes and applies only to that Node. The control has no server-local folder picker because the Profile's Agentlet may run on another machine. Binding locks the override with the rest of execution preparation; a locked explicit value remains visible as a read-only summary.
- After sending: **running → done / error**.
- `AgentThreadService` owns lifecycle for every node-backed invocation, regardless of policy. Admission publishes a new token and `running` before dispatch, installs cancellation before slow preparation, and keeps the turn lease through settlement. `AgentNodeLifecycle` fills only freshly read empty, never-submitted content and projects the matching terminal result. Existing content, previous submission tokens, and legacy conversation history prevent follow-ups from replacing authored text.
- Loading and reconnect observe server state; they never infer success from old history or repair status in the browser. A persisted running node without live tracking after restart does not establish an outcome, introduce a new badge, or trigger replay. Existing retry/admission behavior remains unchanged.
From 8f63daa260130245743c90567e0b54afcc9b5487 Mon Sep 17 00:00:00 2001
From: Yuqing Yang
Date: Mon, 5 Oct 2026 02:51:11 +0000
Subject: [PATCH 29/30] fix: hide Agent Node cwd controls
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../WorkingDirectoryOverride.test.tsx | 98 +++++++--
.../ChatPanel/WorkingDirectoryOverride.tsx | 193 +++++++++++++-----
.../src/components/Panels/ChatPanel/index.tsx | 34 +--
apps/web/src/i18n/resources/en/common.json | 8 +
apps/web/src/i18n/resources/zh-CN/common.json | 8 +
docs/architecture/question-node.md | 2 +-
6 files changed, 258 insertions(+), 85 deletions(-)
diff --git a/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.test.tsx b/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.test.tsx
index 73ec530b3..319144233 100644
--- a/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.test.tsx
+++ b/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.test.tsx
@@ -3,7 +3,15 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ afterEach,
+ assert,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+} from 'vitest';
import { i18n } from '@/i18n';
@@ -18,6 +26,20 @@ async function render(
await act(async () => root.render( ));
}
+async function openPopover() {
+ const trigger = container.querySelector('button');
+ assert(trigger);
+ await act(async () => trigger.click());
+}
+
+function nodeOverrideInput(): HTMLInputElement {
+ const input = document.body.querySelector(
+ 'input[aria-label="Node override"]',
+ );
+ assert(input);
+ return input;
+}
+
beforeEach(async () => {
await i18n.changeLanguage('en');
container = document.createElement('div');
@@ -34,55 +56,69 @@ describe('WorkingDirectoryOverride', () => {
it('shows Profile inheritance and updates it without persisting a copy', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
await render({
+ profileAlias: 'First Profile',
profileWorkingDirPath: '/profiles/first',
editable: true,
saving: false,
onSave,
});
+ expect(document.body.textContent).not.toContain(
+ 'Inheriting Profile directory',
+ );
+ await openPopover();
- const input = container.querySelector('input')!;
+ const input = nodeOverrideInput();
expect(input.placeholder).toBe('/profiles/first');
- expect(container.textContent).toContain(
+ expect(document.body.textContent).toContain(
'Inheriting Profile directory: /profiles/first',
);
+ expect(document.body.textContent).toContain('First Profile');
await render({
+ profileAlias: 'Second Profile',
profileWorkingDirPath: '/profiles/second',
editable: true,
saving: false,
onSave,
});
- expect(container.querySelector('input')?.value).toBe('');
- expect(container.querySelector('input')?.placeholder).toBe(
- '/profiles/second',
+ const updatedInput = document.body.querySelector(
+ 'input[aria-label="Node override"]',
);
+ expect(updatedInput?.value).toBe('');
+ expect(updatedInput?.placeholder).toBe('/profiles/second');
+ expect(document.body.textContent).toContain('Second Profile');
expect(onSave).not.toHaveBeenCalled();
});
it('preserves an explicit override across Profile changes and clears to null', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
await render({
+ profileAlias: 'First Profile',
profileWorkingDirPath: '/profiles/first',
workingDirPath: '/node/work',
editable: true,
saving: false,
onSave,
});
+ await openPopover();
await render({
+ profileAlias: 'Second Profile',
profileWorkingDirPath: '/profiles/second',
workingDirPath: '/node/work',
editable: true,
saving: false,
onSave,
});
- const input = container.querySelector('input')!;
+ const input = nodeOverrideInput();
expect(input.value).toBe('/node/work');
await act(async () => {
- Object.getOwnPropertyDescriptor(
+ const valueSetter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
'value',
- )!.set!.call(input, '');
+ )?.set;
+ assert(valueSetter);
+ valueSetter.call(input, '');
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
});
@@ -92,16 +128,20 @@ describe('WorkingDirectoryOverride', () => {
it('keeps a locked explicit override visible but hides locked inheritance', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
await render({
+ profileAlias: 'Default Profile',
profileWorkingDirPath: '/profiles/default',
workingDirPath: '/node/work',
editable: false,
saving: false,
onSave,
});
- expect(container.textContent).toContain('/node/work');
- expect(container.querySelector('input')).toBeNull();
+ expect(container.textContent).not.toContain('/node/work');
+ await openPopover();
+ expect(document.body.textContent).toContain('/node/work');
+ expect(document.body.querySelector('input')).toBeNull();
await render({
+ profileAlias: 'Default Profile',
profileWorkingDirPath: '/profiles/default',
editable: false,
saving: false,
@@ -113,23 +153,51 @@ describe('WorkingDirectoryOverride', () => {
it('surfaces server validation errors without replacing the draft', async () => {
const onSave = vi.fn().mockRejectedValue(new Error('Must be absolute'));
await render({
+ profileAlias: 'Default Profile',
profileWorkingDirPath: '/profiles/default',
editable: true,
saving: false,
onSave,
});
- const input = container.querySelector('input')!;
+ await openPopover();
+ const input = nodeOverrideInput();
await act(async () => {
- Object.getOwnPropertyDescriptor(
+ const valueSetter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
'value',
- )!.set!.call(input, 'relative/path');
+ )?.set;
+ assert(valueSetter);
+ valueSetter.call(input, 'relative/path');
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
});
- expect(container.querySelector('[role="alert"]')?.textContent).toBe(
+ expect(document.body.querySelector('[role="alert"]')?.textContent).toBe(
'Must be absolute',
);
expect(input.value).toBe('relative/path');
});
+
+ it('marks an explicit override and restores inheritance explicitly', async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined);
+ await render({
+ profileAlias: 'Default Profile',
+ profileWorkingDirPath: '/profiles/default',
+ workingDirPath: '/node/work',
+ editable: true,
+ saving: false,
+ onSave,
+ });
+ expect(
+ container.querySelector(
+ '[aria-label="Node working directory: /node/work"]',
+ ),
+ ).not.toBeNull();
+ await openPopover();
+ const restore = [...document.body.querySelectorAll('button')].find(
+ (button) => button.textContent === 'Restore Profile inheritance',
+ );
+ assert(restore);
+ await act(async () => restore.click());
+ expect(onSave).toHaveBeenCalledWith(null);
+ });
});
diff --git a/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.tsx b/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.tsx
index 32c25bdd8..588c76975 100644
--- a/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.tsx
+++ b/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.tsx
@@ -1,12 +1,17 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
-import { useEffect, useState } from 'react';
+import { Folder } from 'lucide-react';
+import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
+import { Button } from '@/components/Common/Button';
+import { cn } from '@/components/Common/cn';
+import { Popover } from '@/components/Common/Popover';
import { TextInput } from '@/components/Common/TextInput';
interface WorkingDirectoryOverrideProps {
+ profileAlias: string;
profileWorkingDirPath: string;
workingDirPath?: string;
editable: boolean;
@@ -15,6 +20,7 @@ interface WorkingDirectoryOverrideProps {
}
export function WorkingDirectoryOverride({
+ profileAlias,
profileWorkingDirPath,
workingDirPath,
editable,
@@ -22,6 +28,9 @@ export function WorkingDirectoryOverride({
onSave,
}: WorkingDirectoryOverrideProps) {
const { t } = useTranslation();
+ const triggerRef = useRef(null);
+ const inputRef = useRef(null);
+ const [isOpen, setIsOpen] = useState(false);
const [draft, setDraft] = useState(workingDirPath ?? '');
const [error, setError] = useState(null);
@@ -32,6 +41,14 @@ export function WorkingDirectoryOverride({
if (!editable && !workingDirPath) return null;
+ const hasOverride = Boolean(workingDirPath);
+ const effectivePath = workingDirPath ?? profileWorkingDirPath;
+ const triggerTitle = hasOverride
+ ? t('chat.workingDirectoryOverrideActive', { path: workingDirPath })
+ : t('chat.workingDirectoryOverrideInherited', {
+ path: profileWorkingDirPath,
+ });
+
const commit = async () => {
const trimmed = draft.trim();
if (trimmed === (workingDirPath ?? '')) return;
@@ -47,59 +64,127 @@ export function WorkingDirectoryOverride({
}
};
- if (!editable) {
- return (
-
-
- {t('chat.workingDirectoryOverride')}
-
-
- {workingDirPath}
-
-
- {t('chat.workingDirectoryNodeOnlyLocked')}
-
-
- );
- }
-
return (
-
-
- {t('chat.workingDirectoryOverride')}
-
-
setDraft(event.target.value)}
- onBlur={() => void commit()}
- onKeyDown={(event) => {
- if (event.key === 'Enter') {
- event.preventDefault();
- event.currentTarget.blur();
- } else if (event.key === 'Escape') {
- setDraft(workingDirPath ?? '');
- setError(null);
- event.currentTarget.blur();
- }
- }}
- placeholder={profileWorkingDirPath}
- aria-label={t('chat.workingDirectoryOverride')}
- mono
- disabled={saving}
- className="w-full"
- />
-
- {draft.trim()
- ? t('chat.workingDirectoryNodeOnly')
- : t('chat.workingDirectoryInherited', {
- path: profileWorkingDirPath,
- })}
-
- {error && (
-
- {error}
-
- )}
-
+ <>
+ setIsOpen((open) => !open)}
+ className={cn('relative', isOpen && 'bg-hover')}
+ >
+
+ {hasOverride ? (
+
+ ) : null}
+
+ {isOpen ? (
+ setIsOpen(false)}
+ onOpenAutoFocus={() => inputRef.current?.focus()}
+ className="w-[min(24rem,var(--popover-available-width))] p-3"
+ >
+
+
+
+ {t('chat.workingDirectoryOverride')}
+
+
+ {editable
+ ? t('chat.workingDirectoryOverrideDescription')
+ : t('chat.workingDirectoryNodeOnlyLocked')}
+
+
+
+
+ {t('chat.workingDirectoryProfile')}
+
+ {profileAlias}
+ {profileWorkingDirPath ? (
+ <>
+
+ {t('chat.workingDirectoryProfileDefault')}
+
+
+ {profileWorkingDirPath}
+
+ >
+ ) : null}
+
+ {t('chat.workingDirectoryEffective')}
+
+
+ {effectivePath}
+
+
+ {editable ? (
+
+
+ {t('chat.workingDirectoryNodeOverride')}
+
+
setDraft(event.target.value)}
+ onBlur={() => void commit()}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ event.currentTarget.blur();
+ } else if (event.key === 'Escape') {
+ setDraft(workingDirPath ?? '');
+ setError(null);
+ }
+ }}
+ placeholder={profileWorkingDirPath}
+ aria-label={t('chat.workingDirectoryNodeOverride')}
+ mono
+ disabled={saving}
+ className="w-full"
+ />
+
+ {draft.trim()
+ ? t('chat.workingDirectoryNodeOnly')
+ : t('chat.workingDirectoryInherited', {
+ path: profileWorkingDirPath,
+ })}
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+ {workingDirPath ? (
+ {
+ setDraft('');
+ void onSave(null).catch((saveError) => {
+ setError(
+ saveError instanceof Error
+ ? saveError.message
+ : t('chat.workingDirectoryOverrideSaveFailed'),
+ );
+ });
+ }}
+ className="mt-2"
+ >
+ {t('chat.workingDirectoryRestoreInheritance')}
+
+ ) : null}
+
+ ) : null}
+
+
+ ) : null}
+ >
);
}
diff --git a/apps/web/src/components/Panels/ChatPanel/index.tsx b/apps/web/src/components/Panels/ChatPanel/index.tsx
index ea01cfa19..da626fb18 100644
--- a/apps/web/src/components/Panels/ChatPanel/index.tsx
+++ b/apps/web/src/components/Panels/ChatPanel/index.tsx
@@ -1090,21 +1090,25 @@ export const ChatPanel = ({
slashLoading={slashLoading}
onSlashMenuIntent={refreshSlashCommands}
agentSelectorSlot={
-
-
- {showWorkingDirectoryOverride && (
+
+
+ {showWorkingDirectoryOverride ? (
- )}
+ ) : null}
}
acpSelectorsSlot={
diff --git a/apps/web/src/i18n/resources/en/common.json b/apps/web/src/i18n/resources/en/common.json
index 85933262c..abfa140b1 100644
--- a/apps/web/src/i18n/resources/en/common.json
+++ b/apps/web/src/i18n/resources/en/common.json
@@ -772,10 +772,18 @@
},
"chat": {
"workingDirectoryOverride": "Working directory override",
+ "workingDirectoryOverrideDescription": "Optionally use a different directory for this Agent Node.",
+ "workingDirectoryOverrideActive": "Node working directory: {{path}}",
+ "workingDirectoryOverrideInherited": "Working directory inherited from Profile: {{path}}",
"workingDirectoryInherited": "Inheriting Profile directory: {{path}}",
"workingDirectoryNodeOnly": "Applies only to this Agent Node.",
"workingDirectoryNodeOnlyLocked": "Node-only override captured for this execution.",
"workingDirectoryOverrideSaveFailed": "Failed to save working directory override",
+ "workingDirectoryProfile": "Profile",
+ "workingDirectoryProfileDefault": "Profile directory",
+ "workingDirectoryEffective": "Effective directory",
+ "workingDirectoryNodeOverride": "Node override",
+ "workingDirectoryRestoreInheritance": "Restore Profile inheritance",
"showEarlierTurns": "Show {{count}} earlier turns",
"loadingEarlierTurns": "Loading earlier turns…",
"backToBottom": "Back to bottom",
diff --git a/apps/web/src/i18n/resources/zh-CN/common.json b/apps/web/src/i18n/resources/zh-CN/common.json
index 5e11167b0..b601f7285 100644
--- a/apps/web/src/i18n/resources/zh-CN/common.json
+++ b/apps/web/src/i18n/resources/zh-CN/common.json
@@ -772,10 +772,18 @@
},
"chat": {
"workingDirectoryOverride": "工作目录覆盖",
+ "workingDirectoryOverrideDescription": "可选择仅为此 Agent 节点使用不同的目录。",
+ "workingDirectoryOverrideActive": "节点工作目录:{{path}}",
+ "workingDirectoryOverrideInherited": "工作目录继承自 Profile:{{path}}",
"workingDirectoryInherited": "继承 Profile 目录:{{path}}",
"workingDirectoryNodeOnly": "仅应用于此 Agent 节点。",
"workingDirectoryNodeOnlyLocked": "此执行已固定使用该节点专属覆盖。",
"workingDirectoryOverrideSaveFailed": "保存工作目录覆盖失败",
+ "workingDirectoryProfile": "Profile",
+ "workingDirectoryProfileDefault": "Profile 目录",
+ "workingDirectoryEffective": "有效目录",
+ "workingDirectoryNodeOverride": "节点覆盖",
+ "workingDirectoryRestoreInheritance": "恢复继承 Profile",
"showEarlierTurns": "显示更早的 {{count}} 个轮次",
"loadingEarlierTurns": "正在加载更早的轮次…",
"backToBottom": "回到底部",
diff --git a/docs/architecture/question-node.md b/docs/architecture/question-node.md
index 88dcfae85..d3ae49620 100644
--- a/docs/architecture/question-node.md
+++ b/docs/architecture/question-node.md
@@ -77,7 +77,7 @@ Created like any node via `CREATE_NODES` ([resolveAddNodes.ts](../../apps/web/sr
- **Idle** → double-click opens compose (§5).
- An idle node with `agentBindingPolicy: fixed` opens compose with its persisted binding and a read-only Agent selector. Ordinary Editing nodes retain the picker; Bound nodes cannot switch execution identity even when a first control created no messages.
-- An Editing Node bound to an external Profile exposes an optional Node-specific working-directory override below the Agent selector. Empty means inherit the selected Profile without persisting a copy; an explicit value survives Profile changes and applies only to that Node. The control has no server-local folder picker because the Profile's Agentlet may run on another machine. Binding locks the override with the rest of execution preparation; a locked explicit value remains visible as a read-only summary.
+- An Editing Node bound to an external Profile exposes a Folder button beside the Agent selector. Its Popover shows the Profile and effective directories and optionally edits a Node-specific working-directory override. Empty means inherit the selected Profile without persisting a copy; an explicit value marks the trigger, survives Profile changes, and applies only to that Node. The control has no server-local folder picker because the Profile's Agentlet may run on another machine. Binding locks the override with the rest of execution preparation; a locked explicit value remains available through the same Popover as read-only context.
- After sending: **running → done / error**.
- `AgentThreadService` owns lifecycle for every node-backed invocation, regardless of policy. Admission publishes a new token and `running` before dispatch, installs cancellation before slow preparation, and keeps the turn lease through settlement. `AgentNodeLifecycle` fills only freshly read empty, never-submitted content and projects the matching terminal result. Existing content, previous submission tokens, and legacy conversation history prevent follow-ups from replacing authored text.
- Loading and reconnect observe server state; they never infer success from old history or repair status in the browser. A persisted running node without live tracking after restart does not establish an outcome, introduce a new badge, or trigger replay. Existing retry/admission behavior remains unchanged.
From 579de14fbcab56734536abb5eb98a3537598accc Mon Sep 17 00:00:00 2001
From: Yuqing Yang
Date: Mon, 5 Oct 2026 07:27:29 +0000
Subject: [PATCH 30/30] fix: show inherited Agent Node cwd
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Panels/ChatPanel/WorkingDirectoryOverride.test.tsx | 6 ++++--
.../Panels/ChatPanel/WorkingDirectoryOverride.tsx | 2 --
apps/web/src/components/Panels/ChatPanel/index.tsx | 3 ++-
docs/architecture/question-node.md | 2 +-
4 files changed, 7 insertions(+), 6 deletions(-)
diff --git a/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.test.tsx b/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.test.tsx
index 319144233..1ab9d8a38 100644
--- a/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.test.tsx
+++ b/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.test.tsx
@@ -125,7 +125,7 @@ describe('WorkingDirectoryOverride', () => {
expect(onSave).toHaveBeenCalledWith(null);
});
- it('keeps a locked explicit override visible but hides locked inheritance', async () => {
+ it('keeps locked explicit and inherited directories available read-only', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
await render({
profileAlias: 'Default Profile',
@@ -147,7 +147,9 @@ describe('WorkingDirectoryOverride', () => {
saving: false,
onSave,
});
- expect(container.textContent).toBe('');
+ expect(container.querySelector('button')).not.toBeNull();
+ expect(document.body.textContent).toContain('/profiles/default');
+ expect(document.body.querySelector('input')).toBeNull();
});
it('surfaces server validation errors without replacing the draft', async () => {
diff --git a/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.tsx b/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.tsx
index 588c76975..56dc8e492 100644
--- a/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.tsx
+++ b/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.tsx
@@ -39,8 +39,6 @@ export function WorkingDirectoryOverride({
setError(null);
}, [workingDirPath]);
- if (!editable && !workingDirPath) return null;
-
const hasOverride = Boolean(workingDirPath);
const effectivePath = workingDirPath ?? profileWorkingDirPath;
const triggerTitle = hasOverride
diff --git a/apps/web/src/components/Panels/ChatPanel/index.tsx b/apps/web/src/components/Panels/ChatPanel/index.tsx
index da626fb18..77c67e5ee 100644
--- a/apps/web/src/components/Panels/ChatPanel/index.tsx
+++ b/apps/web/src/components/Panels/ChatPanel/index.tsx
@@ -830,7 +830,8 @@ export const ChatPanel = ({
conversationOwnerSource?.agentLaunchOverrides?.workingDirPath;
const showWorkingDirectoryOverride =
!!activeConversationView &&
- ((!!selectedExternalProfile && preparationEditable) ||
+ agentBinding.kind === 'external' &&
+ ((!!selectedExternalProfile && !!selectedExternalProfile.workingDirPath) ||
!!workingDirectoryOverride);
const handleSaveWorkingDirectory = useCallback(
async (workingDirPath: string | null) => {
diff --git a/docs/architecture/question-node.md b/docs/architecture/question-node.md
index d3ae49620..7e4f81a84 100644
--- a/docs/architecture/question-node.md
+++ b/docs/architecture/question-node.md
@@ -77,7 +77,7 @@ Created like any node via `CREATE_NODES` ([resolveAddNodes.ts](../../apps/web/sr
- **Idle** → double-click opens compose (§5).
- An idle node with `agentBindingPolicy: fixed` opens compose with its persisted binding and a read-only Agent selector. Ordinary Editing nodes retain the picker; Bound nodes cannot switch execution identity even when a first control created no messages.
-- An Editing Node bound to an external Profile exposes a Folder button beside the Agent selector. Its Popover shows the Profile and effective directories and optionally edits a Node-specific working-directory override. Empty means inherit the selected Profile without persisting a copy; an explicit value marks the trigger, survives Profile changes, and applies only to that Node. The control has no server-local folder picker because the Profile's Agentlet may run on another machine. Binding locks the override with the rest of execution preparation; a locked explicit value remains available through the same Popover as read-only context.
+- An external Agent Node exposes a Folder button beside the Agent selector whenever its effective directory is known, including when a locked Node inherits its Profile directory. Its Popover shows the Profile and effective directories and, while Editing, optionally edits a Node-specific working-directory override. Empty means inherit the selected Profile without persisting a copy; an explicit value marks the trigger, survives Profile changes, and applies only to that Node. The control has no server-local folder picker because the Profile's Agentlet may run on another machine. Binding locks the override with the rest of execution preparation; the same Popover remains available as read-only execution context whether the locked directory is inherited or overridden.
- After sending: **running → done / error**.
- `AgentThreadService` owns lifecycle for every node-backed invocation, regardless of policy. Admission publishes a new token and `running` before dispatch, installs cancellation before slow preparation, and keeps the turn lease through settlement. `AgentNodeLifecycle` fills only freshly read empty, never-submitted content and projects the matching terminal result. Existing content, previous submission tokens, and legacy conversation history prevent follow-ups from replacing authored text.
- Loading and reconnect observe server state; they never infer success from old history or repair status in the browser. A persisted running node without live tracking after restart does not establish an outcome, introduce a new badge, or trigger replay. Existing retry/admission behavior remains unchanged.