Browse Source

test(snapshot): retain failed reasoning projection

Tianyi Cui 2 weeks ago
parent
commit
9424841d68

+ 2 - 2
.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.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/testing/2026-08-24-session-log-snapshot-corpus.md
-2026-08-24-session-log-snapshot-corpus.md: 57f90f7fd77a0797be0986bca6009d696d5ed581
-2026-08-24-session-log-snapshot-corpus.zh.md: 135afc0fc76b523a36e2b84488237a84b5c0bd4f
+2026-08-24-session-log-snapshot-corpus.md: 16e8c65f62ef0c47f4b6ce63275d1e9403bbee96
+2026-08-24-session-log-snapshot-corpus.zh.md: c89c8d53955b539ec5f27c54095cde3a43addc3e

+ 2 - 0
.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.md

@@ -20,6 +20,8 @@ The recorded session remains the primary input and expected output. Human-origin
 
 Fixture decoding and comparison depend only on the selected JSONL content; filenames identify inventory roles but are not parser inputs. The same strict static catalog validates replay, seed, record, refresh, and normalized comparison paths.
 
+Headless stderr reconstruction expands embedded reasoning from both `assistant/message` and log-only `assistant/attempt` settlements, so failed or retried reasoning remains part of the projected process output.
+
 Each parent or child role uses `session[.<ordinal>][.vN].jsonl`, with v0 encoded by an omitted version and every filename matching its header. Replay, record, and refresh select the numerically highest generation per role. Most owners omit `sessionFormat` and track the current writer; a bounded historical owner declares its exact version and closed coverage names. The v2 corpus keeps selected v0 roles for multi-hop, packed-row, retry/failure, and shipped-profile coverage plus selected v1 roles for the adjacent structural edge. Record and refresh never rewrite an explicitly retained historical fixture, rename a committed generation, or delete one through automatic cleanup. Reviewed source-tree curation removes a predecessor only after the same role has a verified current successor. The corpus policy requires current selected roles to remain the majority and caps historical selected roles at ten; lower predecessor generations may remain beside a selected current successor.
 
 Scenario-owned HTTP fixtures separate the stable authority recorded in the session from their transport listener. Each fixture binds loopback port `0`, lets the operating system allocate and bind the port atomically, and maps the recorded URL or endpoint through the real provider to that listener. Any process-global transport interception matches only the recorded endpoint, is owned by the fixture fiber, and is restored before the listener closes.

+ 2 - 0
.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.zh.md

@@ -20,6 +20,8 @@ Status: implemented
 
 Fixture 解码与比较只取决于选定 JSONL 内容;文件名标识 inventory role,但不是 parser 输入。replay、seed、record、refresh 与规范化比较路径都使用同一个严格静态 catalog 校验。
 
+Headless stderr 重建会同时展开 `assistant/message` 与仅写入日志的 `assistant/attempt` settlement 中嵌入的 reasoning,因此失败或重试尝试的 reasoning 仍属于进程输出投影。
+
 每个 parent 或 child 角色都使用 `session[.<ordinal>][.vN].jsonl`;v0 省略版本,且每个文件名都与其 header 一致。回放、录制与刷新按角色选择数值最高的 generation。大多数 owner 省略 `sessionFormat` 并跟随当前 writer;受限的历史 owner 会声明精确版本与封闭 coverage 名称。v2 语料保留选定 v0 角色,覆盖多跳、打包行、重试/失败与随附 profile,并保留选定 v1 角色覆盖相邻结构 edge。录制与刷新绝不改写显式保留的历史 fixture、重命名已提交 generation 或通过自动清理删除 generation。受审阅的源树整理只有在同角色存在已验证的当前后继后才移除前代。语料策略要求选定当前角色始终占多数,并将选定历史角色上限设为十个;更低的前代 generation 可以保留在选定当前后继旁。
 
 场景拥有的 HTTP fixture 将会话中录制的稳定 authority 与传输 listener 分离。每个 fixture 在回环地址上绑定端口 `0`,由操作系统以一次原子操作分配并绑定端口,再将录制的 URL 或 endpoint 通过真实 provider 映射到该 listener。任何进程全局传输拦截只匹配录制 endpoint,由 fixture fiber 拥有,并在关闭 listener 前恢复。

+ 2 - 0
apps/web/tests/chat-scroll-contract.e2e.ts

@@ -672,6 +672,8 @@ describe('web e2e: long Chat scroll contract', () => {
           { timeout: 15_000 },
         ).toBeGreaterThan(chunksBeforeRelease + 5)
         await nextPaint(world.page)
+        // The loop appends tool/result before starting the next model request;
+        // these synchronous Host listeners therefore observe it before later chunks.
         expect(world.events.some(event => event.type === 'tool/result')).toBe(true)
         expect(Math.abs((await flowTop(world.page, awayAnchor.key)) - awayAnchor.top))
           .toBeLessThanOrEqual(GEOMETRY_TOLERANCE)

+ 21 - 17
snapshots/session/headless.snapshot.ts

@@ -375,7 +375,8 @@ function stderrFromSession(log: string): string {
     }
     if (!started) continue
     const data = record.data as JsonObject | undefined
-    if (record.type === 'assistant/message' && Array.isArray(data?.stream)) {
+    if ((record.type === 'assistant/message' || record.type === 'assistant/attempt')
+      && Array.isArray(data?.stream)) {
       for (const entry of data.stream) {
         if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
           throw new Error('headless snapshot embedded stream has an invalid entry')
@@ -681,24 +682,27 @@ describe('headless recorded-session snapshots', () => {
     ].join('\n'))
   })
 
-  it('reconstructs reasoning stderr from embedded current Assistant streams', () => {
-    const log = [
-      { type: 'turn/start', data: { turn: 1 } },
-      {
-        type: 'assistant/message',
-        data: {
-          stream: [
-            { type: 'chunk', chunk: { type: 'block-start', index: 0, blockType: 'reasoning' } },
-            { type: 'reasoning-chunks', texts: ['first', ' thought'] },
-            { type: 'chunk', chunk: { type: 'block-start', index: 1, blockType: 'text' } },
-          ],
+  it.each(['assistant/message', 'assistant/attempt'] as const)(
+    'reconstructs reasoning stderr from embedded current %s streams',
+    (eventType) => {
+      const log = [
+        { type: 'turn/start', data: { turn: 1 } },
+        {
+          type: eventType,
+          data: {
+            stream: [
+              { type: 'chunk', chunk: { type: 'block-start', index: 0, blockType: 'reasoning' } },
+              { type: 'reasoning-chunks', texts: ['first', ' thought'] },
+              { type: 'chunk', chunk: { type: 'block-start', index: 1, blockType: 'text' } },
+            ],
+          },
         },
-      },
-      { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } },
-    ].map(record => JSON.stringify(record)).join('\n')
+        { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } },
+      ].map(record => JSON.stringify(record)).join('\n')
 
-    expect(stderrFromSession(log)).toBe('dsh: reasoning:\nfirst thought\n')
-  })
+      expect(stderrFromSession(log)).toBe('dsh: reasoning:\nfirst thought\n')
+    },
+  )
 
   for (const scenario of scenarios) {
     const skipped = scenario.manifest.platform === 'posix' && process.platform === 'win32'