07akioni 2 месяцев назад
Родитель
Сommit
6978dbfb13

+ 2 - 2
.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.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-28-todo-plan-clears-on-next-turn.md
-2026-07-28-todo-plan-clears-on-next-turn.md: 64c7207d334689817c3a392a0866158e4c1b927d
-2026-07-28-todo-plan-clears-on-next-turn.zh.md: 9df4d952888df9e83f84deb3f2d587b5f9e2ff77
+2026-07-28-todo-plan-clears-on-next-turn.md: daf25fd92eae4c6429a07c6864a20a828fa9dbcd
+2026-07-28-todo-plan-clears-on-next-turn.zh.md: 10b1c1c6b15d54b5cc30f4bbe36bb5e23befa3b6

+ 1 - 1
.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md

@@ -28,4 +28,4 @@ Host `backscanTodos` (and the fixture parallel) walks the full log from the tail
 
 ## Consequences
 
-Interactive hosts and the history projection share one lifetime rule; reopening a session restores a plan only when no later turn has started. Partial supersession of the session-long standing-plan wording in [web todo display](2026-07-23-web-todo-display.md) and [`todo_write` tool](2026-06-29-todo-write-tool.md): event-sourcing, last-write-wins replacement, and the two render surfaces stay there; this note owns turn-boundary clearance. Coverage: client session specs for live clear + replay empty, host history projection after a post-write `turn/start`, and the existing web todo-display snapshot (fixture turn 65 remains the log tip, so the strip still pins).
+Interactive hosts and the history projection share one lifetime rule; reopening a session restores a plan only when no later turn has started. Partial supersession of the session-long standing-plan wording in [web todo display](2026-07-23-web-todo-display.md) and [`todo_write` tool](2026-06-29-todo-write-tool.md): event-sourcing, last-write-wins replacement, and the two render surfaces stay there; this note owns turn-boundary clearance. Coverage: client session specs for live clear + replay empty, host history projection after a post-write `turn/start`, the standing-plan web todo-display snapshot (fixture turn 65 remains the log tip), plus assembled web/TUI snapshots that start the next turn and pin the strip gone.

+ 1 - 1
.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.zh.md

@@ -28,4 +28,4 @@ Host 的 `backscanTodos`(以及 fixture 平行实现)从日志尾部向前
 
 ## Consequences
 
-交互式宿主与 history 投影共用同一生命周期规则;重开会话仅在其后没有更新的 turn 启动时恢复计划。对 [web todo 展示](2026-07-23-web-todo-display.md) 与 [`todo_write` 工具](2026-06-29-todo-write-tool.md) 中「会话级站立计划」表述构成部分取代:事件溯源、后写覆盖与两个渲染面仍归那两份 Note;本 Note 拥有 turn 边界清空。覆盖:客户端 session 对实时清空 + 回放为空的用例、host 在写入后的 `turn/start` 之后的 history 投影,以及既有的 web todo-display 快照(fixture 第 65 轮仍是日志末端,计划条仍被钉住)。
+交互式宿主与 history 投影共用同一生命周期规则;重开会话仅在其后没有更新的 turn 启动时恢复计划。对 [web todo 展示](2026-07-23-web-todo-display.md) 与 [`todo_write` 工具](2026-06-29-todo-write-tool.md) 中「会话级站立计划」表述构成部分取代:事件溯源、后写覆盖与两个渲染面仍归那两份 Note;本 Note 拥有 turn 边界清空。覆盖:客户端 session 对实时清空 + 回放为空的用例、host 在写入后的 `turn/start` 之后的 history 投影、站立计划的 web todo-display 快照(fixture 第 65 轮仍是日志末端),以及开启下一 turn 并钉住计划条消失的 assembled web/TUI 快照。

+ 28 - 0
apps/web/tests/todo-display.snapshot.ts

@@ -189,3 +189,31 @@ it('collapses the plan strip to the count summary and restores it', async () =>
   fireEvent.click(header)
   expect(panel.querySelectorAll('li')).toHaveLength(3)
 })
+
+it('hides the plan strip when the next turn starts', async () => {
+  boot()
+  await openFixtureSession()
+  expect(document.querySelector('[data-testid="todo-panel"]')).not.toBeNull()
+
+  const composer = await screen.findByPlaceholderText('Message the agent', {}, { timeout: 10_000 })
+  fireEvent.change(composer, { target: { value: '下一轮清空计划' } })
+  fireEvent.keyDown(composer, { key: 'Enter' })
+
+  await screen.findByText('下一轮清空计划', { exact: true }, { timeout: 10_000 })
+  await waitFor(() => {
+    expect(document.querySelector('[data-testid="todo-panel"]')).toBeNull()
+  }, { timeout: 10_000 })
+
+  expect({
+    promptVisible: screen.getByText('下一轮清空计划', { exact: true }).textContent,
+    panelGone: document.querySelector('[data-testid="todo-panel"]') === null,
+    // Historical todo_write row stays in the flow; only the dock strip clears.
+    rowStillPresent: document.querySelector('[data-sample="todo-row"]') !== null,
+  }).toMatchInlineSnapshot(`
+    {
+      "panelGone": true,
+      "promptVisible": "下一轮清空计划",
+      "rowStillPresent": true,
+    }
+  `)
+})

+ 2 - 2
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: 16c1124ec812b9f030ce8266a16cdb8f5db0e6cc
-README.zh.md: a3d2a2dfdd1662afee65ec45e26b1ef1029f44b5
+README.md: acbe04a9710250618623d47c175baccc9c312a2c
+README.zh.md: 002f0678bc8b339fba66f543eab1a06a8ee123f8

+ 1 - 1
packages/client/runtime/README.md

@@ -2,7 +2,7 @@
 
 English | [中文](README.zh.md)
 
-Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. `ConversationSnapshot` carries `todos` — the session's current todo projection: taken from the tail history page's full-log value (host-computed, independent of the page window), preserved across an older-page prepend, and overwritten by each live `todo/write` (last write wins). A tail response that omits the field means the log holds no `todo/write`, so the list resets to empty — a plan the log never kept (a write lost to a host crash) disappears on the next open or resync.
+Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. `ConversationSnapshot` carries `todos` — the standing plan (latest `todo/write` with no later `turn/start`): taken from the tail history page's full-log projection (host-computed, independent of the page window), preserved across an older-page prepend when the window never re-determines it, overwritten by each live `todo/write`, and cleared on each live `turn/start`. A tail response that omits the field means no plan stands (never wrote one, or a later turn retired it), so the list resets to empty — including a plan the log never kept (a write lost to a host crash) on the next open or resync.
 
 ## Workspace and Session lists
 

+ 1 - 1
packages/client/runtime/README.zh.md

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表/scope/history 状态;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd);客户端不持有任何实体化之前的会话状态——Agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约:api-contracts v3 §4。`ConversationSnapshot` 携带 `todos`——会话当前的 todo 投影:取自尾页 history 携带的全量 log 值(host 计算,独立于分页窗口),跨往前翻页保留,并被每次实时 `todo/write` 覆盖(后写胜出)。尾页响应省略该字段即表示 log 中没有任何 `todo/write`,因此列表复位为空——log 从未留下的计划(写入因 host 崩溃丢失)会在下一次打开或 resync 时消失。
+客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表/scope/history 状态;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd);客户端不持有任何实体化之前的会话状态——Agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约:api-contracts v3 §4。`ConversationSnapshot` 携带 `todos`——站立计划(其后没有更晚 `turn/start` 的最近一次 `todo/write`):取自尾页 history 携带的全量 log 投影(host 计算,独立于分页窗口);窗口本身未再判定计划时跨往前翻页保留;被每次实时 `todo/write` 覆盖,并在每次实时 `turn/start` 清空。尾页响应省略该字段即表示当前没有站立计划(从未写入,或更晚的 turn 已退役),因此列表复位为空——也包括 log 从未留下的计划(写入因 host 崩溃丢失)在下一次打开或 resync 时消失。
 
 ## Workspace 与 Session 列表
 

+ 3 - 2
packages/client/runtime/src/client/sessions/conversation.ts

@@ -243,7 +243,8 @@ export interface ConversationSnapshot {
    */
   blank: boolean
   lastAgentError: string | null
-  /** Current whole-list `todo/write` projection — the tail page's full-log value, then each live
-   *  write (last write wins); empty = the log holds no plan. */
+  /** Standing plan strip: the latest `todo/write` with no later `turn/start`. Seeded from the
+   *  tail history page, overwritten by each live write, cleared on each live `turn/start`;
+   *  empty when no plan stands. */
   todos: readonly TodoItem[]
 }

+ 1 - 1
packages/host/apiproxy/src/api/sessions.schema.ts

@@ -139,7 +139,7 @@ export const historyEntrySchema = z.object({
   view: toolEventViewSchema.optional(),
 }) satisfies z.ZodType<Wire<HistoryEntry>>
 
