From a7e43d4346647546ea0f489566f130e45495d27e Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 7 Aug 2026 14:26:50 +0800 Subject: [PATCH 01/20] refactor: drop the create-by-name workspace route The Web picker collapsed onto the directory flow (see the one-route-to-add-a-workspace Agent Note), leaving workspace.create({ name }) with no product consumer. Delete the whole feed line: the wire schema's name member and WorkspaceApi spelling, the gateway's workspaceRoot config/default and the mkdir branch, the client seam that carried the name (WorkspaceCreateInput, WorkspacesService.create, intentName), the dsh web --workspace-root flag, and the fixture's name handling. workspace-name-conflict stays as workspace.rename's duplicate-title error. --- ...-31-one-route-to-add-a-workspace.i18n.yaml | 4 +- ...2026-07-31-one-route-to-add-a-workspace.md | 2 +- ...6-07-31-one-route-to-add-a-workspace.zh.md | 2 +- apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 2 +- apps/cli/reference/README.zh.md | 2 +- apps/cli/src/args.ts | 8 +- apps/cli/src/web.ts | 10 +-- apps/cli/tests/args.spec.ts | 4 +- docs/config-catalog.md | 6 +- packages/bundle/web-app/cordis.patch.yml | 2 +- .../client/connection/src/client/fixture.ts | 9 +- .../client/connection/tests/fixture.spec.ts | 21 ++--- .../runtime/src/client/contract/workspaces.ts | 6 +- .../runtime/src/client/workspaces/manager.ts | 2 +- .../runtime/src/client/workspaces/service.ts | 6 +- .../src/client/workspaces/workspace.ts | 3 +- .../runtime/tests/workspaces-service.spec.ts | 6 +- .../client/test-runtime/src/workspaces.ts | 11 ++- .../test-runtime/tests/runtime.spec.tsx | 6 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 4 +- packages/host/apiproxy/README.zh.md | 4 +- packages/host/apiproxy/src/api-proxy.ts | 74 +--------------- .../host/apiproxy/src/api/workspace.schema.ts | 10 +-- packages/host/apiproxy/src/api/workspace.ts | 19 ++--- packages/host/apiproxy/src/index.ts | 12 +-- .../apiproxy/tests/api-proxy-approval.spec.ts | 4 +- .../apiproxy/tests/api-proxy-blank.spec.ts | 2 +- .../apiproxy/tests/api-proxy-cold.spec.ts | 22 ++--- .../apiproxy/tests/api-proxy-commands.spec.ts | 2 +- .../apiproxy/tests/api-proxy-config.spec.ts | 2 +- .../apiproxy/tests/api-proxy-fork.spec.ts | 1 - .../apiproxy/tests/api-proxy-models.spec.ts | 4 +- .../tests/api-proxy-projections.spec.ts | 2 +- .../apiproxy/tests/api-proxy-question.spec.ts | 2 +- .../apiproxy/tests/api-proxy-rename.spec.ts | 2 +- .../apiproxy/tests/api-proxy-search.spec.ts | 2 +- .../tests/api-proxy-subagents.spec.ts | 2 +- .../apiproxy/tests/api-proxy-view.spec.ts | 10 +-- .../tests/api-proxy-workspace.spec.ts | 85 +++++++++---------- .../apiproxy/tests/client-handler.spec.ts | 4 +- .../host/apiproxy/tests/rpc-schemas.spec.ts | 6 +- .../todo/tool-todo/tests/projection.spec.ts | 2 +- 44 files changed, 145 insertions(+), 252 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml index 1c0cc5644d..691511d76b 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.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 .agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md -2026-07-31-one-route-to-add-a-workspace.md: 5d002265b5eb1178bb1dbc7bd17f8b364d9b9856 -2026-07-31-one-route-to-add-a-workspace.zh.md: 0a59d3a505eb921b4ec980abaefedfcad8a3c294 +2026-07-31-one-route-to-add-a-workspace.md: 853d641e0a2c0044ee7bfd6ed42bcc3763520192 +2026-07-31-one-route-to-add-a-workspace.zh.md: 6a2884d75138ddc241276131f7ff85080fc5d794 diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md index 5d002265b5..853d641e0a 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md +++ b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md @@ -27,7 +27,7 @@ The direct-open path carries the busy rule the menu entry states: while a pick i ## Wire and CLI residue -The host's `workspace.create` still accepts `{ name }`, and `dsh web --workspace-root` still feeds its target directory, but no product surface reaches either any more. The same is true of the client seam that carried the name to the wire: `WorkspaceCreateInput`, `WorkspacesService.create`'s `{ name }` arm, `intentName`'s name branch, and the manager's "name under workspaceRoot" contract. `apps/cli/README.md` and its Chinese counterpart still document `--workspace-root` as creating named Workspaces. The whole set is marked for deletion at the call site in `packages/host/apiproxy/src/api-proxy.ts` and left to a follow-up change: it is backend, client-seam, and CLI surface with its own reviewer and its own test fallout (the api-proxy workspace suite, the runtime workspace suite, the config catalog), and the release-blocking part of this decision is the UI. +Deleted in the follow-up change this section used to scope: `workspace.create` accepts only `{ path }` (the `name` member left the wire schema and `WorkspaceApi`), the gateway lost its `workspaceRoot` config and default, the client seam narrowed to the path spelling (`WorkspaceCreateInput`, `WorkspacesService.create`, `intentName`), and the `dsh web --workspace-root` flag is gone together with its `apps/cli` reference lines. `workspace-name-conflict` remains on the wire as `workspace.rename`'s duplicate-title error. ## Testing diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md index 0a59d3a505..6a2884d751 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md @@ -27,7 +27,7 @@ Status: implemented ## Wire and CLI residue -Host 侧的 `workspace.create` 仍接受 `{ name }`,`dsh web --workspace-root` 也仍在为它提供目标目录,但已没有任何产品表层会走到它们。把名称送到 wire 的客户端一段同样如此:`WorkspaceCreateInput`、`WorkspacesService.create` 的 `{ name }` 分支、`intentName` 的名称分支,以及 manager 中"workspaceRoot 下的 name"这一契约。`apps/cli/README.md` 及其中文对照本也仍把 `--workspace-root` 记为"创建具名 Workspace"。这一整套都在 `packages/host/apiproxy/src/api-proxy.ts` 的调用点标记为待删除,并留给后续改动:它横跨 backend、客户端 seam 与 CLI 面,有各自的 reviewer 和各自的测试波及面(api-proxy workspace 套件、runtime workspace 套件、配置目录),而本决定中阻塞发布的部分是 UI。 +本节曾划定的后续删除已经落地:`workspace.create` 只接受 `{ path }`(`name` 成员已从 wire schema 与 `WorkspaceApi` 移除),网关失去了 `workspaceRoot` 配置及其默认值,客户端 seam 收窄为 path 写法(`WorkspaceCreateInput`、`WorkspacesService.create`、`intentName`),`dsh web --workspace-root` flag 连同其 `apps/cli` reference 文档行一并删除。`workspace-name-conflict` 仍留在 wire 上,作为 `workspace.rename` 的重名错误。 ## Testing diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 07d7810529..db6a4c6dbe 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/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 apps/cli/reference/README.md -README.md: 8b8a0e7dbebafedd6a4f8d988adb3fd11c7bd026 -README.zh.md: d1d6d5a594596a8be5db30021163f0fcea4a95bf +README.md: 9adf7e4b238a96c5497c14709ba7ddc08eff13af +README.zh.md: d4f2c607f1b6067c0d936761cb86301950597e1d diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 8b8a0e7dbe..9adf7e4b23 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -37,7 +37,7 @@ Git-hosted plugins that ship sources build during install through their `prepare ## Web alias -`dsh web` is a hardcoded alias for `--profile web` that additionally accepts the Web flag family. `--host`, `--port`, `--workspace-root`, and repeatable `--trusted-host` values become patches over the composed rows; their owning plugin schemas validate them at boot. `--dev` switches the web-runtime row to development mode and inserts the client-plugin HMR receiver; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. +`dsh web` is a hardcoded alias for `--profile web` that additionally accepts the Web flag family. `--host`, `--port`, and repeatable `--trusted-host` values become patches over the composed rows; their owning plugin schemas validate them at boot. `--dev` switches the web-runtime row to development mode and inserts the client-plugin HMR receiver; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. ```sh dsh web diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index d1d6d5a594..d4f2c607f1 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -37,7 +37,7 @@ Git 托管、随附源码的插件在安装期间通过其 `prepare` 脚本构 ## Web 别名 -`dsh web` 是 `--profile web` 的硬编码别名,并额外接受 Web flag 系列。`--host`、`--port`、`--workspace-root` 和可重复的 `--trusted-host` 值会成为作用在组合行之上的 patch;负责这些值的插件 schema 会在启动时验证它们。`--dev` 把 web-runtime 行切换到开发模式并插入客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。 +`dsh web` 是 `--profile web` 的硬编码别名,并额外接受 Web flag 系列。`--host`、`--port` 和可重复的 `--trusted-host` 值会成为作用在组合行之上的 patch;负责这些值的插件 schema 会在启动时验证它们。`--dev` 把 web-runtime 行切换到开发模式并插入客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。 ```sh dsh web diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 310b5b03a2..349a127916 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -40,7 +40,6 @@ interface WebInvocation { host?: string port?: number dev: boolean - workspaceRoot?: string /** Extra authorities for the /api browser-trust fence. */ trustedHosts?: string[] } @@ -62,7 +61,6 @@ interface WebOptions { host?: string port?: string dev?: boolean - workspaceRoot?: string trustedHost?: string[] dumpConfig?: boolean dumpDefaultConfig?: boolean @@ -152,7 +150,6 @@ Examples: .option('--host ', 'bind host; pass 0.0.0.0 to reach it from another machine') .option('--port ', 'listen port; pass 0 to let the OS pick a free one') .option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)') - .option('--workspace-root ', 'parent directory for workspaces created from the browser UI') .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') .option('--dump-config', 'print the composed web-profile tree (with the user layer and any --patch) and exit') .option('--dump-default-config', 'print the web profile\'s bundle layers (no user layer) and exit') @@ -172,8 +169,8 @@ Examples: // dropping them would print a tree that differs from the same // invocation's boot. if (options.host !== undefined || options.port !== undefined || options.dev === true - || options.workspaceRoot !== undefined || options.trustedHost !== undefined) { - program.error('error: config dumps take no web flags (--host/--port/--dev/--workspace-root/--trusted-host)') + || options.trustedHost !== undefined) { + program.error('error: config dumps take no web flags (--host/--port/--dev/--trusted-host)') } resolved = { mode: 'dump-config', profile: 'web', defaultOnly, patches } return @@ -187,7 +184,6 @@ Examples: ...options.host !== undefined && { host: options.host }, ...options.port !== undefined && { port: Number(options.port) }, dev: options.dev === true, - ...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot }, ...options.trustedHost !== undefined && { trustedHosts: options.trustedHost }, } }) diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 64051416c4..ca6a53c3e0 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -1,7 +1,7 @@ /** * `dsh web` — the browser-surface alias over the profile boot: `--profile web` - * plus the Web flag family (`--host/--port/--dev/--workspace-root/ - * --trusted-host`), each flag becoming a patch over the composed profile + * plus the Web flag family (`--host/--port/--dev/--trusted-host`), each flag + * becoming a patch over the composed profile * tree. All web runtime glue (dist serving, prompt section, URL line) lives * in the `@deepseek-ai/dsh-web-app` bundle; this launcher only derives * flag patches and the LAN-trust snapshot. @@ -58,7 +58,6 @@ export interface WebFlags { host?: string port?: number dev: boolean - workspaceRoot?: string trustedHosts?: string[] } @@ -82,7 +81,6 @@ function deriveWebFlagPatches( } if (flags.host !== undefined) put('webserver', 'host', flags.host) if (flags.port !== undefined) put('webserver', 'port', flags.port) - if (flags.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', flags.workspaceRoot) const composedHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host const { lanAddresses, trustedHosts } = resolveLanTrust(flags.host ?? composedHost, flags.trustedHosts ?? []) if (trustedHosts.length > 0) { @@ -118,8 +116,8 @@ export function webSurfaceContextEnabled(rows: ProfileRows): boolean { } /** - * Serve the browser UI from the web profile. Host/port/workspace-root flags - * are passed through only when given (absent, the composed profile values + * Serve the browser UI from the web profile. Host/port flags are passed + * through only when given (absent, the composed profile values * stand); `web-runtime.mode` and `lanAddresses` are launcher-derived on * every boot. The URL line is printed by the web-app bundle's runtime row * after Loader settlement. diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 93bfb62cc6..66e1a0db40 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -29,8 +29,8 @@ describe('parseDshArgs', () => { .toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml', 'b.yml'] }) expect(parse(['web'])).toEqual({ mode: 'web', dev: false, patches: [] }) expect(parse(['web', '--patch', 'web.yml'])).toEqual({ mode: 'web', dev: false, patches: ['web.yml'] }) - expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w'])) - .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w', patches: [] }) + expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev'])) + .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, patches: [] }) expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9'])) .toEqual({ mode: 'web', dev: false, patches: [], trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] }) }) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 13ebcc5b32..edb1576689 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -577,18 +577,16 @@ Source: [`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-c Requires: `agents` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `tools` · `userInteraction` · `workspace` ```ts config-catalog -/** Gateway plugin config: host-level agent routing and Workspace creation root. */ +/** Gateway plugin config: host-level agent routing. */ export interface Config { /** Default provider route for created/resumed agents. */ provider: string /** Default model id. */ model: string - /** Parent directory for name-created Workspaces; defaults to the Host cwd. */ - workspaceRoot?: string } ``` -Source: [`packages/host/apiproxy/src/index.ts:33`](../packages/host/apiproxy/src/index.ts) +Source: [`packages/host/apiproxy/src/index.ts:32`](../packages/host/apiproxy/src/index.ts) ## `@deepseek-ai/dsh-host-directory-picker-browse` diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 624e9e37af..ddb5c97b7b 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -4,7 +4,7 @@ # # A patch replaces the targeted row's whole `config`, so each row below # restates every key it owns. The `dsh web` launcher alias turns --host/--port/ -# --dev/--workspace-root/--trusted-host into further patches over these rows +# --dev/--trusted-host into further patches over these rows # (`--dev` inserts the dsh-client-hmr row). # ── surface-specific values the base deliberately omits ───────────────────── diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index dc2f8c5967..f92a0da870 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2122,15 +2122,14 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { archivedSessionIds: [...archivedSessionIds], }), create: (request) => { - const { path, name } = request.payload - const target = path ?? `/tmp/fixture-workspaces/${name ?? ''}` - const existing = workspaces.find(w => w.path === target) + const { path } = request.payload + const existing = workspaces.find(w => w.path === path) if (existing !== undefined) return ok(request, { workspace: { ...existing }, created: false }) const now = new Date().toISOString() const created: WorkspaceView = { workspaceId: wid(`fx-ws-${nextWorkspace++}`), - path: target, - title: name ?? target.split('/').filter(Boolean).at(-1) ?? target, + path, + title: path.split('/').filter(Boolean).at(-1) ?? path, sessionIds: [], createdAt: now, updatedAt: now, diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index f608190936..5b9824fcde 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -546,7 +546,7 @@ describe('createFixtureApi', () => { } })() await new Promise(resolve => setTimeout(resolve, 10)) - const created = await api.workspace.create(req({ name: 'nova' })) + const created = await api.workspace.create(req({ path: '/tmp/fixture-workspaces/nova' })) if (!created.result.ok) throw new Error('create failed') expect(created.result.value.created).toBe(true) expect(created.result.value.workspace).toMatchObject({ @@ -554,16 +554,7 @@ describe('createFixtureApi', () => { }) await consuming expect(seen).toEqual([{ type: 'host/workspace-changed', workspace: created.result.value.workspace }]) - // path spelling falls back to the basename when no title/name rides along. - const pathOnly = await api.workspace.create(req({ path: '/tmp/fixture-elsewhere/base' })) - if (!pathOnly.result.ok) throw new Error('pathOnly failed') - expect(pathOnly.result.value.workspace.title).toBe('base') - // Degenerate spellings reach the impl unfiltered (the fixture carrier has - // no schema gate): both-absent falls back to the bucket dir, and a - // basename-less path serves as its own title. - const bare = await api.workspace.create(req({})) - if (!bare.result.ok) throw new Error('bare failed') - expect(bare.result.value.workspace).toMatchObject({ path: '/tmp/fixture-workspaces/', title: 'fixture-workspaces' }) + // A basename-less path serves as its own title. const rootPath = await api.workspace.create(req({ path: '/' })) if (!rootPath.result.ok) throw new Error('rootPath failed') expect(rootPath.result.value.workspace.title).toBe('/') @@ -584,7 +575,7 @@ describe('createFixtureApi', () => { const missing = await api.workspace.rename(req({ workspaceId: 'fx-ws-void' as WorkspaceId, title: 'x' })) expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'fx-ws-void' } } }) - await api.workspace.create(req({ name: 'occupied' })) + await api.workspace.create(req({ path: '/tmp/fixture-workspaces/occupied' })) const conflict = await api.workspace.rename(req({ workspaceId: wsid, title: ' occupied ' })) expect(conflict.result).toMatchObject({ ok: false, error: { code: 'workspace-name-conflict', details: { name: 'occupied' } } }) @@ -722,7 +713,7 @@ describe('createFixtureApi', () => { expect(initialSessions.result).toMatchObject({ ok: true, value: { items: [] } }) expect(initialWorkspaces.result).toMatchObject({ ok: true, value: { items: [] } }) - const made = await api.workspace.create(req({ name: 'nova' })) + const made = await api.workspace.create(req({ path: '/tmp/fixture-workspaces/nova' })) if (!made.result.ok) throw new Error('workspace create failed') const abort = new AbortController() const framesPromise = collect(api.events.host(req({}), abort.signal), abort, frames => frames.length === 2) @@ -991,7 +982,7 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true) expect((await client.host.describe({})).result.ok).toBe(true) expect((await client.workspace.list({})).result.ok).toBe(true) - const workspace = await client.workspace.create({ name: 'via-client' }) + const workspace = await client.workspace.create({ path: '/tmp/fixture-workspaces/via-client' }) if (!workspace.result.ok) throw new Error('workspace create failed') expect(workspace.result.value.workspace.title).toBe('via-client') const wsid = workspace.result.value.workspace.workspaceId @@ -1049,7 +1040,7 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { }) const client = new FixtureApiClient() await expect(client.sessions.list({})).resolves.toMatchObject({ result: { ok: true, value: { items: [] } } }) - const made = await client.workspace.create({ name: 'query-workspace' }) + const made = await client.workspace.create({ path: '/tmp/fixture-workspaces/query-workspace' }) if (!made.result.ok) throw new Error('workspace create failed') const abort = new AbortController() const framesPromise = collect(client.events.host({}, abort.signal), abort, frames => frames.length === 2) diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts index 3e64ef3717..ad896bbdaf 100644 --- a/packages/client/runtime/src/client/contract/workspaces.ts +++ b/packages/client/runtime/src/client/contract/workspaces.ts @@ -27,11 +27,11 @@ export interface IWorkspaces { */ startSession(workspaceId?: WorkspaceId): void /** - * Create a Workspace by name or register an existing path. - * @param input - exactly one Host create spelling. + * Register an existing path as a Workspace. + * @param input - the Host create payload. * @returns the created or idempotently resolved Workspace. */ - create(input: { name: string } | { path: string }): Promise + create(input: { path: string }): Promise /** * Open the Host's native directory picker. * @returns the selected path, or null when the user cancelled. diff --git a/packages/client/runtime/src/client/workspaces/manager.ts b/packages/client/runtime/src/client/workspaces/manager.ts index ccf0c46fe1..df3aa8fe28 100644 --- a/packages/client/runtime/src/client/workspaces/manager.ts +++ b/packages/client/runtime/src/client/workspaces/manager.ts @@ -120,7 +120,7 @@ export class WorkspaceManager { /** * Create or resolve a real Workspace, then publish its returned snapshot * without waiting for the changed frame. - * @param input - name under workspaceRoot or an existing absolute path. + * @param input - the existing absolute path to adopt. * @returns the wire result. */ async create(input: WorkspaceCreateInput): Promise> { diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 69910fd0c4..c0f71b92db 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -186,11 +186,11 @@ export class WorkspacesService implements IWorkspaces { } /** - * Create a Workspace by name or register an existing path. - * @param input - exactly one Host create spelling. + * Register an existing path as a Workspace. + * @param input - the Host create payload. * @returns the created or idempotently resolved Workspace. */ - async create(input: { name: string } | { path: string }): Promise { + async create(input: { path: string }): Promise { const result = await this.manager.create(input) if (!result.ok) throw new WorkspaceCreateError(result.error) return result.value.workspace diff --git a/packages/client/runtime/src/client/workspaces/workspace.ts b/packages/client/runtime/src/client/workspaces/workspace.ts index afa4dd65b6..f6657c7053 100644 --- a/packages/client/runtime/src/client/workspaces/workspace.ts +++ b/packages/client/runtime/src/client/workspaces/workspace.ts @@ -8,7 +8,7 @@ import type { ObservableSnapshot } from '../contract/store.ts' import { Notifier } from '../sessions/notifier.ts' /** Host input retained by a local Workspace until materialization succeeds. */ -export type WorkspaceCreateInput = { name: string } | { path: string } +export type WorkspaceCreateInput = { path: string } /** Observable state of a client-local Workspace intent. */ export interface WorkspaceIntentSnapshot { @@ -137,7 +137,6 @@ export class Workspace implements ObservableSnapshot { } function intentName(input: WorkspaceCreateInput): string { - if ('name' in input) return input.name const trimmed = input.path.replace(/[\\/]+$/, '') return trimmed.split(/[\\/]/).pop() ?? input.path } diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index 832a1ff71a..dd38a3119c 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -59,7 +59,7 @@ describe('WorkspaceManager', () => { expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'error', error: { message: 'wire down' } }) }) - it('creates by name/path, prepends a new row, and folds failures', async () => { + it('creates by path, prepends a new row, and folds failures', async () => { const api = new FakeApiClient() const manager = new WorkspaceManager(api) api.onWorkspaceCreate = payload => Promise.resolve(ok({ @@ -67,8 +67,8 @@ describe('WorkspaceManager', () => { created: true, payload, } as never)) - await expect(manager.create({ name: 'created' })).resolves.toMatchObject({ ok: true }) - expect(api.callsOf('workspace.create')).toEqual([{ name: 'created' }]) + await expect(manager.create({ path: '/w/created' })).resolves.toMatchObject({ ok: true }) + expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/created' }]) expect(manager.getSnapshot().items[0]?.workspaceId).toBe('created') api.onWorkspaceCreate = () => Promise.reject(new Error('create transport')) diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index 7e626a3660..9e1061ec8c 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -73,18 +73,17 @@ export class TestWorkspaces implements IWorkspaces { /** * Create a Workspace (recorded). The default echoes a view derived from * the input; stub for failure or list-coupled flows. - * @param input - exactly one Host create spelling. + * @param input - the Host create payload. * @returns the created Workspace view. */ - async create(input: { name: string } | { path: string }): Promise { + async create(input: { path: string }): Promise { this.calls.push({ method: 'create', args: [input] }) const stub = this.stubs.get('create') if (stub !== undefined) return await (stub(input) as Promise) - const title = 'name' in input ? input.name : input.path return { - workspaceId: `ws-${title}` as WorkspaceId, - title, - path: 'path' in input ? input.path : `/${input.name}`, + workspaceId: `ws-${input.path}` as WorkspaceId, + title: input.path, + path: input.path, sessionIds: [], } as unknown as WorkspaceView } diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index f92d21b4b5..d0b3d75d52 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -559,8 +559,8 @@ describe('workspaces action face', () => { it('records every IWorkspaces verb with inert defaults and honors stubs', async () => { const runtime = await SlotTestRuntime.create() const ws = runtime.workspaces - const created = await ws.create({ name: 'alpha' }) - expect(created.title).toBe('alpha') + const created = await ws.create({ path: '/tmp/alpha' }) + expect(created.title).toBe('/tmp/alpha') const registered = await ws.create({ path: '/tmp/beta' }) expect(registered.path).toBe('/tmp/beta') await expect(ws.pickDirectory()).resolves.toBeNull() @@ -584,7 +584,7 @@ describe('workspaces action face', () => { ws.stub('openPath', () => Promise.resolve()) ws.stub('insertSessionBefore', () => Promise.resolve({ workspaceId: 'w1', title: '', path: '', sessionIds: [] } as never)) ws.stub('archiveSession', () => Promise.resolve()) - expect((await ws.create({ name: 'y' })).title).toBe('X') + expect((await ws.create({ path: '/y' })).title).toBe('X') await expect(ws.pickDirectory()).resolves.toBe('/picked') expect((await ws.rename('w1' as WorkspaceId, 'z')).title).toBe('S') await ws.delete('w1' as WorkspaceId) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 38c79f4617..97aad6680c 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/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/host/apiproxy/README.md -README.md: 0963476a767801b465a6ead24feb0ecc9988b5f5 -README.zh.md: e3634c5f92f3a3723eb3c14e39223d9d9550c6f9 +README.md: 1caf9f2ee61fbf36a18b18ff2d1e7e230ee4b7f6 +README.zh.md: c42312bb9972c4c5b02ec4dbc5c7d4402921a9c1 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 0963476a76..1caf9f2ee6 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml). +The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml). ## Contract layer (`/api`) @@ -24,7 +24,7 @@ Session model routing is a session-domain contract. `session.models` returns the Pending queued input is a live control-plane contract, not conversation history. The gateway derives the complete `next-turn` queue from durable `agent/inbox/spliced` mutations and broadcasts authoritative `session/queue` snapshots after each change and on reconnect; pending `next-step` steering stays outside this Web projection. Within `next-step`, user-origin messages carry the `steering` placement while injected context (approval notices, task completion, attached snapshots) carries `context` and is not surfaced until claimed. The message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications remain available to lifecycle observers but do not build the queue view. `session.updateQueue` addresses one `MessageId`; edit and remove mutate the attached Agent through `Inbox.splice()`. A claim's pure deletion splice wins races before pre-step admission, so a later operation returns `queue-item-not-found`. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking message in FIFO order, and the browser never resends or promotes it. Queue operations never resume a cold session, and the client never infers retirement from turn or status events. -Workspace and Session lists are separate reconnect baselines. `workspace.create({ name })` creates a uniquely titled directory under the configured root, while `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. +Workspace and Session lists are separate reconnect baselines. `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. `session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index e3634c5f92..c42312bb99 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -所有客户端形态共用的 API 网关:TS 契约(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, workspaceRoot?}`,提供 `ctx.apiProxy`)。该包在设计上与传输方式无关,不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml)。 +所有客户端形态共用的 API 网关:TS 契约(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model}`,提供 `ctx.apiProxy`)。该包在设计上与传输方式无关,不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml)。 ## 契约层(`/api`) @@ -24,7 +24,7 @@ 待处理的 queued 输入属于实时控制平面契约,而非对话历史。网关根据持久 `agent/inbox/spliced` 变更派生完整的 `next-turn` 队列,并在每次变更后及重连时广播权威 `session/queue` 快照;待处理的 `next-step` steering(中途引导)不进入此 Web 投影。在 `next-step` 内,用户来源的消息携带 `steering` placement,而注入上下文(审批通知、任务完成、附加快照)携带 `context`,领取前不对外呈现。面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知仍供生命周期观察方使用,但不用于构建队列视图。`session.updateQueue` 通过 `MessageId` 寻址单个项;编辑和移除经已挂载 Agent 的 `Inbox.splice()` 修改队列。claim 的纯删除 splice 会在 pre-step 准入前赢得竞态,因此之后的操作返回 `queue-item-not-found`。`session.cancel` 仅中止活动轮次并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。 -Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ name })` 会在配置根目录下创建显示标题唯一的目录,而 `workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 +Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 `session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前内容视图中的 user、assistant 和 steering 匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 19fb0fe8a2..5ad2318945 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -5,7 +5,6 @@ import { randomUUID } from 'node:crypto' import { mkdir, stat } from 'node:fs/promises' -import { join } from 'node:path' import type { Context } from 'cordis' import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent' import type { Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus } from '@deepseek-ai/dsh-agent' @@ -333,8 +332,6 @@ export interface ApiProxyDefaults { model: string /** Default project directory for new sessions whose create request carries no cwd. */ cwd: string - /** Parent directory for name-created workspaces. */ - workspaceRoot: string /** Native open-with-default-application; injectable for carrier tests. */ openPath?: (path: string, signal: AbortSignal) => Promise /** Native text-editor handoff; injectable for settings-document tests. */ @@ -668,9 +665,6 @@ class SessionCwdConflict extends Error { } } -/** Host failed before the registry could adopt a name-created directory. */ -class WorkspaceDirectoryCreationError extends Error {} - /** An explicit Host naming operation would duplicate another Workspace title. */ class WorkspaceNameConflictError extends Error { constructor(readonly workspaceName: string) { @@ -1183,29 +1177,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } /** Resolve or create one path while holding the Host's workspace-create chain. */ - function ensureWorkspace( - path: string, - title: string | undefined, - rejectExistingName = false, - createDirectory = false, - ): Promise<{ workspace: Workspace; created: boolean }> { + function ensureWorkspace(path: string): Promise<{ workspace: Workspace; created: boolean }> { const operation = workspaceCreationChain.then(async () => { - if (rejectExistingName && title !== undefined - && ctx.workspace.list().some(workspace => workspace.title === title)) { - throw new WorkspaceNameConflictError(title) - } - if (createDirectory) { - try { - await mkdir(path, { recursive: true }) - } catch (error: unknown) { - throw new WorkspaceDirectoryCreationError( - `failed to create workspace directory "${path}": ${String(error)}`, - ) - } - } const existing = await ctx.workspace.resolveByPath(path) if (existing !== undefined) return { workspace: existing, created: false } - return { workspace: await ctx.workspace.create(path, title), created: true } + return { workspace: await ctx.workspace.create(path), created: true } }) workspaceCreationChain = operation.then(() => undefined, () => undefined) return operation @@ -2035,54 +2011,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro })) }, - // Exactly one of path/name arrives (schema refine). Existing-folder - // adoption reuses its canonical path; create-by-name rejects a name - // already present in the registry. - // TODO: the create-by-name branch lost its last product consumer when - // the Web picker collapsed onto the directory flow - // (.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md). - // Delete it with the wire schema's `name` member, this - // `defaults.workspaceRoot`, the client seam that carried the name - // (`WorkspaceCreateInput`, `WorkspacesService.create`'s `{ name }` arm, - // `intentName`'s name branch, the manager's "name under workspaceRoot" - // contract), and the `dsh web --workspace-root` flag plus its apps/cli - // README lines, which exist only to feed it. async create(request) { - const { payload } = request - let path: string - if (payload.name !== undefined) { - const name = payload.name.trim() - if (name === '' || name === '.' || name === '..' || /[/\\]/.test(name)) { - return err(request, { - code: 'workspace-invalid-path', - message: `workspace name must be one non-empty path segment, got "${payload.name}"`, - details: { path: payload.name }, - }) - } - path = join(defaults.workspaceRoot, name) - } else { - path = payload.path as string - } + const { path } = request.payload try { - const name = payload.name?.trim() - const { workspace, created } = await ensureWorkspace( - path, - name, - name !== undefined, - name !== undefined, - ) + const { workspace, created } = await ensureWorkspace(path) return ok(request, { workspace: workspaceView(workspace), created }) } catch (error: unknown) { - if (error instanceof WorkspaceNameConflictError) { - return err(request, { - code: 'workspace-name-conflict', - message: error.message, - details: { name: error.workspaceName }, - }) - } - if (error instanceof WorkspaceDirectoryCreationError) { - return err(request, { code: 'internal', message: error.message, details: {} }) - } // The registry rejects a path that does not resolve to an existing // directory (realpath ENOENT / not-a-directory) — the business // error of the typed-path flow, surfaced as a validation failure. diff --git a/packages/host/apiproxy/src/api/workspace.schema.ts b/packages/host/apiproxy/src/api/workspace.schema.ts index 20b3038301..5ad5a0b96b 100644 --- a/packages/host/apiproxy/src/api/workspace.schema.ts +++ b/packages/host/apiproxy/src/api/workspace.schema.ts @@ -31,14 +31,10 @@ export const workspaceListValueSchema = z.object({ archivedSessionIds: z.array(sessionIdSchema), }) satisfies z.ZodType>> -/** workspace.create request payload: exactly one of path/name (the contract's create spellings). */ +/** workspace.create request payload: the existing directory to adopt. */ export const workspaceCreateRequestSchema = z.object({ - path: z.string().optional(), - name: z.string().optional(), -}).refine( - payload => (payload.path === undefined) !== (payload.name === undefined), - { message: 'workspace.create requires exactly one of path / name' }, -) satisfies z.ZodType>> + path: z.string(), +}) satisfies z.ZodType>> /** workspace.create response value. */ export const workspaceCreateValueSchema = z.object({ diff --git a/packages/host/apiproxy/src/api/workspace.ts b/packages/host/apiproxy/src/api/workspace.ts index d5307e27a8..64feb27f80 100644 --- a/packages/host/apiproxy/src/api/workspace.ts +++ b/packages/host/apiproxy/src/api/workspace.ts @@ -46,19 +46,14 @@ export interface WorkspaceApi { list(request: RpcRequest<{}>): Promise> /** - * Creates (or idempotently resolves) a workspace. Exactly one of `path` / - * `name` (schema-enforced): `path` registers an EXISTING directory (no - * mkdir — a missing or non-directory path fails with `workspace-invalid-path`); - * `name` is a single path segment the host mkdirs under its default project - * root before registering. Either spelling resolving to a directory already - * owned by a workspace returns that workspace (`created: false`) for the - * existing-folder spelling. Create-by-name rejects an existing title with - * `workspace-name-conflict`; path adoption allows distinct canonical paths - * whose basenames produce the same display title. - * A new name-created workspace uses `name` as both directory name and title; - * a path-created workspace uses the registry's basename title default. + * Creates (or idempotently resolves) a workspace over an EXISTING directory + * (no mkdir — a missing or non-directory path fails with + * `workspace-invalid-path`). A path resolving to a directory already owned + * by a workspace returns that workspace (`created: false`). Adoption allows + * distinct canonical paths whose basenames produce the same display title; + * the registry's basename title default names the new workspace. */ - create(request: RpcRequest<{ path?: string; name?: string }>): + create(request: RpcRequest<{ path: string }>): Promise> /** diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index e279575ff4..a649dabccb 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -8,7 +8,6 @@ * routes — physical carriers wrap `ctx.apiProxy` themselves. */ -import { resolve } from 'node:path' import { Context, Service } from 'cordis' import z from 'schemastery' import type { ApiProxy } from './api/index.ts' @@ -29,20 +28,18 @@ declare module 'cordis' { } } -/** Gateway plugin config: host-level agent routing and Workspace creation root. */ +/** Gateway plugin config: host-level agent routing. */ export interface Config { /** Default provider route for created/resumed agents. */ provider: string /** Default model id. */ model: string - /** Parent directory for name-created Workspaces; defaults to the Host cwd. */ - workspaceRoot?: string } /** * The API gateway service: implements the ApiProxy contract over the composed * host context and provides it as `ctx.apiProxy`. The Host cwd is the default - * project directory and the fallback parent for name-created Workspaces. + * project directory. */ export class ApiProxyService extends Service implements ApiProxy { static inject = [ @@ -53,7 +50,6 @@ export class ApiProxyService extends Service implements ApiProxy { static Config: z = z.object({ provider: z.string().required(), model: z.string().required(), - workspaceRoot: z.string(), }) readonly sessions: ApiProxy['sessions'] @@ -71,12 +67,10 @@ export class ApiProxyService extends Service implements ApiProxy { constructor(ctx: Context, config: Config) { super(ctx, 'apiProxy') - const cwd = process.cwd() const api = createApiProxy(ctx, { provider: config.provider, model: config.model, - cwd, - workspaceRoot: resolve(config.workspaceRoot ?? cwd), + cwd: process.cwd(), }) this.sessions = api.sessions this.subagents = api.subagents diff --git a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts index 4833667583..4734d4e457 100644 --- a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts @@ -27,7 +27,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> { await ctx.plugin(UserInteractionService) await ctx.plugin(AgentRegistry) await ctx.plugin(ApprovalService) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) return { ctx, api } } @@ -217,7 +217,7 @@ describe('approval pending registry', () => { await ctx.plugin(ApprovalService) let api!: ApiProxy const fiber = ctx.plugin(Object.assign((fiberCtx: Context) => { - api = createApiProxy(fiberCtx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + api = createApiProxy(fiberCtx, { provider: 'p', model: 'm', cwd: '/tmp' }) }, { inject: ['sessions', 'agents', 'userInteraction', 'approval'] })) await fiber.await() const abort = new AbortController() diff --git a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts b/packages/host/apiproxy/tests/api-proxy-blank.spec.ts index 4f8637068e..008d35d568 100644 --- a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-blank.spec.ts @@ -35,7 +35,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy; attach: (sessio await ctx.plugin(AgentRegistry) return { ctx, - api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }), + api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }), attach: (session) => { ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) }, diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index 78a67ef642..14f7905633 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -62,7 +62,7 @@ describe('sessions.list cold merge', () => { return undefined }, }) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const response = await api.sessions.list(request({})) expect(response.result.ok).toBe(true) @@ -90,7 +90,7 @@ describe('attached updatedAt excludes end-seed', () => { await ctx.plugin(SessionStore) await ctx.plugin(UserInteractionService) await ctx.plugin(AgentRegistry) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) // Old work, resumed just now: the log tail would report the pickup. const worked = 1_000_000 @@ -148,7 +148,7 @@ describe('cold history recovery view', () => { inspect: (id: SessionId, signal?: AbortSignal) => coordinator.inspect(id, signal), locate: () => undefined, } as never) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const history = await api.sessions.history(request({ sessionId, beforeSeq: 2, maxMessages: 10 })) if (!history.result.ok) throw new Error('history failed') @@ -216,7 +216,7 @@ describe('subagent ownership fence', () => { locate: () => undefined, } as never) const resume = vi.spyOn(ctx.agents, 'resume') - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const history = await api.sessions.history(request({ sessionId })) expect(history.result.ok).toBe(true) @@ -275,7 +275,7 @@ describe('subagent ownership fence', () => { // instead of answering `agent-busy`. const resume = vi.spyOn(ctx.agents, 'resume') .mockRejectedValue(new Error('registry unavailable in this bench')) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const prompt = await api.sessions.prompt(request({ sessionId, @@ -316,7 +316,7 @@ describe('subagent ownership fence', () => { }) const startingChild = { id: startingSession.id, session: startingSession, status: 'idle', ctx } as Agent ctx.agents.enter(startingChild, parent) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const stopped = await api.sessions.cancel(request({ sessionId: originChild.id })) expect(stopped.result.ok).toBe(false) @@ -362,7 +362,7 @@ describe('subagent ownership fence', () => { const followup = vi.fn() const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent ctx.agents.register(agent) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const response = await api.sessions.prompt(request({ sessionId: agent.id, @@ -380,7 +380,7 @@ describe('degenerate composition (no persistence, no factory)', () => { await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const listed = await api.sessions.list(request({})) expect(listed.result.ok).toBe(true) @@ -405,7 +405,7 @@ describe('degenerate composition (no persistence, no factory)', () => { list: () => Promise.resolve([]), inspect, } as never) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const response = await api.sessions.history(request({ sessionId: sid('session-missing') })) expect(response.result.ok).toBe(false) @@ -431,7 +431,7 @@ describe('sessions.prompt synchronous rejection', () => { followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') }, steer: () => { throw new Error('agent "session-throwing" lifecycle disposed') }, } as unknown as Agent) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) for (const mode of ['queue', 'steer'] as const) { const response = await api.sessions.prompt(request({ @@ -475,7 +475,7 @@ describe('sessions.prompt synchronous rejection', () => { ctx.agents.register(child) throw new Error('session id already published') }) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const models = await api.sessions.models(request({ sessionId })) expect(models.result.ok).toBe(false) diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index 1ab33897e3..8b4866eba9 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -25,7 +25,7 @@ import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts' import { RpcId } from '../src/api/rpc.ts' import { createApiProxy } from '../src/api-proxy.ts' -const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' } +const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp' } function request

