diff --git a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml index 437bd200dc..5669911951 100644 --- a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.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-06-30-session-store-fork-api.md -2026-06-30-session-store-fork-api.md: 5342deba8ca879026d32ee1420cb3c0fdf67c500 -2026-06-30-session-store-fork-api.zh.md: 51a3e0ce50aff10a9812c91d24dc6e78a56c43ba +2026-06-30-session-store-fork-api.md: 69ff85e1f137f4f263bf951af0a3f655411c606a +2026-06-30-session-store-fork-api.zh.md: 3304a6f384c9004b3572c95881f832a4aa21b77c diff --git a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md index 5342deba8c..69ff85e1f1 100644 --- a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md +++ b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md @@ -28,6 +28,12 @@ class SessionStore extends Service { An empty prefix is forkable; any non-empty boundary must be a safe existing sequence outside an open turn. Typed errors distinguish missing sources, stale objects, duplicate child ids, invalid boundaries, and prefixes ending during execution. Broader log validation and crash repair remain with their existing owners. +### Host and browser adaptation + +The Host `session.fork` RPC accepts `atSeq` as an anchor within the desired turn rather than as the store's inclusive safe boundary. It selects the first `turn/end` at or after that anchor; an omitted or past-end anchor selects the last completed turn. An anchor already in the log but not followed by a matching `turn/end` returns `fork-unavailable` and never falls back to an earlier turn, so a message action cannot silently omit the clicked message. + +The Host creates the child through the agent registry with the selected seed and lineage, and pre-publication setup installs the latest logged provider, model, and reasoning target before the child can run. It then attaches the child to the source Workspace. An attachment failure returns `workspace-attach-failed` with the already-published child id; the client reconciles that child into its summary list before surfacing the error. The Session-row action uses the last completed turn, while a message action supplies its event seq; both open the child after success, and lineage expansion makes it visible beneath the source. + ## Alternatives considered **Separate `ctx.sessionFork` service.** This was the first implementation, but review showed it overfit the capability-seam pattern. The code had no swappable backend, no extra event surface, no independent ownership lifecycle, and no durable behavior beyond `ctx.sessions.create({ seed, meta })`. Keeping a separate package would make callers discover and install a second service just to perform policy around a session-store primitive. @@ -40,4 +46,4 @@ An empty prefix is forkable; any non-empty boundary must be a safe existing sequ The public surface stays small and discoverable: live session branching is part of `ctx.sessions`, next to `create({ seed })`, rather than a standalone service or a two-step helper pair. Persistence continues to work through existing `session/created` and `session/flush` behavior: a forked child starts life with seeded events, so existing backends persist that seed once and preserve `parentSession` / `seedLength` in the header. -The v1 scope still excludes ACP `session/fork`, unloaded persisted-session forking, model-facing tools, and subagent refactors. If a future ACP method is added, it should advertise the capability only after it has protocol and snapshot coverage; this Agent Note adds no ACP wire behavior, so no ACP snapshot is required. Fork-child replay remains covered by the existing [seed-boundary testing Agent Note](../testing/2026-06-22-fork-child-replay-seed-boundary.md), while this API gets focused `dsh-session` unit tests plus JSONL persistence coverage. +The v1 scope still excludes ACP `session/fork`, unloaded persisted-session forking, model-facing tools, and subagent refactors. If a future ACP method is added, it should advertise the capability only after it has protocol and snapshot coverage; this Agent Note adds no ACP wire behavior, so no ACP snapshot is required. Fork-child replay remains covered by the existing [seed-boundary testing Agent Note](../testing/2026-06-22-fork-child-replay-seed-boundary.md); focused store, Host, carrier, and client tests pin the boundary and reconciliation contracts, while the real Chromium scenario pins the assembled message action and lineage tree. diff --git a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md index 51a3e0ce50..3304a6f384 100644 --- a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md @@ -28,6 +28,12 @@ class SessionStore extends Service { 空前缀可以被 fork;任何非空边界都必须是位于开放轮次之外且安全、已存在的序号。类型化的错误区分源缺失、对象陈旧、子 id 重复、边界无效和前缀结束于执行过程中等情况。更广泛的日志校验与崩溃恢复仍由其现有的负责方处理。 +### Host 与浏览器适配 + +Host 的 `session.fork` RPC 接受 `atSeq`,并将其视为所需轮次内的锚点,而非 store 中包含该序号的安全边界。它选择该锚点处或其后的首个 `turn/end`;锚点省略或超过末尾时,选择最后一个已完成轮次。若锚点已在日志中,但从该锚点起找不到匹配的 `turn/end`,则返回 `fork-unavailable`,绝不回退到更早的轮次,因此消息操作不会静默遗漏所点击的消息。 + +Host 通过 agent(智能体)注册表,以选定的种子和谱系创建子会话;发布前 setup 会先安装日志中最新的提供方、模型和推理(reasoning)目标,子会话才能运行。随后,Host 将子会话附加到源 Workspace。若附加失败,则返回 `workspace-attach-failed` 及已发布的子会话 id;客户端先将该子会话对账到摘要列表,再向调用方报告错误。Session 行操作使用最后一个已完成轮次,消息操作则提供其事件 seq;两者都会在成功后打开子会话,展开谱系后可在源会话下看到它。 + ## 曾考虑的替代方案 **独立的 `ctx.sessionFork` 服务。** 这是最初的实现,但评审表明它过度套用了 capability-seam 模式。代码没有可替换的后端、没有额外的事件面、没有独立的所有权生命周期,也没有超出 `ctx.sessions.create({ seed, meta })` 的持久化行为。保留独立包会迫使调用方为了在会话存储原语之上执行一层策略而去发现并安装第二个服务。 @@ -40,4 +46,4 @@ class SessionStore extends Service { 公开接口保持精简且易于发现:活跃会话分支是 `ctx.sessions` 的一部分,紧邻 `create({ seed })`,而非一个独立服务或一对两步辅助函数。持久化继续通过现有的 `session/created` 和 `session/flush` 行为运作:fork 出的子会话以种子事件开始生命,因此现有后端只需持久化该种子一次,并在 header 中保存 `parentSession`/`seedLength`。 -v1 范围仍然排除 ACP(Agent Client Protocol) `session/fork`、对未加载的已持久化会话的 fork、面向模型的工具,以及 subagent 重构。如果未来添加 ACP 方法,应在具备协议与快照覆盖后才广播该能力;本 Agent Note 不添加任何 ACP 协议行为,因此不需要 ACP 快照。fork 子会话的回放仍由现有的[种子边界测试 Agent Note](../testing/2026-06-22-fork-child-replay-seed-boundary.md) 覆盖,而本 API 则获得专门的 `dsh-session` 单元测试加 JSONL 持久化覆盖。 +v1 范围仍然排除 ACP(Agent Client Protocol) `session/fork`、对未加载的已持久化会话的 fork、面向模型的工具,以及 subagent 重构。如果未来添加 ACP 方法,应在具备协议与快照覆盖后才广播该能力;本 Agent Note 不添加任何 ACP 协议行为,因此不需要 ACP 快照。fork 子会话的回放仍由现有的[种子边界测试 Agent Note](../testing/2026-06-22-fork-child-replay-seed-boundary.md) 覆盖;store、Host、载体与客户端的专项测试固定边界和对账契约,真实 Chromium 场景则固定组装后的消息操作与谱系树。 diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index aa14308d43..d3c4f45fd7 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, @@ -19,6 +20,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/message-actions', import // a new recording (workspace-management / sidebar-scrollbar pattern). const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') +const FORK_EXPECTED = join(SNAPSHOT_DIR, 'fork.expected.md') const MODE = webSnapshotMode() const SEED_ID = 'message-actions-web-e2e' @@ -85,9 +87,28 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) }) + it.skipIf(MODE === 'record')('forks the session through the settled user-message action', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-message-fork')) + await page.getByRole('button', { name: '在新对话中分支' }).first().click() + await expect.poll( + () => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SEED_ID)), + { timeout: 15_000 }, + ).toBeDefined() + await expect.poll( + () => page.locator('[role="treeitem"][aria-selected="true"]').count(), + { timeout: 10_000 }, + ).toBe(1) + const tree = await captureStableAria( + page, + '[role="tree"][aria-label="Sessions"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(FORK_EXPECTED, tree, MODE) + }) + it.skipIf(MODE === 'record')('issued zero model calls and kept a closed inventory', async () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['fork.expected.md', 'ui.expected.md']) }) }) diff --git a/apps/web/tests/snapshots/message-actions/fork.expected.md b/apps/web/tests/snapshots/message-actions/fork.expected.md new file mode 100644 index 0000000000..2afb7d4d23 --- /dev/null +++ b/apps/web/tests/snapshots/message-actions/fork.expected.md @@ -0,0 +1,9 @@ +- tree "Sessions": + - treeitem "Ungrouped 2 sessions" [expanded]: + - img + - text: Ungrouped 2 sessions + - treeitem "Collapse Use the read tool twice 1min" [expanded]: + - button "Collapse": + - img + - text: Use the read tool twice 1min + - treeitem "Use the read tool twice now" [selected] diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index 19ba02d99d..e21b3782a8 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -31,7 +31,7 @@ - img - button "在新对话中分支": - img -- text: {{clock}} +- text: {{clock}} cache hit 98% · 15,962 tokens · 1 turns · 2 steps - textbox "Message the agent" - button "Add attachment": - img @@ -41,4 +41,3 @@ - text: deepseek-v4-flash - img - button "Send message" [disabled] -- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 135 tok diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 282dfbee56..916125ff6f 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1053,14 +1053,20 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { }) } const log = logs.get(sessionId) ?? [] - // Host-parallel boundary: first turn/end at or after atSeq, falling - // back to the last completed turn; no completed turn = fork-unavailable. - const boundary = (atSeq === undefined ? undefined : log.find(e => e.type === 'turn/end' && e.seq >= atSeq)) - ?? log.findLast(e => e.type === 'turn/end') + const lastSeq = log.at(-1)?.seq ?? -1 + const anchoredBoundary = atSeq === undefined + ? undefined + : log.find(e => e.type === 'turn/end' && e.seq >= atSeq) + const boundary = anchoredBoundary + ?? (atSeq === undefined || atSeq > lastSeq + ? log.findLast(e => e.type === 'turn/end') + : undefined) if (boundary === undefined) { return err(request, { code: 'fork-unavailable', - message: `session ${sessionId} has no completed turn`, + message: atSeq !== undefined && atSeq <= lastSeq + ? `session ${sessionId} has not completed the turn containing event ${String(atSeq)}` + : `session ${sessionId} has no completed turn`, details: { sessionId }, }) } diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index fa4c87adb2..4ef6fd488c 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/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/runtime/README.md -README.md: 766d8516225cd46cb1a3a80c832d1cf55e816140 -README.zh.md: 9b514afca91f604b3e895187de3b5532bf22a692 +README.md: db6388dccee6e065066cc70e141073d99822ce0f +README.zh.md: e1b6902d0d30972bd613553081e0f0e8d5da2a05 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 766d851622..db6388dcce 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -28,6 +28,10 @@ SlotsService gives the renderer separate bare observables for `useSessions` and `SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title. `ISession.rename` settles the `title` projection cell directly from the unary response's `{title, seq}` under the same higher-seq-wins rule — the list row and every `useProjection('title')` reader update ahead of the push frame, whose later replay of the same seq is a no-op. +## Session forking + +`ISessions.fork({sessionId, atSeq?})` resolves only after the child summary is locally addressable, carrying source lineage and cwd with `blank: false`; callers choose whether to open it. A `workspace-attach-failed` response still identifies a child already published by the Host, so `SessionManager` reconciles that partial success before `SessionForkError` reaches the caller instead of making a retry create a duplicate child. + ## Session model selection Each resident `Session` owns a `modelSelection` snapshot containing the current provider/model target, provider-grouped directory, provider-local failures, and the `idle`/`loading`/`ready`/`selecting`/`error` state. History establishes or refreshes the current target, opening a selector refreshes the directory, and selection failures preserve the last target and usable groups. Directory and selection operations share a monotonically increasing generation so an older response cannot overwrite a newer selection. A reconnect rebuild restores the target reported by the Host without replacing unchanged selection substructure. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 9b514afca9..e1b6902d0d 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -28,6 +28,10 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 `SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更高的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含实际的持久化标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷态持久化会话会保持该回退值,直到打开或恢复会话,促使主机折叠并投影由日志支撑的标题。`ISession.rename` 用 unary 响应中的 `{title, seq}` 直接结算 `title` 投影格,遵循同一 seq 高者胜规则——列表行和所有 `useProjection('title')` 读者在推送帧到达前即更新;推送帧随后重放同一 seq 时为无操作。 +## 会话 fork + +`ISessions.fork({sessionId, atSeq?})` 只在子会话摘要已能在本地寻址后才完成;该摘要携带源会话的谱系和 cwd,且 `blank: false`,由调用方决定是否打开。即使响应为 `workspace-attach-failed`,其中仍会标识 Host 已发布的子会话,因此 `SessionManager` 会先将这一部分成功对账,再让 `SessionForkError` 到达调用方,避免重试创建重复的子会话。 + ## 会话模型选择 每个常驻 `Session` 都拥有一个 `modelSelection` 快照,其中包含当前提供方/模型目标、按提供方分组的目录、逐提供方失败记录,以及 `idle`/`loading`/`ready`/`selecting`/`error` 状态。历史记录会建立或刷新当前目标,打开选择器会刷新目录;选择失败会保留上一个目标和可用分组。目录与选择操作共用单调递增的代次,因此较旧响应无法覆盖较新的选择。重连重建会恢复 Host 报告的目标,同时不替换未变化的选择子结构。 diff --git a/packages/client/runtime/src/client/contract/sessions.ts b/packages/client/runtime/src/client/contract/sessions.ts index 7c147df80c..1b59c3c27c 100644 --- a/packages/client/runtime/src/client/contract/sessions.ts +++ b/packages/client/runtime/src/client/contract/sessions.ts @@ -33,7 +33,8 @@ export interface ISessions { * Fork a session from a completed-turn prefix of the source; on resolution * the child is in the list store and `open()` can target it. * @param opts - source session id and the optional event seq anchoring the - * cut (the boundary is the first turn/end at or after it). + * cut (the boundary is the first turn/end at or after it; an in-log + * anchor in an open turn is unavailable rather than clipped backward). * @returns the child session id. */ fork(opts: { sessionId: SessionId; atSeq?: number }): Promise diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index f60d7b51e8..4ba134b321 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -293,7 +293,8 @@ export class SessionManager { * Contract session.fork; on success merge the child into summaries * immediately (same synchronous-addressability guarantee as create). The * child carries the source's history, so it is never blank; lineage rides - * parentSessionId so the list nests it under its source. + * parentSessionId so the list nests it under its source. A child published + * before Workspace attachment fails is also reconciled into the list. * @param opts - source session and the optional seq anchoring the cut. * @returns the fork result (the child session id). */ @@ -306,9 +307,12 @@ export class SessionManager { sessionId: opts.sessionId, ...opts.atSeq === undefined ? {} : { atSeq: opts.atSeq }, }) - if (result.ok) { + const childId = result.ok + ? result.value.sessionId + : workspaceAttachSessionId(result.error) + if (childId !== undefined) { this.recordMutation({ kind: 'upsert', summary: { - sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: false, + sessionId: childId, updatedAt: Date.now(), running: false, blank: false, parentSessionId: opts.sessionId, ...(source?.cwd !== undefined ? { cwd: source.cwd } : {}), } }) diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index b5bf951605..741aba4c24 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -338,7 +338,8 @@ export class SessionsService implements ISessions { * synchronous-addressability guarantee as {@link SessionsService.create}: * on resolution the child is in the list store and open() can target it). * @param opts - source session id and the optional event seq anchoring the - * cut (the boundary is the first turn/end at or after it). + * cut (the boundary is the first turn/end at or after it; an in-log + * anchor in an open turn is unavailable rather than clipped backward). * @returns the child session id. * @throws {SessionForkError} with the source id. */ diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 33b46538d9..afe6308249 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -277,6 +277,23 @@ describe('remaining branches', () => { expect(manager.getListSnapshot().items[0]).not.toHaveProperty('cwd') }) + it('reconciles a fork child published before workspace attachment fails', async () => { + const api = new FakeApiClient() + api.onFork = () => Promise.resolve(err({ + code: 'workspace-attach-failed', + message: 'forked but unattached', + details: { sessionId: S2, workspaceId: 'w1' }, + } as never)) + const manager = new SessionManager(api) + const result = await manager.fork({ sessionId: S1 }) + expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } }) + expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ + sessionId: S2, + parentSessionId: S1, + blank: false, + })]) + }) + it('reconciles a preallocated id after an ordinary transport failure', async () => { const api = new FakeApiClient() api.onCreate = () => Promise.reject(new Error('response lost')) diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index c5084112eb..eddc6efe76 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -201,7 +201,7 @@ describe('sessions', () => { await runtime.dispose() }) - it('records service-face calls; open() moves the selection and clear() empties it', async () => { + it('records service-face calls; open() moves selection, clear() empties it, and fork() echoes the source', async () => { const runtime = await runtimeWithFrame() await runtime.sessions.add({ id: 's1' }) await runtime.sessions.add({ id: 's2' }) @@ -211,9 +211,11 @@ describe('sessions', () => { runtime.sessions.clear() await runtime.flush() expect(runtime.sessions.list.getSnapshot().current).toBeUndefined() + await expect(runtime.sessions.fork({ sessionId: 's1' as SessionId, atSeq: 7 })).resolves.toBe('s1') expect(runtime.sessions.calls).toEqual([ { method: 'open', args: ['s1'] }, { method: 'clear', args: [] }, + { method: 'fork', args: [{ sessionId: 's1', atSeq: 7 }] }, ]) await runtime.dispose() }) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 1588367646..d5d1dcfbfb 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: fc466190a744a1c13094ca6ebf62755d5bf49c98 -README.zh.md: f6fbff9c1e5d005b64e928680bbf401d94e4ce79 +README.md: 02834be207e45bb1ff65b75a0c75a884db3be315 +README.zh.md: 84f9d79660207a68305d75ac0e7deaecdedf00d6 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index fc466190a7..02834be207 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -38,7 +38,7 @@ None; this package neither assembles nor sends a provider request. - **Stats-line durations cover the in-window flow only** — LLM and tool wall times fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted. - **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly. -- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under text output only; branch remains a chrome stub. +- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under text output only; branch forks through the turn containing that message and opens the child, while a fork failure leaves the source selected. - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export. - **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today. - **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index f6fbff9c1e..84f9d79660 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -36,9 +36,9 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 ## 已知限制与暂缓事项 -- **统计行的耗时只覆盖窗口内消息流**:LLM 与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。 +- **统计行的耗时只覆盖窗口内消息流**:LLM(大语言模型)与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。 - **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。 -- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在 text 输出下;分支仍是 chrome stub。 +- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在 text 输出下;分支会 fork 到包含该消息的轮次末尾并打开子会话,而 fork 失败时源会话保持选中。 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。 - **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。 - **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。 diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 536911a16a..a8f5d98981 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/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-workspace/README.md -README.md: a1b58f4abe0925be3b426d10344777e46caa9ba0 -README.zh.md: a472507bc45549c8feb55a75d294cbd7b3138cc5 +README.md: f0dbd3de4ac5b7934b4077b2986f5def5be329c8 +README.zh.md: b9f068c6d1709f0a01d22c3fb1cdc391764d666b diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index a1b58f4abe..f0dbd3de4a 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -6,6 +6,8 @@ Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sideba The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. +The Session row's Fork action forks at the source's last completed turn and opens the child; the lineage-aware tree nests it beneath the source. A failure leaves the current selection unchanged. + Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored. ## Model Experience @@ -18,5 +20,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **No Session deletion or fork control** — the Session menu's Fork and Delete rows remain visual-only (Rename is wired); Workspace registration deletion does not delete Sessions. +- **No Session deletion control** — the Session menu's Delete row remains visual-only; Workspace registration deletion does not delete Sessions. - **Native folder selection depends on the local Host carrier** — under the `-native` composition, fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. Remote-capable picking is the `-browse` composition's in-app flow. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index a472507bc4..b9f068c6d1 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -6,6 +6,8 @@ 该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。 +Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork 并打开子会话;感知谱系的树会将子会话嵌套在源会话下。失败不会改变当前选中项。 + 两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。 ## 模型体验 @@ -18,5 +20,5 @@ ## 已知限制与暂缓事项 -- **没有 Session 删除与 fork 控件**:Session 菜单的 Fork 与 Delete 行仍仅提供视觉效果(Rename 已接线);删除 Workspace 注册记录不会删除 Session。 +- **没有 Session 删除控件**:Session 菜单的 Delete 行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。 - **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。可远程的选取是 `-browse` 组合的应用内流程。 diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 035bfe06a7..933b49bdf0 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -481,8 +481,8 @@ export function WorkspaceBrowser({ {/* Always-mounted seat keeps the region's flex slot while the list itself is wide-only. */} -
- {wide && (groupBy === 'flat' +
+ {wide && (groupBy === 'flat' ? : ( { setMenuOpen(false) }} items={SESSION_MENU_ITEMS} - onSelect={(id) => { - setMenuOpen(false) - if (id === 'rename') onRename(node.id, row.title) - if (id === 'fork') onFork(node.id) // delete stays visual-only. + onSelect={(id) => { + setMenuOpen(false) + if (id === 'rename') onRename(node.id, row.title) + if (id === 'fork') onFork(node.id) // delete stays visual-only. }} portal closeOnPointerLeave diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index dc8e9379dc..44a311d6d3 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -67,9 +67,9 @@ describe('workspace browser rows', () => { } const onOpen = vi.fn() const onToggle = vi.fn() - const view = render( - , + const view = render( + , ) const parentRow = screen.getByText('Parent').closest('[role="treeitem"]')! @@ -88,9 +88,9 @@ describe('workspace browser rows', () => { view.rerender( , ) expect(screen.getByRole('button', { name: 'Expand' })).toBeTruthy() @@ -156,15 +156,16 @@ describe('workspace browser rows', () => { expect(screen.queryByRole('button', { name: /Workspace actions/ })).toBeNull() }) - it('session row menu opens without opening the session and dispatches rename', () => { + it('session row menu opens without opening the session and dispatches rename and fork', () => { const onOpen = vi.fn() const onRename = vi.fn() + const onFork = vi.fn() const node: SessionNode = { id: sid('s1'), title: 'One', children: [], hasChildren: false, expanded: false, running: false, updatedAt: 0, - } - render() + } + render() fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' })) expect(onOpen).not.toHaveBeenCalled() expect(screen.getByRole('menuitem', { name: 'Delete session' }).className).toMatch(/danger/) @@ -173,9 +174,10 @@ describe('workspace browser rows', () => { expect(screen.queryByRole('menu')).toBeNull() expect(onRename).toHaveBeenCalledWith(node.id, 'One') expect(onOpen).not.toHaveBeenCalled() - // Fork and Delete stay visual-only. fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' })) fireEvent.click(screen.getByRole('menuitem', { name: 'Fork session' })) + expect(onFork).toHaveBeenCalledWith(node.id) + // Delete stays visual-only. fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' })) fireEvent.click(screen.getByRole('menuitem', { name: 'Delete session' })) expect(onRename).toHaveBeenCalledOnce() @@ -189,9 +191,9 @@ describe('workspace browser rows', () => { const node: SessionNode = { id: sid('p'), title: 'Parent', children: [], hasChildren: true, expanded: false, running: false, updatedAt: 0, - } - render() + } + render() expect(screen.queryByRole('button', { name: 'Expand' })).toBeNull() }) @@ -201,9 +203,9 @@ describe('workspace browser rows', () => { const node: SessionNode = { id: sid('s1'), title: 'Hovered', children: [], hasChildren: false, expanded: false, running: true, updatedAt: 0, - } - render() + } + render() const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement fireEvent.pointerEnter(wrapper) act(() => { vi.advanceTimersByTime(500) }) @@ -228,9 +230,9 @@ describe('workspace browser rows', () => { const node: SessionNode = { id: sid('s1'), title: 'Quiet', children: [], hasChildren: false, expanded: false, running: false, updatedAt: 0, - } - render() + } + render() fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) act(() => { vi.advanceTimersByTime(500) }) expect(screen.getByText('Idle')).toBeTruthy() @@ -246,9 +248,9 @@ describe('workspace browser rows', () => { expanded: false, running: false, updatedAt: 0, } const inactive = dragProps() - const { rerender } = render( - , + const { rerender } = render( + , ) const row = screen.getByRole('treeitem') stubRect(row) @@ -264,9 +266,9 @@ describe('workspace browser rows', () => { expect(inactive.end).toHaveBeenCalledOnce() const active = dragProps({ active: true, marker: 'before' }) - rerender( - , + rerender( + , ) stubRect(screen.getByRole('treeitem')) // Top half hovers/drops 'before'; bottom half 'after' (row mid = 117). @@ -278,9 +280,9 @@ describe('workspace browser rows', () => { expect(active.drop).toHaveBeenCalledWith('after') const after = dragProps({ active: true, marker: 'after' }) - rerender( - , + rerender( + , ) expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/) }) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 236cc5968f..95c3643886 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: 8f9deb6add7d30bf1609cc7febcb1febafe392c1 -README.zh.md: 399d45208b6d3f4152c27556523b6944432bec66 +README.md: 3ec21f90a495fe42e40c4407e0a81faa34a1e427 +README.zh.md: 5bcd310c23c35b851216176d69b13965dd2e1c3e diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 8f9deb6add..3ec21f90a4 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -16,6 +16,8 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key (the bespoke `session/title` frame is retired). Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`. +`session.fork` maps an optional event anchor to the first `turn/end` at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns `fork-unavailable` rather than clipping backward. The published child inherits the source's seeded history, cwd, latest logged provider/model/reasoning target, and lineage before joining the source Workspace. If Workspace attachment fails, `workspace-attach-failed` carries the already-published child id so clients can reconcile it. The [SessionStore fork decision](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) owns the boundary rationale. + Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`. Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. The operation queries only an attached Agent and never resumes a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events. @@ -43,7 +45,7 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `src/api-proxy.ts` and is still minimal (questions only, no approvals). -- **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code. +- **Reserved seams stay out of `RpcMethodMap`** — `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code. - **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists. - **Linux native picker requires desktop tooling** — under the `native` capability, `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the [native backend README](../directory-picker-native/README.md)). - **A cold session's `updatedAt` counts a mere pickup as a write (per-file backends only)** — the attached projection excludes the `session/end-seed` boundary, because picking a session up is not activity, but a cold session's `updatedAt` is its log file's mtime and every durable write refreshes that, the boundary included. `agentFor()` resumes a cold session on first touch, so merely opening one in a client writes it. This applies only where `locate()` resolves a per-session artifact, i.e. JSONL; SQLite returns `undefined`, so its cold sessions fall back to `createdAt` and are skewed the other way — too old rather than too new — independently of this boundary. A session touched without being worked in therefore sorts newer than its last real activity until it attaches. Separating the two needs a log read, which is exactly what the mtime path exists to avoid; a stored last-activity field in the index would fix it at the source, scoped in the [last-activity-index Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md). diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 399d45208b..5bcd310c23 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -16,7 +16,9 @@ 会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧(专设的 `session/title` 帧已下线)。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。 -会话模型路由属于会话领域契约。`session.models` 返回选中的提供方/模型/推理(reasoning)目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。 +`session.fork` 将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`,不会向较早位置裁剪。发布后的子会话会先继承源会话的种子历史、cwd、日志中最新的提供方/模型/推理(reasoning)目标及谱系,再加入源 Workspace。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id,供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)给出边界设计的理由。 + +会话模型路由属于会话领域契约。`session.models` 返回选中的提供方/模型/推理目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。 待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering(中途引导)不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。该操作只查询当前已挂载的 Agent,绝不恢复冷会话,因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。 @@ -43,7 +45,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr ## 已知限制与延期工作 - **`respond` 路由已经发布,但待处理交互状态仍属宿主侧工作**:协议形状(POST `/api/respond`、`RpcReceipt`)已经定型;使延迟或重复回答具有明确语义的待处理表位于 `src/api-proxy.ts`,目前仍很精简(只支持问题,不支持审批)。 -- **预留 seam 不进入 `RpcMethodMap`**:`session.fork`、`prompt.mode: 'inject'`、`task.list`、`host.listModels` 和描述字段 `hostInstanceId` 都是已记录的预留项;未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。 +- **预留 seam 不进入 `RpcMethodMap`**:`prompt.mode: 'inject'`、`task.list`、`host.listModels` 和描述字段 `hostInstanceId` 都是已记录的预留项;未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。 - **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。 - **Linux 原生选择器依赖桌面工具**:在 `native` 能力下,Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是 browse 后端(见 [native 后端 README](../directory-picker-native/README.md))。 -- **冷会话的 `updatedAt` 会把一次单纯的拾起算作写入(仅逐文件后端)**:已附加投影排除了 `session/end-seed` 边界,因为接手一个会话不算活动;但冷会话的 `updatedAt` 取自其日志文件的 mtime,而每一次持久写入都会刷新它,包括这条边界。`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就会写入它。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONL;SQLite 返回 `undefined`,因此它的冷会话回退到 `createdAt`,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会排在它最后一次真实活动之后。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见[最后活动索引 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)。 +- **冷会话的 `updatedAt` 会把一次单纯的拾起算作写入(仅逐文件后端)**:已附加投影排除了 `session/end-seed` 边界,因为接手一个会话不算活动;但冷会话的 `updatedAt` 取自其日志文件的 mtime,而每一次持久写入都会刷新它,包括这条边界。`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就会写入它。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONL;SQLite 返回 `undefined`,因此它的冷会话回退到 `createdAt`,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会按晚于其最后一次真实活动的时间排序。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见[最后活动索引 Agent Note(agent 决策记录)](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index e3c73746a9..bcd9873263 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1154,15 +1154,23 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if ('error' in found) return err(request, found.error) const source = found.agent.session const events = source.events - // Boundary: the first turn/end at or after atSeq (fork includes that - // whole turn); an overshooting atSeq or an omitted one falls back to - // the last completed turn. - const boundary = (atSeq === undefined ? undefined : events.find(e => e.type === 'turn/end' && e.seq >= atSeq)) - ?? events.findLast(e => e.type === 'turn/end') + // An in-log anchor belongs to the turn containing it and must never + // clip backward to an earlier completed turn. Omitted and past-end + // anchors retain the last-completed-turn shortcut. + const lastSeq = events.at(-1)?.seq ?? -1 + const anchoredBoundary = atSeq === undefined + ? undefined + : events.find(e => e.type === 'turn/end' && e.seq >= atSeq) + const boundary = anchoredBoundary + ?? (atSeq === undefined || atSeq > lastSeq + ? events.findLast(e => e.type === 'turn/end') + : undefined) if (boundary === undefined) { return err(request, { code: 'fork-unavailable', - message: `session "${sessionId}" has no completed turn to fork from`, + message: atSeq !== undefined && atSeq <= lastSeq + ? `session "${sessionId}" has not completed the turn containing event ${String(atSeq)}` + : `session "${sessionId}" has no completed turn to fork from`, details: { sessionId }, }) } @@ -1183,6 +1191,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro seedLength: cut, }, agentOptions, + setup: installTarget, }) } catch (error: unknown) { return err(request, { diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 70a4003a1a..d386241bf1 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -243,10 +243,11 @@ export interface SessionsApi { * anchors the cut: the boundary is the first `turn/end` at or after it * (a message's fork button passes the message seq, so the fork includes * that whole turn); a boundary past the log end, or an omitted `atSeq`, - * falls back to the source's last completed turn. A source with no - * completed turn fails with `fork-unavailable`. The child inherits the - * source cwd (and its workspace attachment) and records - * `parentSessionId` lineage; the seed prefix carries the source title. + * falls back to the source's last completed turn. An in-log anchor whose + * turn is still open fails with `fork-unavailable` instead of clipping to + * an earlier turn. The child inherits the source cwd, latest logged model + * target, workspace attachment, and `parentSessionId` lineage; the seed + * prefix carries the source title. */ fork(request: RpcRequest<{ sessionId: SessionId; atSeq?: number }>): Promise> diff --git a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts new file mode 100644 index 0000000000..64cb1188f5 --- /dev/null +++ b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts @@ -0,0 +1,151 @@ +/** Session-fork boundaries, lineage, and inherited model routing. */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent' +import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import type { LlmCallConfig } from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import type { Session, SessionId } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' + +const sid = (id: string): SessionId => id as SessionId + +let nextRpc = 1 +function request

(payload: P): RpcRequest

{ + return { rpcId: RpcId(`fork-${String(nextRpc++)}`), payload } +} + +async function composed(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + ctx.provide('workspace', { list: () => [] } as never) + ctx.agents.setFactory({ + createAgent: async (ownerCtx: Context, options: CreateAgentOptions): Promise => { + const session = ctx.sessions.create(options.sessionId, { + ...options.seed === undefined ? {} : { seed: [...options.seed] }, + ...options.meta === undefined ? {} : { meta: options.meta }, + }) + const agent = {} as Agent + const agentCtx = ownerCtx.extend({ agent }) + Object.assign(agent, { id: session.id, session, status: 'idle', ctx: agentCtx }) + await options.setup?.(agentCtx) + ctx.agents.register(agent) + return { agent, dispose: () => Promise.resolve() } + }, + resume: () => Promise.reject(new Error('fork test sources are live')), + }) + return ctx +} + +function liveAgent(ctx: Context, id: string, turns: number, openTail = false): Session { + const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj' } }) + for (let turn = 1; turn <= turns; turn++) { + session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: `prompt ${String(turn)}` }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + } + if (openTail) { + session.append('turn/start', { turn: turns + 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'open prompt' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + } + ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) + return session +} + +const api = (ctx: Context) => createApiProxy(ctx, { + provider: 'default-provider', + model: 'default-model', + cwd: '/tmp', + workspaceRoot: '/tmp', +}) + +describe('sessions.fork', () => { + it('cuts at the anchored completed turn and records lineage and cwd', async () => { + const ctx = await composed() + const source = liveAgent(ctx, 'session-source', 2) + const response = await api(ctx).sessions.fork(request({ sessionId: source.id, atSeq: 1 })) + expect(response.result.ok).toBe(true) + if (!response.result.ok) return + const child = ctx.sessions.get(response.result.value.sessionId) + expect(child?.events.length).toBe(3) + expect(child?.header.parentSession).toBe(source.id) + expect(child?.header.cwd).toBe('/proj') + await ctx.fiber.dispose() + }) + + it('uses the last completed turn only for omitted and past-end anchors', async () => { + const ctx = await composed() + const source = liveAgent(ctx, 'session-tail', 2, true) + const proxy = api(ctx) + const omitted = await proxy.sessions.fork(request({ sessionId: source.id })) + expect(omitted.result.ok).toBe(true) + if (omitted.result.ok) { + expect(ctx.sessions.get(omitted.result.value.sessionId)?.events.length).toBe(6) + } + const pastEnd = await proxy.sessions.fork(request({ sessionId: source.id, atSeq: 999 })) + expect(pastEnd.result.ok).toBe(true) + await ctx.fiber.dispose() + }) + + it('rejects an in-log anchor whose turn is still open', async () => { + const ctx = await composed() + const source = liveAgent(ctx, 'session-open', 1, true) + const anchor = source.events.at(-1)?.seq ?? 0 + const response = await api(ctx).sessions.fork(request({ sessionId: source.id, atSeq: anchor })) + expect(response.result).toMatchObject({ + ok: false, + error: { code: 'fork-unavailable', details: { sessionId: source.id } }, + }) + if (!response.result.ok) expect(response.result.error.message).toMatch(/has not completed/) + await ctx.fiber.dispose() + }) + + it('installs the latest logged model target before the child can run', async () => { + const ctx = await composed() + const source = liveAgent(ctx, 'session-routed', 1) + source.append('request/header', { + header: { + config: { + provider: 'inherited-provider', + model: 'inherited-model', + reasoningEffort: ReasoningEffortId('high'), + }, + }, + reason: 'initial', + }) + const response = await api(ctx).sessions.fork(request({ sessionId: source.id })) + expect(response.result.ok).toBe(true) + if (!response.result.ok) return + const child = ctx.agents.get(response.result.value.sessionId) + if (child === undefined) throw new Error('fork did not publish the child agent') + const assembly = await child.ctx.systemPrompt.assemble() + expect(assembly.variables).toMatchObject({ + provider: 'inherited-provider', + model: 'inherited-model', + }) + const fallback: LlmCallConfig = { provider: 'default-provider', model: 'default-model' } + await expect(agentEvents(child.ctx, child).waterfall( + 'agent/request', 1, 0, new AbortController().signal, () => Promise.resolve(fallback), + )).resolves.toMatchObject({ + provider: 'inherited-provider', + model: 'inherited-model', + reasoningEffort: 'high', + }) + await ctx.fiber.dispose() + }) +}) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 2981c9ddea..b7ace8f832 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -111,6 +111,21 @@ describe('unary round trip', () => { expect(response.result).toEqual({ ok: true, value: { items: [{ sessionId: 's1', updatedAt: 7, running: false, blank: false }] } }) }) + it('routes session fork with its optional cut anchor through the wire', async () => { + let seen: RpcRequest<{ sessionId: SessionId; atSeq?: number }> | undefined + const api = scriptedApi({ + sessions: { + fork: (request) => { + seen = request + return ok(request, { sessionId: sid('s-child') }) + }, + }, + }) + const response = await client(api).sessions.fork({ sessionId: sid('s-parent'), atSeq: 7 }) + expect(seen?.payload).toEqual({ sessionId: 's-parent', atSeq: 7 }) + expect(response.result).toEqual({ ok: true, value: { sessionId: 's-child' } }) + }) + it('routes workspace rename, delete, and insertSessionBefore through the wire', async () => { const api = scriptedApi() const c = client(api)