From 2b8a0a8cffc5bf146975904e1df763a2e582f38a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 5 Aug 2026 18:51:11 +0800 Subject: [PATCH] feat(web): choose the agent preset on the new-session screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer seat spent nearly all its life disabled: a session's composition is fixed once a turn has run. Move the choice to the new-session screen beside the workspace picker, where it still works, and let the session header report what a running session runs. The hero pick is staged rather than applied — that screen precedes the session it belongs to. It lands when a session becomes current and is still blank, which covers both the session a workspace connect creates and the blank one it reuses; riding `sessions.create` would miss the second. It is spent on first use, matching the workspace picker. Fix the durability the header field claimed but never had: `agentPreset` was declared on `SessionHeader` and dropped by the JSONL header line, the SQLite sessions row, the derived query index, and the cold list projection, so every resumed session came back composed from nothing. Add the web e2e lane that would have caught it — the one lane that mounts the shipped roster, which needed `cordis:group` in the scaffold's Loader builtins, as `mountRootInclude` already registers. --- .../2026-08-03-per-session-agent-presets.md | 4 + ...2026-08-03-per-session-agent-presets.zh.md | 4 + apps/web/tests/agent-preset-selection.e2e.ts | 153 +++++++++ apps/web/tests/scaffold.ts | 27 +- .../agent-preset-selection/header.expected.md | 4 + .../agent-preset-selection/hero.expected.md | 8 + .../agent-preset-selection/menu.expected.md | 6 + apps/web/tsconfig.json | 1 + packages/client/README.i18n.yaml | 4 +- packages/client/README.md | 1 + packages/client/README.zh.md | 1 + .../runtime/src/client/sessions/service.ts | 4 +- .../client/ui-agent-preset/README.i18n.yaml | 4 +- packages/client/ui-agent-preset/README.md | 20 +- packages/client/ui-agent-preset/README.zh.md | 20 +- .../src/client/AgentPresetLabel.module.css | 23 ++ .../src/client/AgentPresetLabel.tsx | 62 ++++ .../src/client/AgentPresetSeat.module.css | 54 ++- .../src/client/AgentPresetSeat.tsx | 61 ++-- .../ui-agent-preset/src/client/index.ts | 95 ++++-- .../ui-agent-preset/src/client/locales.ts | 12 +- .../ui-agent-preset/src/client/seat-store.ts | 117 +++++-- .../src/client/settings-store.ts | 13 +- .../ui-agent-preset/tests/apply.spec.ts | 172 +++++++--- .../ui-agent-preset/tests/components.spec.tsx | 163 +++++---- .../tests/settings-store.spec.ts | 321 +++++++++++------- .../ui-conversation/src/client/apply.ts | 2 +- .../src/client/contract/slots.ts | 37 +- .../src/client/skeleton/ConversationRoot.tsx | 1 + .../src/client/skeleton/InputBar.tsx | 1 - .../ui-conversation/tests/chat-apply.spec.tsx | 6 +- .../ui-conversation/tests/input-bar.spec.tsx | 2 +- .../ui-conversation/tests/skeleton.spec.tsx | 3 + packages/host/apiproxy/src/api-proxy.ts | 10 +- packages/preset/agent-presets/src/mount.ts | 25 +- .../agent-presets/tests/discovery.spec.ts | 15 + .../fixtures/user/two-broken/agent.cordis.yml | 7 + .../agent-presets/tests/metadata.spec.ts | 4 + .../preset/agent-presets/tests/mount.spec.ts | 10 +- .../agent-presets/tests/session.spec.ts | 62 ++++ .../README.i18n.yaml | 4 +- .../session-persistence-jsonl/README.md | 2 +- .../session-persistence-jsonl/README.zh.md | 2 +- .../session-persistence-jsonl/src/format.ts | 5 + .../tests/jsonl.spec.ts | 21 ++ .../session-persistence-sqlite/src/index.ts | 8 +- .../session-persistence-sqlite/src/schema.ts | 5 +- .../tests/sqlite.spec.ts | 22 +- .../session-query-sqlite/src/index.ts | 19 +- .../session-query-sqlite/src/schema.ts | 4 +- .../session-query-sqlite/tests/sqlite.spec.ts | 5 +- tsconfig.host.json | 1 + 52 files changed, 1238 insertions(+), 399 deletions(-) create mode 100644 apps/web/tests/agent-preset-selection.e2e.ts create mode 100644 apps/web/tests/snapshots/agent-preset-selection/header.expected.md create mode 100644 apps/web/tests/snapshots/agent-preset-selection/hero.expected.md create mode 100644 apps/web/tests/snapshots/agent-preset-selection/menu.expected.md create mode 100644 packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css create mode 100644 packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx create mode 100644 packages/preset/agent-presets/tests/fixtures/user/two-broken/agent.cordis.yml create mode 100644 packages/preset/agent-presets/tests/session.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md index 25099b48c5..936ca19952 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md @@ -63,6 +63,10 @@ Which preset an unnamed session gets is a user setting (`agent-presets.default`) **The preset id is model-visible and must be logged.** It determines the tool set and prompt, so a resumed session has to restore the same composition; recording it is a session fact, not runtime state. It rides the session header beside `cwd`, and the summary carries it so a picker shows what a session actually runs rather than the deployment's current default. +**A durable header field is not durable until every backend writes it.** `agentPreset` landed on `SessionHeader` with the right rationale and neither persistence backend carried it: the JSONL header line, the SQLite `sessions` row, and the derived query index each map the header column by column, so a resumed session came back with no preset and the surfaces that name it fell silent. `summarizeCold` had the same shape — it hand-built the cold list row instead of reusing the shared projection. A field declared durable needs a test that crosses a real store, not only the type that declares it. + +**The choice belongs to the screen where it still works.** The composer seat spent almost its whole life disabled, since the preset is fixed once a turn has run. It moved to the new-session screen beside the workspace picker, where the pick is *staged*: that screen precedes the session it applies to, and the stage lands when a session becomes current and is still blank — covering both the session a workspace connect creates and the blank one it reuses, which riding `sessions.create` would miss. It is spent on first use, matching the workspace picker beside it. What a running session runs is then a read-only label in its header: a control there would promise a switch the host refuses outright. + ## Alternatives considered **Add a preset tier to the scoped registries.** `ScopedLayers.merge()` combines the global layer with exactly one exact-scope layer. A middle tier would let many sessions share one mounted composition, but it changes `dsh-scope` and every scope-aware registry to save a cost measured in milliseconds, and it gives a preset's registrations a lifetime no agent owns. diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md index 07a518d3bf..b639b1a23b 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md @@ -64,6 +64,10 @@ Status: implemented **preset id 对模型可见,必须写入日志。** 它决定工具集与提示词,因此被恢复的会话必须还原同一份组装;记录它属于会话事实,而非运行时状态。它与 `cwd` 并列写在会话头部,并由会话摘要携带,使选择器显示的是某个会话实际运行的 preset,而非部署当前的默认值。 +**持久化的头部字段,在每个后端都写入之前都算不上持久。** `agentPreset` 带着正确的理由落在了 `SessionHeader` 上,而两个持久化后端都没有携带它:JSONL 头部行、SQLite `sessions` 行、以及派生的查询索引各自逐列映射头部,于是被恢复的会话回来时没有 preset,所有据以命名它的表层随之失声。`summarizeCold` 是同一个形状——它手工拼装冷列表行,而没有复用共享的投影。声明为持久的字段,需要一个跨越真实存储的测试,而不只是声明它的那个类型。 + +**这个选择属于它仍然可用的那个界面。** composer 座位几乎一生都处于禁用状态,因为一旦跑过一个轮次,preset 即固定。它移到了新建会话界面、工作区选择器旁边,选择在那里是**暂存**的:该界面先于它要应用到的会话存在,暂存值在某个会话成为当前会话且仍为空白时落地——这既覆盖工作区连接新建的会话,也覆盖它复用的那个空白会话,而搭 `sessions.create` 的便车会漏掉后者。它一经使用即被清空,与旁边的工作区选择器一致。至于运行中的会话在跑什么,则是其标题旁的一个只读标签:在那里放控件,等于承诺一次宿主会断然拒绝的切换。 + ## 考虑过的替代方案 **在 scope 注册表中新增 preset 分层。** `ScopedLayers.merge()` 把全局层与恰好一个精确 scope 层合并。新增中间层可以让多个会话共用一份已挂载的组装,但它要改动 `dsh-scope` 及每个 scope 感知的注册表,换来的只是毫秒级的开销节省,而且会让 preset 的注册获得一个没有任何 agent 拥有的生命周期。 diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts new file mode 100644 index 0000000000..69672f49e1 --- /dev/null +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -0,0 +1,153 @@ +// Web e2e scenario: agent-preset selection. The roster's `roots` is an +// assembly fact the CLI entry resolves and patches in, so every other lane +// boots with an empty roster and no preset surface at all; this is the one +// lane that mounts the SHIPPED presets and puts them in front of a browser. +// +// Two surfaces, one host rule: a session's composition is fixed when the +// session starts. Before that, the new-session chip stages the choice beside +// the workspace picker — the only screen where it still works. After it, the +// session header names what the session runs and offers no control at all, +// because the host answers `agent-preset-locked` to anything else. +// +// Zero model calls: no replay fixture mounts, so a stray stream fails loud. +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, + webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/agent-preset-selection', import.meta.url)) +const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md') +const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md') +const HEADER_EXPECTED = join(SNAPSHOT_DIR, 'header.expected.md') +/** The shipped roster, beside the composition that names it. */ +const SHIPPED_PRESETS = fileURLToPath(new URL('../../cli/config/agent-presets', import.meta.url)) +const MODE = webSnapshotMode() +const SEED_ID = 'agent-preset-selection-web-e2e' + +/** + * A settled one-turn session with no model content: this lane asserts chrome + * around a conversation, not a conversation, and a recorded turn would tie + * the golden to a provider's wording for no gain. + * @returns a tokenized session log ending on a closed turn. + */ +function seedLog(): string { + const time = 1784974100000 + const at = (index: number, event: Record): string => + JSON.stringify({ ...event, seq: index, time: time + index }) + return [ + JSON.stringify({ type: 'session', version: 0, id: '{{sessionId}}', createdAt: time, cwd: '{{cwd}}/workspace' }), + at(0, { type: 'turn/start', data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user', rpcId: 'seed' } } } }), + at(1, { + type: 'user/message', + data: { content: [{ type: 'text', text: 'Seeded turn.' }], source: { kind: 'user', rpcId: 'seed' } }, + surfaceOp: 'append', + }), + at(2, { type: 'session/title', data: { title: 'Seeded turn', messageSeqs: [1], source: { kind: 'fallback' } } }), + at(3, { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }), + ].join('\n') +} + +describe('web e2e: agent-preset selection', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({ + agentPresets: { roots: [{ path: SHIPPED_PRESETS, trust: 'system' }], default: 'standard' }, + }) + // A resumed session runs what it was created with; seeding one that + // records `minimal` is what makes the header label a claim about the + // session rather than an echo of the current default. + await seedSession(scaffold, seedLog(), SEED_ID, 'minimal') + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('offers the chip on the new-session screen, beside the workspace picker', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-hero')) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + + const snapshot = await captureStableAria(page, '[class*="heroWorkspaceRow"]', scaffold.workspaceCwd) + + await compareOrRefreshGolden(HERO_EXPECTED, snapshot, MODE) + // The chip opens on the deployment default, by the name that preset + // publishes rather than its directory name. + expect(snapshot).toContain('标准模式') + }) + + it('names every preset and what it is for', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-menu')) + await page.getByRole('button', { name: '标准模式' }).click() + const menu = page.getByRole('menu') + await menu.waitFor({ timeout: 10_000 }) + + const snapshot = await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd) + + await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE) + // Every shipped preset, each with the sentence saying what it composes — + // the id alone never said what a preset does. + expect(snapshot).toContain('极简模式') + expect(snapshot).toContain('创造模式') + await page.keyboard.press('Escape') + }) + + it('applies the staged pick to the blank session, and the host honors it', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-stage')) + await page.getByRole('button', { name: '标准模式' }).click() + await page.getByRole('menuitem', { name: /极简模式/ }).click() + + // The chip stages; the blank session the workspace connect produced is + // what the stage lands on. The host's own answer is what comes back. + await expect.poll(async () => { + const response = await fetch(`${scaffold.baseUrl}/api/session.list`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', rpcId: 'agent-preset-stage', method: 'session.list', payload: {}, + }), + }) + const body = await response.json() as { + result: { value?: { sessions: { blank: boolean; agentPreset?: string }[] } } + } + return JSON.stringify(body.result.value?.sessions ?? body.result) + }, { timeout: 15_000 }).toContain('minimal') + }) + + it('labels a resumed session with the preset it was created under', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-header')) + // The seeded session's cwd is the scaffold root rather than the connected + // workspace, so it lists under Ungrouped; the group collapses by default. + await page.getByRole('treeitem', { name: /^Ungrouped/ }).click() + await page.locator('[role="treeitem"]').last().click() + await page.getByText('Seeded turn.').waitFor({ timeout: 15_000 }) + + const snapshot = await captureStableAria(page, '[class*="titleRow"]', scaffold.workspaceCwd) + + await compareOrRefreshGolden(HEADER_EXPECTED, snapshot, MODE) + expect(snapshot).toContain('极简模式') + // Static chrome, not a control: the header can only report a composition + // the host would refuse to change. + expect(snapshot).not.toContain('button "极简模式"') + }) + + it('drove every surface without a page error or a stream warning', () => { + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) +}) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index acc2116531..8a3e7a2c79 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -180,6 +180,20 @@ export interface LaunchOptions { /** Credential reference resolved by the shipped search provider. */ apiKeyEnv: string } + /** + * Replace the roster the scaffold mounts by default (the shipped directory + * at `system` trust, default `standard`). Supply this only to change WHICH + * presets a scenario sees — a writable user root, a different default — + * never to turn the roster on: without one every session composes an agent + * with no tools, no persona, and no token meter, which is not a shape the + * product ever boots in. The patch lands after the default, so it wins. + */ + agentPresets?: { + /** Roots to discover, in precedence order; the shipped directory is `system`. */ + roots: { path: string; trust: 'system' | 'user' }[] + /** The preset a session that names none is composed from. */ + default: string + } /** Leave the current welcome notice unacknowledged; ordinary scenarios publish it as complete before browser boot. */ welcomeNoticePending?: boolean /** @@ -319,6 +333,9 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { +export async function seedSession( + scaffold: WebScaffold, + fixtureText: string, + id: string, + agentPreset?: string, +): Promise { const events = parseSessionLog(realizeSeedFixture(scaffold, fixtureText, id)) if (events.length === 0) throw new Error('seed fixture has no events') const last = events[events.length - 1]! @@ -547,6 +571,7 @@ export async function seedSession(scaffold: WebScaffold, fixtureText: string, id createdAt: Date.now() - 60_000, cwd: scaffold.workspaceCwd, delegationDepth: 0, + ...agentPreset === undefined ? {} : { agentPreset }, } const seeder = new Context() try { diff --git a/apps/web/tests/snapshots/agent-preset-selection/header.expected.md b/apps/web/tests/snapshots/agent-preset-selection/header.expected.md new file mode 100644 index 0000000000..ef2ad4ef57 --- /dev/null +++ b/apps/web/tests/snapshots/agent-preset-selection/header.expected.md @@ -0,0 +1,4 @@ +- navigation "Session hierarchy": + - button "Seeded turn" [disabled] +- img +- text: 极简模式 diff --git a/apps/web/tests/snapshots/agent-preset-selection/hero.expected.md b/apps/web/tests/snapshots/agent-preset-selection/hero.expected.md new file mode 100644 index 0000000000..f2d54eb579 --- /dev/null +++ b/apps/web/tests/snapshots/agent-preset-selection/hero.expected.md @@ -0,0 +1,8 @@ +- button "Choose workspace": + - img + - text: workspace + - img +- button "标准模式": + - img + - text: 标准模式 + - img diff --git a/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md b/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md new file mode 100644 index 0000000000..10ffa973f0 --- /dev/null +++ b/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md @@ -0,0 +1,6 @@ +- menu: + - menuitem "标准模式 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。": + - text: 标准模式 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。 + - img + - menuitem "极简模式 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。" + - menuitem "创造模式 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。" diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index dd5fe879e7..428fe9bdd2 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -57,6 +57,7 @@ "tests/skill-invocation-policy.e2e.ts", "tests/permission-policy-context.e2e.ts", "tests/access-confirmation.e2e.ts", + "tests/agent-preset-selection.e2e.ts", "tests/shipped-composition.e2e.ts", "tests/goal-bar.e2e.ts", "tests/startup-auto-selection.e2e.ts", diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 503a747087..d06ce83a69 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/README.md -README.md: b950772d4cad6d873426f8aee6416fa56afca2ee -README.zh.md: 8f1f7f46777b7037e8baa04c9ec16ef74ffd478d +README.md: a0888b0d02d3d6ed234489d686ae9ad996e11674 +README.zh.md: a6e4e42c12c97802897c6d58efdcc7468d45db8c diff --git a/packages/client/README.md b/packages/client/README.md index b950772d4c..c2400ce36d 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -32,6 +32,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha | [`ui-permission/`](ui-permission/README.md) | Configures default permissions and switches the current session's access. | | [`ui-plan/`](ui-plan/README.md) | Presents active plan-mode status and its exit control. | | [`ui-question/`](ui-question/README.md) | Presents interactive questions requested by the agent. | +| [`ui-agent-preset/`](ui-agent-preset/README.md) | Selects a session's agent preset and authors preset compositions. | | [`ui-settings/`](ui-settings/README.md) | Hosts the settings interface and its extension areas. | | [`ui-settings-general/`](ui-settings-general/README.md) | Provides the general settings section. | | [`ui-models/`](ui-models/README.md) | Provides model-provider configuration and DeepSeek onboarding. | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 8f1f7f4677..b88c28df77 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -32,6 +32,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U | [`ui-permission/`](ui-permission/README.md) | 配置默认权限并切换当前会话的访问模式。 | | [`ui-plan/`](ui-plan/README.md) | 展示生效中的 plan mode 状态及其退出控件。 | | [`ui-question/`](ui-question/README.md) | 展示 agent 请求的交互式问题。 | +| [`ui-agent-preset/`](ui-agent-preset/README.md) | 选择会话的 agent 预设,并创作预设组装。 | | [`ui-settings/`](ui-settings/README.md) | 承载设置界面及其扩展区域。 | | [`ui-settings-general/`](ui-settings-general/README.md) | 提供常规设置分区。 | | [`ui-models/`](ui-models/README.md) | 提供模型提供方配置与 DeepSeek 配置引导。 | diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index b199c0724a..a70cec7483 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -47,8 +47,8 @@ export interface SessionSummary { cwd?: string /** * Agent preset this session's agent was composed from; absent when the - * deployment composes no presets. A composer seat shows what the session - * actually runs rather than the deployment's current default. + * deployment composes no presets. The session header labels what the + * session actually runs rather than the deployment's current default. */ agentPreset?: string parentId?: SessionId diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 97e553be99..a8c3072416 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-agent-preset/README.md -README.md: be159855010c1a0af7d3f712d9551000826d4820 -README.zh.md: 73c07751f4e5cf1cea43d2723027162f09da10f6 +README.md: 3df8e43892ea619155918b28a171d30b6f9d1a3d +README.zh.md: 4725517c6f2ebf5b172bd01023cdef0de6f542ac diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index 45e4ceabbb..6261984835 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -2,17 +2,23 @@ English | [中文](README.zh.md) -The agent-preset surfaces: a General-settings row choosing which [preset](../../preset/agent-presets/README.md) new sessions are composed from, a composer seat choosing this session's, and a settings section that authors the compositions themselves. +The agent-preset surfaces: a General-settings row choosing which [preset](../../preset/agent-presets/README.md) new sessions are composed from, a chip on the new-session screen choosing the next session's, a read-only label in the session header, and a settings section that authors the compositions themselves. ## Why it is a new-session preference A session's preset is fixed when the session is created — the host refuses to adopt an existing session under a different one, because that session's history was produced under the first preset's tools. So this row cannot be a live switch, and it says so: changing it applies to sessions started afterwards while running sessions keep the composition they began with. -## The composer seat +## The new-session chip -A second surface, in the composer tool row left of the model select: the preset THIS session runs. It shows the session's own recorded preset rather than the deployment default, because a resumed session runs what it was created with. +A second surface, beside the workspace picker on the new-session screen. It sits there rather than in the composer because that is where the choice is still open: a control that spends most of its life disabled belongs on the screen where it still works. -The switch exists only while the conversation has not started. After the first turn the seat becomes a plain label — offering a disabled menu would suggest the choice is merely unavailable rather than gone. The host enforces the same rule and answers `agent-preset-locked`, so a stale client cannot slip a switch past it. +The chip opens on the deployment default and its pick is *staged* — the screen precedes the session it would apply to. The stage reaches a session when one becomes current and is still blank, which covers both the session the workspace connect created and the blank one it reused; riding along on `sessions.create` would miss the second. It is spent on first use, so the next new session opens on the default again, exactly like the workspace picker beside it. + +A session that has started is refused rather than queued: the host answers `agent-preset-locked`, and the stage is dropped instead of waiting for a session that will never accept it. + +## The session-header label + +A third surface, beside the session title: the preset THIS session runs, as static chrome. A control there would promise a switch the host refuses outright. It reads the preset from the session's own summary — a resumed session runs what it was created with, not today's default — and resolves the display name against the same roster the General row reads. ## What it reads and writes @@ -24,7 +30,7 @@ The row re-reads on `settings/changed` for its own namespace and on `connection/ ## The management section -A third surface, its own settings page (`settings.section` id `agent-presets`, ordered after Models — choosing a model is routine, composing an agent is the deployment-shaping act behind it): the roster as rows, and one composition open in a YAML editor at a time. +A fourth surface, its own settings page (`settings.section` id `agent-presets`, ordered after Models — choosing a model is routine, composing an agent is the deployment-shaping act behind it): the roster as rows, and one composition open in a YAML editor at a time. A shipped preset opens read-only. It is the known-good composition a local one is written against, so reading it is the point and overwriting it is not — the deployment's copy is what a broken local preset is compared against. Authoring therefore starts by duplicating: **New preset** copies the current default, and **Duplicate** copies any row, because a copy always lands in the local root regardless of where the text came from. @@ -38,7 +44,7 @@ Setting the default writes the `agent-presets` settings namespace, which the hos ## When the surfaces are absent -A deployment that composes no presets answers with an empty roster, and the row, the seat, and the section all render nothing — every session then shares the host composition, and there is nothing to choose between or manage. A deployment that configures no writable root answers `authorable: false`, and the section stays a read-only browser: the rows still open, but creating is offered nowhere rather than through a button whose save always fails. +A deployment that composes no presets answers with an empty roster, and the row, the chip, the label, and the section all render nothing — every session then shares the host composition, and there is nothing to choose between or manage. A deployment that configures no writable root answers `authorable: false`, and the section stays a read-only browser: the rows still open, but creating is offered nowhere rather than through a button whose save always fails. ## Model Experience @@ -50,6 +56,6 @@ No direct invalidation. Changing the default never touches a running session's p ## Known Limitations and Deferred Work -- **Presets are listed by id** — a preset carries no display metadata, so the menus and rows show directory names. +- **A preset without metadata is listed by id** — display text is optional, and a preset that publishes none (every preset authored by duplicating another starts that way) shows its directory name. - **The editor is a plain textarea** — no YAML syntax highlighting, folding, or schema completion; the host's shape check on save is the only validation. - **A saved composition is not mounted** — a preset that parses but names a missing plugin is accepted, and fails at the next session that selects it. diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index 3552e92675..fb860812b1 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -2,17 +2,23 @@ [English](README.md) | 中文 -agent preset 的各个表层:General 设置中的一行,用于选择新建会话据以组装的 [preset](../../preset/agent-presets/README.md);composer 中的一个座位,用于选择**本会话**的 preset;以及一个设置页分区,用于创作组装本身。 +agent preset 的各个表层:General 设置中的一行,用于选择新建会话据以组装的 [preset](../../preset/agent-presets/README.md);新建会话界面上的一枚 chip,用于选择**下一个会话**的 preset;会话标题旁的一个只读标签;以及一个设置页分区,用于创作组装本身。 ## 为什么它是"新建会话"的偏好设置 会话的 preset 在创建时即固定——宿主拒绝以不同 preset 接管已存在的会话,因为该会话的历史是在最初那份 preset 的工具下产生的。因此本行不可能是实时切换,它也如实说明了这一点:更改只对此后开启的会话生效,而运行中的会话保持它们开始时的组装。 -## composer 座位 +## 新建会话 chip -第二个表层,位于 composer 工具行、模型选择器左侧:**本会话**所运行的 preset。它显示会话自身记录的 preset 而非部署默认值,因为被恢复的会话运行的是它创建时的那一份。 +第二个表层,位于新建会话界面上、工作区选择器旁边。它落在这里而非 composer,是因为这里才是选择仍然成立的地方:一个大部分时间处于禁用状态的控件,属于它仍然可用的那个界面。 -切换只在对话尚未开始时存在。第一个轮次之后,该座位变为纯文本标签——展示一个禁用的菜单会让人以为这个选择只是暂时不可用,而非已经消失。宿主执行同一条规则并返回 `agent-preset-locked`,因此过期的客户端无法绕过它。 +chip 以部署默认值打开,其选择是**暂存**的——该界面先于它要应用到的会话存在。暂存值会在某个会话成为当前会话且仍为空白时抵达该会话;这既覆盖工作区连接新建的会话,也覆盖它复用的那个空白会话,而搭 `sessions.create` 的便车会漏掉后者。暂存值一经使用即被清空,因此下一个新会话重新以默认值打开——与它旁边的工作区选择器完全一致。 + +已经开始的会话会被直接拒绝而非排队:宿主返回 `agent-preset-locked`,暂存值随之丢弃,而不是去等一个永远不会接受它的会话。 + +## 会话标题旁的标签 + +第三个表层,位于会话标题旁:**本会话**所运行的 preset,作为静态装饰呈现。在那里放一个控件,等于承诺一次宿主会断然拒绝的切换。它从会话自身的摘要读取 preset——被恢复的会话运行的是它创建时的那一份,而非今天的默认值——并在 General 行所读的同一份名单上解析显示名称。 ## 它读什么、写什么 @@ -24,7 +30,7 @@ agent preset 的各个表层:General 设置中的一行,用于选择新建 ## 管理分区 -第三个表层,独立的设置页(`settings.section`,id 为 `agent-presets`,排在「模型」之后——选模型是日常操作,而组装 agent 是它背后那件塑造部署形态的事):名单以行呈现,同一时刻有一份组装在 YAML 编辑器中打开。 +第四个表层,独立的设置页(`settings.section`,id 为 `agent-presets`,排在「模型」之后——选模型是日常操作,而组装 agent 是它背后那件塑造部署形态的事):名单以行呈现,同一时刻有一份组装在 YAML 编辑器中打开。 随部署提供的 preset 以只读方式打开。它是本地 preset 据以编写的已知良好组装,因此能读到它正是意义所在,而覆写它则不是——部署自带的那一份正是用来对照有问题的本地 preset 的。因此创作从复制开始:**新建 preset** 复制当前默认值,**复制**则复制任意一行;无论文本来自何处,副本总是落在本地根目录,所以副本总是可写的。 @@ -38,7 +44,7 @@ id 会成为目录名,因此编辑器复刻宿主自身的约束规则(`[a-z ## 何时不显示这些表层 -未组装任何 preset 的部署返回空名单,本行、座位与分区都不渲染任何内容——此时每个会话共用宿主组装,也就无从选择或管理。未配置可写根目录的部署返回 `authorable: false`,分区随之退化为只读浏览:各行仍可打开,但任何位置都不提供"新建",而不是给出一个保存必然失败的按钮。 +未组装任何 preset 的部署返回空名单,本行、chip、标签与分区都不渲染任何内容——此时每个会话共用宿主组装,也就无从选择或管理。未配置可写根目录的部署返回 `authorable: false`,分区随之退化为只读浏览:各行仍可打开,但任何位置都不提供"新建",而不是给出一个保存必然失败的按钮。 ## Model Experience @@ -50,6 +56,6 @@ Indirectly, through the preset a later session is composed from; [`dsh-agent-pre ## Known Limitations and Deferred Work -- **preset 按 id 列出** —— preset 不携带展示用元数据,因此菜单与各行显示的是目录名。 +- **没有元数据的 preset 按 id 列出** —— 展示文本是可选的,未发布任何展示文本的 preset(每个由复制他人而来的 preset 起初都是如此)显示的是它的目录名。 - **编辑器是纯文本域** —— 没有 YAML 语法高亮、折叠或 schema 补全;保存时宿主的形状检查是唯一的校验。 - **保存的组装不会被挂载** —— 能解析但引用了缺失插件的 preset 会被接受,并在下一个选择它的会话处失败。 diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css new file mode 100644 index 0000000000..5468f0d592 --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css @@ -0,0 +1,23 @@ +/* Session-header agent-preset label: static chrome, never a control. */ + +.label { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 180px; + padding: 0 8px; + height: 22px; + border-radius: 6px; + background: var(--dsw-alias-fill-tsp-secondary); + font-size: 12px; + line-height: 22px; + color: var(--dsw-alias-label-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.icon { + flex: none; + opacity: 0.7; +} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx new file mode 100644 index 0000000000..82688dd7c2 --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx @@ -0,0 +1,62 @@ +/** + * The session header's agent-preset label. + * + * Read-only by construction: a session's composition is fixed once its + * conversation starts, and a header is only worth reading after that. Offering + * a control here would promise a switch the host refuses; naming what the + * session runs is the honest affordance, and the choice itself lives on the + * new-session screen ({@link AgentPresetSeat}). + */ + +import { useEffect } from 'react' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +// Type-only: pulls the ui-conversation SlotMap merge (the header actions). +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { AgentPresetSettingsState } from './settings-store.ts' +import css from './AgentPresetLabel.module.css' + +/** Registration-side business face for the header label. */ +export interface AgentPresetLabelInjected { + hooks: { + /** Roster snapshot bound by the renderer as useAgentPresets. */ + agentPresets: SnapshotStore + } + /** Read the roster, so the label can show a name rather than an id. */ + load: () => Promise +} + +/** Full component props. */ +export type AgentPresetLabelProps = + PropsRuntime<'conversation.session.header.actions'> + & PropsLocale<'settings.agentPreset'> + & InjectFace + +/** + * Render this session's agent-preset name beside its title. + * @param props - composed slot props. + * @returns the label, or null when the session records no preset. + */ +export function AgentPresetLabel({ + sessionId, useSessions, useAgentPresets, load, t, +}: AgentPresetLabelProps) { + const preset = useSessions(state => state.byId[sessionId]?.agentPreset) + const options = useAgentPresets(state => state.options) + + useEffect(() => { + // Deployments that compose no presets never label anything, so the roster + // is only worth a request once a session reports one. + if (preset !== undefined) void load() + }, [preset, load]) + + if (preset === undefined) return null + + const option = options.find(entry => entry.id === preset) + return ( + + + {option?.name ?? preset} + + ) +} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css index f63bbf60d6..a4e4c50309 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css @@ -1,22 +1,29 @@ -/* Agent-preset seat: a compact selector pill in the composer tool row. */ +/* Agent-preset chip on the new-session screen, beside the workspace picker. + Geometry mirrors HeroShell's .workspace so the two read as one row. */ .seat { display: inline-flex; align-items: center; - gap: 2px; - height: 28px; - padding: 0 6px 0 8px; + gap: 4px; + max-width: min(100%, 240px); + min-height: 28px; + padding: 0 8px; border: none; - border-radius: 8px; + border-radius: 12px; background: transparent; + color: var(--dsw-alias-label-primary); font-size: 13px; line-height: 20px; - color: var(--dsw-alias-label-secondary); + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; cursor: pointer; } -.seat:hover:not(:disabled) { - background: var(--dsw-alias-fill-tsp-secondary); +.seat:not(:disabled):hover, +.seat[aria-expanded='true'] { + background: var(--dsw-alias-interactive-bg-hover); } .seat:disabled { @@ -24,11 +31,34 @@ color: var(--dsw-alias-label-quaternary); } -.chevron { +.seatIcon { flex: none; - opacity: 0.6; + color: var(--dsw-alias-label-primary); } -.locked { - cursor: default; +.chevron { + flex: none; + color: var(--dsw-alias-label-caption); +} + +/* Menu rows carry the name over its description: the id alone never said what + a preset does, which is why the metadata exists. */ +.item { + display: flex; + flex-direction: column; + gap: 2px; + max-width: 280px; +} + +.itemName { + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-primary); +} + +.itemDesc { + font-size: 12px; + line-height: 16px; + color: var(--dsw-alias-label-caption); + white-space: normal; } diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx index 3e8d08479a..8e18471fbc 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx @@ -1,44 +1,50 @@ /** - * Composer seat for the session's agent preset. + * The agent-preset chip on the new-session screen, beside the workspace + * picker. * - * The switch exists only while the conversation has not started: after the - * first turn the session's history was produced under this preset's tools, so - * the seat becomes a plain label rather than offering a choice it cannot honor. + * It lives here rather than in the composer because the choice is only + * available before a conversation starts: once a turn has run, the session's + * history was produced under that preset's tools and the host refuses to swap + * them. A control that spends most of its life disabled belongs on the screen + * where it still works. + * + * The menu opens on the staged choice, which starts as the deployment default. + * Picking stages; the choice reaches a session when one becomes current. */ import { useEffect, useState } from 'react' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' -// Type-only: pulls the ui-conversation SlotMap merge (the agentPreset seat). +import { IconChevronDownOutline14, IconThinkOutline16, Menu } from '@deepseek-ai/dsh-client-ui-primitives' +// Type-only: pulls the ui-conversation SlotMap merge (the hero seat). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { AgentPresetSeatState } from './seat-store.ts' import css from './AgentPresetSeat.module.css' -/** Registration-side business face for the composer seat. */ +/** Registration-side business face for the hero chip. */ export interface AgentPresetSeatInjected { hooks: { /** Seat snapshot bound by the renderer as useAgentPresetSeat. */ agentPresetSeat: SnapshotStore } - /** Load the roster and this session's state when the seat first renders. */ + /** Read the roster when the chip first renders. */ load: () => Promise - /** Switch this session to another preset. */ + /** Stage one preset for the next session. */ select: (id: string) => Promise } /** Full component props. */ export type AgentPresetSeatProps = - PropsRuntime<'conversation.input.agentPreset'> + PropsRuntime<'conversation.hero.agentPreset'> & PropsLocale<'settings.agentPreset'> & InjectFace /** - * Render the session's agent-preset seat. - * @param props - composed slot props; `locked` is the composer's own busy state. - * @returns the seat, or null when the deployment composes no presets. + * Render the new-session agent-preset chip. + * @param props - composed slot props. + * @returns the chip, or null when the deployment composes no presets. */ -export function AgentPresetSeat({ load, select, useAgentPresetSeat, locked, t }: AgentPresetSeatProps) { +export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPresetSeatProps) { const state = useAgentPresetSeat(snapshot => snapshot) const [open, setOpen] = useState(false) @@ -46,20 +52,11 @@ export function AgentPresetSeat({ load, select, useAgentPresetSeat, locked, t }: void load() }, [load]) - useEffect(() => { - if (state.switchable) return - setOpen(false) - }, [state.switchable]) - // Nothing to choose between: the deployment composes no presets and every // session shares the host composition. if (state.options.length === 0 || state.current === '') return null - // Past the first turn the preset is a fact about this session, not a - // control — showing a disabled menu would suggest it could still be changed. - if (!state.switchable) { - return {state.current} - } + const chosen = state.options.find(option => option.id === state.current) return ( { setOpen(false) }} items={state.options.map(option => ({ id: option.id, - label: option.trust === 'user' ? `${option.id} · ${t('userTrust')}` : option.id, + // Name and description together: the id alone never said what a + // preset does, which is the whole reason the metadata exists. + label: ( + + {option.name ?? option.id} + {option.description ?? t('noDescription')} + + ), }))} selectedId={state.current} onSelect={(id) => { setOpen(false) void select(id) }} - align="end" + align="start" portal anchor={( )} diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index 09c2105774..79fb6b5b07 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -1,13 +1,13 @@ /** - * Agent-preset surface plugin, browser half — three surfaces over one roster: - * a General-settings row for the default preset, a composer seat for the - * session about to start, and a settings section that authors the compositions - * themselves. + * Agent-preset surface plugin, browser half — four surfaces over one roster: + * a General-settings row for the default preset, a chip on the new-session + * screen for the session about to start, a read-only label in the session + * header, and a settings section that authors the compositions themselves. * * A running session keeps the composition it began with (the host refuses to - * adopt an existing session under a different preset), so the General row is - * a new-session preference rather than a live switch, and the seat stops - * offering a choice once the conversation starts. + * adopt an existing session under a different preset). That is what splits + * the choice from the display: the General row and the hero chip are both + * before-the-fact, while the header only reports what a session already runs. */ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' @@ -15,7 +15,9 @@ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client import type {} from '@deepseek-ai/dsh-client-locale/client' // Type-only: pulls the settings shell's SlotMap merge (the 'settings.section' entry). import type {} from '@deepseek-ai/dsh-client-ui-settings/client' -import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { AgentPresetLabel } from './AgentPresetLabel.tsx' +import type { AgentPresetLabelInjected } from './AgentPresetLabel.tsx' import { AgentPresetRow } from './AgentPresetRow.tsx' import type { AgentPresetRowInjected } from './AgentPresetRow.tsx' import { AgentPresetSeat } from './AgentPresetSeat.tsx' @@ -23,14 +25,16 @@ import type { AgentPresetSeatInjected } from './AgentPresetSeat.tsx' import { AgentPresetSection } from './AgentPresetSection.tsx' import type { AgentPresetSectionInjected } from './AgentPresetSection.tsx' import { AgentPresetSeatController } from './seat-store.ts' +import type { SeatSessionSummary } from './seat-store.ts' import { AgentPresetSectionController } from './section-store.ts' import { en, zh } from './locales.ts' import { AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController } from './settings-store.ts' +export type { AgentPresetLabelInjected, AgentPresetLabelProps } from './AgentPresetLabel.tsx' export type { AgentPresetRowInjected, AgentPresetRowProps } from './AgentPresetRow.tsx' export type { AgentPresetSeatInjected, AgentPresetSeatProps } from './AgentPresetSeat.tsx' export type { AgentPresetSectionInjected, AgentPresetSectionProps } from './AgentPresetSection.tsx' -export type { AgentPresetSeatState } from './seat-store.ts' +export type { AgentPresetSeatState, SeatSessionSummary } from './seat-store.ts' export { draftBlocker, type AgentPresetSectionState, type PresetDraft, type PresetRow, } from './section-store.ts' @@ -74,35 +78,56 @@ export function apply(ctx: ClientContext): void { return () => { for (const dispose of disposers) dispose() } }, 'ui-agent-preset: settings refresh') - // The composer seat: one controller per session, because the switch and the - // "may it still switch" bit are both per-session facts. + // The new-session chip and the header label: one controller, because the + // staged choice belongs to the flow rather than to any one session. ctx.inject(['slots', 'conversation', 'sessions'], (scope: ClientContext) => { const api = (scope.get('connection') as ConnectionHandle).api - const seats = new Map() - const seatFor = (sessionId: SessionId): AgentPresetSeatController => { - const existing = seats.get(sessionId) - if (existing !== undefined) return existing - const created = new AgentPresetSeatController(api, sessionId, () => { - const summary = scope.sessions.list.getSnapshot().byId[sessionId] - return summary === undefined - ? undefined - : { blank: summary.blank, ...summary.agentPreset === undefined ? {} : { agentPreset: summary.agentPreset } } - }) - seats.set(sessionId, created) - return created - } - scope.effect(() => scope.slots.register({ - name: 'conversation.input.agentPreset', - locale: 'settings.agentPreset', - inject: (sessionId: SessionId): AgentPresetSeatInjected => { - const seat = seatFor(sessionId) - return { - hooks: { agentPresetSeat: seat.store }, - load: () => seat.load(), - select: (id: string) => seat.select(id), + const seat = new AgentPresetSeatController(api, (): SeatSessionSummary | undefined => { + const state = scope.sessions.list.getSnapshot() + const summary = state.current === undefined ? undefined : state.byId[state.current] + return summary === undefined + ? undefined + : { + id: summary.id, + blank: summary.blank, + ...summary.agentPreset === undefined ? {} : { agentPreset: summary.agentPreset }, } - }, - }, AgentPresetSeat), 'ui-agent-preset: composer seat registration') + }) + + const seatInjected = (): AgentPresetSeatInjected => ({ + hooks: { agentPresetSeat: seat.store }, + load: () => seat.load(), + select: (id: string) => seat.select(id), + }) + + const labelInjected = (): AgentPresetLabelInjected => ({ + hooks: { agentPresets: controller.store }, + load: () => controller.load(), + }) + + scope.effect(() => { + // Connecting a workspace either creates a blank session or reuses one, + // and either way the chip's pick predates it — so the stage is applied + // when the session arrives, not when it was made. + const stop = scope.sessions.list.subscribe(() => { void seat.apply() }) + const chip = scope.slots.register({ + name: 'conversation.hero.agentPreset', + locale: 'settings.agentPreset', + inject: seatInjected, + }, AgentPresetSeat) + const label = scope.slots.register({ + name: 'conversation.session.header.actions', + id: 'agent-preset', + order: 20, + locale: 'settings.agentPreset', + inject: labelInjected, + }, AgentPresetLabel) + return () => { + stop() + chip() + label() + } + }, 'ui-agent-preset: new-session chip and header label') }) const sectionInjected = (): AgentPresetSectionInjected => ({ diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts index b39df23781..1b4d940e44 100644 --- a/packages/client/ui-agent-preset/src/client/locales.ts +++ b/packages/client/ui-agent-preset/src/client/locales.ts @@ -1,8 +1,8 @@ -/** Locale bundles for the agent-preset settings row, composer seat, and management section. */ +/** Locale bundles for the agent-preset settings row, hero chip, header label, and management section. */ /** Locale keys these surfaces render. */ export type AgentPresetSettingsKey = - | 'title' | 'description' | 'loading' | 'error' | 'userTrust' | 'seatHint' | 'lockedHint' + | 'title' | 'description' | 'loading' | 'error' | 'userTrust' | 'seatHint' | 'headerHint' | 'nav' | 'sectionIntro' | 'builtIn' | 'defaultBadge' | 'setDefault' | 'edit' | 'view' | 'duplicate' | 'delete' | 'newPreset' | 'presetId' | 'presetIdPlaceholder' | 'copyOf' | 'displayName' | 'displayNamePlaceholder' | 'displayDescription' | 'displayDescriptionPlaceholder' @@ -18,8 +18,8 @@ export const en: Record = { loading: 'Loading presets…', error: 'Could not load agent presets.', userTrust: 'Custom', - seatHint: 'Agent preset for this session — switchable until you send the first message', - lockedHint: 'This session\'s agent preset is fixed once the conversation starts', + seatHint: 'Agent preset for the session you are about to start', + headerHint: 'The agent preset this session runs, fixed when it started', nav: 'Agent presets', sectionIntro: 'A preset is the plugin composition one session\'s agent runs — its tools, prompt, and capabilities. ' @@ -68,8 +68,8 @@ export const zh: Record = { loading: '正在加载预设…', error: '无法加载 Agent 预设。', userTrust: '自定义', - seatHint: '本会话的 Agent 预设 —— 发送第一条消息前可切换', - lockedHint: '会话开始后,其 Agent 预设即固定', + seatHint: '即将开始的这个会话所用的 Agent 预设', + headerHint: '本会话运行的 Agent 预设,开始时即固定', nav: 'Agent 预设', sectionIntro: '预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。内置预设只读;复制一份即可改成自己的。', builtIn: '内置', diff --git a/packages/client/ui-agent-preset/src/client/seat-store.ts b/packages/client/ui-agent-preset/src/client/seat-store.ts index 4af7ec32d7..b071d8efd4 100644 --- a/packages/client/ui-agent-preset/src/client/seat-store.ts +++ b/packages/client/ui-agent-preset/src/client/seat-store.ts @@ -1,10 +1,13 @@ /** - * Composer-seat controller: what one session may switch to, and whether it - * still may. + * Hero-chip controller: which preset the NEXT session gets. * - * A session's composition is fixed once its conversation starts, so the seat - * reads the session's own `blank` bit rather than a local guess — the host - * enforces the same rule and answers `agent-preset-locked` to a late attempt. + * The new-session screen has no session, so a pick is staged rather than + * applied. It reaches a session when one becomes current and is still blank — + * whether the workspace connect created it or reused an existing blank one, + * which is why staging cannot simply ride along on `sessions.create`. + * + * The stage is forgotten once applied: the next new session starts from the + * deployment default again, matching the workspace picker beside it. */ import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' @@ -14,33 +17,49 @@ import { import { messageOf } from './settings-store.ts' import type { AgentPresetOption } from './settings-store.ts' -/** Composer-seat snapshot for one session. */ +/** Hero-chip snapshot. */ export interface AgentPresetSeatState { - /** Presets the deployment supplies; empty means the seat renders nothing. */ + /** Presets the deployment supplies; empty means the chip renders nothing. */ options: readonly AgentPresetOption[] - /** The preset this session runs, empty until the roster and summary load. */ + /** The staged choice, empty until the roster loads. */ current: string - /** False once the conversation has started — the switch is gone for good. */ - switchable: boolean - /** A rejected switch's message, cleared by the next attempt. */ + /** A rejected apply's message, cleared by the next attempt. */ error: string | null busy: boolean } const INITIAL: AgentPresetSeatState = { - options: [], current: '', switchable: false, error: null, busy: false, + options: [], current: '', error: null, busy: false, } -/** Reads what one session may switch to and performs the switch. */ +/** One session's identity and whether it has started. */ +export interface SeatSessionSummary { + /** The session the chip would apply its staged choice to. */ + id: SessionId + /** False once a turn has run — applying is refused from then on. */ + blank: boolean + /** The preset the session already runs, when the summary reports one. */ + agentPreset?: string +} + +/** Stages the next session's preset and applies it when one appears. */ export class AgentPresetSeatController { - /** Seat snapshot the renderer subscribes to. */ + /** Chip snapshot the renderer subscribes to. */ readonly store: SnapshotStore = createSnapshotStore(INITIAL) + /** + * The deployment default, so a consumed stage can fall back to it without + * re-reading the roster. + */ + private fallback = '' + + /** Set while a pick is waiting for a session; cleared once applied. */ + private staged: string | undefined + constructor( - private readonly api: IApiClient, - private readonly sessionId: SessionId, - /** Reads this session's blank bit and recorded preset from the session list. */ - private readonly summary: () => { blank: boolean; agentPreset?: string } | undefined, + private readonly api: Pick, + /** The session the hero is about to hand over to, when there is one. */ + private readonly currentSession: () => SeatSessionSummary | undefined, ) {} private set(patch: Partial): void { @@ -48,25 +67,26 @@ export class AgentPresetSeatController { } /** - * Load the roster and reconcile with this session's own state. + * Read the roster and open the chip on the deployment default. * @returns once the snapshot reflects the host. */ async load(): Promise { - const summary = this.summary() try { const response = await this.api.agentPresets.list({}) if (!response.result.ok) { this.set({ error: response.result.error.message }) return } - const presets = response.result.value.presets + const { presets } = response.result.value + this.fallback = presets.find(preset => preset.isDefault)?.id ?? presets[0]?.id ?? '' this.set({ - options: presets.map(preset => ({ id: preset.id, trust: preset.trust })), - // The session's recorded preset wins over the roster default: a - // resumed session runs what it was created with, not what the - // deployment now prefers. - current: summary?.agentPreset ?? presets.find(preset => preset.isDefault)?.id ?? '', - switchable: summary?.blank ?? false, + options: presets.map(preset => ({ + id: preset.id, + trust: preset.trust, + ...preset.name === undefined ? {} : { name: preset.name }, + ...preset.description === undefined ? {} : { description: preset.description }, + })), + current: this.staged ?? this.fallback, error: null, }) } catch (error) { @@ -75,23 +95,48 @@ export class AgentPresetSeatController { } /** - * Switch this session to another preset. - * @param id - the preset to compose the session's agent from. - * @returns once the switch settled; a rejection leaves the previous value. + * Stage one preset for the next session, applying it immediately when a + * blank session is already current. + * @param id - the preset to stage. + * @returns once the stage settled, and the apply too when one happened. */ async select(id: string): Promise { - const before = this.store.getSnapshot() - if (before.busy || id === before.current || !before.switchable) return - this.set({ busy: true, error: null, current: id }) + if (this.store.getSnapshot().busy) return + this.staged = id + this.set({ current: id, error: null }) + await this.apply() + } + + /** + * Hand the staged choice to the current session, if there is one to take it. + * + * Called both by `select()` and by whoever observes the current session + * changing, because the session may appear either before or after the pick. + * @returns once the switch settled, or immediately when there is nothing to do. + */ + async apply(): Promise { + const staged = this.staged + const session = this.currentSession() + if (staged === undefined || session === undefined) return + // A started session's history was produced under its own composition; the + // host refuses the swap, so the stage is no longer meaningful. + if (!session.blank || session.agentPreset === staged) { + this.staged = undefined + return + } + this.set({ busy: true, error: null }) try { - const response = await this.api.agentPresets.select({ sessionId: this.sessionId, agentPreset: id }) + const response = await this.api.agentPresets.select({ sessionId: session.id, agentPreset: staged }) + this.staged = undefined if (!response.result.ok) { - this.set({ busy: false, current: before.current, error: response.result.error.message }) + this.set({ busy: false, error: response.result.error.message, current: this.fallback }) return } + // Consumed: the next new session opens on the deployment default again. this.set({ busy: false, current: response.result.value.agentPreset }) } catch (error) { - this.set({ busy: false, current: before.current, error: messageOf(error) }) + this.staged = undefined + this.set({ busy: false, error: messageOf(error), current: this.fallback }) } } } diff --git a/packages/client/ui-agent-preset/src/client/settings-store.ts b/packages/client/ui-agent-preset/src/client/settings-store.ts index 4a8ad93134..ef48cd0b17 100644 --- a/packages/client/ui-agent-preset/src/client/settings-store.ts +++ b/packages/client/ui-agent-preset/src/client/settings-store.ts @@ -51,10 +51,14 @@ export async function writeDefaultPreset( /** One selectable preset. */ export interface AgentPresetOption { - /** Preset id, written to Settings and shown as the label. */ + /** Preset id, written to Settings and the label's fallback. */ id: string /** Whether the preset ships with the deployment or was authored locally. */ trust: 'system' | 'user' + /** Display name the preset published, absent when it published none. */ + name?: string + /** One sentence on what the preset is for. */ + description?: string } /** Agent-preset settings-row snapshot. */ @@ -109,7 +113,12 @@ export class AgentPresetSettingsController { this.set({ status: 'ready', error: null, - options: presets.map(preset => ({ id: preset.id, trust: preset.trust })), + options: presets.map(preset => ({ + id: preset.id, + trust: preset.trust, + ...preset.name === undefined ? {} : { name: preset.name }, + ...preset.description === undefined ? {} : { description: preset.description }, + })), // A roster can mark nothing default: settings can name a preset that // was since deleted, and the picker still has to show something. currentValue: presets.find(preset => preset.isDefault)?.id ?? first.id, diff --git a/packages/client/ui-agent-preset/tests/apply.spec.ts b/packages/client/ui-agent-preset/tests/apply.spec.ts index 0d9d80a01e..8d83196737 100644 --- a/packages/client/ui-agent-preset/tests/apply.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.spec.ts @@ -1,7 +1,7 @@ /** - * Registration: the General row, the settings section, and the per-session - * composer seat all come from one apply, and each defers until the slot it - * fills has been declared. A pushed settings change refreshes the surfaces + * Registration: the General row, the settings section, the new-session chip, + * and the header label all come from one apply, and each defers until the slot + * it fills has been declared. A pushed settings change refreshes the surfaces * that are already showing, so a default set from one converges the other. */ @@ -12,6 +12,8 @@ import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject } from '@deepseek-ai/dsh-client-ui-agent-preset/client' +import { AgentPresetLabel } from '../src/client/AgentPresetLabel.tsx' +import type { AgentPresetLabelInjected } from '../src/client/AgentPresetLabel.tsx' import { AgentPresetRow } from '../src/client/AgentPresetRow.tsx' import type { AgentPresetRowInjected } from '../src/client/AgentPresetRow.tsx' import { AgentPresetSection } from '../src/client/AgentPresetSection.tsx' @@ -44,7 +46,10 @@ async function bench() { }), write: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: 'standard' } } }), remove: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } }), - select: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: 'standard' } } }), + select: (payload: { agentPreset: string }) => { + calls.push(`select:${payload.agentPreset}`) + return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } }) + }, }, settings: { update: (payload: { patch: unknown }) => { calls.push(`settings:${JSON.stringify(payload.patch)}`); return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } }) }, @@ -65,14 +70,36 @@ function declareRoot(slots: SlotsService): () => void { } as never, () => null) } -/** The composer's own declaration, which the seat registration waits for. */ -function declareComposer(slots: SlotsService): () => void { +/** The conversation's own declarations, which the chip and label wait for. */ +function declareConversation(slots: SlotsService): () => void { return slots.register({ name: 'conversation', - children: { 'conversation.input.agentPreset': { kind: 'single', scope: 'session' } }, + children: { + 'conversation.hero.agentPreset': { kind: 'single', scope: 'root' }, + 'conversation.session.header.actions': { kind: 'list', scope: 'session' }, + }, } as never, () => null) } +/** A sessions double whose list can be moved and whose changes are pushed. */ +function sessionsDouble(state: { + current?: string + byId: Record +}) { + const listeners = new Set<() => void>() + return { + list: { + getSnapshot: () => state, + subscribe: (fn: () => void) => { + listeners.add(fn) + return () => listeners.delete(fn) + }, + }, + /** Push a list change the way the runtime's store does. */ + notify: () => { for (const fn of listeners) fn() }, + } +} + describe('ui-agent-preset apply', () => { it('declares the services it uses', () => { expect(inject).toEqual(['slots', 'locale', 'connection']) @@ -190,54 +217,121 @@ describe('ui-agent-preset apply', () => { expect(calls.length - before).toBe(1) }) - it('gives each session its own seat controller and drops its registrations on disposal', async () => { + it('registers the new-session chip and the header label, and drops both on disposal', async () => { const { ctx, slots } = await bench() declareRoot(slots) - const conversation = declareComposer(slots) + const conversation = declareConversation(slots) ctx.provide('conversation', {} as never) - ctx.provide('sessions', { list: { getSnapshot: () => ({ byId: { s1: { blank: true, agentPreset: 'standard' } } }) } } as never) + ctx.provide('sessions', sessionsDouble({ byId: {} }) as never) const fiber = ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply }) await fiber.await() - const seat = slots.entries('conversation.input.agentPreset')[0]! - expect(seat.component).toBe(AgentPresetSeat) - const make = seat.inject as unknown as (id: string) => AgentPresetSeatInjected - // Same session, same controller; a different session gets its own, because - // "may it still switch" is a per-session fact. - expect(make('s1').hooks.agentPresetSeat).toBe(make('s1').hooks.agentPresetSeat) - expect(make('s2').hooks.agentPresetSeat).not.toBe(make('s1').hooks.agentPresetSeat) + const chip = slots.entries('conversation.hero.agentPreset')[0]! + expect(chip.component).toBe(AgentPresetSeat) + const label = slots.entries('conversation.session.header.actions')[0]! + expect(label.component).toBe(AgentPresetLabel) + expect(label.options).toMatchObject({ id: 'agent-preset', order: 20 }) await fiber.dispose() - expect(slots.entries('conversation.input.agentPreset')).toHaveLength(0) + expect(slots.entries('conversation.hero.agentPreset')).toHaveLength(0) + expect(slots.entries('conversation.session.header.actions')).toHaveLength(0) expect(slots.entries('settings.section')).toHaveLength(0) conversation() }) - it('reads a seat\'s session state through the live session list', async () => { + it('applies the staged choice to the blank session the flow lands on', async () => { + const { ctx, slots, calls } = await bench() + declareRoot(slots) + declareConversation(slots) + ctx.provide('conversation', {} as never) + const state: { + current?: string + byId: Record + } = { byId: {} } + const sessions = sessionsDouble(state) + ctx.provide('sessions', sessions as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply }).await() + const chip = (slots.entries('conversation.hero.agentPreset')[0]! + .inject as unknown as () => AgentPresetSeatInjected)() + + await chip.load() + // Picked on the hero screen, where there is no session yet. + await chip.select('minimal') + expect(calls).not.toContain('select:minimal') + + state.current = 's1' + state.byId['s1'] = { id: 's1', blank: true, agentPreset: 'standard' } + sessions.notify() + + // Connecting a workspace produced the session; the stage reaches it there. + await vi.waitFor(() => { expect(calls).toContain('select:minimal') }) + }) + + it('applies the stage to a session that records no preset of its own', async () => { + const { ctx, slots, calls } = await bench() + declareRoot(slots) + declareConversation(slots) + ctx.provide('conversation', {} as never) + const sessions = sessionsDouble({ + current: 's1', + byId: { s1: { id: 's1', blank: true } }, + }) + ctx.provide('sessions', sessions as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply }).await() + const chip = (slots.entries('conversation.hero.agentPreset')[0]! + .inject as unknown as () => AgentPresetSeatInjected)() + + await chip.load() + await chip.select('minimal') + + // A session created before the deployment composed presets records none; + // reading that as "already runs it" would drop the pick on the floor. + expect(calls).toContain('select:minimal') + }) + + it('forgets the stage once it has been spent', async () => { + const { ctx, slots, calls } = await bench() + declareRoot(slots) + declareConversation(slots) + ctx.provide('conversation', {} as never) + const state = { + current: 's1', + byId: { s1: { id: 's1', blank: true, agentPreset: 'standard' } }, + } + const sessions = sessionsDouble(state) + ctx.provide('sessions', sessions as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply }).await() + const chip = (slots.entries('conversation.hero.agentPreset')[0]! + .inject as unknown as () => AgentPresetSeatInjected)() + + await chip.load() + await chip.select('minimal') + const spent = calls.filter(call => call === 'select:minimal').length + sessions.notify() + sessions.notify() + + // Every later list movement would re-apply a stage that was not cleared, + // switching sessions the user never picked for. + await Promise.resolve() + expect(calls.filter(call => call === 'select:minimal')).toHaveLength(spent) + }) + + it('gives the header label the same roster the General row reads', async () => { const { ctx, slots } = await bench() declareRoot(slots) - declareComposer(slots) + declareConversation(slots) ctx.provide('conversation', {} as never) - const byId: Record = {} - ctx.provide('sessions', { list: { getSnapshot: () => ({ byId }) } } as never) + ctx.provide('sessions', sessionsDouble({ byId: {} }) as never) await ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply }).await() - const make = slots.entries('conversation.input.agentPreset')[0]! - .inject as unknown as (id: string) => AgentPresetSeatInjected + const label = (slots.entries('conversation.session.header.actions')[0]! + .inject as unknown as () => AgentPresetLabelInjected)() + const row = (slots.entries('settings.general.item')[0]! + .inject as unknown as () => AgentPresetRowInjected)() - const seat = make('s1') - await seat.load() - const unknownSession = seat.hooks.agentPresetSeat.getSnapshot().switchable - // A session created before presets existed records none; the seat then - // shows the roster default rather than an empty control. - byId['s1'] = { blank: true } - await seat.load() - expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('standard') - byId['s1'] = { blank: true, agentPreset: 'standard' } - await seat.load() + await label.load() - // A session the list has not caught up to offers no switch rather than - // guessing that it is blank. - expect(unknownSession).toBe(false) - expect(seat.hooks.agentPresetSeat.getSnapshot()).toMatchObject({ switchable: true, current: 'standard' }) - await make('s1').select('standard') + // One roster behind both: the label resolves a name the settings row's own + // load already fetched, rather than issuing a second read per session. + expect(label.hooks.agentPresets).toBe(row.hooks.agentPreset) + expect(label.hooks.agentPresets.getSnapshot().options).toEqual([{ id: 'standard', trust: 'system' }]) }) }) diff --git a/packages/client/ui-agent-preset/tests/components.spec.tsx b/packages/client/ui-agent-preset/tests/components.spec.tsx index 42741f18ab..eff1e047e9 100644 --- a/packages/client/ui-agent-preset/tests/components.spec.tsx +++ b/packages/client/ui-agent-preset/tests/components.spec.tsx @@ -1,15 +1,18 @@ // @vitest-environment jsdom /** - * The two picker surfaces: the General-settings row that names the default for - * later sessions, and the composer seat that names this one's — the seat stops - * being a control once the conversation starts, because the history from there - * on was produced under the preset's tools. + * The three conversation-adjacent surfaces: the General-settings row naming the + * default for later sessions, the new-session chip naming the next one's, and + * the session header's read-only label. The split is the host's rule — a + * session's history is produced under its preset's tools, so the choice is + * only ever offered before one starts. */ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { AgentPresetLabel } from '../src/client/AgentPresetLabel.tsx' +import type { AgentPresetLabelProps } from '../src/client/AgentPresetLabel.tsx' import { AgentPresetRow } from '../src/client/AgentPresetRow.tsx' import type { AgentPresetRowProps } from '../src/client/AgentPresetRow.tsx' import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx' @@ -30,8 +33,10 @@ const ROW_READY: AgentPresetSettingsState = { const SEAT_READY: AgentPresetSeatState = { current: 'standard', - options: [{ id: 'standard', trust: 'system' }, { id: 'mine', trust: 'user' }], - switchable: true, + options: [ + { id: 'standard', trust: 'system', name: '标准模式', description: '完整的编码 agent。' }, + { id: 'mine', trust: 'user' }, + ], busy: false, error: null, } @@ -47,18 +52,37 @@ function renderRow(state: Partial = {}) { return actions } -function renderSeat(state: Partial = {}, locked = false) { +function renderSeat(state: Partial = {}) { const store = createSnapshotStore({ ...SEAT_READY, ...state }) const actions = { load: vi.fn(() => Promise.resolve()), select: vi.fn(() => Promise.resolve()) } render( en[key], } as unknown as AgentPresetSeatProps)} />) return actions } +function renderLabel( + summary: { blank: boolean; agentPreset?: string } | undefined, + roster: Partial = {}, +) { + // The chip and the label read the same roster, metadata included. + const store = createSnapshotStore({ + ...ROW_READY, options: SEAT_READY.options, ...roster, + }) + const sessions = createSnapshotStore({ byId: summary === undefined ? {} : { s1: summary } }) + const load = vi.fn(() => Promise.resolve()) + const view = render( en[key], + } as unknown as AgentPresetLabelProps)} />) + return { load, view } +} + describe('the General-settings row', () => { it('reads the roster once and shows the current default', async () => { const actions = renderRow() @@ -140,85 +164,65 @@ describe('the General-settings row', () => { }) }) -describe('the composer seat', () => { - it('reads the roster once and offers the session\'s preset', async () => { +describe('the new-session chip', () => { + it('reads the roster once and shows the staged preset by name', async () => { const actions = renderSeat() await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) }) - expect(screen.getByRole('button').textContent).toContain('standard') + expect(screen.getByRole('button').textContent).toContain('标准模式') expect(screen.getByRole('button').getAttribute('title')).toBe(en.seatHint) }) - it('switches the session and closes the menu', () => { + it('offers each preset with what it is for', () => { + renderSeat() + + fireEvent.click(screen.getByRole('button')) + + // The id alone never said what a preset does; the description is the + // whole reason a preset can publish metadata at all. + expect(screen.getByText('完整的编码 agent。')).toBeTruthy() + // A preset that published none still reads as a row, with its id standing + // in for the name. + expect(screen.getByText(en.noDescription)).toBeTruthy() + expect(screen.getByText('mine')).toBeTruthy() + }) + + it('falls back to the id when the staged preset published no name', () => { + renderSeat({ current: 'mine' }) + + expect(screen.getByRole('button').textContent).toContain('mine') + }) + + it('stages the picked preset and closes the menu', () => { const actions = renderSeat() fireEvent.click(screen.getByRole('button')) - fireEvent.click(screen.getByText(`mine · ${en.userTrust}`)) + fireEvent.click(screen.getByText('mine')) expect(actions.select).toHaveBeenCalledWith('mine') expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false') }) - it('becomes a plain label once the conversation has started', () => { - renderSeat({ switchable: false }) - - // Not a disabled menu: that would suggest the preset could still change. - expect(screen.queryByRole('button')).toBeNull() - expect(screen.getByTitle(en.lockedHint).textContent).toBe('standard') - }) - - it('closes an open menu the moment the session stops being blank', () => { - const store = createSnapshotStore(SEAT_READY) - render( Promise.resolve()), - select: vi.fn(() => Promise.resolve()), - locked: false, - useAgentPresetSeat: bindSnapshotSelector(store), - t: (key: keyof typeof en) => en[key], - } as unknown as AgentPresetSeatProps)} />) - fireEvent.click(screen.getByRole('button')) - - act(() => { store.set({ ...SEAT_READY, switchable: false }) }) - - expect(screen.getByTitle(en.lockedHint)).toBeTruthy() - }) - - it('disables the trigger while the composer or a switch is busy', () => { + it('disables the trigger while a switch is in flight', () => { renderSeat({ busy: true }) - expect(screen.getByRole('button')).toHaveProperty('disabled', true) - cleanup() - renderSeat({}, true) expect(screen.getByRole('button')).toHaveProperty('disabled', true) }) - it('shows a failed switch on the trigger', () => { + it('shows a refused switch on the trigger', () => { renderSeat({ error: 'session has already started' }) expect(screen.getByRole('button').getAttribute('title')).toBe('session has already started') }) it('renders nothing before the roster arrives or when there is none', () => { - const empty = render( Promise.resolve()), - select: vi.fn(() => Promise.resolve()), - locked: false, - useAgentPresetSeat: bindSnapshotSelector( - createSnapshotStore({ ...SEAT_READY, options: [] })), - t: (key: keyof typeof en) => en[key], - } as unknown as AgentPresetSeatProps)} />) - expect(empty.container.firstChild).toBeNull() + const empty = renderSeat({ options: [] }) + expect(empty).toBeTruthy() + expect(screen.queryByRole('button')).toBeNull() cleanup() - const unresolved = render( Promise.resolve()), - select: vi.fn(() => Promise.resolve()), - locked: false, - useAgentPresetSeat: bindSnapshotSelector( - createSnapshotStore({ ...SEAT_READY, current: '' })), - t: (key: keyof typeof en) => en[key], - } as unknown as AgentPresetSeatProps)} />) - expect(unresolved.container.firstChild).toBeNull() + renderSeat({ current: '' }) + expect(screen.queryByRole('button')).toBeNull() }) it('closes on an outside dismissal', () => { @@ -230,3 +234,42 @@ describe('the composer seat', () => { expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false') }) }) + +describe('the session-header label', () => { + it('names the preset the session runs, and never offers a switch', async () => { + const { load } = renderLabel({ blank: false, agentPreset: 'standard' }) + + await waitFor(() => { expect(load).toHaveBeenCalledTimes(1) }) + // A control here would promise a switch the host refuses outright. + expect(screen.queryByRole('button')).toBeNull() + expect(screen.getByTitle('完整的编码 agent。').textContent).toBe('标准模式') + }) + + it('falls back to the id, and to the generic hint, when metadata is absent', () => { + renderLabel({ blank: true, agentPreset: 'mine' }) + + expect(screen.getByTitle(en.headerHint).textContent).toBe('mine') + }) + + it('shows the id until the roster resolves it', () => { + renderLabel({ blank: false, agentPreset: 'standard' }, { options: [] }) + + // The session's own summary is the authority on which preset it runs; the + // roster only supplies the display name, and its arrival is a later frame. + expect(screen.getByTitle(en.headerHint).textContent).toBe('standard') + }) + + it('renders nothing, and reads no roster, when the session records no preset', async () => { + const absent = renderLabel({ blank: true }) + expect(absent.view.container.firstChild).toBeNull() + cleanup() + + // A session the list has not caught up to is the same answer: a deployment + // that composes no presets must not pay for a roster read per header. + const unknown = renderLabel(undefined) + expect(unknown.view.container.firstChild).toBeNull() + await act(async () => { await Promise.resolve() }) + expect(absent.load).not.toHaveBeenCalled() + expect(unknown.load).not.toHaveBeenCalled() + }) +}) diff --git a/packages/client/ui-agent-preset/tests/settings-store.spec.ts b/packages/client/ui-agent-preset/tests/settings-store.spec.ts index bde4a305d0..a9082bcc92 100644 --- a/packages/client/ui-agent-preset/tests/settings-store.spec.ts +++ b/packages/client/ui-agent-preset/tests/settings-store.spec.ts @@ -11,6 +11,7 @@ import { AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController, messageOf, } from '../src/client/settings-store.ts' import { AgentPresetSeatController } from '../src/client/seat-store.ts' +import type { SeatSessionSummary } from '../src/client/seat-store.ts' interface Recorded { ns: string; patch: unknown } @@ -60,6 +61,20 @@ describe('the agent-preset settings controller', () => { ]) }) + it('carries the display metadata a preset published', async () => { + const controller = new AgentPresetSettingsController(fakeApi([ + { id: 'standard', trust: 'system', isDefault: true, name: '标准模式', description: '完整的编码 agent。' }, + ] as never)) + + await controller.load() + + // Surfaces beyond this row read the same options; the id alone never said + // what a preset does. + expect(controller.store.getSnapshot().options).toEqual([ + { id: 'standard', trust: 'system', name: '标准模式', description: '完整的编码 agent。' }, + ]) + }) + it('reports an empty roster as unavailable, not as an error', async () => { const controller = new AgentPresetSettingsController(fakeApi([])) @@ -121,100 +136,6 @@ describe('the agent-preset settings controller', () => { expect(state.status).toBe('error') expect(state.error).toBe('host down') }) -}) - -describe('the composer seat controller', () => { - /** A seat over a fixed session summary. */ - function seat( - presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[], - summary: { blank: boolean; agentPreset?: string } | undefined, - options: { writes?: Recorded[]; failSelect?: string; failList?: string; throwOn?: 'list' | 'select' } = {}, - ): AgentPresetSeatController { - const api = { - agentPresets: { - list: () => { - if (options.throwOn === 'list') return Promise.reject(new Error('socket closed')) - return Promise.resolve(options.failList === undefined - ? { rpcId: 'r', result: { ok: true as const, value: { presets } } } - : { rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failList, details: {} } } }) - }, - select: (payload: { agentPreset: string }) => { - if (options.throwOn === 'select') return Promise.reject(new Error('socket closed')) - options.writes?.push({ ns: 'select', patch: payload.agentPreset }) - return Promise.resolve(options.failSelect === undefined - ? { rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } } - : { rpcId: 'r', result: { ok: false as const, error: { code: 'agent-preset-locked', message: options.failSelect, details: {} } } }) - }, - }, - } as unknown as IApiClient - return new AgentPresetSeatController(api, 's1' as never, () => summary) - } - - const ROSTER: { id: string; trust: 'system' | 'user'; isDefault: boolean }[] = [ - { id: 'standard', trust: 'system', isDefault: true }, - { id: 'minimal', trust: 'system', isDefault: false }, - ] - - it('shows what the session runs, not the deployment default', async () => { - const controller = seat(ROSTER, { blank: true, agentPreset: 'minimal' }) - - await controller.load() - - // A resumed session runs what it was created with; showing `standard` - // because it is the current default would be a lie about this session. - expect(controller.store.getSnapshot().current).toBe('minimal') - expect(controller.store.getSnapshot().switchable).toBe(true) - }) - - it('falls back to the roster default when the session records none', async () => { - const controller = seat(ROSTER, { blank: true }) - - await controller.load() - - expect(controller.store.getSnapshot().current).toBe('standard') - }) - - it('is not switchable once the conversation has started', async () => { - const controller = seat(ROSTER, { blank: false, agentPreset: 'standard' }) - - await controller.load() - - expect(controller.store.getSnapshot().switchable).toBe(false) - }) - - it('refuses to switch a session that already started', async () => { - const writes: Recorded[] = [] - const controller = seat(ROSTER, { blank: false, agentPreset: 'standard' }, { writes }) - await controller.load() - - await controller.select('minimal') - - // The host enforces the same rule; the seat simply never asks. - expect(writes).toEqual([]) - expect(controller.store.getSnapshot().current).toBe('standard') - }) - - it('switches a blank session and keeps the host\'s answer', async () => { - const writes: Recorded[] = [] - const controller = seat(ROSTER, { blank: true, agentPreset: 'standard' }, { writes }) - await controller.load() - - await controller.select('minimal') - - expect(writes).toEqual([{ ns: 'select', patch: 'minimal' }]) - expect(controller.store.getSnapshot().current).toBe('minimal') - }) - - it('restores the previous value when the host rejects the switch', async () => { - const controller = seat(ROSTER, { blank: true, agentPreset: 'standard' }, { failSelect: 'already started' }) - await controller.load() - - await controller.select('minimal') - - const state = controller.store.getSnapshot() - expect(state.current).toBe('standard') - expect(state.error).toBe('already started') - }) it('shows the first preset when the roster marks none default', async () => { // Settings can name a preset that was since deleted; the picker still has @@ -269,42 +190,204 @@ describe('the composer seat controller', () => { // showing "mine" would be claiming a default that does not exist. expect(controller.store.getSnapshot()).toMatchObject({ currentValue: 'standard', error: 'socket closed' }) }) +}) - it('reports a refused roster read without emptying the seat', async () => { - const controller = seat(ROSTER, { blank: true }, { failList: 'host down' }) +describe('the new-session chip controller', () => { + /** A chip over a current session the test can move. */ + function chip( + presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[], + current: { id: string; blank: boolean; agentPreset?: string } | undefined, + options: { writes?: Recorded[]; failSelect?: string; failList?: string; throwOn?: 'list' | 'select' } = {}, + ): AgentPresetSeatController { + const api = { + agentPresets: { + list: () => { + if (options.throwOn === 'list') return Promise.reject(new Error('socket closed')) + return Promise.resolve(options.failList === undefined + ? { rpcId: 'r', result: { ok: true as const, value: { presets } } } + : { rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failList, details: {} } } }) + }, + select: (payload: { agentPreset: string }) => { + if (options.throwOn === 'select') return Promise.reject(new Error('socket closed')) + options.writes?.push({ ns: 'select', patch: payload.agentPreset }) + return Promise.resolve(options.failSelect === undefined + ? { rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } } + : { rpcId: 'r', result: { ok: false as const, error: { code: 'agent-preset-locked', message: options.failSelect, details: {} } } }) + }, + }, + } as unknown as IApiClient + return new AgentPresetSeatController(api, () => current as SeatSessionSummary | undefined) + } + + const ROSTER: { id: string; trust: 'system' | 'user'; isDefault: boolean }[] = [ + { id: 'standard', trust: 'system', isDefault: true }, + { id: 'minimal', trust: 'system', isDefault: false }, + ] + + it('opens on the deployment default', async () => { + const controller = chip(ROSTER, undefined) + + await controller.load() + + // The chip names the session about to start, and nothing about it is + // decided yet — the default is the honest opening value. + expect(controller.store.getSnapshot().current).toBe('standard') + expect(controller.store.getSnapshot().options).toEqual([ + { id: 'standard', trust: 'system' }, + { id: 'minimal', trust: 'system' }, + ]) + }) + + it('shows the first preset when the roster marks none default', async () => { + const controller = chip([{ id: 'minimal', trust: 'system', isDefault: false }], undefined) + + await controller.load() + + // Settings can name a preset that was since deleted; the chip still has + // to open on something rather than render nothing. + expect(controller.store.getSnapshot().current).toBe('minimal') + }) + + it('carries the display metadata into the menu rows', async () => { + const controller = chip([ + { id: 'standard', trust: 'system', isDefault: true, name: '标准模式', description: '完整的编码 agent。' }, + ] as never, undefined) + + await controller.load() + + expect(controller.store.getSnapshot().options).toEqual([ + { id: 'standard', trust: 'system', name: '标准模式', description: '完整的编码 agent。' }, + ]) + }) + + it('opens on nothing when the deployment composes no presets', async () => { + const controller = chip([], undefined) + + await controller.load() + + // An empty roster is a valid deployment: every session shares the host + // composition, and the chip renders nothing rather than an empty control. + expect(controller.store.getSnapshot().current).toBe('') + }) + + it('stages a pick made before any session exists', async () => { + const writes: Recorded[] = [] + const controller = chip(ROSTER, undefined, { writes }) + await controller.load() + + await controller.select('minimal') + + // Nothing to switch yet: the new-session screen precedes the session. + expect(writes).toEqual([]) + expect(controller.store.getSnapshot().current).toBe('minimal') + }) + + it('applies the stage to the blank session the flow lands on', async () => { + const writes: Recorded[] = [] + const current = { id: 's1', blank: true, agentPreset: 'standard' } + const controller = chip(ROSTER, current, { writes }) + await controller.load() + await controller.select('minimal') + + expect(writes).toEqual([{ ns: 'select', patch: 'minimal' }]) + expect(controller.store.getSnapshot().current).toBe('minimal') + }) + + it('spends the stage exactly once', async () => { + const writes: Recorded[] = [] + const controller = chip(ROSTER, { id: 's1', blank: true, agentPreset: 'standard' }, { writes }) + await controller.load() + await controller.select('minimal') + + await controller.apply() + await controller.apply() + + // Every later list movement calls apply(); an unspent stage would keep + // switching sessions the user never picked for. + expect(writes).toEqual([{ ns: 'select', patch: 'minimal' }]) + }) + + it('drops the stage against a session that already started', async () => { + const writes: Recorded[] = [] + const controller = chip(ROSTER, { id: 's1', blank: false, agentPreset: 'standard' }, { writes }) + await controller.load() + + await controller.select('minimal') + + // The host enforces the same rule; the chip simply never asks. + expect(writes).toEqual([]) + }) + + it('drops the stage when the session already runs it', async () => { + const writes: Recorded[] = [] + const controller = chip(ROSTER, { id: 's1', blank: true, agentPreset: 'minimal' }, { writes }) + await controller.load() + + await controller.select('minimal') + + expect(writes).toEqual([]) + }) + + it('falls back to the default when the host refuses the switch', async () => { + const controller = chip( + ROSTER, { id: 's1', blank: true, agentPreset: 'standard' }, { failSelect: 'already started' }) + await controller.load() + + await controller.select('minimal') + + // Showing `minimal` after a refusal would claim a composition the session + // never got. + expect(controller.store.getSnapshot()).toMatchObject({ current: 'standard', error: 'already started' }) + }) + + it('falls back to the default when the switch never reaches the host', async () => { + const controller = chip( + ROSTER, { id: 's1', blank: true, agentPreset: 'standard' }, { throwOn: 'select' }) + await controller.load() + + await controller.select('minimal') + + expect(controller.store.getSnapshot()) + .toMatchObject({ current: 'standard', busy: false, error: 'socket closed' }) + }) + + it('ignores a pick while a switch is in flight', async () => { + const writes: Recorded[] = [] + const controller = chip(ROSTER, { id: 's1', blank: true, agentPreset: 'standard' }, { writes }) + await controller.load() + + const first = controller.select('minimal') + await controller.select('standard') + await first + + expect(writes).toEqual([{ ns: 'select', patch: 'minimal' }]) + }) + + it('keeps a staged pick across a roster refresh', async () => { + const controller = chip(ROSTER, undefined) + await controller.load() + await controller.select('minimal') + + await controller.load() + + // A settings push re-reads the roster; it must not silently discard what + // the user picked for the session they are about to start. + expect(controller.store.getSnapshot().current).toBe('minimal') + }) + + it('reports a refused roster read without emptying the chip', async () => { + const controller = chip(ROSTER, undefined, { failList: 'host down' }) await controller.load() - // The seat keeps whatever it last showed rather than claiming this session - // has no preset; the message says why it could not refresh. expect(controller.store.getSnapshot()).toMatchObject({ error: 'host down', options: [] }) }) it('reports a transport that rejects the roster read', async () => { - const controller = seat(ROSTER, { blank: true }, { throwOn: 'list' }) + const controller = chip(ROSTER, undefined, { throwOn: 'list' }) await controller.load() expect(controller.store.getSnapshot().error).toBe('socket closed') }) - - it('restores the previous preset when the switch never reaches the host', async () => { - const controller = seat(ROSTER, { blank: true, agentPreset: 'standard' }, { throwOn: 'select' }) - await controller.load() - - await controller.select('minimal') - - // Showing `minimal` after a failed switch would claim a composition the - // session never got. - expect(controller.store.getSnapshot()).toMatchObject({ current: 'standard', busy: false, error: 'socket closed' }) - }) - - it('reports no options when the session is unknown to the list yet', async () => { - const controller = seat([], undefined) - - await controller.load() - - expect(controller.store.getSnapshot().options).toEqual([]) - expect(controller.store.getSnapshot().switchable).toBe(false) - }) }) diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 1a074dac1e..208fcf7892 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -165,6 +165,7 @@ export function apply(ctx: Context): void { 'conversation.input.left': { kind: 'list', scope: 'session' }, 'conversation.input.right': { kind: 'list', scope: 'session' }, 'conversation.hero.workspace': { kind: 'single', scope: 'root' }, + 'conversation.hero.agentPreset': { kind: 'single', scope: 'root' }, }, inject: (sessionId: SessionId | undefined): ConversationInjected => ({ selectWorkspace: async (workspaceId) => { @@ -228,7 +229,6 @@ export function apply(ctx: Context): void { children: { 'conversation.input.plan': { kind: 'single', scope: 'session' }, 'conversation.input.model': { kind: 'single', scope: 'session' }, - 'conversation.input.agentPreset': { kind: 'single', scope: 'session' }, }, inject: (sessionId: SessionId | undefined): ComposerBarInjected => { if (sessionId === undefined) { diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 81468a6b2f..477062fb12 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -61,6 +61,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * reads the global workspace list. */ 'conversation.hero.workspace': { kind: 'single'; scope: 'root'; owner: EmptyWorkspaceOwnerProps } + /** + * The agent-preset chip beside the workspace picker on the new-session + * screen. Root scope: no session exists yet, so the choice is staged for + * the next one rather than applied to a current one. + */ + 'conversation.hero.agentPreset': { kind: 'single'; scope: 'root'; owner: HeroAgentPresetOwnerProps } // 'conversation.input.overlay' merges in ui-slash (dedup ruling: the // dependency direction is the hard constraint — ui-slash cannot import // this package, while this package's input contract already imports @@ -102,12 +108,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * empty-until-registered contract as the plan seat. */ 'conversation.input.model': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps } - /** - * The agent-preset seat in the composer tool row, left of the model. - * Same empty-until-registered contract as the other two; its owner - * decides on its own whether the session may still switch. - */ - 'conversation.input.agentPreset': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps } } /** @@ -129,6 +129,28 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } } +/** Owner share of the hero agent-preset chip: the shell supplies nothing. */ +export interface HeroAgentPresetOwnerProps { + /** Marker field: the chip owns its own roster, staging, and menu state. */ + children?: never +} + +/** Owner share of the strict session content seat. */ +export interface ConversationSessionOwnerProps { + /** + * Wrap the view ring in the transcript scrollport that also hosts the + * sticky composer seat (whole `'conversation.composer'` chain output). + * Supplied for every real session (hero/settling/active) so the composer + * keeps one tree seat across the blank → active flip; the header stays + * outside that wrapper as ordinary column chrome (`flex: none`), while + * active CSS sticks the seat to the bottom of the same scrollport so wheel + * over the footer scrolls the flow. + * @param view - the session view-ring content (null while blank chrome is hidden). + * @returns the scrollport containing `view` and the sticky composer seat. + */ + wrapActiveBody?: (view: ReactNode) => ReactNode +} + /** Header actions derive their state from the standard session/global kit. */ export interface ConversationHeaderActionOwnerProps {} @@ -331,7 +353,7 @@ export interface InputControlOwnerProps { /** Full composer-bar props: standard kit & owner share & control-seat render share & injected share (hooks bound) & locale seat. */ export type ComposerBarProps = PropsRuntime<'conversation.composer.bar'> - & PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model' | 'conversation.input.agentPreset'> + & PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model'> & InjectFace & PropsLocale<'conversation'> @@ -361,6 +383,7 @@ export type ConversationSlotProps = | 'conversation.input.dock' | 'conversation.composer.dock' | 'conversation.input.left' | 'conversation.input.right' | 'conversation.hero.workspace' + | 'conversation.hero.agentPreset' > & ConversationInjected & PropsLocale<'conversation'> diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index bc5f7bdc01..04db99e3e3 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -117,6 +117,7 @@ export function ConversationRoot({ }, onClose: () => { setPickerOpen(false) }, })} + {renderSlot('conversation.hero.agentPreset', {})} ) diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 9687582427..131f63c49d 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -512,7 +512,6 @@ export function InputBar({
{rightItems} - {renderSlot('conversation.input.agentPreset', { locked })} {renderSlot('conversation.input.model', { locked })} {/* {machineBusy && } */} diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index df8fff6719..beeab4b129 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -85,9 +85,11 @@ describe('apply wiring', () => { expect(conversationHeader?.store).toBe(conversationSession?.store) expect(details?.store).toBe(conversationSession?.store) expect(chatView?.store).toBe(conversationSession?.store) - // The hero workspace picker hole rides the conversation entry's children - // declaration (the empty-state occupant is gone). + // The hero holes ride the conversation entry's children declaration (the + // empty-state occupant is gone). Both are root-scoped: the new-session + // screen precedes the session either would belong to. expect(b.slots.spec('conversation.hero.workspace')).toEqual({ kind: 'single', scope: 'root' }) + expect(b.slots.spec('conversation.hero.agentPreset')).toEqual({ kind: 'single', scope: 'root' }) expect(b.slots.entries('settings.general.item').map(entry => entry.options.id)).toEqual(['composer-enter']) await b.runtime.dispose() }) diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 116aeeb26d..7e9cccfa2f 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -693,7 +693,7 @@ describe('command launcher chrome and control seats', () => { expect(view.queryByLabelText(/^访问模式/)).toBeNull() // Every seat dispatched, nothing rendered. expect(slotCalls.map(c => c.key)).toEqual([ - 'conversation.input.plan', 'conversation.input.agentPreset', 'conversation.input.model', + 'conversation.input.plan', 'conversation.input.model', ]) expect(view.queryByLabelText('Plan mode')).toBeNull() expect(view.queryByLabelText('Model')).toBeNull() diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index ba2e75f5f7..adf90fa996 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -407,6 +407,9 @@ describe('ConversationRoot resident composer', () => { const chip = b.view.getByRole('button', { name: '选择工作区' }) expect((chip as HTMLButtonElement).disabled).toBe(false) expect(b.slotCalls).toContain('conversation.hero.workspace') + // The agent-preset chip sits in the same row, for the same reason: both + // choices are only open before the first message. + expect(b.slotCalls).toContain('conversation.hero.agentPreset') }) it('prompt failure renders the promptError strip (ordinary failure, no transaction UI)', () => { diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 8bba4f0844..5ca83a503a 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -328,12 +328,10 @@ async function summarizeCold( // a cold log to check for turns would defeat the index read, so a listed // cold session is served as not-blank (its log holds its conversation). blank: false, - ...meta.parentSession === undefined ? {} : { parentSessionId: meta.parentSession }, - ...meta.origin === undefined ? {} : { origin: meta.origin }, - /* v8 ignore next -- the empty arm needs a cwd-less meta, but list() - filters those out (legacy logs are not served); the conditional mirrors - summarize() shape. */ - ...meta.cwd === undefined ? {} : { cwd: meta.cwd }, + // Header-only: reading the log for a blank-window preset switch would + // defeat the same index read, and attaching the session replaces this row + // with `summarize()`, which resolves the switch from the events. + ...sessionListFields(meta), } } diff --git a/packages/preset/agent-presets/src/mount.ts b/packages/preset/agent-presets/src/mount.ts index fabe475d7b..9635f0b008 100644 --- a/packages/preset/agent-presets/src/mount.ts +++ b/packages/preset/agent-presets/src/mount.ts @@ -285,6 +285,25 @@ export function inactiveRows(tree: EntryTree): string[] { return lines } +/** + * The reportable text of a mount failure. + * + * The loader reports several failed rows as one `AggregateError`, whose own + * message names none of them; without flattening, a composition that fails on + * two rows says only "loader entries failed to apply" and the operator has + * nothing to act on. + * @param error - the value the mount rejected with. + * @returns a single-line-per-cause description. + */ +function mountDetail(error: unknown): string { + /* v8 ignore next -- every path into the mount's catch throws an Error: the loader + wraps a row's thrown value before it propagates, and this module's own + rejections are Errors. The fallback keeps a hostile value readable. */ + if (!(error instanceof Error)) return String(error) + if (!(error instanceof AggregateError)) return error.message + return [error.message, ...error.errors.map(cause => `- ${mountDetail(cause)}`)].join('\n') +} + /** * Mount `preset` under `agentCtx` and return only once every row is usable. * @@ -341,10 +360,6 @@ export async function mountPreset(agentCtx: Context, preset: AgentPreset): Promi // Swallows only this subtree's teardown failure. The mount error below is // the actionable one, and the discarded fiber is unreachable either way. } - /* v8 ignore next -- every path into this catch throws an Error: the loader - wraps a row's thrown value before it propagates, and this module's own - rejections are Errors. The fallback keeps a hostile value readable. */ - const detail = error instanceof Error ? error.message : String(error) - throw new PresetMountError(preset.id, `${detail} (${preset.path})`, { cause: error }) + throw new PresetMountError(preset.id, `${mountDetail(error)} (${preset.path})`, { cause: error }) } } diff --git a/packages/preset/agent-presets/tests/discovery.spec.ts b/packages/preset/agent-presets/tests/discovery.spec.ts index fbb7f08374..fec3821083 100644 --- a/packages/preset/agent-presets/tests/discovery.spec.ts +++ b/packages/preset/agent-presets/tests/discovery.spec.ts @@ -28,6 +28,21 @@ describe('display order', () => { // alphabetical behind them rather than interleaving unpredictably. expect(found.map(preset => preset.id)).toEqual(['zulu', 'alpha', 'bravo', 'yankee']) }) + + it('breaks a tie between equal declared orders by id', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-order-tie-')) + for (const id of ['yankee', 'alpha']) { + await mkdir(join(root, id), { recursive: true }) + await writeFile(join(root, id, COMPOSITION_FILE), '[]\n') + await writeFile(join(root, id, 'preset.yml'), 'order: 1\n') + } + + const found = await scanRoot({ path: root, trust: 'system' }) + + // Two presets claiming the same slot must still list in a stable order: + // a directory-scan order would reshuffle the picker between reads. + expect(found.map(preset => preset.id)).toEqual(['alpha', 'yankee']) + }) }) describe('preset discovery', () => { diff --git a/packages/preset/agent-presets/tests/fixtures/user/two-broken/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/user/two-broken/agent.cordis.yml new file mode 100644 index 0000000000..1533565f58 --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/user/two-broken/agent.cordis.yml @@ -0,0 +1,7 @@ +# Two rows that cannot load: the Loader reports several failed entries as one +# AggregateError whose own message names none of them, so this fixture is what +# proves the mount diagnostic flattens the causes. +- id: first-missing + name: ../../plugins/does-not-exist.js +- id: second-missing + name: ../../plugins/also-missing.js diff --git a/packages/preset/agent-presets/tests/metadata.spec.ts b/packages/preset/agent-presets/tests/metadata.spec.ts index 31727a2f6a..8bdabe1c59 100644 --- a/packages/preset/agent-presets/tests/metadata.spec.ts +++ b/packages/preset/agent-presets/tests/metadata.spec.ts @@ -95,6 +95,10 @@ describe('rendering display metadata', () => { expect(await readPresetMetadata(dir)).toEqual({ name: '创造模式', description: '可以改自己的组装。' }) }) + it('stores a declared order', () => { + expect(renderPresetMetadata({ name: '标准模式', order: 1 })).toBe('name: 标准模式\norder: 1\n') + }) + it('omits an absent field rather than writing it blank', () => { expect(renderPresetMetadata({ name: '极简模式' })).toBe('name: 极简模式\n') // Description without a name is legal too: the picker falls back to the id. diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index 54b9fce254..1224be7283 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -146,6 +146,14 @@ describe('rejecting a composition that cannot be used', () => { expect(toolNames(ctx)).toEqual([]) }) + it('names every failed row, not just the count', async () => { + // The Loader folds several failed rows into one AggregateError whose own + // message names none of them; unflattened, the operator is told only that + // "loader entries failed to apply" and has nothing to act on. + await expect(agentOn(ctx, 'sess-two-broken', 'two-broken')) + .rejects.toThrow(/first-missing[\s\S]*second-missing/) + }) + it('names the unresolved service when a row never activates', async () => { await expect(agentOn(ctx, 'sess-pending', 'pending')) .rejects.toThrow(/waiting for serviceThatDoesNotExist/) @@ -204,7 +212,7 @@ describe('the preset roster', () => { const listed = await ctx.agentPresets.list() expect(listed.map(preset => preset.id).sort()) - .toEqual(['broken', 'isolated', 'late', 'leaky', 'minimal', 'pending', 'standard']) + .toEqual(['broken', 'isolated', 'late', 'leaky', 'minimal', 'pending', 'standard', 'two-broken']) expect(listed.find(preset => preset.id === 'standard')?.trust).toBe('system') }) diff --git a/packages/preset/agent-presets/tests/session.spec.ts b/packages/preset/agent-presets/tests/session.spec.ts new file mode 100644 index 0000000000..d87c4d1937 --- /dev/null +++ b/packages/preset/agent-presets/tests/session.spec.ts @@ -0,0 +1,62 @@ +/** + * Which preset a session ran is a question about its LOG, not its header: the + * header records the creation-time choice, and a switch made during the blank + * window is an event. Every reconstruction — the list row, the header label, + * resume, fork — goes through this resolver, so a resolver that read the header + * alone would rebuild a switched session under a composition its own history + * contradicts. + */ + +import { describe, expect, it } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import { resolveSessionPreset } from '../src/session.ts' + +/** A header carrying the creation-time preset, if any. */ +function header(agentPreset?: string): SessionHeader { + return { + version: 0, + id: SessionId('s'), + createdAt: 1, + delegationDepth: 0, + ...agentPreset === undefined ? {} : { agentPreset }, + } +} + +/** One logged selection, as `agentPreset.select` appends it. */ +function selected(agentPreset: string, seq: number): SessionEvent { + return { type: 'agent-preset/selected', seq, time: seq, data: { agentPreset } } +} + +describe('resolving which preset a session ran', () => { + it('reads the creation-time value when nothing was switched', () => { + expect(resolveSessionPreset({ header: header('standard'), events: [] })).toBe('standard') + }) + + it('prefers a logged switch over the header', () => { + // The switch's effect outlives the blank window it was made in: the turns + // that follow run under the newer composition. + expect(resolveSessionPreset({ header: header('standard'), events: [selected('minimal', 0)] })) + .toBe('minimal') + }) + + it('takes the last switch when a session was moved twice', () => { + expect(resolveSessionPreset({ + header: header('standard'), + events: [selected('minimal', 0), selected('cordis', 1)], + })).toBe('cordis') + }) + + it('finds a switch behind later events', () => { + const later = { type: 'turn/end', seq: 2, time: 2, data: { turn: 1 } } as SessionEvent + + expect(resolveSessionPreset({ header: header(), events: [selected('minimal', 0), later] })) + .toBe('minimal') + }) + + it('reports none when the deployment composes no presets', () => { + // A valid deployment: every session shares the host composition, and no + // surface should invent a preset name for it. + expect(resolveSessionPreset({ header: header(), events: [] })).toBeUndefined() + }) +}) diff --git a/packages/session-persistence/session-persistence-jsonl/README.i18n.yaml b/packages/session-persistence/session-persistence-jsonl/README.i18n.yaml index 763b55a1a4..5210bcbdba 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.i18n.yaml +++ b/packages/session-persistence/session-persistence-jsonl/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-persistence/session-persistence-jsonl/README.md -README.md: cd087539bde2433fcdb70b2c511ff30880a877e1 -README.zh.md: 144b404e04f8a5fd3623d9329e4fbcafd328524d +README.md: 7cd3b36f40e00e37c161ebb30eaae95f86337203 +README.zh.md: a7395c94eb8adcddf30b3b8452c5426d57ae3a4e diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index cd087539bd..79bc4cebe6 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -14,7 +14,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence session.jsonl # only with compression: 'none' ``` -- The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, origin?, delegationDepth }`. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. Every subsequent logical line is one storage record; `assistant/chunk` events are never dropped, and `seq` stays contiguous across the decoded log (`events[i].seq === i`). +- The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, origin?, delegationDepth, agentPreset? }`. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. `agentPreset` is durable because it decides the resumed session's tools and prompt — restoring a different composition would replay history the model can no longer act on. Every subsequent logical line is one storage record; `assistant/chunk` events are never dropped, and `seq` stays contiguous across the decoded log (`events[i].seq === i`). - A storage record is a `SessionEvent` JSON verbatim, or — for an eligible run when `packChunks` is enabled — a **packed chunk row** (`text-chunks` / `reasoning-chunks` / `tool-call-chunks`; bare slash-less tags like the header's `session`, so row tags cannot be confused with event types): one line holding a run of ≥3 consecutive same-block `assistant/chunk` delta events, `seq0`/`time0` plus per-member `dt` gaps reconstructing every member's `seq`/`time` exactly. The lossless codec lives in `@deepseek-ai/dsh-session` (`packChunkRuns`/`decodeStorageRecord`) and whitelists exact shapes — anything unrecognized stores verbatim. Reading is layout-blind: `load` always decodes rows, so packed, unpacked, and mixed files load identically. - The project directory keeps the normalized cwd readable for navigation and is bounded for filesystem component limits. Separator replacement and truncation are intentionally lossy, so cwd strings that normalize alike share a project directory; session ids still select distinct session directories. On a case-insensitive filesystem, identity validation accepts an alternate path spelling only when filesystem canonicalization resolves both spellings to the same transcript. The configured root remains deployment-controlled: it may be project-local, shared, temporary, or centralized. The [project-session directory decision](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) records this tradeoff. - Session ids are unvalidated branded strings, so they are injectively escaped to a single safe path segment before use (no traversal, no collision). The resulting directory is reserved for additional session-owned artifacts; discovery reads only the fixed transcript filename. diff --git a/packages/session-persistence/session-persistence-jsonl/README.zh.md b/packages/session-persistence/session-persistence-jsonl/README.zh.md index 144b404e04..9bd4d34f29 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.zh.md +++ b/packages/session-persistence/session-persistence-jsonl/README.zh.md @@ -14,7 +14,7 @@ JSONL 持久会话存储后端:`SessionPersistence` 的一个具体实现(`d session.jsonl # only with compression: 'none' ``` -- 第一个逻辑行是不可变的 `SessionHeader`,标记为 `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, origin?, delegationDepth }`。`delegationDepth` 在磁盘上必需,顶层会话为 `0`;缺失或无效值会拒绝日志。后续每个逻辑行是一条存储记录;`assistant/chunk` 事件绝不丢弃,且 `seq` 在解码日志中保持连续(`events[i].seq === i`)。 +- 第一个逻辑行是不可变的 `SessionHeader`,标记为 `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, origin?, delegationDepth, agentPreset? }`。`delegationDepth` 在磁盘上必需,顶层会话为 `0`;缺失或无效值会拒绝日志。`agentPreset` 必须持久化,因为它决定了被恢复会话的工具与提示词——恢复成另一套组装,就会重放模型已无法据以行动的历史。后续每个逻辑行是一条存储记录;`assistant/chunk` 事件绝不丢弃,且 `seq` 在解码日志中保持连续(`events[i].seq === i`)。 - 存储记录是原样 `SessionEvent` JSON,或在 `packChunks` 已启用且连续段符合条件时写入的**打包分片行**(`text-chunks` / `reasoning-chunks` / `tool-call-chunks`;像 header 的 `session` 一样不带斜杠,因此行 tag 不会与事件类型混淆):一行保存至少 3 个连续同 block `assistant/chunk` delta 事件,`seq0`/`time0` 和每成员 `dt` 间隔精确重建每个成员的 `seq`/`time`。无损 codec 位于 `@deepseek-ai/dsh-session`(`packChunkRuns`/`decodeStorageRecord`),并使用精确形态 allowlist:任何未识别内容原样存储。读取与布局无关:`load` 始终解码行,因此打包、非打包和混合文件加载结果一致。 - 项目目录保留规范化 cwd 可读,并限制在文件系统组件上限内。分隔符替换和截断刻意有损,因此规范化相同的 cwd 字符串共享项目目录;会话 id 仍选择不同会话目录。在不区分大小写的文件系统上,只有文件系统规范化将两种写法解析到同一 transcript(文本记录)时,身份验证才接受备选路径写法。配置根仍由部署控制:可以是项目本地、共享、临时或集中式。[项目会话目录决策](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) 记录这项取舍。 - 会话 id 是未验证的带品牌类型的字符串,因此在使用前单射转义为一个安全路径段(无遍历、无冲突)。结果目录保留给其他会话自有产物;发现只读取固定 transcript 文件名。 diff --git a/packages/session-persistence/session-persistence-jsonl/src/format.ts b/packages/session-persistence/session-persistence-jsonl/src/format.ts index 96e8221c65..fd62306b1a 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/format.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/format.ts @@ -39,6 +39,7 @@ export interface HeaderLine { seedLength?: number origin?: 'subagent' delegationDepth: number + agentPreset?: string } /** @@ -57,6 +58,7 @@ export function toHeaderLine(header: SessionHeader): HeaderLine { ...header.seedLength !== undefined ? { seedLength: header.seedLength } : {}, ...header.origin !== undefined ? { origin: header.origin } : {}, delegationDepth: header.delegationDepth ?? 0, + ...header.agentPreset !== undefined ? { agentPreset: header.agentPreset } : {}, } } @@ -78,6 +80,7 @@ export function fromHeaderLine(line: HeaderLine): SessionHeader { ...line.seedLength !== undefined ? { seedLength: line.seedLength } : {}, ...line.origin !== undefined ? { origin: line.origin } : {}, delegationDepth: line.delegationDepth, + ...line.agentPreset !== undefined ? { agentPreset: line.agentPreset } : {}, } } @@ -98,6 +101,8 @@ function isHeaderLine(value: unknown): value is HeaderLine { && !Object.is((value as { delegationDepth: number }).delegationDepth, -0) && ((value as { origin?: unknown }).origin === undefined || (value as { origin?: unknown }).origin === 'subagent') + && ((value as { agentPreset?: unknown }).agentPreset === undefined + || typeof (value as { agentPreset?: unknown }).agentPreset === 'string') ) } diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 03a7f90d65..6a551f277a 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -806,6 +806,27 @@ describe('SessionPersistenceJsonl: scanLog unit', () => { expect(() => scanLog(Buffer.from(log))).toThrow(/session header/) }) + it('round-trips the agent preset a session was composed from', () => { + const line = toHeaderLine({ + version: 0, + id: SessionId('composed'), + createdAt: 1, + delegationDepth: 0, + agentPreset: 'minimal', + }) + const log = `${JSON.stringify(line)}\n` + + // The preset decides the resumed session's tools and prompt; dropping it + // on disk would restore a composition the logged history contradicts. + expect(scanLog(Buffer.from(log)).meta.agentPreset).toBe('minimal') + }) + + it('rejects a session header whose agentPreset is not a string', () => { + const log = '{"type":"session","version":0,"id":"bad-preset","createdAt":1,"delegationDepth":0,"agentPreset":7}\n' + + expect(() => scanLog(Buffer.from(log))).toThrow(/session header/) + }) + it('a seq gap after the last turn/end bounds the preserved tail (torn fragment tolerated)', () => { const log = [ JSON.stringify({ type: 'session', version: 0, id: 'g', createdAt: 1, delegationDepth: 0 }), diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 173d1bf857..c45213461e 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -372,8 +372,8 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers private writeRow(meta: SessionHeader): void { this.db.prepare(` INSERT INTO sessions - (id, version, created_at, cwd, parent_session, seed_length, origin, delegation_depth, incarnation, revision) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0) + (id, version, created_at, cwd, parent_session, seed_length, origin, delegation_depth, agent_preset, incarnation, revision) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0) ON CONFLICT(id) DO UPDATE SET version = excluded.version, created_at = excluded.created_at, @@ -381,7 +381,8 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers parent_session = excluded.parent_session, seed_length = excluded.seed_length, origin = excluded.origin, - delegation_depth = excluded.delegation_depth + delegation_depth = excluded.delegation_depth, + agent_preset = excluded.agent_preset `).run( meta.id, meta.version, @@ -391,6 +392,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers meta.seedLength ?? null, meta.origin ?? null, meta.delegationDepth ?? null, + meta.agentPreset ?? null, randomUUID(), ) } diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index a9830316a8..c7a4de7233 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -17,7 +17,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee * layout; orthogonal to a session's own `version` (which versions the EVENT * vocabulary, stored per session in the `sessions` row). */ -export const SCHEMA_VERSION = 13 +export const SCHEMA_VERSION = 14 /** SQLite application id protecting unrelated databases from persistence writes. */ export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850 @@ -42,6 +42,7 @@ export interface SessionRow { /** Monotonic log-change token incremented in each mutating transaction. */ revision: number delegation_depth: number | null + agent_preset: string | null } /** An `events` table row: one `SessionEvent` mapped 1:1 (`data` is JSON text). */ @@ -125,6 +126,7 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM seed_length INTEGER, origin TEXT, delegation_depth INTEGER, + agent_preset TEXT, incarnation TEXT NOT NULL, revision INTEGER NOT NULL ) STRICT; @@ -184,6 +186,7 @@ export function rowToMeta(row: SessionRow): SessionHeader { ...row.seed_length !== null ? { seedLength: row.seed_length } : {}, ...row.origin !== null ? { origin: row.origin } : {}, ...row.delegation_depth !== null ? { delegationDepth: row.delegation_depth } : {}, + ...row.agent_preset !== null ? { agentPreset: row.agent_preset } : {}, } } diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index d215696748..5220935599 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -172,6 +172,7 @@ describe('rowToMeta', () => { incarnation: 'with-origin', revision: 1, delegation_depth: null, + agent_preset: null, })).toMatchObject({ id: 'with-origin', origin: 'subagent' }) }) @@ -187,8 +188,27 @@ describe('rowToMeta', () => { incarnation: 'fractional', revision: 1, delegation_depth: null, + agent_preset: null, })).toThrow('stored session createdAt must be a non-negative safe integer') }) + + it('restores the agent preset a session was composed from', () => { + // The preset decides the resumed session's tools and prompt; a row that + // dropped it would rebuild a composition the stored history contradicts. + expect(rowToMeta({ + id: 'composed', + version: 0, + created_at: 1, + cwd: null, + parent_session: null, + seed_length: null, + origin: null, + incarnation: 'composed', + revision: 1, + delegation_depth: null, + agent_preset: 'minimal', + })).toMatchObject({ agentPreset: 'minimal' }) + }) }) describe('SessionPersistenceSqlite: durability and crash semantics', () => { @@ -638,7 +658,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) it('exposes the schema version constant', () => { - expect(SCHEMA_VERSION).toBe(13) + expect(SCHEMA_VERSION).toBe(14) }) it('keeps the revision stable for an empty repair hook', async () => { diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index ef3a920878..045834f7c0 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -162,6 +162,7 @@ interface SessionHeaderRow { parent_session: string | null seed_length: number | null delegation_depth: number | null + agent_preset: string | null } interface SearchRow extends SessionHeaderRow { @@ -552,8 +553,8 @@ export class SessionQuerySqlite extends SessionQueryService { const db = this._requireDb() db.prepare(` INSERT INTO persisted_sessions - (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, revision, generation) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, agent_preset, revision, generation) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( entry.header.id, entry.header.version, @@ -562,6 +563,7 @@ export class SessionQuerySqlite extends SessionQueryService { entry.header.parentSession ?? null, entry.header.seedLength ?? null, entry.header.delegationDepth ?? null, + entry.header.agentPreset ?? null, revision, generation, ) @@ -588,8 +590,8 @@ export class SessionQuerySqlite extends SessionQueryService { const db = this._requireDb() db.prepare(` INSERT INTO temp.live_sessions - (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, fingerprint, persisted, generation) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, agent_preset, fingerprint, persisted, generation) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( entry.header.id, entry.header.version, @@ -598,6 +600,7 @@ export class SessionQuerySqlite extends SessionQueryService { entry.header.parentSession ?? null, entry.header.seedLength ?? null, entry.header.delegationDepth ?? null, + entry.header.agentPreset ?? null, entry.fingerprint, persisted ? 1 : 0, generation, @@ -692,7 +695,7 @@ export class SessionQuerySqlite extends SessionQueryService { const db = this._requireDb() const live = db.prepare( `SELECT - id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, generation + id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, agent_preset, generation FROM temp.live_sessions WHERE id = ?`, ).get(sessionId) as (SessionHeaderRow & { generation: number }) | undefined @@ -702,7 +705,7 @@ export class SessionQuerySqlite extends SessionQueryService { if (persistenceBinding.service !== undefined) { const persisted = db.prepare( `SELECT - id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, generation + id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, agent_preset, generation FROM persisted_sessions WHERE id = ?`, ).get(sessionId) as (SessionHeaderRow & { generation: number }) | undefined @@ -761,6 +764,7 @@ function selectedDocumentsSql(): { sql: string } { ps.parent_session AS parent_session, ps.seed_length AS seed_length, ps.delegation_depth AS delegation_depth, + ps.agent_preset AS agent_preset, 0 AS live, 1 AS persisted, CAST(pd.seq AS INTEGER) AS seq, @@ -783,6 +787,7 @@ function selectedDocumentsSql(): { sql: string } { ls.parent_session AS parent_session, ls.seed_length AS seed_length, ls.delegation_depth AS delegation_depth, + ls.agent_preset AS agent_preset, 1 AS live, CASE WHEN ? = 1 THEN ls.persisted ELSE 0 END AS persisted, CAST(ld.seq AS INTEGER) AS seq, @@ -891,6 +896,7 @@ function sameHeader(a: SessionHeader, b: SessionHeader): boolean { && a.parentSession === b.parentSession && a.seedLength === b.seedLength && (a.delegationDepth ?? 0) === (b.delegationDepth ?? 0) + && a.agentPreset === b.agentPreset } function rowHeader(row: SessionHeaderRow): SessionHeader { @@ -902,6 +908,7 @@ function rowHeader(row: SessionHeaderRow): SessionHeader { ...row.parent_session === null ? {} : { parentSession: row.parent_session as SessionId }, ...row.seed_length === null ? {} : { seedLength: row.seed_length }, ...row.delegation_depth === null ? {} : { delegationDepth: row.delegation_depth }, + ...row.agent_preset === null ? {} : { agentPreset: row.agent_preset }, } } diff --git a/packages/session-query/session-query-sqlite/src/schema.ts b/packages/session-query/session-query-sqlite/src/schema.ts index 59cec8819c..6ad031f77f 100644 --- a/packages/session-query/session-query-sqlite/src/schema.ts +++ b/packages/session-query/session-query-sqlite/src/schema.ts @@ -5,7 +5,7 @@ import { mkdir, open } from 'node:fs/promises' import { dirname, resolve } from 'node:path' /** Current derived-index schema version. Incompatible versions reset in place. */ -export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 7 +export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 8 /** SQLite application id protecting unrelated databases from derived resets. */ export const SESSION_QUERY_SQLITE_APPLICATION_ID = 0x44534851 @@ -118,6 +118,7 @@ function ensurePersistentSchema(db: DatabaseSync): void { parent_session TEXT, seed_length INTEGER, delegation_depth INTEGER, + agent_preset TEXT, revision TEXT NOT NULL, generation INTEGER NOT NULL ) STRICT @@ -147,6 +148,7 @@ function ensureTemporarySchema(db: DatabaseSync): void { parent_session TEXT, seed_length INTEGER, delegation_depth INTEGER, + agent_preset TEXT, fingerprint TEXT NOT NULL, persisted INTEGER NOT NULL CHECK (persisted IN (0, 1)), generation INTEGER NOT NULL diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index 36be5cb9e5..96cd94537f 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -280,7 +280,10 @@ describe('SQLite session search', () => { it('searches two-character Unicode61 tokens in live-only sessions', async () => { const ctx = await liveContext({ path: ':memory:', snippetChars: 20 }) const session = ctx.sessions.create(SessionId('live'), { - meta: { cwd: '/work', createdAt: 10, seedLength: 1, delegationDepth: 2 }, + // agentPreset rides along: the index rebuilds the header a caller reads, + // and a session listed under the wrong composition is a lie about what it + // ran. The full-header comparison below is what pins every column. + meta: { cwd: '/work', createdAt: 10, seedLength: 1, delegationDepth: 2, agentPreset: 'minimal' }, }) session.append( 'user/message', diff --git a/tsconfig.host.json b/tsconfig.host.json index 043ed309fa..9595bd9295 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -44,6 +44,7 @@ "apps/web/tests/skill-invocation-policy.e2e.ts", "apps/web/tests/permission-policy-context.e2e.ts", "apps/web/tests/access-confirmation.e2e.ts", + "apps/web/tests/agent-preset-selection.e2e.ts", "apps/web/tests/shipped-composition.e2e.ts", "apps/web/tests/goal-bar.e2e.ts", "apps/web/tests/startup-auto-selection.e2e.ts",