(payload: P): RpcRequest

{ return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload } diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index 54235c0218..e6c799a236 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -24,7 +24,7 @@ import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts' import { RpcId } from '../src/api/rpc.ts' import { createApiProxy } from '../src/api-proxy.ts' -const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' } +const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp' } let nextRpc = 1 function request

(payload: P): RpcRequest

{ diff --git a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts index 83955f2d8b..4ac934e365 100644 --- a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts @@ -85,7 +85,6 @@ const api = (ctx: Context) => createApiProxy(ctx, { provider: 'default-provider', model: 'default-model', cwd: '/tmp', - workspaceRoot: '/tmp', }) describe('sessions.fork', () => { diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index c2dfdae7a7..87ea408bab 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -125,7 +125,7 @@ describe('Web session model selection', () => { model: 'private-preview', reasoningEffort: ReasoningEffortId('max'), }) - const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp' }) const catalog = expectValue(await api.sessions.models(request({ sessionId }))) expect(catalog.current).toEqual({ @@ -160,7 +160,7 @@ describe('Web session model selection', () => { it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => { const { ctx, agent, sessionId } = await harness() - const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp' }) const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 } const signal = new AbortController().signal diff --git a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts index a1775a8025..c1efc32c97 100644 --- a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts @@ -68,7 +68,7 @@ function seedMessages(session: Session, count: number): void { } } -const api = (ctx: Context) => createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) +const api = (ctx: Context) => createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) describe('session.history projections block', () => { it('serves the unit value on the tail page with asOfSeq = last event seq', async () => { diff --git a/packages/host/apiproxy/tests/api-proxy-question.spec.ts b/packages/host/apiproxy/tests/api-proxy-question.spec.ts index e8eaae813f..c3ae82fe06 100644 --- a/packages/host/apiproxy/tests/api-proxy-question.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-question.spec.ts @@ -13,7 +13,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> { await ctx.plugin(UserInteractionService) return { ctx, - api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }), + api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }), } } diff --git a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts index 15c7361024..b3630eafb5 100644 --- a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts @@ -68,7 +68,7 @@ function liveAgent(ctx: Context, id: string, turns: number): Session { return session } -const api = (ctx: Context) => createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) +const api = (ctx: Context) => createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) describe('sessions.rename', () => { it('accepts through the composed title service: normalized user-source event, echoed seq', async () => { diff --git a/packages/host/apiproxy/tests/api-proxy-search.spec.ts b/packages/host/apiproxy/tests/api-proxy-search.spec.ts index 57bb05df4f..1160d4dd18 100644 --- a/packages/host/apiproxy/tests/api-proxy-search.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-search.spec.ts @@ -27,7 +27,7 @@ vi.mock('node:fs/promises', async (importOriginal) => { }) const sid = (value: string): SessionId => value as SessionId -const defaults = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' } +const defaults = { provider: 'p', model: 'm', cwd: '/tmp' } function request(query: string): RpcRequest<{ query: string }> { return { rpcId: RpcId(`search-${query}`), payload: { query } } diff --git a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts index c761484da5..580dfa280c 100644 --- a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts @@ -88,7 +88,7 @@ function bench(options: { ctx.provide('sessionProjections', { snapshot, restore, onChanged: () => () => {} }) ctx.provide('userInteraction', { registerProvider: () => () => {} }) const api = createApiProxy(ctx, { - provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp', + provider: 'p', model: 'm', cwd: '/tmp', }) return { api, getAgent, listChildren, inspect, snapshot, restore, followup, parent } } diff --git a/packages/host/apiproxy/tests/api-proxy-view.spec.ts b/packages/host/apiproxy/tests/api-proxy-view.spec.ts index 43083545db..0427443841 100644 --- a/packages/host/apiproxy/tests/api-proxy-view.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-view.spec.ts @@ -105,7 +105,7 @@ async function collect(iterable: AsyncIterable>, count: num describe('mux live view computation', () => { it('attaches the three standard card views, omits view without a presenter, soft-falls on throw', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const abort = new AbortController() const stream = api.events.mux({ rpcId: RpcId('t-mux'), payload: {} }, abort.signal) const collected = collect(stream, 9, abort) @@ -170,7 +170,7 @@ describe('mux live view computation', () => { it('serves history entries with call/result views, backscan pairing, and soft-falls', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const session = ctx.sessions.create() // history resolves the agent first; a live structural stub is enough (only // .session is read on this path). @@ -238,7 +238,7 @@ describe('mux live view computation', () => { it('counts only append-origin messages toward maxMessages and keeps compaction provenance whole', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const session = ctx.sessions.create() ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) session.append('turn/start', { turn: 1 }) @@ -287,7 +287,7 @@ describe('mux live view computation', () => { it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const abort = new AbortController() const stream = api.events.mux({ rpcId: RpcId('t-mux3'), payload: {} }, abort.signal) @@ -308,7 +308,7 @@ describe('mux live view computation', () => { it('pairs a result after turn/end via the in-memory backscan fallback', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const abort = new AbortController() const stream = api.events.mux({ rpcId: RpcId('t-mux2'), payload: {} }, abort.signal) const collected = collect(stream, 4, abort) diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index af315ffcd0..b548b36702 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -59,7 +59,7 @@ function stubAgent(session: Session): Agent { /** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */ async function harness( - workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))), + root = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))), picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null }, extras: { openPath?: (path: string, signal: AbortSignal) => Promise } = {}, ) { @@ -102,11 +102,17 @@ async function harness( const api = createApiProxy(ctx, { provider: 'test', model: 'test-model', - cwd: workspaceRoot, - workspaceRoot, + cwd: root, ...extras.openPath === undefined ? {} : { openPath: extras.openPath }, }) - return { api, ctx, storageDomain, workspaceRoot } + return { api, ctx, storageDomain, root } +} + +/** Stage one directory under the harness root for path adoption. */ +function stageDir(root: string, name: string): string { + const path = join(root, name) + mkdirSync(path) + return path } describe('host.pickDirectory', () => { @@ -244,30 +250,26 @@ describe('host.openPath', () => { }) describe('workspace.create', () => { - it('serializes concurrent names and rejects the duplicate', async () => { - const { api, workspaceRoot } = await harness() + it('serializes concurrent creates of one path into a single registration', async () => { + const { api, root } = await harness() + const target = join(root, 'alpha') + mkdirSync(target) const responses = await Promise.all([ - api.workspace.create(request({ name: 'alpha' })), - api.workspace.create(request({ name: 'alpha' })), + api.workspace.create(request({ path: target })), + api.workspace.create(request({ path: target })), ]) - const created = responses.find(response => response.result.ok) - const duplicate = responses.find(response => !response.result.ok) + const values = responses.map(response => expectOk(response)) + const created = values.find(value => value.created) + const resolved = values.find(value => !value.created) - expect(created).toBeDefined() - expect(expectOk(created!)).toMatchObject({ - created: true, - workspace: { path: join(workspaceRoot, 'alpha'), title: 'alpha' }, - }) - expect(duplicate?.result).toMatchObject({ - ok: false, - error: { code: 'workspace-name-conflict', details: { name: 'alpha' } }, - }) - expect(existsSync(join(workspaceRoot, 'alpha'))).toBe(true) + expect(created).toMatchObject({ workspace: { path: target, title: 'alpha' } }) + expect(resolved?.workspace.workspaceId).toBe(created?.workspace.workspaceId) + expect(expectOk(await api.workspace.list(request({}))).items).toHaveLength(1) }) - it('adopts only existing directories and rejects unsafe names', async () => { - const { api, workspaceRoot } = await harness() - const existing = join(workspaceRoot, 'existing') + it('adopts only existing directories', async () => { + const { api, root } = await harness() + const existing = join(root, 'existing') mkdirSync(existing) const first = expectOk(await api.workspace.create(request({ path: existing }))) const repeated = expectOk(await api.workspace.create(request({ path: existing }))) @@ -281,21 +283,16 @@ describe('workspace.create', () => { const reopened = expectOk(await api.workspace.create(request({ path: existing }))) expect(reopened.workspace.title).toBe('renamed-existing') - const missing = join(workspaceRoot, 'missing') + const missing = join(root, 'missing') const missingResult = await api.workspace.create(request({ path: missing })) expect(missingResult.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } }) expect(existsSync(missing)).toBe(false) - - for (const name of ['', '.', '..', 'a/b', 'a\\b']) { - const invalid = await api.workspace.create(request({ name })) - expect(invalid.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } }) - } }) it('adopts different paths that derive the same Workspace title', async () => { - const { api, workspaceRoot } = await harness() - const first = join(workspaceRoot, 'one', 'project') - const second = join(workspaceRoot, 'two', 'project') + const { api, root } = await harness() + const first = join(root, 'one', 'project') + const second = join(root, 'two', 'project') mkdirSync(first, { recursive: true }) mkdirSync(second, { recursive: true }) const firstResult = expectOk(await api.workspace.create(request({ path: first }))) @@ -316,8 +313,8 @@ describe('workspace.create', () => { describe('session creation and Workspace membership', () => { it('attaches a preallocated idempotent session while cwd-only sessions stay ungrouped', async () => { - const { api, ctx } = await harness() - const workspace = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace + const { api, ctx, root } = await harness() + const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace const sessionId = SessionId('session-workspace-preallocated') expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) @@ -343,8 +340,8 @@ describe('session creation and Workspace membership', () => { }) it('retains a published session when attachment fails and repairs it on retry', async () => { - const { api, ctx } = await harness() - const created = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace + const { api, ctx, root } = await harness() + const created = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace const workspace = ctx.workspace.list()[0] if (workspace === undefined) throw new Error('workspace missing from registry') vi.spyOn(workspace, 'attachSession').mockRejectedValueOnce(new Error('simulated write failure')) @@ -394,7 +391,7 @@ describe('Host Workspace increments', () => { }) it('streams committed Workspace and Session increments after empty baselines', async () => { - const { api } = await harness() + const { api, root } = await harness() expect(expectOk(await api.workspace.list(request({}))).items).toEqual([]) expect(expectOk(await api.sessions.list(request({}))).items).toEqual([]) @@ -402,7 +399,7 @@ describe('Host Workspace increments', () => { const stream: AsyncIterator> = api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() const workspaceIncrement = nextHostFrame(stream) - const workspace = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace + const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace expect(await workspaceIncrement).toMatchObject({ payload: { type: 'host/workspace-changed', workspace: { workspaceId: workspace.workspaceId } }, }) @@ -430,7 +427,7 @@ describe('Host Workspace increments', () => { }) it('does not publish a Workspace whose registry-order commit fails', async () => { - const { api, storageDomain } = await harness() + const { api, storageDomain, root } = await harness() const domain = storageDomain.get('workspace') if (domain === undefined) throw new Error('workspace domain is not open') vi.spyOn(domain.global, 'set').mockRejectedValueOnce(new Error('simulated registry order failure')) @@ -439,7 +436,7 @@ describe('Host Workspace increments', () => { api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() const next = stream.next() - const failed = await api.workspace.create(request({ name: 'ghost' })) + const failed = await api.workspace.create(request({ path: stageDir(root, 'ghost') })) expect(failed.result.ok).toBe(false) expect(expectOk(await api.workspace.list(request({}))).items).toEqual([]) abort.abort() @@ -447,8 +444,8 @@ describe('Host Workspace increments', () => { }) it('deletes the registration, keeps its session and folder, and streams one removal', async () => { - const { api, ctx } = await harness() - const workspace = expectOk(await api.workspace.create(request({ name: 'delete-me' }))).workspace + const { api, ctx, root } = await harness() + const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'delete-me') }))).workspace const sessionId = SessionId('session-kept-after-workspace-delete') expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) @@ -480,8 +477,8 @@ describe('Host Workspace increments', () => { }) it('archives a session into the global set, keeps its accounting, and streams the set once', async () => { - const { api } = await harness() - const workspace = expectOk(await api.workspace.create(request({ name: 'archive-home' }))).workspace + const { api, root } = await harness() + const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'archive-home') }))).workspace const sessionId = SessionId('session-to-archive') expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) expect(expectOk(await api.workspace.list(request({}))).archivedSessionIds).toEqual([]) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 490e0ad7f1..a11e168f56 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -378,8 +378,8 @@ describe('workspace domain round trip', () => { expect(archivedResponse.result).toEqual({ ok: true, value: { archivedSessionIds: ['s-arch'] } }) }) - it('rejects a create payload violating the exactly-one refine at the handler', async () => { - const response = await client(scriptedApi()).workspace.create({}) + it('rejects a pathless create payload at the handler schema', async () => { + const response = await client(scriptedApi()).workspace.create({} as never) expect(response.result.ok).toBe(false) if (!response.result.ok) expect(response.result.error.code).toBe('bad-request') }) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index b65861c1ae..4639b5c5ee 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -324,11 +324,9 @@ describe('workspace domain schemas', () => { expect(() => workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: 's1' })).toThrow() }) - it('create requires exactly one of path/name (both refine arms)', () => { + it('create requires a path', () => { expect(workspaceCreateRequestSchema.parse({ path: '/p' }).path).toBe('/p') - expect(workspaceCreateRequestSchema.parse({ name: 'n' }).name).toBe('n') - expect(() => workspaceCreateRequestSchema.parse({})).toThrow(/exactly one/) - expect(() => workspaceCreateRequestSchema.parse({ path: '/p', name: 'n' })).toThrow(/exactly one/) + expect(() => workspaceCreateRequestSchema.parse({})).toThrow() expect(workspaceCreateValueSchema.parse({ workspace: view, created: false }).created).toBe(false) }) diff --git a/packages/todo/tool-todo/tests/projection.spec.ts b/packages/todo/tool-todo/tests/projection.spec.ts index f1932b9955..5bf96a22a1 100644 --- a/packages/todo/tool-todo/tests/projection.spec.ts +++ b/packages/todo/tool-todo/tests/projection.spec.ts @@ -45,7 +45,7 @@ async function harness(withTodoTool: boolean): Promise { if (withTodoTool) await ctx.plugin(ToolTodo, { allowParallelInProgress: true }) const session = ctx.sessions.create() ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) return { ctx, session, From 40ee7f5e27983e313271fa607d138460ab89b6a7 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 7 Aug 2026 14:48:26 +0800 Subject: [PATCH 02/20] docs,test: settle review follow-ups for the create-by-name deletion Rewrite the three sibling Agent Note pairs that still described create-by-name as current (workspace-ui-product-flow, session-list-browsing-and-manual-order, same-basename-workspace-adoption) and the one-route note's own alternative and section title; delete scripts/hero-composer-dom-continuity.mjs, which drove the name dialog removed by the one-route change; mark WorkspaceRegistry.create's now test-only title parameter with a deletion TODO; pin the retired { name } spelling as a schema rejection; align the workspace spec on stageDir and the fixture spec title on path creates. --- ...same-basename-workspace-adoption.i18n.yaml | 4 +- ...-07-31-same-basename-workspace-adoption.md | 4 +- ...-31-same-basename-workspace-adoption.zh.md | 4 +- ...n-list-browsing-and-manual-order.i18n.yaml | 4 +- ...-session-list-browsing-and-manual-order.md | 2 +- ...ssion-list-browsing-and-manual-order.zh.md | 2 +- ...-07-25-workspace-ui-product-flow.i18n.yaml | 4 +- .../2026-07-25-workspace-ui-product-flow.md | 7 +- ...2026-07-25-workspace-ui-product-flow.zh.md | 7 +- ...-31-one-route-to-add-a-workspace.i18n.yaml | 4 +- ...2026-07-31-one-route-to-add-a-workspace.md | 4 +- ...6-07-31-one-route-to-add-a-workspace.zh.md | 4 +- .../client/connection/tests/fixture.spec.ts | 2 +- .../tests/api-proxy-workspace.spec.ts | 6 +- .../host/apiproxy/tests/rpc-schemas.spec.ts | 2 + packages/workspace/workspace/src/index.ts | 5 ++ scripts/hero-composer-dom-continuity.mjs | 78 ------------------- 17 files changed, 34 insertions(+), 109 deletions(-) delete mode 100644 scripts/hero-composer-dom-continuity.mjs diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.i18n.yaml index 990e7e39bb..e7bdf260be 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.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 .agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md -2026-07-31-same-basename-workspace-adoption.md: ed53804ea64df0d61db16e579c3d65af803dbb97 -2026-07-31-same-basename-workspace-adoption.zh.md: 82cfb7d90afca28f8e666742a758fda0202909f3 +2026-07-31-same-basename-workspace-adoption.md: 1192558632fdc8bd732ea59f0eca5051f49f5d2c +2026-07-31-same-basename-workspace-adoption.zh.md: 9c1f1ffd221936e24300b223ad395e1f44552810 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md index ed53804ea6..1192558632 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md @@ -14,7 +14,7 @@ A Workspace is identified by its stable id and canonical directory path, while i The Host's `workspace.create({ path })` adoption route inherits that rule. The Workspace manager, picker, grouping tree, selection, rename, deletion, and Session creation continue to use `WorkspaceId`, so equal labels neither merge records nor redirect an operation. The sidebar hover card exposes each canonical path when the labels need disambiguation. -Explicit naming remains stricter. `workspace.create({ name })` and `workspace.rename` continue to reject a title already registered, as described by [manual Workspace naming](../feature/2026-07-25-session-list-browsing-and-manual-order.md). This prevents a user from deliberately introducing another ambiguous label while accepting collisions imposed by existing directory names. The path-adoption rule supersedes only the title-conflict clauses in the [Workspace product flow](../feature/2026-07-25-workspace-ui-product-flow.md) and [native directory picker](../feature/2026-07-27-native-workspace-directory-picker.md). +Explicit naming remains stricter. `workspace.rename` continues to reject a title already registered, as described by [manual Workspace naming](../feature/2026-07-25-session-list-browsing-and-manual-order.md). This prevents a user from deliberately introducing another ambiguous label while accepting collisions imposed by existing directory names. The path-adoption rule supersedes only the title-conflict clauses in the [Workspace product flow](../feature/2026-07-25-workspace-ui-product-flow.md) and [native directory picker](../feature/2026-07-27-native-workspace-directory-picker.md). The durable schema does not change: Workspace records already store id, path, and title independently, bootstrap can derive equal basenames, and startup validates duplicate paths rather than titles. @@ -30,7 +30,7 @@ Workspace registry and Host API tests create two real directories under differen **Use the full path as every Workspace title.** This removes the collision but makes the primary navigation label unnecessarily long. The full path remains available in the hover detail while the concise basename stays useful. -**Permit collisions from explicit rename and create-by-name operations too.** The registry supports that state, but those operations intentionally ask the user to choose a display name. Retaining their conflict response preserves the existing naming guard without blocking filesystem-selected paths. +**Permit collisions from the explicit rename operation too.** The registry supports that state, but rename intentionally asks the user to choose a display name. Retaining its conflict response preserves the existing naming guard without blocking filesystem-selected paths. ## Consequences diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.zh.md index 82cfb7d90a..9c1f1ffd22 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.zh.md @@ -14,7 +14,7 @@ Workspace 的身份由其稳定 id 和规范目录路径确定,标题则是可 Host 的 `workspace.create({ path })` 接纳入口沿用该规则。Workspace 管理器、选择器、分组树、选择、重命名、删除和 Session 创建仍使用 `WorkspaceId`,因此相同标签既不会合并记录,也不会把操作指向其他记录。需要区分相同标签时,侧边栏悬停详情卡会显示各自的规范路径。 -显式命名仍采用更严格的规则。`workspace.create({ name })` 和 `workspace.rename` 仍会拒绝已注册的标题,具体见[手动 Workspace 命名](../feature/2026-07-25-session-list-browsing-and-manual-order.md)。这既防止用户主动引入另一个难以区分的标签,又允许既有目录名称造成的重名。路径接纳规则仅取代 [Workspace 产品流](../feature/2026-07-25-workspace-ui-product-flow.md)和[原生目录选择器](../feature/2026-07-27-native-workspace-directory-picker.md)中的标题冲突条款。 +显式命名仍采用更严格的规则。`workspace.rename` 仍会拒绝已注册的标题,具体见[手动 Workspace 命名](../feature/2026-07-25-session-list-browsing-and-manual-order.md)。这既防止用户主动引入另一个难以区分的标签,又允许既有目录名称造成的重名。路径接纳规则仅取代 [Workspace 产品流](../feature/2026-07-25-workspace-ui-product-flow.md)和[原生目录选择器](../feature/2026-07-27-native-workspace-directory-picker.md)中的标题冲突条款。 持久化 schema 未变:Workspace 记录本就分别存储 id、path 和 title,引导初始化可以派生出相同的 basename,启动校验检查的是重复路径而非重复标题。 @@ -30,7 +30,7 @@ Workspace 注册表与 Host API 测试会在不同父目录下创建两个末级 **将完整路径用作每个 Workspace 的标题。** 这会消除冲突,却使主导航标签不必要地过长。完整路径仍可在悬停详情中查看,而简洁的 basename 仍有价值。 -**也允许显式重命名和按名称创建操作产生重名。** 注册表支持这种状态,但这些操作本就是明确要求用户选择显示名称。保留冲突响应可维持现有命名防护,同时不阻止从文件系统选取的路径。 +**也允许显式重命名操作产生重名。** 注册表支持这种状态,但该操作本就是明确要求用户选择显示名称。保留冲突响应可维持现有命名防护,同时不阻止从文件系统选取的路径。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml index fe955d125d..c8f5bfdce4 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.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 .agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md -2026-07-25-session-list-browsing-and-manual-order.md: bd04e7f74c8a4540d68e60ad68965e05de76bce9 -2026-07-25-session-list-browsing-and-manual-order.zh.md: 8ec5f71943a68a70f46fbd4c7702e4556b1892ec +2026-07-25-session-list-browsing-and-manual-order.md: 2894542e7b3b720702c764dbae4112b001e2602c +2026-07-25-session-list-browsing-and-manual-order.zh.md: 81c613a6d2ac738a993f82207ef7c3820cd751f0 diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md index bd04e7f74c..2894542e7b 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md @@ -24,7 +24,7 @@ The group-by menu offers two modes, WorkSpace / In one list. WorkSpace mode rend ### workspace.rename -`workspace.rename({ workspaceId, title })`: the title is trimmed and must be non-blank; both the same-title no-op and the duplicate check evaluate inside the Host's serialized workspace-operation chain (shared with create-by-name, so concurrent explicit naming operations cannot interleave a duplicate or an out-of-order fake success), and a conflict returns `workspace-name-conflict`. Path adoption may derive a title already present because canonical path, not title, owns identity ([decision](../bug-fix/2026-07-31-same-basename-workspace-adoption.md)). Durability goes through `setTitle`'s mutate path, and the `domain/changed` listener broadcasts the `host/workspace-changed` frame automatically. The UI is a standard modal with a client-side duplicate pre-check. +`workspace.rename({ workspaceId, title })`: the title is trimmed and must be non-blank; both the same-title no-op and the duplicate check evaluate inside the Host's serialized workspace-operation chain (shared with path adoption and deletion, so concurrent workspace operations cannot interleave a duplicate or an out-of-order fake success), and a conflict returns `workspace-name-conflict`. Path adoption may derive a title already present because canonical path, not title, owns identity ([decision](../bug-fix/2026-07-31-same-basename-workspace-adoption.md)). Durability goes through `setTitle`'s mutate path, and the `domain/changed` listener broadcasts the `host/workspace-changed` frame automatically. The UI is a standard modal with a client-side duplicate pre-check. ### Manual order: insertSessionBefore replaces activity pinning diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md index 8ec5f71943..81c613a6d2 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md @@ -24,7 +24,7 @@ group-by 菜单提供 WorkSpace / In one list 两种模式。WorkSpace 模式按 ### workspace.rename -`workspace.rename({ workspaceId, title })`:title trim 后非空;同名 no-op 与重名查重都在 Host 的 Workspace 操作串行链内求值(与按名称创建共链,并发的显式命名操作不能穿插出重名或乱序假成功),冲突返回 `workspace-name-conflict`。按路径收编可以派生出已有 title,因为拥有身份的是 canonical path,而不是 title(见[身份决策](../bug-fix/2026-07-31-same-basename-workspace-adoption.md))。落盘经 `setTitle` 的 mutate 通道,`domain/changed` 监听自动广播 `host/workspace-changed` 帧。UI 为标准 Modal,client 侧另做重名预检。 +`workspace.rename({ workspaceId, title })`:title trim 后非空;同名 no-op 与重名查重都在 Host 的 Workspace 操作串行链内求值(与按路径收编和删除共链,并发的 Workspace 操作不能穿插出重名或乱序假成功),冲突返回 `workspace-name-conflict`。按路径收编可以派生出已有 title,因为拥有身份的是 canonical path,而不是 title(见[身份决策](../bug-fix/2026-07-31-same-basename-workspace-adoption.md))。落盘经 `setTitle` 的 mutate 通道,`domain/changed` 监听自动广播 `host/workspace-changed` 帧。UI 为标准 Modal,client 侧另做重名预检。 ### 手动排序:insertSessionBefore 取代活动置顶 diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml index d8232afa44..c12ab62d53 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.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 .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md -2026-07-25-workspace-ui-product-flow.md: 7a6a41e19d2930fbcbf7ba5fc9e6809d96e23166 -2026-07-25-workspace-ui-product-flow.zh.md: a40f374fd794b11bff0de72cbc822fd638cd267c +2026-07-25-workspace-ui-product-flow.md: 9f241562c2d07801b22619c8e1406984bab22aba +2026-07-25-workspace-ui-product-flow.zh.md: 7fca17d32837deede4fb751ca4317582d796e005 diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md index 7a6a41e19d..9f241562c2 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md @@ -19,13 +19,12 @@ The Host provides the following GUI wiring on the Workspace entity: | RPC | Behavior | | --- | --- | | `workspace.list` | Returns persistent Workspaces in order and filters out Session ids that fail header validation | -| `workspace.create({ name })` | Creates a directory and Workspace at `workspaceRoot/name`; fails on a display-name conflict | | `workspace.create({ path })` | Adopts an existing directory by canonical path; basename-derived display titles may repeat | | `workspace.delete({ workspaceId })` | Removes the Workspace registration while retaining its directory and session logs; its Sessions become Ungrouped | | `session.create({ workspaceId, sessionId? })` | Resolves cwd from the Workspace, idempotently creates a Session with an optional preallocated id, and attaches it | | `session.create({ cwd })` | Remains available to non-Workspace callers and creates an Ungrouped Session | -`workspaceRoot` is an independent Host setting that falls back to the Host cwd when unset; it is unrelated to `storageRoot`, which stores Workspace domain data. The Host stream pushes Workspace and Session deltas, including `host/workspace-removed`, and the Client refreshes the `workspace.list` and `session.list` baselines separately after reconnecting. Registration-deletion ownership and safety are defined in the [Workspace registration deletion Agent Note](2026-07-27-workspace-registration-deletion.md). +The Host stream pushes Workspace and Session deltas, including `host/workspace-removed`, and the Client refreshes the `workspace.list` and `session.list` baselines separately after reconnecting. Registration-deletion ownership and safety are defined in the [Workspace registration deletion Agent Note](2026-07-27-workspace-registration-deletion.md). A Workspace's `sessionIds` is an ordered candidate index. A membership projection requires both that an id appear in the index and that the corresponding canonicalized `SessionHeader.cwd` equal the Workspace path; SessionHeader does not gain a `workspaceId`. A Session whose cwd matches but whose id is absent from the index remains Ungrouped, while an indexed id is filtered out if its header is missing, its cwd is invalid, or its cwd does not match. Two Workspace indexes claiming the same Session is corrupt state and fails loudly. @@ -52,7 +51,7 @@ When no Workspace exists, the page creates a frontend Workspace object named `wo Top-level New Session, the plus button on a Workspace row, and the Workspace picker all invoke the same New Session action. An explicit Workspace id becomes the target directly; when none is specified, the action uses the most recent Workspace, or the Workspace Intent if no real Workspace exists. The Workspace picker's one Add workspace action ([one-route Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md); it was a pair of Use-an-existing-folder and create-by-name actions when this was decided) immediately creates a real Workspace when the user confirms a directory, then retargets the frontend Session to it; an explicitly created empty Workspace remains even if the user sends no message. -A new Workspace takes its display name from the directory it was created in. Distinct canonical paths may share the same basename-derived title ([identity decision](../bug-fix/2026-07-31-same-basename-workspace-adoption.md)); explicit create-by-name and rename operations retain their duplicate-title checks. Moving Sessions across Workspaces, manual adoption from Ungrouped, and separate display-name and directory-name inputs remain outside this flow. +A new Workspace takes its display name from the directory it was created in. Distinct canonical paths may share the same basename-derived title ([identity decision](../bug-fix/2026-07-31-same-basename-workspace-adoption.md)); the explicit rename operation retains its duplicate-title check. Moving Sessions across Workspaces, manual adoption from Ungrouped, and separate display-name and directory-name inputs remain outside this flow. ### First send and recovery @@ -108,7 +107,7 @@ The Sidebar and conversation empty hero receive standardized actions through slo - Workspace list performs one reentrant bootstrap using only headers; an initialized empty registry does not initialize again after restart, and membership reads validate both the index and canonical cwd. - The initial default target is determined exactly once after both baselines are ready; Workspace groups are not reordered as a whole by hydration or Session activity, and an active Session moves only itself to the front. - A frontend Session under a real Workspace temporarily counts toward the sidebar total, while a Workspace Intent remains hidden; neither publication nor refresh leaves duplicate rows or counts. -- The UI and Host admit distinct same-basename directories as separate Workspaces, while explicit create-by-name and rename operations reject duplicate titles; cwd-only Sessions, Sessions with invalid historical cwd values, and unattached Sessions remain Ungrouped. +- The UI and Host admit distinct same-basename directories as separate Workspaces, while the explicit rename operation rejects duplicate titles; cwd-only Sessions, Sessions with invalid historical cwd values, and unattached Sessions remain Ungrouped. - Confirmed Workspace deletion removes only the registration, retains the current Session, directory, files, and session log, and survives reload; package tests pin unary/frame/baseline races and failure rollback. - Keyless runnable snapshots cover the zero state, explicit creation, and the first send; package-level tests cover bootstrap, membership validation, ordering, idempotency, failure recovery, and arbitrary frame order. diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md index a40f374fd7..7fca17d328 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md @@ -19,13 +19,12 @@ Host 在 Workspace entity 上提供以下 GUI 接线: | RPC | 行为 | | --- | --- | | `workspace.list` | 返回持久有序的 Workspace,并过滤未通过 header 校验的 Session id | -| `workspace.create({ name })` | 在 `workspaceRoot/name` 创建目录和 Workspace;显示名冲突时失败 | | `workspace.create({ path })` | 按 canonical path 收编已有目录;由 basename 派生的显示名可以重复 | | `workspace.delete({ workspaceId })` | 移除 Workspace 注册记录,同时保留目录和会话日志;相关 Session 进入 Ungrouped | | `session.create({ workspaceId, sessionId? })` | 从 Workspace 解析 cwd,以可选预分配 id 幂等创建 Session 并 attach | | `session.create({ cwd })` | 保留给非 Workspace 调用方,创建 Ungrouped Session | -`workspaceRoot` 是独立 Host 配置,未配置时回退到 Host cwd;它与保存 Workspace domain 数据的 `storageRoot` 无关。Host stream 推送 Workspace 与 Session 增量,包括 `host/workspace-removed`;Client 重连后分别刷新 `workspace.list` 与 `session.list` 基线。删除注册记录的所有权与安全边界由 [Workspace 注册记录删除 Agent Note](2026-07-27-workspace-registration-deletion.md)定义。 +Host stream 推送 Workspace 与 Session 增量,包括 `host/workspace-removed`;Client 重连后分别刷新 `workspace.list` 与 `session.list` 基线。删除注册记录的所有权与安全边界由 [Workspace 注册记录删除 Agent Note](2026-07-27-workspace-registration-deletion.md)定义。 Workspace 的 `sessionIds` 是有序候选索引。成员投影同时要求 id 位于索引且对应 `SessionHeader.cwd` canonical 后等于 Workspace path;SessionHeader 不增加 `workspaceId`。cwd 匹配但未入索引的 Session 保持 Ungrouped,索引命中但 header 缺失、cwd 无效或 cwd 不匹配的 id 被过滤。同一 Session 被两个 Workspace 索引占用属于损坏状态并 fail loud。 @@ -52,7 +51,7 @@ Session 自己持有首条输入并驱动一条内部流水线:必要时以预 顶部 New Session、Workspace 行内加号和 Workspace picker 最终都调用同一 New Session 动作:显式 Workspace id 直接成为目标,未指定时使用最近 Workspace,没有真实 Workspace 时使用 Workspace Intent。Workspace picker 的单一 Add workspace 动作(见[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md);本决策做出时是 Use an existing folder 与按名称创建两个动作)会在用户确认目录时立即创建真实 Workspace,再把前端 Session 定位到该 Workspace;即使用户不发送消息,显式创建的空 Workspace 也保留。 -新建 Workspace 的显示名取自其所在目录。不同 canonical path 可以拥有相同的 basename 派生显示名(见[身份决策](../bug-fix/2026-07-31-same-basename-workspace-adoption.md));显式的按名称创建和重命名操作仍保留显示名重名检查。跨 Workspace 移动 Session、从 Ungrouped 手动收编以及分别输入显示名和目录名仍不在此动线范围内。 +新建 Workspace 的显示名取自其所在目录。不同 canonical path 可以拥有相同的 basename 派生显示名(见[身份决策](../bug-fix/2026-07-31-same-basename-workspace-adoption.md));显式的重命名操作仍保留显示名重名检查。跨 Workspace 移动 Session、从 Ungrouped 手动收编以及分别输入显示名和目录名仍不在此动线范围内。 ### 首次发送与恢复 @@ -108,7 +107,7 @@ Sidebar 与 conversation empty hero 通过 slot 获得标准化动作:`startSe - Workspace list 只读取 header 完成一次可重入 bootstrap;initialized 的空 registry 重启不重复初始化,成员读取同时校验索引与 canonical cwd。 - 初始默认目标只在两份基线 ready 后确定一次;Workspace 组不因 hydration 或 Session 活跃整体重排,单个活跃 Session 只前移自身。 - 真实 Workspace 下的前端 Session 临时计入 sidebar 数量,Workspace Intent 保持隐藏,发布与刷新都不会留下重复行或重复计数。 -- UI 与 Host 会将 canonical path 不同但 basename 相同的目录接纳为独立 Workspace,而显式的按名称创建和重命名操作会拒绝重复显示名;cwd-only Session、无效历史 cwd 和未 attach Session 保持 Ungrouped。 +- UI 与 Host 会将 canonical path 不同但 basename 相同的目录接纳为独立 Workspace,而显式的重命名操作会拒绝重复显示名;cwd-only Session、无效历史 cwd 和未 attach Session 保持 Ungrouped。 - 经确认的 Workspace 删除只移除注册记录,保留当前 Session、目录、文件和会话日志,并在刷新后保持该状态;包级测试固定一元响应/帧/基线竞态和失败回滚行为。 - keyless runnable snapshot 覆盖零态、显式创建和首次发送;包级测试覆盖 bootstrap、成员校验、排序、幂等、失败恢复及任意 frame 顺序。 diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml index 691511d76b..e1a166fd0c 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.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 .agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md -2026-07-31-one-route-to-add-a-workspace.md: 853d641e0a2c0044ee7bfd6ed42bcc3763520192 -2026-07-31-one-route-to-add-a-workspace.zh.md: 6a2884d75138ddc241276131f7ff85080fc5d794 +2026-07-31-one-route-to-add-a-workspace.md: d0a1a820a8eb0a47245d99635b5e1e0448d7eca5 +2026-07-31-one-route-to-add-a-workspace.zh.md: 3486fcdae24a77d3ba82234355ba52d5f43e22d0 diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md index 853d641e0a..d0a1a820a8 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md +++ b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md @@ -25,7 +25,7 @@ The direct-open path carries the busy rule the menu entry states: while a pick i `WorkspaceCreateFlow` is now `WorkspacePickFlow` and its `createOnly` prop is `addOnly`; the injected `createWorkspace` narrows from `{ name } | { path }` to `{ path }`. -## Wire and CLI residue +## Wire and CLI follow-up (shipped) Deleted in the follow-up change this section used to scope: `workspace.create` accepts only `{ path }` (the `name` member left the wire schema and `WorkspaceApi`), the gateway lost its `workspaceRoot` config and default, the client seam narrowed to the path spelling (`WorkspaceCreateInput`, `WorkspacesService.create`, `intentName`), and the `dsh web --workspace-root` flag is gone together with its `apps/cli` reference lines. `workspace-name-conflict` remains on the wire as `workspace.rename`'s duplicate-title error. @@ -45,7 +45,7 @@ Deleted in the follow-up change this section used to scope: `workspace.create` a **Keep the menu shell for entries we might add later (clone a repo, remote directory).** Rejected under "require a current owner and need": no such entry exists, and restoring a menu when one arrives is a smaller change than shipping an empty frame now. -**Delete the wire's create-by-name branch in the same change.** Rejected for this PR: it is backend/CLI surface with a different reviewer and a wider test fallout, and the urgent decision is the UI. See the residue section — it is marked, not forgotten. +**Delete the wire's create-by-name branch in the same change.** Rejected for the UI PR: it was backend/CLI surface with a different reviewer and a wider test fallout, and the urgent decision was the UI. The deletion shipped as its own follow-up change; the follow-up section above records what it removed. **Register the workspace through the host in the e2e scaffold instead of driving the dialog.** Rejected: it would have decoupled all 15 scenarios from the picker, so nothing in the lane would prove the surviving route reaches a live composer. Every scenario now walks the real dialog to adopt its directory; only the create-a-folder half is concentrated in one scenario, because repeating it everywhere makes the shared helper non-idempotent for no extra signal. diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md index 6a2884d751..3486fcdae2 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md @@ -25,7 +25,7 @@ Status: implemented `WorkspaceCreateFlow` 现更名为 `WorkspacePickFlow`,其 `createOnly` prop 更名为 `addOnly`;注入的 `createWorkspace` 从 `{ name } | { path }` 收窄为 `{ path }`。 -## Wire and CLI residue +## Wire and CLI follow-up (shipped) 本节曾划定的后续删除已经落地:`workspace.create` 只接受 `{ path }`(`name` 成员已从 wire schema 与 `WorkspaceApi` 移除),网关失去了 `workspaceRoot` 配置及其默认值,客户端 seam 收窄为 path 写法(`WorkspaceCreateInput`、`WorkspacesService.create`、`intentName`),`dsh web --workspace-root` flag 连同其 `apps/cli` reference 文档行一并删除。`workspace-name-conflict` 仍留在 wire 上,作为 `workspace.rename` 的重名错误。 @@ -45,7 +45,7 @@ Status: implemented **为将来可能新增的入口(克隆仓库、远程目录)保留菜单壳。** 否决,依据"require a current owner and need":这样的入口目前并不存在,而等它到来时再恢复菜单,比现在就发一个空壳的改动更小。 -**在同一改动中删除 wire 的按名称创建分支。** 本 PR 否决:那是 backend/CLI 面,reviewer 不同、测试波及面更广,而紧急的决定是 UI。见 residue 一节——它是被标记了,不是被遗忘了。 +**在同一改动中删除 wire 的按名称创建分支。** UI PR 否决:那是 backend/CLI 面,reviewer 不同、测试波及面更广,而当时紧急的决定是 UI。删除随后作为独立的后续改动落地,上文 follow-up 一节记录了它移除的内容。 **在 e2e scaffold 中经 host 注册 workspace,而不驱动对话框。** 否决:那会让全部 15 个场景与选择器解耦,整条 lane 将无法证明幸存的这条路径能走到可用的 composer。现在每个场景都会走真实对话框来接纳自己的目录;只有"新建文件夹"那一半集中在一个场景里,因为处处重复只会让共享辅助函数失去幂等性,却换不来额外信号。 diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 5b9824fcde..6bb6e2e4ab 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -535,7 +535,7 @@ describe('createFixtureApi', () => { expect(reused.result.value).toMatchObject({ created: false, workspace: { workspaceId: 'fx-ws-fixture' } }) }) - it('workspace.create by name mints a new entity and pushes host/workspace-changed', async () => { + it('workspace.create on a fresh path mints a new entity and pushes host/workspace-changed', async () => { const api = createFixtureApi() const abort = new AbortController() const seen: HostFrame[] = [] diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index b548b36702..15b2e47989 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -252,8 +252,7 @@ describe('host.openPath', () => { describe('workspace.create', () => { it('serializes concurrent creates of one path into a single registration', async () => { const { api, root } = await harness() - const target = join(root, 'alpha') - mkdirSync(target) + const target = stageDir(root, 'alpha') const responses = await Promise.all([ api.workspace.create(request({ path: target })), api.workspace.create(request({ path: target })), @@ -269,8 +268,7 @@ describe('workspace.create', () => { it('adopts only existing directories', async () => { const { api, root } = await harness() - const existing = join(root, 'existing') - mkdirSync(existing) + const existing = stageDir(root, 'existing') const first = expectOk(await api.workspace.create(request({ path: existing }))) const repeated = expectOk(await api.workspace.create(request({ path: existing }))) expect(first).toMatchObject({ created: true, workspace: { path: existing, title: 'existing' } }) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 4639b5c5ee..90cee6e8cd 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -327,6 +327,8 @@ describe('workspace domain schemas', () => { it('create requires a path', () => { expect(workspaceCreateRequestSchema.parse({ path: '/p' }).path).toBe('/p') expect(() => workspaceCreateRequestSchema.parse({})).toThrow() + // The retired create-by-name spelling stays a clean schema rejection. + expect(() => workspaceCreateRequestSchema.parse({ name: 'n' })).toThrow() expect(workspaceCreateValueSchema.parse({ workspace: view, created: false }).created).toBe(false) }) diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts index 71401862f2..904959c8ec 100644 --- a/packages/workspace/workspace/src/index.ts +++ b/packages/workspace/workspace/src/index.ts @@ -139,6 +139,11 @@ export class WorkspaceRegistry extends Service { * @param title - Display title used only when a new record is created. * @returns the existing or newly durable workspace. */ + // TODO: `title` lost its last production caller when the gateway's + // create-by-name branch was deleted + // (.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md); + // drop the parameter with its @param clause and the `create(path, title?)` + // lines in this package's README pair. async create(path: string, title?: string): Promise { const canonical = await realpathNormalize(path) if (!(await stat(canonical)).isDirectory()) { diff --git a/scripts/hero-composer-dom-continuity.mjs b/scripts/hero-composer-dom-continuity.mjs deleted file mode 100644 index ef39052b1b..0000000000 --- a/scripts/hero-composer-dom-continuity.mjs +++ /dev/null @@ -1,78 +0,0 @@ -// Regression drive for the unified hero composer (0729-0357-hero-unify): -// cold start with zero workspaces -> create a workspace -> type. Asserts the -// composer textarea is the SAME DOM node across the disabled->live flip (a -// remount drops the __heroMark marker property) — the session-maybe -// composer.bar contract. -// -// Prereqs: `pnpm run build`, then a fresh server against empty state: -// rm -rf .storages && DSH_HOME=$(mktemp -d) node --experimental-transform-types \ -// --import ./scripts/tspath-loader.ts apps/cli/src/bin.ts web --port 44285 \ -// --workspace-root $(mktemp -d) -// Run: node scripts/hero-composer-dom-continuity.mjs -// (BASE_URL overrides the target; screenshots land in .artifacts/.) -import { createRequire } from 'node:module' - -// playwright is a devDependency of apps/web only — resolve through its tree. -const require = createRequire(new URL('../apps/web/package.json', import.meta.url)) -const { chromium } = require('playwright') - -const BASE = process.env.BASE_URL ?? 'http://127.0.0.1:44285' -const SHOTS = new URL('../.artifacts/screenshots/0729-0357-hero-unify/', import.meta.url).pathname - -const browser = await chromium.launch() -const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }) -page.on('console', msg => { if (msg.type() === 'error') console.log('[console.error]', msg.text()) }) -page.on('pageerror', err => { console.log('[pageerror]', err.message) }) - -await page.goto(BASE) -await page.waitForSelector('textarea', { timeout: 20000 }) -await page.screenshot({ path: SHOTS + '01-cold-start.png' }) - -const initial = await page.evaluate(() => { - const boxes = [...document.querySelectorAll('textarea')] - boxes.forEach((b, i) => { b.__heroMark = 'alive-' + i }) - return boxes.map(b => ({ disabled: b.disabled, placeholder: b.placeholder })) -}) -console.log('cold-start textareas:', JSON.stringify(initial)) - -// Open the picker and create a workspace by name (typed-input flow). The name -// must be unique per registry; keystrokes go through pressSequentially so the -// dialog's React onChange enables the submit button. -await page.getByRole('button', { name: 'Choose workspace' }).click() -await page.getByText('Create a new workspace').click() -await page.screenshot({ path: SHOTS + '03-create-form.png' }) -const nameBox = page.getByPlaceholder('Workspace name') -await nameBox.click() -const wsName = 'proj-' + Date.now().toString(36) -await nameBox.pressSequentially(wsName, { delay: 30 }) -await page.locator('button:text-is("Create workspace")').click() - -// Wait for the composer to go live (placeholder flips, textarea enabled). -await page.waitForFunction(() => { - const box = document.querySelector('textarea') - return box !== null && !box.disabled -}, { timeout: 20000 }) -await page.screenshot({ path: SHOTS + '04-live.png' }) - -const after = await page.evaluate(() => { - const boxes = [...document.querySelectorAll('textarea')] - return boxes.map(b => ({ - mark: b.__heroMark ?? 'REMOUNTED', - disabled: b.disabled, - placeholder: b.placeholder, - })) -}) -console.log('post-pick textareas:', JSON.stringify(after)) - -// Type into the live composer. -await page.locator('textarea').first().fill('hello from acceptance run') -const typed = await page.evaluate(() => document.querySelector('textarea')?.value) -console.log('typed value:', JSON.stringify(typed)) -await page.screenshot({ path: SHOTS + '05-typed.png' }) - -const survived = after.length === 1 && after[0].mark === 'alive-0' -console.log(survived - ? 'DOM-CONTINUITY: PASS (same textarea node across cold-start -> live)' - : 'DOM-CONTINUITY: FAIL ' + JSON.stringify(after)) -await browser.close() -process.exit(survived && typed === 'hello from acceptance run' ? 0 : 1) From b7fe3de8f184cdcec5ab20eb7205660368e4038a Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 7 Aug 2026 14:54:07 +0800 Subject: [PATCH 03/20] chore(knip): drop the stale playwright ignore for the deleted hero script The root-scripts workspace ignore existed only for scripts/hero-composer-dom-continuity.mjs; apps/web declares its own playwright dependency for the e2e lane. --- knip.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/knip.json b/knip.json index 6dc4b56dcd..fbf456d3b9 100644 --- a/knip.json +++ b/knip.json @@ -27,9 +27,6 @@ "scripts/**/*.ts", "scripts/**/*.mjs", "scripts/**/*.cjs" - ], - "ignoreDependencies": [ - "playwright" ] }, "examples": { From b5934b6f7ce7793c149c2d35f06d31a47e2d2e66 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 11:04:44 +0800 Subject: [PATCH 04/20] cleanup(web): remove the steering interjection caption Steering bubbles render as plain user bubbles; a mid-turn steer is recognizable by its position in the flow. The runtime SteeringMessageNode projection and pending-steering lifecycle are unchanged. Partially supersedes the 2026-08-04 context-source and steer marks note; the new simplification note owns the removal rationale. --- ...b-context-source-and-steer-marks.i18n.yaml | 4 +-- ...8-04-web-context-source-and-steer-marks.md | 5 +-- ...4-web-context-source-and-steer-marks.zh.md | 5 +-- ...ve-steering-interjection-caption.i18n.yaml | 6 ++++ ...eb-remove-steering-interjection-caption.md | 36 +++++++++++++++++++ ...remove-steering-interjection-caption.zh.md | 36 +++++++++++++++++++ .../plan-review/approved.expected.md | 2 +- .../snapshots/steering/mid-steer.expected.md | 2 +- .../snapshots/steering/settled.expected.md | 2 +- .../client/ui-conversation/README.i18n.yaml | 4 +-- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/chat/MessageItem.module.css | 9 ----- .../src/client/chat/MessageItem.tsx | 12 ++----- .../ui-conversation/src/client/locales.ts | 2 -- .../tests/chat-branch-tails.spec.tsx | 3 +- .../ui-conversation/tests/chat-view.spec.tsx | 4 --- 17 files changed, 97 insertions(+), 39 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.md create mode 100644 .agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml index cdcbcd8d4e..6bc552736e 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.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 .agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md -2026-08-04-web-context-source-and-steer-marks.md: d74badedaa9623014dbba10ae68ca680daf75de1 -2026-08-04-web-context-source-and-steer-marks.zh.md: b2fd990f6f868fdf422ba97ddd25d19705041e3a +2026-08-04-web-context-source-and-steer-marks.md: 0285f3ef1d7cc9dda77322d6b6e61367ee0f12eb +2026-08-04-web-context-source-and-steer-marks.zh.md: 872ab30d7235bae55d4350a98654c5d16e34b968 diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md index d74badedaa..0285f3ef1d 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md @@ -37,11 +37,12 @@ The transcript names all three roles a non-prompt message can play — injected ## Testing - `packages/client/runtime` unit coverage pins each source kind, the label fallbacks when a name field is missing, empty, or wrongly typed, the unnamed degradation for a source with no readable kind, and steering reconstruction on reset and live append paths. -- `packages/client/ui-conversation` jsdom coverage pins the role title, the producer label beside it, the label's survival while expanded, the roleless header, and the steering caption on both durable and pending bubbles. -- The keyless assembled-Web goldens carry the named header and the steering caption, so the assembled transcript — not only component tests — proves the marks. +- `packages/client/ui-conversation` jsdom coverage pins the role title, the producer label beside it, the label's survival while expanded, and the roleless header. +- The keyless assembled-Web goldens carry the named header, so the assembled transcript — not only component tests — proves the marks. ## Consequences +- **Superseded in part.** The steering-caption clause of the Decision no longer describes master: the [caption removal](../simplification/2026-08-10-web-remove-steering-interjection-caption.md) deleted the `插话` / `Interjection` caption, leaving a mid-turn steer recognizable only by its position in the flow. The context-source and recall naming below stays current, and the `SteeringMessageNode` projection is unchanged. - A reader can attribute every non-prompt message in the transcript at a glance, and the header stays honest for logs this client version has never seen a producer for. - Producer names in the UI are package-shaped (`dsh-tool-skill`, `@deepseek-ai/dsh-system-prompt`) wherever the source carries only a plugin id. That is the cost of refusing a client-side name table; a producer that wants a better label must record one in its source fields. - `ContextMessageNode` gains a required field, so every constructed node — including test fixtures — must supply it. diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md index b2fd990f6f..872ab30d72 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md @@ -37,11 +37,12 @@ transcript 为非提示消息可能承担的三种角色分别命名:注入上 ## 测试 - `packages/client/runtime` 单元覆盖钉住每个来源分支、名称字段缺失/为空/类型不符时的回退、来源没有可读 kind 时的无名降级,以及 reset 和实时 append 路径上的 steering 重建。 -- `packages/client/ui-conversation` 的 jsdom 覆盖钉住角色标题、标题旁的生产者名称、展开后该名称的留存、无名时的标题形态,以及持久与待处理气泡上的 steering 标注。 -- 无密钥的组装 Web 黄金基线携带带名称的标题栏与 steering 标注,因此证明这些标识的是组装后的 transcript,而不只是组件测试。 +- `packages/client/ui-conversation` 的 jsdom 覆盖钉住角色标题、标题旁的生产者名称、展开后该名称的留存,以及无名时的标题形态。 +- 无密钥的组装 Web 黄金基线携带带名称的标题栏,因此证明这些标识的是组装后的 transcript,而不只是组件测试。 ## 后果 +- **部分被取代。** 决策中的 steering 标注条款已不再描述 master:[标注移除决策](../simplification/2026-08-10-web-remove-steering-interjection-caption.md)删除了 `插话` / `Interjection` 标注,轮次中途的 steer 只能靠它在消息流中的位置辨认。下列上下文来源与召回命名仍然有效,`SteeringMessageNode` 投影未变。 - 读者一眼即可归因 transcript 中每一条非提示消息;即便面对本客户端版本从未见过其生产者的日志,标题栏依然如实。 - 只要来源仅携带插件 id,UI 中的生产者名称就呈现为包名形态(`dsh-tool-skill`、`@deepseek-ai/dsh-system-prompt`)。这是拒绝客户端名称表的代价;想要更好标签的生产者必须在来源字段中记录该标签。 - `ContextMessageNode` 增加了一个必填字段,因此每一处构造该节点的代码——包括测试 fixture——都必须提供它。 diff --git a/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.i18n.yaml new file mode 100644 index 0000000000..7194db5780 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.md +2026-08-10-web-remove-steering-interjection-caption.md: 2c396f54945fb1c3626f2e5fcc849e81893c23f0 +2026-08-10-web-remove-steering-interjection-caption.zh.md: 85d76e977a908393ba5e3a385b804acce3063660 diff --git a/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.md b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.md new file mode 100644 index 0000000000..2c396f5494 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.md @@ -0,0 +1,36 @@ +# Agent Note: Remove the steering interjection caption + +Status: implemented + +English | [中文](2026-08-10-web-remove-steering-interjection-caption.zh.md) + +## Problem + +The [context-source and steer marks decision](../feature/2026-08-04-web-context-source-and-steer-marks.md) captioned every durable and pending steering bubble with `插话` / `Interjection` so the transcript could say which right-aligned bubble interrupted a running turn. The caption repeats what the flow already shows: a steering bubble sits mid-turn, between the assistant content it interrupted, while a turn-opening prompt sits at a turn boundary. A permanent line of tertiary text above every steer bubble buys no reading a position-aware reader does not already have, and it is the only chrome any user-style bubble carries, so it also breaks the otherwise uniform right-aligned rhythm. + +## Decision + +Steering renders exactly as a user bubble. `UserStyleBubble` has no steering flag, the `message.steering` locale key and the `.steeringMark` style are deleted, and `PendingSteeringBubble` and `UserMessageNodeView` pass only content and actions. A mid-turn steer is recognizable by its position inside the running turn's flow, and by nothing else. + +The runtime distinction is untouched. `SteeringMessageNode` projection from durable `agent/inbox/spliced` history, the `data-pending-steering` attribute, and the pending-to-durable hand-off all remain: the pending lifecycle needs the node identity regardless of presentation, and tests still locate pending bubbles through the attribute. + +This partially supersedes the steering clause of the [context-source and steer marks decision](../feature/2026-08-04-web-context-source-and-steer-marks.md); its context-source and recall naming stays current. The caption has flipped before: the [archived no-steer decision](../../archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md) removed it while the composer could not steer, and the 2026-08-04 decision reintroduced it after the composer gained a Steer gesture. This removal does not revisit the gesture — steering entry, the Queue dock's steer-send action, and the pending lifecycle keep their owners — it judges only that the transcript need not name the result. + +## Alternatives considered + +**Keep the caption.** It is the status quo and cheap to keep, but it decorates every steer bubble forever to encode a fact the bubble's position already states. Chrome that carries no information a reader lacks is removed, not maintained. + +**Remove the `SteeringMessageNode` distinction too.** The node kind is derived from durable inbox history and drives the pending-to-durable hand-off; it is a replay fact, not presentation. Folding it into `UserMessageNode` would change projection behavior for no UI gain. + +**Distinguish steering with quieter chrome (tint, indent, hover-only label).** Any replacement re-raises the same question with a weaker vocabulary. The distinction the transcript needs is positional and already visible; adding subtler decoration keeps the cost and loses the one virtue the text caption had, being explicit. + +## Testing + +- `packages/client/ui-conversation` jsdom coverage pins the plain bubble: the pending hand-off test locates pending bubbles by `data-pending-steering` and asserts the single-bubble hand-off without any caption, and the MessageItem steering arm asserts copy-without-branch on an uncaptioned bubble. +- The keyless assembled-Web goldens (`steering/mid-steer`, `steering/settled`, `plan-review/approved`) replay the unchanged session fixtures with no caption text. + +## Consequences + +- A replayed transcript no longer names steering: a reader infers a mid-turn interjection from its position inside the turn. That inference is weaker than an explicit label for a reader skimming turn boundaries; the decision accepts this. +- A pending steer bubble is visually identical to an ordinary sent bubble until admission; only its missing clock time differs. +- Reintroducing steering chrome of any form requires a new product decision superseding this note. diff --git a/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.zh.md b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.zh.md new file mode 100644 index 0000000000..85d76e977a --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.zh.md @@ -0,0 +1,36 @@ +# Agent Note: Remove the steering interjection caption + +Status: implemented + +[English](2026-08-10-web-remove-steering-interjection-caption.md) | 中文 + +## Problem + +[上下文来源与 steer 标识决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)给每个持久与待处理的 steering 气泡加上了 `插话` / `Interjection` 标注,让 transcript 能说明哪条右对齐气泡打断了正在运行的轮次。这个标注重复了消息流已经呈现的事实:steering 气泡位于轮次中途、夹在被它打断的助手内容之间,而开轮提示位于轮次边界。在每个 steer 气泡上方常驻一行三级文字,并没有让一个能看到位置的读者多读出任何信息,而且它是所有用户样式气泡中唯一带装饰的,还破坏了原本统一的右对齐节奏。 + +## Decision + +steering 完全按用户气泡渲染。`UserStyleBubble` 不再有 steering 标志,`message.steering` locale 键与 `.steeringMark` 样式已删除,`PendingSteeringBubble` 与 `UserMessageNodeView` 只传内容与操作。轮次中途的 steer 只能靠它在运行轮次消息流中的位置辨认,除此之外没有任何标识。 + +运行时的区分保持不变。从持久 `agent/inbox/spliced` 历史投影 `SteeringMessageNode`、`data-pending-steering` 属性、待处理到持久的交接全部保留:待处理生命周期无论呈现如何都需要节点身份,测试也仍通过该属性定位待处理气泡。 + +本决策部分取代[上下文来源与 steer 标识决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)中的 steering 条款;其上下文来源与召回命名仍然有效。这个标注此前已经翻转过一次:[已归档的取消 steer 装饰决策](../../archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md)在 composer 无法 steer 时移除了它,2026-08-04 的决策在 composer 获得 Steer 手势后把它加了回来。本次移除不重议手势本身——steering 入口、Queue dock 的插话发送操作、待处理生命周期各归其主——只判定 transcript 不需要为其结果命名。 + +## Alternatives considered + +**保留标注。** 它是现状,维持成本低,但它永久装饰每个 steer 气泡,只为编码气泡位置已经陈述的事实。不承载读者缺少的信息的装饰应当删除,而不是维护。 + +**连 `SteeringMessageNode` 区分一起删。** 节点类型派生自持久 inbox 历史,驱动待处理到持久的交接;它是回放事实,不是呈现。把它并入 `UserMessageNode` 会改变投影行为,却没有任何 UI 收益。 + +**换更安静的装饰(底色、缩进、悬停标签)。** 任何替代装饰都会用更弱的表达重新提出同一个问题。transcript 需要的区分是位置性的、已经可见的;换成更含蓄的装饰保留了成本,却丢掉了文字标注唯一的优点,就是明确。 + +## Testing + +- `packages/client/ui-conversation` 的 jsdom 覆盖固定了纯气泡行为:待处理交接测试通过 `data-pending-steering` 定位待处理气泡,在没有任何标注的前提下断言单气泡交接;MessageItem 的 steering 分支在无标注气泡上断言可复制且无分支操作。 +- 无密钥的组装 Web goldens(`steering/mid-steer`、`steering/settled`、`plan-review/approved`)用未变的会话 fixture 回放,不含标注文字。 + +## Consequences + +- 回放的 transcript 不再为 steering 命名:读者靠消息在轮次中的位置推断这是一次中途插话。对快速扫读轮次边界的读者,这个推断弱于显式标签;本决策接受这一代价。 +- 待处理的 steer 气泡在被准入前与普通已发送气泡在视觉上完全一致,仅缺少时钟时间。 +- 重新引入任何形式的 steering 装饰都需要一个取代本 note 的新产品决策。 diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index c1cae54bb5..f6ef729c79 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -5,7 +5,7 @@ - tab "Chat" [selected] - tab "Trajectory" - img -- text: "plan Plan mode on. Use /plan off to leave. Interjection Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" +- text: "plan Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" - button "Copy": - img - button "Context injection @deepseek-ai/dsh-system-prompt": diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index 5f3f24f709..c60e8cff4f 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -21,7 +21,7 @@ - img - text: Ask question waiting - status: Deep diving... -- text: "Interjection Interjection: include the word BANANA in your final reply." +- text: "Interjection: include the word BANANA in your final reply." - button "Copy": - img - region "Ready to continue?": diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index d598613fa3..bfb72ade1f 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -19,7 +19,7 @@ - img - img - text: Ask question 1/1 answered -- text: "Interjection Interjection: include the word BANANA in your final reply. {{clock}}" +- text: "Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy": - img - button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.": diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 6c197a5107..dc20ef5753 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: b57f88b5a030a6c20c957e26ea32fb125f106ab4 -README.zh.md: a8a8c4814086cad02c5416078f242ec92a7503d7 +README.md: 1def427a0d6c2d27e0adc7fbfa4a1185bc6b0b57 +README.zh.md: d603e8a337f934f8da9f136f0d42814bb1108acc diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index b57f88b5a0..1def427a0d 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -18,7 +18,7 @@ Approvals take over the composer through the chain this package declares: `Appro The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership. -Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The shared `DisclosureRow` primitive gives this context surface the same compact geometry as other flow rows while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state or summary ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [producer-label decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining source fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble carries an `插话` / `Interjection` caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble. +Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The shared `DisclosureRow` primitive gives this context surface the same compact geometry as other flow rows while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state or summary ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [producer-label decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining source fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble shares the user bubble's presentation unadorned; its mid-turn position in the flow is the only steering signal the transcript shows. A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index a8a8c48140..d603e8a337 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -16,7 +16,7 @@ Chat 业务行是彼此独立的注册表贡献,不是封闭的内建联合。 会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。 -已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态或摘要([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[生产者标签决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源字段列出。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡上方带有 `插话` / `Interjection` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。 +已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态或摘要([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[生产者标签决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源字段列出。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡沿用用户气泡的呈现,不加任何装饰;transcript 中唯一的 steering 信号是它出现在轮次中途的位置。 Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。 diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index c6ca35bb2c..6c86b39e4f 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -8,15 +8,6 @@ gap: 6px; } -/* Steering caption above the bubble: mid-turn interjections carry the same - bubble as a turn-opening prompt, so the transcript names which one this is. */ -.steeringMark { - padding-right: 4px; - color: var(--dsw-alias-label-tertiary); - font-size: 12px; - line-height: 16px; -} - .bubble { /* 525px cap inside the 736 column; percentage keeps narrow windows sane. */ max-width: min(525px, 82%); diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index a0d85e85e8..49342537ef 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -1,7 +1,6 @@ // MessageItem: simple chat nodes — user and consumed-steering bubbles -// (right-aligned, with clock + copy IconActions; steering adds the -// interjection caption that names it; branch lives only under assistant -// answers), pending steering (caption + copy only), context injection, +// (right-aligned, with clock + copy IconActions; branch lives only under +// assistant answers), pending steering (copy only), context injection, // compaction marker, retry disclosure, and unknown-surface JSON rows. import { memo, useEffect, useMemo, useState } from 'react' @@ -151,22 +150,19 @@ function projectUserText(text: string): ReactNode { /** Right-aligned bubble shared by user and steering rows. */ function UserStyleBubble({ - content, actions, pending = false, steering = false, t, + content, actions, pending = false, t, }: { content: readonly unknown[] /** Optional IconActions (or similar) below the bubble; receives the joined text. */ actions?: (text: string) => ReactNode /** Whether this is the Host-authoritative pre-admission steering projection. */ pending?: boolean - /** Marks the bubble as mid-turn steering rather than a turn-opening prompt. */ - steering?: boolean t: ChatViewSlotProps['t'] }): ReactNode { const { text, rest } = contentText(content) const truncated = (total: number): string => t('json.truncated', { total }) return (

- {steering && {t('message.steering')}}
{projectUserText(text)} {rest.map((block, i) => )} @@ -190,7 +186,6 @@ export function PendingSteeringBubble({ content, t }: { ( ( { expect(vi.getTimerCount()).toBe(0) }) - it('consumed steering is captioned as an interjection and keeps copy without branch', () => { + it('consumed steering renders as a plain user bubble and keeps copy without branch', () => { const writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { configurable: true, @@ -242,7 +242,6 @@ describe('MessageItem arms', () => { } as never} />, ) - expect(view.getByText('插话')).toBeTruthy() expect(view.getByText('steer!')).toBeTruthy() expect(view.getByText(/附加内容块/)).toBeTruthy() fireEvent.click(view.getByRole('button', { name: '复制' })) diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 6b2072ddfb..4078edfe94 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -459,9 +459,6 @@ describe('ChatView', () => { expect(view.queryByText('later')).toBeNull() const pendingBubble = view.getByText('interrupt now').closest('[data-pending-steering]') expect(pendingBubble).not.toBeNull() - // Pending and durable steering carry the same interjection caption, so the - // hand-off does not change what the row says it is. - expect(within(pendingBubble as HTMLElement).getByText('插话')).toBeTruthy() fireEvent.click(within(pendingBubble as HTMLElement).getByRole('button', { name: '复制' })) expect(writeText).toHaveBeenCalledWith('interrupt now') expect(within(pendingBubble as HTMLElement).queryByRole('button', { name: '在新对话中分支' })).toBeNull() @@ -483,7 +480,6 @@ describe('ChatView', () => { }) expect(view.getAllByText('interrupt now')).toHaveLength(1) expect(view.container.querySelector('[data-pending-steering]')).toBeNull() - expect(view.getAllByText('插话')).toHaveLength(1) // Only the durable steering bubble: the turn is still running, so its // assistant narration owns no footer yet, and a steering bubble never // carries a branch action. From a3cf2617a8f7fef6846efc0e486478c4f1b70d97 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 11:26:37 +0800 Subject: [PATCH 05/20] =?UTF-8?q?docs(notes):=20fix=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20translate=20zh=20note=20headings,=20point=20superse?= =?UTF-8?q?ssion=20at=20the=20Decision,=20pin=20caption=20absence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...8-04-web-context-source-and-steer-marks.i18n.yaml | 4 ++-- .../2026-08-04-web-context-source-and-steer-marks.md | 2 +- ...26-08-04-web-context-source-and-steer-marks.zh.md | 2 +- ...eb-remove-steering-interjection-caption.i18n.yaml | 2 +- ...10-web-remove-steering-interjection-caption.zh.md | 12 ++++++------ .../ui-conversation/tests/chat-branch-tails.spec.tsx | 1 + 6 files changed, 12 insertions(+), 11 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml index 6bc552736e..86b48310e7 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.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 .agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md -2026-08-04-web-context-source-and-steer-marks.md: 0285f3ef1d7cc9dda77322d6b6e61367ee0f12eb -2026-08-04-web-context-source-and-steer-marks.zh.md: 872ab30d7235bae55d4350a98654c5d16e34b968 +2026-08-04-web-context-source-and-steer-marks.md: 01bdca873a847f70b4b8632b961e01e099ae4f04 +2026-08-04-web-context-source-and-steer-marks.zh.md: b6a9cc5692826b402b5a08ec65a5c8fc3c547b6b diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md index 0285f3ef1d..01bdca873a 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md @@ -42,7 +42,7 @@ The transcript names all three roles a non-prompt message can play — injected ## Consequences -- **Superseded in part.** The steering-caption clause of the Decision no longer describes master: the [caption removal](../simplification/2026-08-10-web-remove-steering-interjection-caption.md) deleted the `插话` / `Interjection` caption, leaving a mid-turn steer recognizable only by its position in the flow. The context-source and recall naming below stays current, and the `SteeringMessageNode` projection is unchanged. +- **Superseded in part.** The steering-caption clause of the Decision no longer describes master: the [caption removal](../simplification/2026-08-10-web-remove-steering-interjection-caption.md) deleted the `插话` / `Interjection` caption, leaving a mid-turn steer recognizable only by its position in the flow. The context-source and recall naming in the Decision stays current, and the `SteeringMessageNode` projection is unchanged. - A reader can attribute every non-prompt message in the transcript at a glance, and the header stays honest for logs this client version has never seen a producer for. - Producer names in the UI are package-shaped (`dsh-tool-skill`, `@deepseek-ai/dsh-system-prompt`) wherever the source carries only a plugin id. That is the cost of refusing a client-side name table; a producer that wants a better label must record one in its source fields. - `ContextMessageNode` gains a required field, so every constructed node — including test fixtures — must supply it. diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md index 872ab30d72..b6a9cc5692 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md @@ -42,7 +42,7 @@ transcript 为非提示消息可能承担的三种角色分别命名:注入上 ## 后果 -- **部分被取代。** 决策中的 steering 标注条款已不再描述 master:[标注移除决策](../simplification/2026-08-10-web-remove-steering-interjection-caption.md)删除了 `插话` / `Interjection` 标注,轮次中途的 steer 只能靠它在消息流中的位置辨认。下列上下文来源与召回命名仍然有效,`SteeringMessageNode` 投影未变。 +- **部分被取代。** 决策中的 steering 标注条款已不再描述 master:[标注移除决策](../simplification/2026-08-10-web-remove-steering-interjection-caption.md)删除了 `插话` / `Interjection` 标注,轮次中途的 steer 只能靠它在消息流中的位置辨认。决策中的上下文来源与召回命名仍然有效,`SteeringMessageNode` 投影未变。 - 读者一眼即可归因 transcript 中每一条非提示消息;即便面对本客户端版本从未见过其生产者的日志,标题栏依然如实。 - 只要来源仅携带插件 id,UI 中的生产者名称就呈现为包名形态(`dsh-tool-skill`、`@deepseek-ai/dsh-system-prompt`)。这是拒绝客户端名称表的代价;想要更好标签的生产者必须在来源字段中记录该标签。 - `ContextMessageNode` 增加了一个必填字段,因此每一处构造该节点的代码——包括测试 fixture——都必须提供它。 diff --git a/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.i18n.yaml index 7194db5780..2f63bbcfd7 100644 --- a/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.md 2026-08-10-web-remove-steering-interjection-caption.md: 2c396f54945fb1c3626f2e5fcc849e81893c23f0 -2026-08-10-web-remove-steering-interjection-caption.zh.md: 85d76e977a908393ba5e3a385b804acce3063660 +2026-08-10-web-remove-steering-interjection-caption.zh.md: 088b36449130f9e8f1be5bec7a3ee4a812b4b6f1 diff --git a/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.zh.md b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.zh.md index 85d76e977a..088b364491 100644 --- a/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.zh.md @@ -1,14 +1,14 @@ -# Agent Note: Remove the steering interjection caption +# Agent Note: 移除 steering 插话标注 Status: implemented [English](2026-08-10-web-remove-steering-interjection-caption.md) | 中文 -## Problem +## 问题 [上下文来源与 steer 标识决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)给每个持久与待处理的 steering 气泡加上了 `插话` / `Interjection` 标注,让 transcript 能说明哪条右对齐气泡打断了正在运行的轮次。这个标注重复了消息流已经呈现的事实:steering 气泡位于轮次中途、夹在被它打断的助手内容之间,而开轮提示位于轮次边界。在每个 steer 气泡上方常驻一行三级文字,并没有让一个能看到位置的读者多读出任何信息,而且它是所有用户样式气泡中唯一带装饰的,还破坏了原本统一的右对齐节奏。 -## Decision +## 决策 steering 完全按用户气泡渲染。`UserStyleBubble` 不再有 steering 标志,`message.steering` locale 键与 `.steeringMark` 样式已删除,`PendingSteeringBubble` 与 `UserMessageNodeView` 只传内容与操作。轮次中途的 steer 只能靠它在运行轮次消息流中的位置辨认,除此之外没有任何标识。 @@ -16,7 +16,7 @@ steering 完全按用户气泡渲染。`UserStyleBubble` 不再有 steering 标 本决策部分取代[上下文来源与 steer 标识决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)中的 steering 条款;其上下文来源与召回命名仍然有效。这个标注此前已经翻转过一次:[已归档的取消 steer 装饰决策](../../archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md)在 composer 无法 steer 时移除了它,2026-08-04 的决策在 composer 获得 Steer 手势后把它加了回来。本次移除不重议手势本身——steering 入口、Queue dock 的插话发送操作、待处理生命周期各归其主——只判定 transcript 不需要为其结果命名。 -## Alternatives considered +## 考虑过的替代方案 **保留标注。** 它是现状,维持成本低,但它永久装饰每个 steer 气泡,只为编码气泡位置已经陈述的事实。不承载读者缺少的信息的装饰应当删除,而不是维护。 @@ -24,12 +24,12 @@ steering 完全按用户气泡渲染。`UserStyleBubble` 不再有 steering 标 **换更安静的装饰(底色、缩进、悬停标签)。** 任何替代装饰都会用更弱的表达重新提出同一个问题。transcript 需要的区分是位置性的、已经可见的;换成更含蓄的装饰保留了成本,却丢掉了文字标注唯一的优点,就是明确。 -## Testing +## 测试 - `packages/client/ui-conversation` 的 jsdom 覆盖固定了纯气泡行为:待处理交接测试通过 `data-pending-steering` 定位待处理气泡,在没有任何标注的前提下断言单气泡交接;MessageItem 的 steering 分支在无标注气泡上断言可复制且无分支操作。 - 无密钥的组装 Web goldens(`steering/mid-steer`、`steering/settled`、`plan-review/approved`)用未变的会话 fixture 回放,不含标注文字。 -## Consequences +## 后果 - 回放的 transcript 不再为 steering 命名:读者靠消息在轮次中的位置推断这是一次中途插话。对快速扫读轮次边界的读者,这个推断弱于显式标签;本决策接受这一代价。 - 待处理的 steer 气泡在被准入前与普通已发送气泡在视觉上完全一致,仅缺少时钟时间。 diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index bd84f46ed6..5d1f027923 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -242,6 +242,7 @@ describe('MessageItem arms', () => { } as never} />, ) + expect(view.queryByText('插话')).toBeNull() expect(view.getByText('steer!')).toBeTruthy() expect(view.getByText(/附加内容块/)).toBeTruthy() fireEvent.click(view.getByRole('button', { name: '复制' })) From ec310e60f81599b8b67c28544b047c2aa9c541de Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 13:12:35 +0800 Subject: [PATCH 06/20] test(web): align steer-all snapshots after master sync --- apps/web/tests/snapshots/steer-all/mid-steer.expected.md | 6 ++++-- apps/web/tests/snapshots/steer-all/settled.expected.md | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md index 998ee98129..8b77a77a0c 100644 --- a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" @@ -17,10 +19,10 @@ - img - text: Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that. - status: Deep diving... -- text: "Interjection Interjection: include the word BANANA in your final reply." +- text: "Interjection: include the word BANANA in your final reply." - button "Copy": - img -- text: "Interjection Interjection: include the word ORANGE in your final reply." +- text: "Interjection: include the word ORANGE in your final reply." - button "Copy": - img - textbox "Message the agent" diff --git a/apps/web/tests/snapshots/steer-all/settled.expected.md b/apps/web/tests/snapshots/steer-all/settled.expected.md index a61f57572e..0899529a09 100644 --- a/apps/web/tests/snapshots/steer-all/settled.expected.md +++ b/apps/web/tests/snapshots/steer-all/settled.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" @@ -19,10 +21,10 @@ - img - img - text: Ask question 1/1 answered -- text: "Interjection Interjection: include the word BANANA in your final reply. {{clock}}" +- text: "Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy": - img -- text: "Interjection Interjection: include the word ORANGE in your final reply. {{clock}}" +- text: "Interjection: include the word ORANGE in your final reply. {{clock}}" - button "Copy": - img - paragraph: "Got it: BANANA and ORANGE." From 5d86a284e548ccfac0557cd2ea4ff106ac9e1306 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 14:18:41 +0800 Subject: [PATCH 07/20] fix(web-app,agent-presets): keep the task registry on the host plane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tool-bash` resolves the background-task registry with `ctx.get('tasks')`, and it sits at the preset's top level. The registry sat inside an entry-local `isolate: { tasks: true }` realm, which is invisible to every sibling row outside it, while the Web surface disabled the host row — so both lookups missed and every `run_in_background` call answered "background tasks unavailable" with `task_output`, `task_list`, and `task_kill` still listed in the catalog. `task_list` returning "(no background tasks)" is what made the outage read as an empty queue rather than a severed producer. That is the `goals` criterion read from inside the preset: a Service a row outside its realm READS belongs to the plane both can see. `tasks` already keys access by owning agent (`assertAccess` compares `task.owner.id`) and mints an independent token per `attachSurface` call, so one host instance serves every session exactly as before presets — the per-preset-standing-mounts note records that sharing `tasks-local` is a return to its design. `minimal` mounts no `tool-tasks`, and the `start()` control-surface gate is a service-wide set that another preset's controls would open for it, so its `tool-bash` disables `run_in_background` and drops the parameter from the schema. Fixes #2141 --- .../agent-presets/code/agent.cordis.yml | 20 +++--- .../agent-presets/cordis/agent.cordis.yml | 20 +++--- .../agent-presets/minimal/agent.cordis.yml | 8 +++ .../agent-presets/standard/agent.cordis.yml | 20 +++--- apps/web/tests/shipped-composition.e2e.ts | 62 +++++++++++++++++++ packages/bundle/web-app/cordis.patch.yml | 13 +++- 6 files changed, 107 insertions(+), 36 deletions(-) diff --git a/apps/cli/config/agent-presets/code/agent.cordis.yml b/apps/cli/config/agent-presets/code/agent.cordis.yml index 65d2716458..d068e00dea 100644 --- a/apps/cli/config/agent-presets/code/agent.cordis.yml +++ b/apps/cli/config/agent-presets/code/agent.cordis.yml @@ -70,17 +70,15 @@ # ── background tasks ──────────────────────────────────────────────────────── -- id: tasks - name: cordis:group - group: true - isolate: - tasks: true - config: - - id: tasks-local - name: '@deepseek-ai/dsh-tasks-local' - - - id: tool-tasks - name: '@deepseek-ai/dsh-tool-tasks' +# Only the model-facing controls. The task REGISTRY stays on the host plane: +# its producers sit outside any realm this file could put it in — `tool-bash` +# above resolves it with `ctx.get`, and an entry-local realm here is invisible +# to every sibling row, so `run_in_background` would answer "background tasks +# unavailable" while these controls sat in the catalog. The registry is keyed by +# owning agent anyway, so one host instance serves every session. What a preset +# chooses is whether its agent can collect and stop background work at all. +- id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' # ── skills ────────────────────────────────────────────────────────────────── diff --git a/apps/cli/config/agent-presets/cordis/agent.cordis.yml b/apps/cli/config/agent-presets/cordis/agent.cordis.yml index f2cdeea159..91fa28a31d 100644 --- a/apps/cli/config/agent-presets/cordis/agent.cordis.yml +++ b/apps/cli/config/agent-presets/cordis/agent.cordis.yml @@ -64,17 +64,15 @@ # ── background tasks ──────────────────────────────────────────────────────── -- id: tasks - name: cordis:group - group: true - isolate: - tasks: true - config: - - id: tasks-local - name: '@deepseek-ai/dsh-tasks-local' - - - id: tool-tasks - name: '@deepseek-ai/dsh-tool-tasks' +# Only the model-facing controls. The task REGISTRY stays on the host plane: +# its producers sit outside any realm this file could put it in — `tool-bash` +# above resolves it with `ctx.get`, and an entry-local realm here is invisible +# to every sibling row, so `run_in_background` would answer "background tasks +# unavailable" while these controls sat in the catalog. The registry is keyed by +# owning agent anyway, so one host instance serves every session. What a preset +# chooses is whether its agent can collect and stop background work at all. +- id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' # ── goals ─────────────────────────────────────────────────────────────────── diff --git a/apps/cli/config/agent-presets/minimal/agent.cordis.yml b/apps/cli/config/agent-presets/minimal/agent.cordis.yml index 8ca6f0dcdf..cbccafe160 100644 --- a/apps/cli/config/agent-presets/minimal/agent.cordis.yml +++ b/apps/cli/config/agent-presets/minimal/agent.cordis.yml @@ -22,8 +22,16 @@ # never reached the model's shell at all. `tool-bash` consumes the host registry # from here; the executor behind it (`bash-sandbox`) is host-plane too, where the # sandbox policy owns it. +# +# `run_in_background` is off because this preset mounts no `tool-tasks`: the +# host task registry gates starts on SOME control surface being attached, and +# that set is process-wide, so another preset's controls would let this agent +# start work it has no `task_output` to collect. Disabling drops the parameter +# from the schema too, which is the honest surface for a two-tool benchmark. - id: tool-bash name: '@deepseek-ai/dsh-tool-bash' + config: + enableRunInBackground: false - id: tool-str-replace-editor name: '@deepseek-ai/dsh-tool-str-replace-editor' diff --git a/apps/cli/config/agent-presets/standard/agent.cordis.yml b/apps/cli/config/agent-presets/standard/agent.cordis.yml index 66407faf1d..f73f4b3fba 100644 --- a/apps/cli/config/agent-presets/standard/agent.cordis.yml +++ b/apps/cli/config/agent-presets/standard/agent.cordis.yml @@ -63,17 +63,15 @@ # ── background tasks ──────────────────────────────────────────────────────── -- id: tasks - name: cordis:group - group: true - isolate: - tasks: true - config: - - id: tasks-local - name: '@deepseek-ai/dsh-tasks-local' - - - id: tool-tasks - name: '@deepseek-ai/dsh-tool-tasks' +# Only the model-facing controls. The task REGISTRY stays on the host plane: +# its producers sit outside any realm this file could put it in — `tool-bash` +# above resolves it with `ctx.get`, and an entry-local realm here is invisible +# to every sibling row, so `run_in_background` would answer "background tasks +# unavailable" while these controls sat in the catalog. The registry is keyed by +# owning agent anyway, so one host instance serves every session. What a preset +# chooses is whether its agent can collect and stop background work at all. +- id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' # ── skills ────────────────────────────────────────────────────────────────── diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 5d929044b3..8241dfc0bc 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -5,6 +5,7 @@ // surface itself. import { tmpdir } from 'node:os' import { afterEach, expect, it } from 'vitest' +import { CallId } from '@deepseek-ai/dsh-llm' import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox' import { SessionId } from '@deepseek-ai/dsh-session' // Empty type imports carry the tools/sandboxPolicy/approval Context merges. @@ -114,3 +115,64 @@ it('assembles the shipped Web catalog with the confined access default', async ( await commandHandle.dispose() } }, 120_000) + +it('lets a preset producer reach the background-task registry', async () => { + scaffold = await launchWebScaffold() + const ctx = scaffold.ctx + const handle = await ctx.agents.create({ + sessionId: SessionId('shipped-background-task'), + meta: { cwd: scaffold.workspaceCwd }, + setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined), + }) + try { + const signal = new AbortController().signal + // `tool-bash` is a preset row and `tasks` is a host registry; the producer + // resolves it with `ctx.get`, so a registry hidden behind a preset realm + // fails here — with every task control still listed in the catalog above. + const started = await ctx.tools.execute({ + signal, + callId: CallId('shipped-bash-background'), + name: 'bash', + arguments: { + command: 'printf SHIPPED_BACKGROUND_OK', + description: 'shipped background probe', + run_in_background: true, + }, + agent: handle.agent, + }) + expect({ isError: started.isError, content: started.content }).toEqual({ + isError: false, + content: [{ type: 'text', text: 'started background task bash-1' }], + }) + + // The control surface reads what the producer started: same registry, one + // owner. A per-preset registry would list nothing here even on success. + const listed = await ctx.tools.execute({ + signal, + callId: CallId('shipped-task-list'), + name: 'task_list', + arguments: {}, + agent: handle.agent, + }) + expect(listed.isError).toBe(false) + expect(listed.content).toEqual([ + { type: 'text', text: expect.stringContaining('bash-1 [bash]') as unknown as string }, + ]) + + // The full round trip: the output a host-plane producer wrote is collected + // through a preset-plane control, which is the linkage the realm severed. + const collected = await ctx.tools.execute({ + signal, + callId: CallId('shipped-task-output'), + name: 'task_output', + arguments: { task_id: 'bash-1', wait: true }, + agent: handle.agent, + }) + expect(collected.isError).toBe(false) + expect(collected.content).toEqual([ + { type: 'text', text: expect.stringContaining('SHIPPED_BACKGROUND_OK') as unknown as string }, + ]) + } finally { + await handle.dispose() + } +}, 120_000) diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index e4c4935a2a..ed08451f1b 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -218,10 +218,17 @@ - id: tool-bash disabled: true -- id: tool-tasks - disabled: true +# The background-task REGISTRY stays on the host plane; only the model-facing +# `task_*` controls move. Its producers — `tool-bash` here, `tool-pty` and a +# non-continuable `tool-subagent` elsewhere — are preset rows that resolve it +# with `ctx.get`, and an entry-local realm around the registry is invisible to +# every sibling row outside that realm, so `run_in_background` answered +# "background tasks unavailable" while the controls sat in the catalog. That is +# the `goals` criterion read from inside the preset: a Service a row outside its +# realm READS belongs to the plane both can see. The registry is keyed by owning +# agent, so one host instance serves every session exactly as before presets. -- id: tasks +- id: tool-tasks disabled: true - id: tool-fs From e56c6234d2f4cdaa517f875fbddbc3b7c36181d3 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 10 Aug 2026 14:49:30 +0800 Subject: [PATCH 08/20] chore: exclude archived Agent Notes from rg --- .../process/2026-07-26-frozen-agent-note-archive.i18n.yaml | 4 ++-- .../process/2026-07-26-frozen-agent-note-archive.md | 6 +++++- .../process/2026-07-26-frozen-agent-note-archive.zh.md | 6 +++++- .rgignore | 2 ++ 4 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 .rgignore diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml index 1ae66cc1b7..df1a615d35 100644 --- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.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 .agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md -2026-07-26-frozen-agent-note-archive.md: 52b43088b276c0c8e263fc8a81a2df1408cc8059 -2026-07-26-frozen-agent-note-archive.zh.md: a37e06e7cbc19b5000d4849cc3ee2ddc9b451ee3 +2026-07-26-frozen-agent-note-archive.md: 0c139c4a5d892de5bdace76b4935edf32586c4c1 +2026-07-26-frozen-agent-note-archive.zh.md: e67f981e5d7ae9d800b725e5ecbaf15dd7fef46b diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md index 52b43088b2..0c139c4a5d 100644 --- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md +++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md @@ -14,6 +14,8 @@ Only implemented Agent Notes can be archived. An implemented note moves when its The archive uses `.agents/notes/archived/{kind}/yyyy-mm-dd-topic.md`; the redundant `implemented` segment is absent. The archival change moves the complete English, Chinese, and consistency-sidecar triplet, leaves `Status: implemented` intact, and inserts `Archived: YYYY-MM-DD` immediately below it in both language files. Relocation, that metadata line, the corresponding sidecar re-record, and mechanical inbound-link repair are the only permitted archival edits. +The root `.rgignore` excludes the archive from searches that traverse a parent directory. Historical queries name the archive directory explicitly, so intentional access remains available without mixing frozen facts into active decision discovery. + After archival, the triplet is permanently frozen and is historical context rather than current authority. It is not updated for renamed packages, changed behavior, translation standards, formatting rules, broken outbound links, or later documentation contracts. Active prose may intentionally link into an archived note, redirect that link to current authority, or delete it. Repository gates therefore validate links into archived files but never treat archived files as link sources. [`verify-archived-agent-notes`](../../../../scripts/verify-archived-agent-notes.ts) owns the frozen boundary. It accepts only the closed set of Agent Note kinds, requires a complete triplet with implemented status and matching valid archive dates, verifies the sidecar against both current Git blob hashes, and seals every artifact by path and SHA-256 content hash in an append-only manifest. Its `--write` mode first proves every existing seal unchanged and then appends only newly archived artifacts. Pull-request CI supplies the trusted base SHA and checks out complete history before running the verifier, so a reused runner's shallow checkout cannot omit the baseline manifest. The ordinary Agent Note format, translation-pairing, wrapping, Markdown-link, package-path, Mermaid, documentation-TypeScript, and type-equivalence gates exclude archive sources; their evolving standards cannot create pressure to edit history. @@ -28,6 +30,8 @@ Supersession is checked while a new Agent Note is being written, not deferred to **Keep every implemented and rejected note active.** Rejected because maintenance effort and search noise grow with records that no longer help a future decision. Rejected notes in particular earn retention only by preventing a plausible fallacy. +**Leave archived notes in default repository search results.** Rejected because archived facts may be stale by design and can outrank current results by lexical match. Historical work can search the archive directory explicitly. + **Defer supersession cleanup to periodic corpus audits.** Rejected because the author of a replacement note has the freshest evidence about ownership and overlap. Postponement leaves redundant active authorities and makes later classification more expensive. **Archive rejected or proposed notes too.** Rejected because archive status means “implemented historical decision.” An obsolete proposal needs an explicit rejection, while a rejection with no guardrail value needs deletion rather than a second low-value holding area. @@ -38,4 +42,4 @@ Supersession is checked while a new Agent Note is being written, not deferred to ## Consequences -The active corpus becomes a set of decisions expected to influence future work, while low-value implemented history remains searchable and linkable without consuming maintenance attention. Writing a new note includes a scoped supersession check, so replacement decisions cannot silently leave redundant active records behind. Rejected clutter can disappear when it no longer protects a meaningful choice, and proposed work cannot quietly evade a verdict through archival. The archive adds a manifest, a dedicated verifier, and an explicit one-time metadata step. Archived facts and outbound links can become stale by design, so readers and agents must treat active code and documentation as authority and cite an archived note only as history. +The active corpus becomes a set of decisions expected to influence future work, while low-value implemented history remains explicitly searchable and linkable without consuming maintenance attention or appearing in parent-directory searches. Writing a new note includes a scoped supersession check, so replacement decisions cannot silently leave redundant active records behind. Rejected clutter can disappear when it no longer protects a meaningful choice, and proposed work cannot quietly evade a verdict through archival. The archive adds a manifest, a dedicated verifier, and an explicit one-time metadata step. Archived facts and outbound links can become stale by design, so readers and agents must treat active code and documentation as authority and cite an archived note only as history. diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md index a37e06e7cb..e67f981e5d 100644 --- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md @@ -14,6 +14,8 @@ implemented Agent Note 作为当前决策记录持续维护,因此活跃记录 归档路径为 `.agents/notes/archived/{kind}/yyyy-mm-dd-topic.md`,其中省略了冗余的 `implemented` 层级。归档变更会移动完整的英文、中文和一致性伴随记录三个文件,保留 `Status: implemented`,并在两种语言的文件中紧接该状态行插入 `Archived: YYYY-MM-DD`。归档时只允许做文件迁移、添加该元数据行、相应地重新记录伴随记录,以及机械修复入站链接。 +根目录的 `.rgignore` 会将归档目录排除在从上层目录开始的搜索之外。查找历史内容时会显式指定归档目录,因此仍可按需访问,同时不会把冻结事实混入对活跃决策的检索。 + 归档后,这三个文件永久冻结,只作为历史背景,不再是当前权威依据。不得因为包重命名、行为变化、翻译标准、格式规则、出站链接失效或后续文档约定而更新归档文件。活跃文档可以有意链接到归档 Agent Note,也可以把该链接重定向到当前权威依据,或直接删除。仓库门禁因此会校验指向归档文件的链接,但绝不把归档文件作为链接源来校验。 [`verify-archived-agent-notes`](../../../../scripts/verify-archived-agent-notes.ts) 负责维护冻结边界。它只接受封闭集合中的 Agent Note 类别,要求三个配对文件完整、状态为 implemented,且归档日期有效并互相匹配;它还会用双方当前的 Git blob hash 校验伴随记录,并在仅追加的 manifest(元数据清单) 中按路径和 SHA-256 内容 hash 封存每项产物。其 `--write` 模式会先证明每条现有封存记录对应的内容都未改变,再仅追加新归档的产物。拉取请求 CI 会提供可信的基准 SHA,并在运行校验器前检出完整历史,因此复用运行器上的浅克隆检出无法漏掉基线 manifest。普通的 Agent Note 格式、翻译配对、换行、Markdown 链接、包路径、Mermaid、文档 TypeScript 和类型等价门禁都排除归档源文件,因此这些门禁持续演进的标准不会产生修改历史记录的压力。 @@ -28,6 +30,8 @@ implemented Agent Note 作为当前决策记录持续维护,因此活跃记录 **继续将每一份 implemented 和 rejected Agent Note 作为活跃记录保留。** 不予采纳,因为不再帮助未来决策的记录会不断增加维护成本和搜索噪声。尤其是 rejected Agent Note,只有能避免一种可能发生的谬误时,才值得保留。 +**让归档 Agent Note 继续出现在默认的仓库搜索结果中。** 不予采纳,因为归档事实按设计可能已经陈旧,并可能仅凭字面匹配就排在当前结果之前。需要查找历史内容时,可以显式搜索归档目录。 + **把取代关系清理留到定期审计记录集合时再做。** 不予采纳,因为替代记录的作者掌握着关于归属和重叠的最新证据。推迟处理会留下冗余的活跃权威依据,并增加日后分类的成本。 **同时归档 rejected 或 proposed Agent Note。** 不予采纳,因为归档状态表达的是「已经实施的历史决策」。过时的提案需要明确转为 rejected;无法提供防错价值的 rejected Agent Note 则应删除,而不是再放入第二个低价值存放区。 @@ -38,4 +42,4 @@ implemented Agent Note 作为当前决策记录持续维护,因此活跃记录 ## 后果 -活跃记录集合由预计仍会影响未来工作的决策组成;未来指导价值较低的实施历史仍可搜索和链接,却不再消耗维护精力。编写新记录时会包含一项范围明确的取代关系检查,因此取代既有决策的新决策无法悄然留下冗余的活跃记录。当被否决的记录不再保护有意义的选择时,可以清除这类杂项;提案也无法通过归档悄悄逃避明确结论。归档机制增加一份 manifest、一个专用校验器和一个显式的一次性元数据步骤。归档中的事实和出站链接可以按设计逐渐陈旧,因此读者和 agent 必须以活跃代码与文档为权威依据,并且仅将归档 Agent Note 作为历史引用。 +活跃记录集合由预计仍会影响未来工作的决策组成;未来指导价值较低的实施历史仍可显式搜索和链接,却不再消耗维护精力,也不会出现在从上层目录开始的搜索中。编写新记录时会包含一项范围明确的取代关系检查,因此取代既有决策的新决策无法悄然留下冗余的活跃记录。当被否决的记录不再保护有意义的选择时,可以清除这类杂项;提案也无法通过归档悄悄逃避明确结论。归档机制增加一份 manifest、一个专用校验器和一个显式的一次性元数据步骤。归档中的事实和出站链接可以按设计逐渐陈旧,因此读者和 agent 必须以活跃代码与文档为权威依据,并且仅将归档 Agent Note 作为历史引用。 diff --git a/.rgignore b/.rgignore new file mode 100644 index 0000000000..6bffdc1726 --- /dev/null +++ b/.rgignore @@ -0,0 +1,2 @@ +# Frozen Agent Notes are historical snapshots, not current search authority. +/.agents/notes/archived/ From 37ebe87087c75d628e551775d90247ae211dbe23 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 15:27:10 +0800 Subject: [PATCH 09/20] fix(tool-tasks): claim completion notices only for the mount's own scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving the task registry to the host plane put every preset's `tool-tasks` listener on ONE `LocalTaskService`. `settle()` computes a single snapshot and walks every registered listener with no scope filter, and it marks `reported` only when a waiter is present — so a task settling without a waiter reached each mount's listener with `reported` false and every one of them injected the same completion into the same owner. Three shipped presets carry `tool-tasks`, and a preset file edit adds a second generation of the same mount, so an agent read N copies of one notice as model-visible durable context. A mount now claims an owner only when the owner's scope chain reaches the mount's own scope. An unscoped mount is the host-plane instance that serves every agent, which keeps the TUI composition and every existing test intact. Registry-side ownership was the alternative: mark `reported` once the first listener claims it. It is wrong because `onTaskDone` is not a notice-only seam — the `dsh-tasks` invariant companion registers a validating listener — so first-claim-wins would silence observers that are not delivering anything. The regression test mounts two scoped `tool-tasks` over one registry and settles an unowned-wait task, which is the only path that reaches the notice listeners at all: the shipped-composition e2e uses `wait: true`, and a waiter marks `reported` before settlement, so that test structurally cannot cover it. Also corrects the standing-mounts Agent Note, which still listed `tasks-local` among the stateful PRESET plugins. Refs #2141 --- ...08-08-per-preset-standing-mounts.i18n.yaml | 4 +- .../2026-08-08-per-preset-standing-mounts.md | 2 +- ...026-08-08-per-preset-standing-mounts.zh.md | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 +- docs/module-graph.zh.md | 3 +- packages/tasks/tool-tasks/README.i18n.yaml | 4 +- packages/tasks/tool-tasks/README.md | 2 + packages/tasks/tool-tasks/README.zh.md | 2 + packages/tasks/tool-tasks/package.json | 2 + packages/tasks/tool-tasks/src/index.ts | 12 ++++- .../tasks/tool-tasks/tests/tool-tasks.spec.ts | 49 +++++++++++++++++++ packages/tasks/tool-tasks/tsconfig.json | 3 ++ pnpm-lock.yaml | 3 ++ 17 files changed, 88 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml index d8c55c9f0a..410e209e28 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.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 .agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md -2026-08-08-per-preset-standing-mounts.md: 834d645f5f293a2e137b8faf662e301f1e8bb971 -2026-08-08-per-preset-standing-mounts.zh.md: 45ce0f4e7dec28e5bf807898dc9cdbf32b8e4eb5 +2026-08-08-per-preset-standing-mounts.md: c2792454f90a88cd6fba36eed8e36104e5fffea4 +2026-08-08-per-preset-standing-mounts.zh.md: 47668c8c2c424eb188aa14bf55986d27bcfb8ee0 diff --git a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md index 834d645f5f..c2792454f9 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md +++ b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md @@ -16,7 +16,7 @@ A preset is one composition per PROCESS, not one per session. The roster mounts Standing mounts fix the class, not the instances: the registrations a reader needs exist for the process lifetime, keyed by preset id, no agent required. What made it cheap -- The stateful preset plugins (`plan-mode`, `token-meter`, `compact-basic`, `tasks-local`) already key state by `Session`/`Agent` — they predate presets. Sharing one instance is a return to their design, not a rewrite. +- The stateful preset plugins (`plan-mode`, `token-meter`, `compact-basic`) already key state by `Session`/`Agent` — they predate presets. Sharing one instance is a return to their design, not a rewrite. `tasks-local` shared that property and has since left the preset plane entirely: producers outside its realm (`tool-bash`, `tool-pty`, a non-continuable `tool-subagent`) resolve the registry with `ctx.get`, which an entry-local realm hides from them, so it is composed on the host plane and only the model-facing `tool-tasks` row stays per preset. - Preset ymls are unchanged: one mount per preset = one Entry per preset, whose entry-local realms (`isolate: : true`) keep two presets' same-named services apart exactly as they kept two sessions' apart. - A shared realm label was NOT an option: `provide()` throws on a second registration under the same realm symbol, so labels pool the REALM, never the instance — a per-session world sharing a label crashes the second mount. diff --git a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md index 45ce0f4e7d..47668c8c2c 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md @@ -16,7 +16,7 @@ Status: implemented 常驻挂载修的是这一类问题而非其中的个例:读取方需要的注册在进程生命周期内始终存在,按 preset id 索引,不需要任何 agent。让它便宜的原因: -- 有状态的 preset 插件(`plan-mode`、`token-meter`、`compact-basic`、`tasks-local`)本就按 `Session`/`Agent` 分键存状态——它们早于 preset 存在。共享一份实例是回归其设计,不是改写。 +- 有状态的 preset 插件(`plan-mode`、`token-meter`、`compact-basic`)本就按 `Session`/`Agent` 分键存状态——它们早于 preset 存在。共享一份实例是回归其设计,不是改写。`tasks-local` 同样具备该性质,且此后已完全离开 preset 平面:realm 之外的生产方(`tool-bash`、`tool-pty`、非 continuable 的 `tool-subagent`)以 `ctx.get` 解析该注册表,而 entry-local realm 对它们不可见,因此它组合在宿主平面,只有面向模型的 `tool-tasks` 行仍留在各 preset 中。 - preset 的 yml 不变:每 preset 挂一次 = 每 preset 一个 Entry,其 entry 本地 realm(`isolate: : true`)让两个 preset 的同名服务互不相干,正如它从前隔开两个会话。 - 共享 realm label **不是**选项:`provide()` 对同一 realm 符号下的第二次注册直接抛错,label 池化的是 REALM 而非实例——按会话挂载的世界里共享 label 会让第二次挂载崩溃。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 60c6e85cca..680f6afa42 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: 18980d22c694647374b9fa4e6dfbf245ff2416c4 -config-catalog.zh.md: a43c561806498ca53a95af815d0cd7686a0100ca +config-catalog.md: f4d275393dd918f1391d33db19222e4e62e80b96 +config-catalog.zh.md: 9b3cf689c8d4c23d1cca1260b7e74c911558d678 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 18980d22c6..f4d275393d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2332,7 +2332,7 @@ export interface Config { } ``` -Source: [`packages/tasks/tool-tasks/src/index.ts:23`](../packages/tasks/tool-tasks/src/index.ts) +Source: [`packages/tasks/tool-tasks/src/index.ts:24`](../packages/tasks/tool-tasks/src/index.ts) ## `@deepseek-ai/dsh-tool-todo` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index a43c561806..9b3cf689c8 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2333,7 +2333,7 @@ export interface Config { } ``` -来源:[`packages/tasks/tool-tasks/src/index.ts:23`](../packages/tasks/tool-tasks/src/index.ts) +来源:[`packages/tasks/tool-tasks/src/index.ts:24`](../packages/tasks/tool-tasks/src/index.ts) ## `@deepseek-ai/dsh-tool-todo` diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 54453b3411..229c8d1cd6 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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 docs/module-graph.md -module-graph.md: c41db02165740b19a9ef751e6f50316a76df28c3 -module-graph.zh.md: 9071dbc0f2e6df8ec7edd1f3e14cd7ff063123fa +module-graph.md: 0f76bfd2dd700d81e2c6fb6faec3d2c0c9655e98 +module-graph.zh.md: 7fa0eb72666e63e72109a272cdc9ce323c60d2fd diff --git a/docs/module-graph.md b/docs/module-graph.md index c41db02165..0f76bfd2dd 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -947,6 +947,7 @@ flowchart TD pkg_tool_tasks --> pkg_invariants pkg_tool_tasks --> pkg_llm pkg_tool_tasks --> pkg_retention + pkg_tool_tasks --> pkg_scope pkg_tool_tasks --> pkg_system_prompt pkg_tool_tasks --> pkg_tasks pkg_tool_tasks --> pkg_tools @@ -1390,7 +1391,7 @@ flowchart TD | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`brand`](../packages/util/brand), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`session-title-all-messages-llm`](../packages/session/session-title-all-messages-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-message-llm`](../packages/session/session-title-first-message-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | -| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 9071dbc0f2..7fa0eb7266 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -949,6 +949,7 @@ flowchart TD pkg_tool_tasks --> pkg_invariants pkg_tool_tasks --> pkg_llm pkg_tool_tasks --> pkg_retention + pkg_tool_tasks --> pkg_scope pkg_tool_tasks --> pkg_system_prompt pkg_tool_tasks --> pkg_tasks pkg_tool_tasks --> pkg_tools @@ -1392,7 +1393,7 @@ flowchart TD | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`brand`](../packages/util/brand), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`session-title-all-messages-llm`](../packages/session/session-title-all-messages-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-message-llm`](../packages/session/session-title-first-message-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | -| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | diff --git a/packages/tasks/tool-tasks/README.i18n.yaml b/packages/tasks/tool-tasks/README.i18n.yaml index 24e6b6892a..8c97357246 100644 --- a/packages/tasks/tool-tasks/README.i18n.yaml +++ b/packages/tasks/tool-tasks/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/tasks/tool-tasks/README.md -README.md: 6e8e889c2330d6991cb384674b011e4d2e988268 -README.zh.md: 355b6736b476fb2434f17ea3857544f32c40adb3 +README.md: 1b63ba7124e9bdbfbf64d70e36e90d1ff13a27c8 +README.zh.md: 946ba9156c4a9d8902f8c47deb6056b2f6525f86 diff --git a/packages/tasks/tool-tasks/README.md b/packages/tasks/tool-tasks/README.md index 6e8e889c23..1b63ba7124 100644 --- a/packages/tasks/tool-tasks/README.md +++ b/packages/tasks/tool-tasks/README.md @@ -20,6 +20,8 @@ When a producer supplies `outputLimitBytes`, `task_output`, terminal `task_kill` An unreported completion injects `background task (: