Преглед на файлове

fix(session): stop forks at the selected turn end

Dudu-0223 преди 2 седмици
родител
ревизия
896a6c3e47

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-11-session-controller-fork-turn-cut.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/bug-fix/2026-09-11-session-controller-fork-turn-cut.md
+2026-09-11-session-controller-fork-turn-cut.md: 0c8d45c861df964a62586827e80c613704ed17f0
+2026-09-11-session-controller-fork-turn-cut.zh.md: 729a42a596264ac618608d26cd52a1ad836b58db

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-09-11-session-controller-fork-turn-cut.md

@@ -0,0 +1,29 @@
+# Agent Note: Session Controller forks stop at the selected turn end
+
+Status: implemented
+
+English | [中文](2026-09-11-session-controller-fork-turn-cut.zh.md)
+
+## Problem
+
+A user input enters the durable inbox before its `turn/start`. Extending a completed-turn fork through the following between-turn events can copy the next input's insertion without its later removal. Continuing the child then executes an input from beyond the selected turn.
+
+## Decision
+
+The [Session Controller](../../../../packages/api/session-controller/README.md) copies the contiguous prefix through the selected `turn/end`, inclusive. Explicit anchors select the first closing event at or after the anchor; omitted and past-end anchors select the last closing event. No event after that closing event belongs to the seed, including queued input, titles, and model settings.
+
+The lower-level `SessionStore.fork()` retains its explicit stable-event semantics from the [log-only event decision](../simplification/2026-07-28-remove-synthetic-log-only-turns.md). Selecting a completed turn in the controller does not request a later stable event.
+
+## Alternatives considered
+
+**Stop only at the next inbox event.** Event-type exceptions still copy unrelated state changes after the selected turn and require the controller to classify plugin-owned events.
+
+**Copy the tail and clear the child's inbox.** Clearing adds child events to cancel input that lies outside the requested prefix, while still inheriting other state from after the selected turn.
+
+## Consequences
+
+A fork inherits the configuration recorded through its selected turn. Later configuration and title events are excluded. The client may independently assign the child's fork title. Events already inside the selected prefix retain their ordinary replay semantics; this decision does not redefine pending input inserted before the selected closing event.
+
+## Verification
+
+Controller tests execute the production loop and check that sending C after forking A excludes the parent's later B from child history and produces one model request. Message, closing-event, omitted, and past-end anchors share this assertion. Model-routing coverage excludes a later configuration change. The Web message-actions snapshot seeds a queued input after the completed turn and verifies the branch action creates a child without that input or its inbox insertion.

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-09-11-session-controller-fork-turn-cut.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: Session Controller 分叉截到选中轮次的结束事件
+
+Status: implemented
+
+English | [中文](2026-09-11-session-controller-fork-turn-cut.md)
+
+## Problem
+
+用户输入会先进入持久化 inbox,再出现对应的 `turn/start`。如果已结束轮次的分叉继续复制后面的轮次间事件,就可能复制下一条输入的入队事件,却没有复制其后的出队事件。继续子会话时,就会执行选中轮次之后的输入。
+
+## Decision
+
+[Session Controller](../../../../packages/api/session-controller/README.zh.md) 复制截至选中 `turn/end` 的连续前缀,并包含该事件。显式锚点选择位于锚点或其后的第一条结束事件;省略锚点或锚点超出日志末尾时,选择最后一条结束事件。结束事件之后的所有事件均不属于种子,包括排队输入、标题和模型设置。
+
+底层 `SessionStore.fork()` 保留[纯日志事件决策](../simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md)规定的显式稳定事件语义。在控制器中选择已结束轮次,不代表请求复制到更晚的稳定事件。
+
+## Alternatives considered
+
+**仅在下一条 inbox 事件处停止。** 按事件类型添加例外仍会复制选中轮次之后的无关状态变化,并要求控制器分类插件所属的事件。
+
+**复制尾部后清空子会话 inbox。** 清空操作会添加子会话事件,以取消本来就在所请求前缀之外的输入,同时仍然继承选中轮次之后的其他状态。
+
+## Consequences
+
+分叉继承截至选中轮次记录的配置。之后的配置和标题事件被排除。客户端可以独立设置子会话的分叉标题。已经位于选中前缀内的事件保留通常的回放语义;本决策不重新定义在选中结束事件之前入队的待处理输入。
+
+## Verification
+
+控制器测试执行生产循环,检查从 A 分叉后发送 C 时,子会话历史不包含父会话后续的 B,并且只产生一次模型请求。消息、结束事件、省略及越界锚点共用此断言。模型路由覆盖排除了后续配置变更。Web 消息操作快照在已结束轮次后植入排队输入,验证分叉操作创建的子会话不包含该输入或其入队事件。