-/** One todo item of the tail page's session-level projection (the todo/write payload shape). */
+/** One todo item of the tail page's standing-plan projection (the todo/write payload shape). */
 export const todoItemSchema = z.object({
   content: z.string(),
   status: z.union([z.literal('pending'), z.literal('in_progress'), z.literal('completed')]),

+ 5 - 4
packages/host/apiproxy/src/api/sessions.ts

@@ -149,10 +149,11 @@ export interface SessionsApi {
    * Each entry pairs the raw SessionEvent with the host-computed view (tool events whose
    * presenter produced one, evaluated against the registry at pagination time); the client
    * rebuilds the surface from the events with the shared fold.
-   * The tail page (beforeSeq absent) also carries `todos` — the session's current todo
-   * projection (latest `todo/write` over the FULL log, independent of the page window) —
-   * so a paged client restores the plan without walking history; absent when the session
-   * never wrote one. Older pages omit it (the projection is session-level, not per-page).
+   * The tail page (beforeSeq absent) also carries `todos` — the standing plan over the
+   * FULL log (latest `todo/write` with no later `turn/start`), independent of the page
+   * window — so a paged client restores the plan without walking history. Absent when no
+   * plan stands (never wrote one, or a later `turn/start` retired it). Older pages omit
+   * it (the projection is session-level, not per-page).
    */
   history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>):
   Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean; todos?: TodoItem[] }>>

+ 38 - 0
packages/ui/tui/tests/snapshots/todo-plan-cleared.expected.txt

@@ -0,0 +1,38 @@
+terminal 96x36 buffer=normal length=36 base=0 viewport=0
+lifecycle started=1 stopped=0 progress=inactive
+title "DSH snapshot"
+cursor hidden column=7 viewportRow=15 bufferRow=15
+viewport
+0| " DEEPSEEK HARNESS"
+  style 1-8 fg=bright-blue bold
+  style 10-16 bold
+1| " Snapshot agent ready."
+  style 1-21 fg=bright-black
+2| " main-session"
+  style 1-12 dim
+3| <blank>
+4| "Assistant                                                                                       "
+  style 0-8 fg=bright-magenta bold underline
+5| "Tracking the steps.                                                                             "
+6| "Model wait 0.0s · Completed 2026-07-21 14:45:00                                                 "
+  style 0-46 dim
+7| <blank>
+8| "You                                                                                             "
+  style 0-2 fg=bright-blue bold underline
+9| "Plan the work.                                                                                  "
+10| <blank>
+11| "You                                                                                             "
+  style 0-2 fg=bright-blue bold underline
+12| "Next question.                                                                                  "
+13| <blank>
+14| "/workspace/project (tui-staging)  deepseek-v4-flash  ↑0 ↓0  0% context"
+  style 0-17 fg=bright-blue bold
+  style 18-31 fg=bright-black
+  style 34-50 fg=bright-black
+  style 53-57 fg=bright-black
+  style 60-69 fg=bright-black
+15| " dsh >                                                                                          "
+  style 1-3 fg=bright-blue bold
+  style 5-6 fg=bright-black
+  style 7-7 inverse
+16-35| <blank>

+ 29 - 0
packages/ui/tui/tests/tui.snapshot.ts

@@ -57,6 +57,7 @@ const CHECKPOINTS = [
   'resume-sessions',
   'status-diagnostics',
   'status-diagnostics-narrow',
+  'todo-plan-cleared',
 ] as const
 
 // Real-loop scenarios own their assertions in separate snapshot suites but
@@ -299,6 +300,34 @@ describe('TUI terminal-state snapshots', () => {
     await disposeSnapshot(harness)
   })
 
+  it('clears the plan strip when the next turn starts', async () => {
+    // Freeze Completed-at formatting: the first turn ends before the next starts,
+    // so the assistant timing line still appears without a Plan strip below it.
+    const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 14, 45, 0).getTime())
+    const harness = await setupSnapshot({
+      beforeMount(session) {
+        appendUser(session, 'Plan the work.')
+        appendAssistant(session, [{ type: 'text', text: 'Tracking the steps.' }])
+        session.append('todo/write', {
+          todos: [
+            { content: 'read code', status: 'completed' },
+            { content: 'write tests', status: 'in_progress' },
+          ],
+        })
+        session.append('step/end', { turn: 1, step: 1 })
+        session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+        session.append('turn/start', {
+          turn: 2,
+          trigger: { kind: 'message', source: { kind: 'user' } },
+        })
+        appendUser(session, 'Next question.')
+      },
+    })
+    await checkpoint('todo-plan-cleared', harness.terminal)
+    nowSpy.mockRestore()
+    await disposeSnapshot(harness)
+  })
+
   it('pins failed-stream retraction, scheduled retry, and eventual success', async () => {
     const harness = await setupSnapshot()
     await renderAfter(harness, () => {