+ 2 - 2
.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.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-28-remove-synthetic-log-only-turns.md
-2026-07-28-remove-synthetic-log-only-turns.md: e5138ad22a68d2c7dd6d201df5522536e1d26414
-2026-07-28-remove-synthetic-log-only-turns.zh.md: 2fb15ff837b4984a069a2a5306f167fe72177f6b
+2026-07-28-remove-synthetic-log-only-turns.md: b35045d036dbb3ea18431b76cec2c41674de5607
+2026-07-28-remove-synthetic-log-only-turns.zh.md: 3dbcd0d9b258f4ea9f4515ba009e75487a96c342

+ 1 - 1
.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md

@@ -20,7 +20,7 @@ Core session invariants continue to enforce core-owned execution relations: turn
 
 The title service appends `session/title` directly after its existing service, revision, cancellation, and live-session checks. The bundled model helper appends its literal `session/title-llm-request` record before dispatch. Persistence admits both through the bounded `session/event` path and drains them at ordinary checkpoints and lifecycle teardown; neither append forces a flush merely because it is between turns. A fallback, auxiliary request record, or accepted provider title may therefore appear after `turn/end` and before the next `turn/start`. Manual compaction uses the same between-turn capability for a `compaction/* { turn: null }` bracket, but explicitly flushes the closed attempt because `/compact` promises durability before releasing queued prompt admission.
 
-A session fork may end at any stable event position outside an open turn, not only at `turn/end`. This preserves standalone title and other plugin-owned log-only records in a default fork while still rejecting a prefix cut through active execution.
+`SessionStore.fork()` may end at any stable event position outside an open turn, not only at `turn/end`. This preserves standalone title and other plugin-owned log-only records in a default store fork while still rejecting a prefix cut through active execution. The [Session Controller fork decision](../bug-fix/2026-09-11-session-controller-fork-turn-cut.md) limits its completed-turn operation to the selected closing event.
 
 The historical [universal turn-enclosure decision](../../archived/architecture/2026-06-15-turn-enclosure-invariant.md) remains useful only as the reason the synthetic mechanism was introduced. The [context-injection decision](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md) established the current meaning: one turn represents one model-loop execution. The [queued manual compaction decision](../feature/2026-07-30-queued-manual-compaction.md) applies that rule to a durable multi-event bracket and owns its marker and admission semantics.
 

+ 1 - 1
.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md

@@ -20,7 +20,7 @@ Status: implemented
 
 标题服务会在完成既有的服务状态、修订、取消和活跃会话检查后,直接追加 `session/title`。随附模型辅助函数会在发起调用前追加其字面量 `session/title-llm-request` 记录。持久化通过有界 `session/event` 路径接纳两者,并在常规检查点与生命周期结束时排空;二者都不会仅因为位于轮次之间就强制刷写。因此,回退标题、辅助请求记录或已接受的提供方标题可以出现在 `turn/end` 之后、下一个 `turn/start` 之前。手动压缩(compaction)利用同一项轮次间能力记录 `compaction/* { turn: null }` 标记对,但会显式刷写已闭合的尝试,因为 `/compact` 承诺在放行排队中的提示词前完成持久化。
 
-会话 fork 可以结束于开放轮次之外的任意稳定事件位置,而不限于 `turn/end`。这样,默认 fork 会保留独立标题和其他插件所属的纯日志记录,同时仍拒绝在活跃执行过程中截断前缀。
+`SessionStore.fork()` 可以结束于开放轮次之外的任意稳定事件位置,而不限于 `turn/end`。这样,默认的 store fork 会保留独立标题和其他插件所属的纯日志记录,同时仍拒绝在活跃执行过程中截断前缀。[Session Controller 分叉决策](../bug-fix/2026-09-11-session-controller-fork-turn-cut.zh.md)将其已结束轮次操作限制在选中的结束事件处。
 
 历史上的[通用轮次封闭决策](../../archived/architecture/2026-06-15-turn-enclosure-invariant.md)如今只适合用于解释为何曾引入合成机制。[上下文注入决策](../architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md)确立了当前语义:一个轮次表示一次模型循环执行。[排队手动压缩决策](../feature/2026-07-30-queued-manual-compaction.zh.md)将该规则应用于持久多事件标记对,并拥有其标记与接纳语义。
 

+ 56 - 7
apps/web/tests/message-actions.e2e.ts

@@ -28,13 +28,14 @@ const SEED_ID = 'message-actions-web-e2e'
 const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.'
 const MID_TURN_TEXT = 'I will read both files before answering.'
 const SECOND_PROMPT = 'Now give the final answer.'
+const NEXT_PROMPT = 'Keep this later input in the original conversation.'
 
 /**
  * Adapt the borrowed recording into response -> tools -> interrupted Think,
- * followed by one ordinary completed response. The first response keeps
+ * followed by two ordinary completed responses. The first response keeps
  * copy/clock but is not a legal branch point; the second is the real turn tail.
  * @param raw - Recorded seeded-history JSONL.
- * @returns A contiguous, closed two-turn fixture.
+ * @returns A contiguous, closed three-turn fixture.
  */
 function completedTailFixture(raw: string): string {
   const decoded = parseSeedFixture(raw)
@@ -137,6 +138,48 @@ function completedTailFixture(raw: string): string {
     }),
     at({ type: 'step/end', data: { turn: 2, step: 1 } }),
     at({ type: 'turn/end', data: { turn: 2, reason: { kind: 'completed' } } }),
+    at({
+      type: 'agent/inbox/spliced',
+      data: {
+        target: 'next-turn', start: 0,
+        inserted: [{
+          role: 'user', id: '{{message:100}}', source: { kind: 'user' },
+          content: [{ type: 'text', text: NEXT_PROMPT }],
+        }],
+      },
+    }),
+    at({ type: 'turn/start', data: { turn: 3 } }),
+    at({
+      type: 'agent/inbox/spliced',
+      data: { target: 'next-turn', start: 0, removedCount: 1, inserted: [] },
+    }),
+    at({ type: 'step/start', data: { turn: 3, step: 1 } }),
+    at({
+      type: 'user/message', surfaceOp: 'append',
+      data: {
+        role: 'user', id: '{{message:100}}', source: { kind: 'user' },
+        content: [{ type: 'text', text: NEXT_PROMPT }],
+      },
+    }),
+    at({
+      type: 'assistant/message', surfaceOp: 'append',
+      data: {
+        turn: 3, step: 1,
+        message: {
+          role: 'assistant', id: '{{message:101}}',
+          source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' },
+          content: [{ type: 'text', text: 'ORIGINAL ONLY' }],
+        },
+        stream: [
+          { type: 'chunk', time: 0, chunk: { type: 'block-start', index: 0, blockType: 'text' } },
+          { type: 'text-chunks', time0: 0, index: 0, dt: [], texts: ['ORIGINAL ONLY'] },
+          { type: 'chunk', time: 0, chunk: { type: 'block-end', index: 0, block: { type: 'text', text: 'ORIGINAL ONLY' } } },
+          { type: 'chunk', time: 0, chunk: { type: 'finish', reason: { kind: 'stop' } } },
+        ],
+      },
+    }),
+    at({ type: 'step/end', data: { turn: 3, step: 1 } }),
+    at({ type: 'turn/end', data: { turn: 3, reason: { kind: 'completed' } } }),
   ]
   return renderSeedFixture(decoded.headerLine, [...kept, ...tail])
 }
@@ -154,7 +197,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
     await writeFile(join(sessionCwd, 'a.txt'), 'alpha\n')
     await writeFile(join(sessionCwd, 'b.txt'), 'beta\n')
     const raw = completedTailFixture(await readFile(SEED, 'utf8'))
-    expect(fixtureUserPrompts(raw), 'adapted seed must carry both prompts').toEqual([PROMPT, SECOND_PROMPT])
+    expect(fixtureUserPrompts(raw), 'adapted seed must carry all prompts').toEqual([PROMPT, SECOND_PROMPT, NEXT_PROMPT])
     expect(parseSeedFixture(raw).events.flatMap(event => event.type === 'request/header'
       ? [event.data.reason]
       : []), 'adapted seed must carry an unchanged resume header').toEqual(['initial', 'resume'])
@@ -193,11 +236,11 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
     await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(4)
     await copyButtons.first().focus()
     const branchButtons = page.getByRole('button', { name: 'Branch into a new conversation' })
-    await expect.poll(() => branchButtons.count(), { timeout: 5_000 }).toBe(2)
+    await expect.poll(() => branchButtons.count(), { timeout: 5_000 }).toBe(3)
     await expect.poll(
       () => branchButtons.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-disabled'))),
       { timeout: 5_000 },
-    ).toEqual(['true', null])
+    ).toEqual(['true', null, null])
     await branchButtons.first().focus()
     await expect.poll(() => page.getByRole('tooltip').textContent(), { timeout: 5_000 })
       .toBe('Available only on the last message of a completed turn')
@@ -219,12 +262,18 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
 
   it.skipIf(MODE === 'record')('forks through the settled-message and session-row actions', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-message-fork'))
-    // The last message action belongs to the completed second-turn assistant.
-    await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click()
+    // The second answer is followed by another completed user turn in the source.
+    await page.getByRole('button', { name: 'Branch into a new conversation' }).nth(1).click()
     await expect.poll(
       () => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SEED_ID)),
       { timeout: 15_000 },
     ).toBeDefined()
+    const child = scaffold.ctx.agents.list().find(agent =>
+      agent.session.header.parentSession === SessionId(SEED_ID))!
+    expect(child.inbox.nextTurn).toEqual([])
+    expect(child.session.snapshotEvents().some(event => event.type === 'agent/inbox/spliced'
+      && event.data.inserted.some(message => message.content.some(part =>
+        part.type === 'text' && part.text === NEXT_PROMPT)))).toBe(false)
     await expect.poll(
       () => page.locator('[role="treeitem"]').count(),
       { timeout: 10_000 },

+ 2 - 2
packages/api/session-controller/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/api/session-controller/README.md
-README.md: 768c8704775d422c9c48d7d40a58d589f715b718
-README.zh.md: 3c1de73d13678b15af233196894b51fffa952f67
+README.md: 8429b2f73199946944ca5a4e634b27d5d2f91f75
+README.zh.md: 3c20a2bfad7d29b764e9c47641e665721da8dce9

+ 3 - 0
packages/api/session-controller/README.md

@@ -37,6 +37,9 @@ The Session object also carries local submission echoes: `session.beginSubmissio
 
 The user-invocable `skills/list` metadata includes the winning provider’s optional instruction-file `path`. The composer can preview that file without loading every skill body or activating a cold Agent.
 
+Fork copies history through the selected completed turn, including its `turn/end`. Events after that point, including queued input and model-setting changes, are excluded. An omitted or past-end anchor selects the last completed turn; an anchor inside an unfinished turn is rejected.
+
+
 <a id="session-media-references"></a>
 ## Session media references
 

+ 3 - 0
packages/api/session-controller/README.zh.md

@@ -37,6 +37,9 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用
 
 面向用户调用的 `skills/list` 元数据包含胜出提供方可选的指令文件 `path`。输入框可据此预览文件,无需加载每个 skill 的正文或激活冷态 Agent。
 
+分叉复制截至选中已结束轮次的历史,并包含其 `turn/end`。该位置之后的事件均被排除,包括排队输入和模型设置变更。省略锚点或锚点超出日志末尾时,选择最后一个已结束轮次;位于未结束轮次内的锚点会被拒绝。
+
+
 <a id="session-media-references"></a>
 ## 会话媒体引用
 

+ 1 - 4
packages/api/session-controller/src/commands.ts

@@ -240,10 +240,7 @@ export class SessionCommandController {
         { sessionId: request.sessionId },
       )
     }
-    let cut = SessionLogOffset(boundary.seq + 1)
-    while (cut < source.events.length && source.events[cut]?.type !== 'turn/start') {
-      cut = SessionLogOffset(cut + 1)
-    }
+    const cut = SessionLogOffset(boundary.seq + 1)
     let workspace: Workspace | undefined
     try {
       workspace = await this.forkWorkspace(source.header)

+ 57 - 2
packages/api/session-controller/tests/session-fork.host.spec.ts

@@ -4,6 +4,8 @@ import { describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
 import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
+import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit'
+import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
 import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
 import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
 import SessionStore, { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
@@ -88,6 +90,53 @@ const remote = (ctx: Context) => createSessionTestRemote(ctx, {
 })
 
 describe('sessions.fork', () => {
+  it.each(['message', 'turn-end', 'omitted', 'past-end'] as const)(
+    'excludes the next user input when forking at %s', async (anchor) => {
+      const ctx = new Context()
+      try {
+        await mountAgentLoopTestDependencies(ctx)
+        const harness = await mountAgentLoopTestHarness(ctx)
+        const adapter = new MockAdapter(Array.from({ length: 4 }, () => textResponse('reply')))
+        ctx.llm.registerAdapter(['mock'], adapter)
+        ctx.provide('workspaceRegistry', { list: () => [] } as never)
+        const source = await harness.create(sid('source'), { provider: 'mock', model: 'mock' })
+        const message = (text: string) => createUserMessage({
+          content: [{ type: 'text', text }], source: { kind: 'user' },
+        })
+        source.followup(message('A'))
+        await source.whenIdle()
+        const boundary = source.session.snapshotEvents().at(-1)!.seq
+        if (anchor === 'message' || anchor === 'turn-end') {
+          source.followup(message('B'))
+          await source.whenIdle()
+        } else {
+          source.inbox.append('next-turn', message('B'))
+        }
+        const original = source.session.snapshotEvents()
+        const atSeq = anchor === 'omitted' ? undefined
+          : anchor === 'past-end' ? source.session.seq + 1
+            : anchor === 'turn-end' ? boundary
+              : original.find(event => event.type === 'assistant/message')!.seq
+        const response = await createSessionTestRemote(ctx, {
+          defaultModelSelection: () => ({ provider: 'mock', model: 'mock' }), cwd: '/tmp',
+        }).fork({ sessionId: source.id, ...(atSeq === undefined ? {} : { atSeq }) })
+        if (!response.ok) throw response.error
+        const child = ctx.agents.get(response.value.sessionId)!
+        const requestCount = adapter.requests.length
+        child.followup(message('C'))
+        await child.whenIdle()
+        const userTexts = child.session.deriveMessages().flatMap(item => item.role === 'user'
+          ? item.content.flatMap(part => part.type === 'text' ? [part.text] : []) : [])
+        expect(userTexts).toEqual(['A', 'C'])
+        expect(adapter.requests.slice(requestCount)).toHaveLength(1)
+        expect(child.session.inheritedEventCount).toBe(boundary + 1)
+        expect(source.session.snapshotEvents()).toEqual(original)
+      } finally {
+        await ctx.fiber.dispose()
+      }
+    },
+  )
+
   it('cuts at the anchored completed turn and records lineage and cwd', async () => {
     const ctx = await composed()
     const source = liveAgent(ctx, 'session-source', 2)
@@ -268,9 +317,10 @@ describe('sessions.fork', () => {
     await ctx.fiber.dispose()
   })
 
-  it('installs the latest logged model selection before the child can run', async () => {
+  it('inherits model selection through the completed turn and excludes later changes', async () => {
     const ctx = await composed()
-    const source = liveAgent(ctx, 'session-routed', 1)
+    const source = liveAgent(ctx, 'session-routed', 0)
+    source.append('turn/start', { turn: 1 })
     source.append('request/header', {
       header: {
         config: {
@@ -281,6 +331,11 @@ describe('sessions.fork', () => {
       },
       reason: 'initial',
     })
+    source.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    source.append('request/header', {
+      header: { config: { provider: 'later-provider', model: 'later-model' } },
+      reason: 'change',
+    })
     const response = await remote(ctx).fork(request({ sessionId: source.id }))
     expect(response.ok).toBe(true)
     if (!response.ok) return

+ 18 - 2
snapshots/web/message-actions/ui.expected.md

@@ -11,6 +11,7 @@
 - navigation "Turn navigation":
   - button "Jump to turn 1"
   - button "Jump to turn 2"
+  - button "Jump to turn 3"
 - button "System prompt":
   - img
   - img
@@ -55,6 +56,21 @@
 - button "Copy":
   - img
 - paragraph: DONE
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}} Keep this later input in the original conversation. {{clock}}
+- button "Copy":
+  - img
+- paragraph: ORIGINAL ONLY
 - button "Copy":
   - img
 - button "Good response":
@@ -75,9 +91,9 @@
   - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
-- button "2 turns 3 steps · {{throughput}} tok/s":
+- button "3 turns 4 steps · {{throughput}} tok/s":
   - img
-  - text: 2 turns 3 steps{{throughput}} tok/s
+  - text: 3 turns 4 steps{{throughput}} tok/s
 - button "7.9K tok · Cache hit 98%":
   - img
   - text: 7.9K tokCache hit 98%