Parcourir la source

fix: complete immutable message migration

_Kerman il y a 2 mois
Parent
commit
350c296cff
27 fichiers modifiés avec 230 ajouts et 59 suppressions
  1. 0 0
      examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
  2. 2 0
      packages/context/workspace-context/tests/workspace-context.spec.ts
  3. 4 2
      packages/core/agent-loop/tests/cancel.spec.ts
  4. 69 13
      packages/core/agent-loop/tests/loop.spec.ts
  5. 6 1
      packages/core/session/tests/fork.spec.ts
  6. 2 0
      packages/core/tools/tests/code-mode.spec.ts
  7. 14 2
      packages/core/tools/tests/tools.spec.ts
  8. 6 1
      packages/examples/agent-spine-demo/tests/agent-core.spec.ts
  9. 1 1
      packages/goal/goal/tests/goal.spec.ts
  10. 3 3
      packages/host/apiproxy/tests/api-proxy-commands.spec.ts
  11. 13 3
      packages/host/apiproxy/tests/api-proxy-view.spec.ts
  12. 5 5
      packages/host/apiproxy/tests/rpc-schemas.spec.ts
  13. 2 2
      packages/llm/llm-pi-ai/tests/convert.spec.ts
  14. 3 2
      packages/llm/llm-retry/tests/retry.spec.ts
  15. 1 1
      packages/llm/llm/src/message.ts
  16. 2 0
      packages/plan/plan-mode/tests/plan-mode.spec.ts
  17. 13 4
      packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts
  18. 23 7
      packages/session-persistence/session-persistence/tests/contract.ts
  19. 8 4
      packages/session-query/session-query/src/index.ts
  20. 27 0
      packages/session-query/session-query/src/snapshot.ts
  21. 2 1
      packages/session-query/session-query/src/tracing.ts
  22. 8 1
      packages/skill/tool-skill/tests/tool-skill.spec.ts
  23. 1 1
      packages/subagent/subagent-inprocess/tests/structured.spec.ts
  24. 4 1
      packages/support/acp-snapshot/src/suite.ts
  25. 3 3
      packages/support/acp-snapshot/tests/suite.spec.ts
  26. 4 0
      packages/tasks/tool-tasks/tests/tool-tasks.spec.ts
  27. 4 1
      packages/ui/tui/tests/tui.spec.ts

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl


+ 2 - 0
packages/context/workspace-context/tests/workspace-context.spec.ts

@@ -3056,6 +3056,8 @@ describe('dynamic nested workspace context injection', () => {
       expect(blocksText(workspaceContextOf(result)?.content)).toContain('nested package rule')
       expect(blocksText(workspaceContextOf(result)?.content)).not.toContain('downstream context')
       expect(result.additionalContexts?.[1]).toEqual({
+        id: expect.any(String) as unknown,
+        role: 'user',
         content: [{ type: 'text', text: 'downstream context' }],
         source: { kind: 'plugin', plugin: 'downstream' },
       })

+ 4 - 2
packages/core/agent-loop/tests/cancel.spec.ts

@@ -338,8 +338,10 @@ describe('Agent.cancel()', () => {
     const result = agent.session.events.find(event => event.type === 'tool/result')
     expect(call?.type === 'tool/call' ? call.data.callId : undefined).toBe('c1')
     expect(result?.type === 'tool/result' ? result.data : undefined).toMatchObject({
-      callId: 'c1',
-      isError: true,
+      message: {
+        source: { kind: 'tool', callId: 'c1' },
+        content: [{ type: 'tool-result', toolCallId: 'c1', isError: true }],
+      },
       error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
     })
 

+ 69 - 13
packages/core/agent-loop/tests/loop.spec.ts

@@ -744,9 +744,24 @@ describe('agent loop', () => {
     expect(steps).toBe(2)
     expect(adapter.requests).toHaveLength(2)
     expect(adapter.requests[1]!.messages).toEqual([
-      { role: 'user', content: [{ type: 'text', text: 'go' }] },
-      { role: 'assistant', content: [{ type: 'text', text: 'first half' }], provenance: { provider: 'mock', model: 'mock' } },
-      { role: 'user', content: [{ type: 'text', text: 'continue after truncation' }] },
+      {
+        id: expect.any(String) as unknown,
+        role: 'user',
+        content: [{ type: 'text', text: 'go' }],
+        source: { kind: 'user' },
+      },
+      {
+        id: expect.any(String) as unknown,
+        role: 'assistant',
+        content: [{ type: 'text', text: 'first half' }],
+        source: { kind: 'model', provider: 'mock', model: 'mock' },
+      },
+      {
+        id: expect.any(String) as unknown,
+        role: 'user',
+        content: [{ type: 'text', text: 'continue after truncation' }],
+        source: { kind: 'plugin', plugin: 'max-tokens-test' },
+      },
     ])
     expect(reasons).toEqual([{ kind: 'max-tokens' }])
   })
@@ -799,13 +814,26 @@ describe('agent loop', () => {
 
     expect(executions).toBe(0)
     expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false)
-    expect(agent.session.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'go' }] }])
+    expect(agent.session.deriveMessages()).toEqual([{
+      id: expect.any(String) as unknown,
+      role: 'user',
+      content: [{ type: 'text', text: 'go' }],
+      source: { kind: 'user' },
+    }])
     expect(reasons).toEqual([{ kind: 'max-tokens' }])
     // Empty content still needs an assistant/message to carry usage; derivation
     // skips that host so it does not create a spurious assistant turn.
     const assistantMessage = agent.session.events.find(e => e.type === 'assistant/message')
     expect(assistantMessage?.type === 'assistant/message' && assistantMessage.data).toEqual({
-      turn: 1, step: 1, content: [], provenance: { provider: 'mock', model: 'mock' }, usage: { inputTokens: 10, outputTokens: 5 },
+      turn: 1,
+      step: 1,
+      message: {
+        id: expect.any(String) as unknown,
+        role: 'assistant',
+        content: [],
+        source: { kind: 'model', provider: 'mock', model: 'mock' },
+      },
+      usage: { inputTokens: 10, outputTokens: 5 },
     })
   })
 
@@ -839,11 +867,20 @@ describe('agent loop', () => {
     expect(assistant.type === 'assistant/message' && assistant.data).toEqual({
       turn: 1,
       step: 1,
-      content: [],
-      provenance: { provider: 'mock', model: 'mock' },
+      message: {
+        id: expect.any(String) as unknown,
+        role: 'assistant',
+        content: [],
+        source: { kind: 'model', provider: 'mock', model: 'mock' },
+      },
     })
     expect(assistant.sourceEventSeqs?.length).toBeGreaterThan(0)
-    expect(agent.session.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'go' }] }])
+    expect(agent.session.deriveMessages()).toEqual([{
+      id: expect.any(String) as unknown,
+      role: 'user',
+      content: [{ type: 'text', text: 'go' }],
+      source: { kind: 'user' },
+    }])
   })
 
   it('appends an empty completion anchor for a normal stop with no usage', async () => {
@@ -864,11 +901,20 @@ describe('agent loop', () => {
     expect(assistant.type === 'assistant/message' && assistant.data).toEqual({
       turn: 1,
       step: 1,
-      content: [],
-      provenance: { provider: 'mock', model: 'mock' },
+      message: {
+        id: expect.any(String) as unknown,
+        role: 'assistant',
+        content: [],
+        source: { kind: 'model', provider: 'mock', model: 'mock' },
+      },
     })
     expect(assistant.sourceEventSeqs?.length).toBe(1)
-    expect(agent.session.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'go' }] }])
+    expect(agent.session.deriveMessages()).toEqual([{
+      id: expect.any(String) as unknown,
+      role: 'user',
+      content: [{ type: 'text', text: 'go' }],
+      source: { kind: 'user' },
+    }])
   })
 
   it('keeps safe max-tokens assistant content while dropping truncated tool calls', async () => {
@@ -889,8 +935,18 @@ describe('agent loop', () => {
 
     expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false)
     expect(agent.session.deriveMessages()).toEqual([
-      { role: 'user', content: [{ type: 'text', text: 'go' }] },
-      { role: 'assistant', content: [{ type: 'text', text: 'partial text' }], provenance: { provider: 'mock', model: 'mock' } },
+      {
+        id: expect.any(String) as unknown,
+        role: 'user',
+        content: [{ type: 'text', text: 'go' }],
+        source: { kind: 'user' },
+      },
+      {
+        id: expect.any(String) as unknown,
+        role: 'assistant',
+        content: [{ type: 'text', text: 'partial text' }],
+        source: { kind: 'model', provider: 'mock', model: 'mock' },
+      },
     ])
   })
 

+ 6 - 1
packages/core/session/tests/fork.spec.ts

@@ -95,7 +95,12 @@ describe('SessionStore.fork', () => {
 
     expect(child.events).toEqual(source.events.slice(0, firstBoundary + 1))
     expect(child.header.seedLength).toBe(firstBoundary + 1)
-    expect(child.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'first' }] }])
+    expect(child.deriveMessages()).toEqual([{
+      id: expect.any(String) as unknown,
+      role: 'user',
+      content: [{ type: 'text', text: 'first' }],
+      source: { kind: 'user' },
+    }])
   })
 
   it('accepts every turn/end reason as an explicit fork boundary', async () => {

+ 2 - 0
packages/core/tools/tests/code-mode.spec.ts

@@ -998,6 +998,8 @@ describe('the run_code dispatch bridge', () => {
 
     expect(result.isError).toBe(true)
     expect(result.additionalContexts).toEqual([{
+      id: expect.any(String) as unknown,
+      role: 'user',
       content: [{ type: 'text', text: 'nested context' }],
       source: { kind: 'plugin', plugin: 'test' },
     }])

+ 14 - 2
packages/core/tools/tests/tools.spec.ts

@@ -385,7 +385,12 @@ describe('ToolRegistry', () => {
       value: { text: 'policy value' },
       content: [{ type: 'text', text: 'render:policy value' }],
       meta: { projected: 'policy value' },
-      additionalContexts: [{ content: [{ type: 'text', text: 'value context' }], source: { kind: 'plugin', plugin: 'test' } }],
+      additionalContexts: [{
+        id: expect.any(String) as unknown,
+        role: 'user',
+        content: [{ type: 'text', text: 'value context' }],
+        source: { kind: 'plugin', plugin: 'test' },
+      }],
     })
   })
 
@@ -518,7 +523,12 @@ describe('ToolRegistry', () => {
       error: { message: 'wrapped failure' },
       content: [{ type: 'text', text: 'wrapper content' }],
       meta: { wrapped: true },
-      additionalContexts: [{ content: [{ type: 'text', text: 'wrapper context' }], source: { kind: 'plugin', plugin: 'test' } }],
+      additionalContexts: [{
+        id: expect.any(String) as unknown,
+        role: 'user',
+        content: [{ type: 'text', text: 'wrapper context' }],
+        source: { kind: 'plugin', plugin: 'test' },
+      }],
     })
   })
 
@@ -1820,6 +1830,8 @@ describe('ToolRegistry', () => {
       callId: CallId('around-context'), name: 'echo', arguments: {},
     })
     expect(result.additionalContexts).toEqual([{
+      id: expect.any(String) as unknown,
+      role: 'user',
       content: [{ type: 'text', text: 'from around dispatch' }],
       source: { kind: 'plugin', plugin: 'test' },
     }])

+ 6 - 1
packages/examples/agent-spine-demo/tests/agent-core.spec.ts

@@ -367,7 +367,12 @@ describe('dsh-agent-spine-demo bundle', () => {
       handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }))
       await waitForIdle(ctx, handle.agent)
 
-      expect(adapter.requests[0]?.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'hi' }] }])
+      expect(adapter.requests[0]?.messages).toEqual([{
+        id: expect.any(String) as unknown,
+        role: 'user',
+        content: [{ type: 'text', text: 'hi' }],
+        source: { kind: 'user' },
+      }])
       await handle.dispose()
       await ctx.fiber.dispose()
     } finally {

+ 1 - 1
packages/goal/goal/tests/goal.spec.ts

@@ -126,7 +126,7 @@ describe('GoalService creation and replay', () => {
     if (change === undefined) throw new Error('expected decoded goal change')
     expect(change).toMatchObject({ operation: 'create', goal: { id: goal.id } })
     expect(context.data.content).toEqual(renderGoalChange(change))
-    expect(session.deriveMessages()).toEqual([{ role: 'user', content: context.data.content }])
+    expect(session.deriveMessages()).toEqual([context.data])
     expect(foldGoal(session.events)).toMatchObject({ goal: { id: goal.id }, roundsStarted: 0 })
     vi.useRealTimers()
   })

+ 3 - 3
packages/host/apiproxy/tests/api-proxy-commands.spec.ts

@@ -265,8 +265,8 @@ describe('session/queued frames', () => {
 
     const liveFrames = (await liveCollected).filter(f => f.type === 'session/queued')
     expect(liveFrames).toEqual([
-      { type: 'session/queued', sessionId: agent.id, content: queued.content, source: { kind: 'user' }, steering: false },
-      { type: 'session/queued', sessionId: agent.id, content: steering.content, source: { kind: 'user' }, steering: true },
+      { type: 'session/queued', sessionId: agent.id, message: queued, steering: false },
+      { type: 'session/queued', sessionId: agent.id, message: steering, steering: true },
     ])
 
     // A fresh mux connection replays the still-pending entries as its baseline.
@@ -308,6 +308,6 @@ describe('session/queued frames', () => {
       api.events.mux({ rpcId: RpcId('t-mux-swept'), payload: {} }, abort.signal), 2, abort)
     const remaining = frames.filter(f => f.type === 'session/queued')
     expect(remaining).toHaveLength(1)
-    expect(remaining[0]).toMatchObject({ content: survivor.content })
+    expect(remaining[0]).toMatchObject({ message: survivor })
   })
 })

+ 13 - 3
packages/host/apiproxy/tests/api-proxy-view.spec.ts

@@ -16,7 +16,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
 import { CallId, createMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
 import type { ContentBlock } from '@deepseek-ai/dsh-llm'
-import type { Session, SessionId } from '@deepseek-ai/dsh-session'
+import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
 import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
 import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
 import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
@@ -111,7 +111,12 @@ describe('mux live view computation', () => {
     const events = frames.filter(f => f.type === 'session/event')
     const byCall = new Map(events
       .filter(f => f.event.type === 'tool/call' || f.event.type === 'tool/result')
-      .map(f => [`${f.event.type}:${(f.event.data as { callId: string }).callId}`, f]))
+      .map(f => [
+        `${f.event.type}:${f.event.type === 'tool/call'
+          ? f.event.data.callId
+          : (f.event.data as SessionEvent<'tool/result'>['data']).message.source.callId}`,
+        f,
+      ]))
 
     expect(byCall.get('tool/call:c-gen')?.view).toEqual({ for: 'call', view: { card: 'generic', title: 'gen call' } })
     expect(byCall.get('tool/call:c-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'echo hi' } })
@@ -189,7 +194,12 @@ describe('mux live view computation', () => {
     const entries = response.result.value.events
     const byKey = new Map(entries
       .filter(entry => entry.event.type === 'tool/call' || entry.event.type === 'tool/result')
-      .map(entry => [`${entry.event.type}:${(entry.event.data as { callId: string }).callId}`, entry]))
+      .map(entry => [
+        `${entry.event.type}:${entry.event.type === 'tool/call'
+          ? entry.event.data.callId
+          : (entry.event.data as SessionEvent<'tool/result'>['data']).message.source.callId}`,
+        entry,
+      ]))
     expect(byKey.get('tool/call:h-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'ls' } })
     expect(byKey.get('tool/result:h-term')?.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } })
     expect('view' in (byKey.get('tool/result:h-orphan') ?? {})).toBe(false)

+ 5 - 5
packages/host/apiproxy/tests/rpc-schemas.spec.ts

@@ -319,8 +319,8 @@ describe('events frame schemas', () => {
       { type: 'approval/resolved', sessionId: 's', approvalId: 'a', outcome: 'allowed-once' },
       { type: 'question/requested', sessionId: 's', questions: [{ id: 'q', question: 'Q?', options: [{ label: 'L' }], multiSelect: true }] },
       { type: 'question/resolved', sessionId: 's', questionRpcId: 'r', outcome: 'answered' },
-      { type: 'session/queued', sessionId: 's', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' }, steering: false },
-      { type: 'session/queued', sessionId: 's', content: [{ type: 'text', text: 'steer' }], source: { kind: 'user' }, steering: true },
+      { type: 'session/queued', sessionId: 's', message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } }, steering: false },
+      { type: 'session/queued', sessionId: 's', message: { id: 'm2', role: 'user', content: [{ type: 'text', text: 'steer' }], source: { kind: 'user' } }, steering: true },
       { type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
     ]
     for (const frame of frames) expect(muxFrameSchema.parse(frame)).toMatchObject({ type: frame.type })
@@ -340,9 +340,9 @@ describe('events frame schemas', () => {
   })
 
   it('rejects a queued frame missing its members', () => {
-    expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', content: 'x', source: { kind: 'user' } })).toThrow()
-    expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', content: [], source: { kind: 'user' } })).toThrow()
-    expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', content: [], source: {}, steering: false })).toThrow()
+    expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', message: 'x', steering: false })).toThrow()
+    expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', message: { id: 'm', role: 'user', content: [], source: { kind: 'user' } } })).toThrow()
+    expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', message: { id: 'm', role: 'user', content: [], source: {} }, steering: false })).toThrow()
   })
 
   it('accepts every host frame branch', () => {

+ 2 - 2
packages/llm/llm-pi-ai/tests/convert.spec.ts

@@ -364,7 +364,7 @@ describe('toPiContext', () => {
   it.each([
     ['provider', { ...validReplay, provider: 'openai' }],
     ['model', { ...validReplay, model: 'deepseek-v4-pro' }],
-  ])('rejects replay metadata whose %s differs from assistant provenance', (field, replayState) => {
+  ])('rejects replay metadata whose %s differs from assistant source', (field, replayState) => {
     try {
       toPiContext({
         provider: 'deepseek',
@@ -382,7 +382,7 @@ describe('toPiContext', () => {
     } catch (error: unknown) {
       expect(error).toBeInstanceOf(LlmError)
       expect((error as LlmError).code).toBe('INVALID_REPLAY_STATE')
-      expect((error as Error).message).toContain(`${field} does not match assistant provenance`)
+      expect((error as Error).message).toContain(`${field} does not match assistant source`)
     }
   })
 

+ 3 - 2
packages/llm/llm-retry/tests/retry.spec.ts

@@ -216,9 +216,10 @@ describe('provider-routed retry policy', () => {
     expect(agent.session.events.filter(item => item.type === 'step/start').map(item => item.data))
       .toEqual([{ turn: 1, step: 1 }, { turn: 2, step: 1 }])
     expect(agent.session.deriveMessages().at(-1)).toEqual({
+      id: expect.any(String) as unknown,
       role: 'assistant',
       content: [{ type: 'text', text: 'done' }],
-      provenance: { provider: 'mock', model: 'mock' },
+      source: { kind: 'model', provider: 'mock', model: 'mock' },
     })
   })
 
@@ -296,7 +297,7 @@ describe('provider-routed retry policy', () => {
     expect(agent.session.deriveMessages().at(-1)).toMatchObject({
       role: 'assistant',
       content: [{ type: 'text', text: 'recovered' }],
-      provenance: { provider: 'mock', model: 'mock' },
+      source: { kind: 'model', provider: 'mock', model: 'mock' },
     })
   })
 

+ 1 - 1
packages/llm/llm/src/message.ts

@@ -128,8 +128,8 @@ export function createAssistantMessage(
     role: 'assistant',
     content: input.content,
     source: {
-      ...input.source,
       kind: 'model',
+      ...input.source,
     },
   })
 }

+ 2 - 0
packages/plan/plan-mode/tests/plan-mode.spec.ts

@@ -527,6 +527,8 @@ describe('/plan', () => {
     })
     expect(ctx.planMode.get(messageAgent)).toEqual({ active: false, pending: true })
     expect(messageSteer).toHaveBeenCalledExactlyOnceWith({
+      id: expect.any(String) as unknown,
+      role: 'user',
       content: [{ type: 'text', text: 'draft the migration' }],
       source: { kind: 'user' },
     })

+ 13 - 4
packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts

@@ -1,4 +1,4 @@
-import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm'
+import { MessageId, createUserMessage, createMessage } from '@deepseek-ai/dsh-llm'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { Context } from 'cordis'
 import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat, symlink } from 'node:fs/promises'
@@ -1291,9 +1291,18 @@ describe('SessionPersistenceJsonl: edge cases', () => {
   it('rejects non-JSON event data: BigInt, function, circular, Map, undefined property', async () => {
     const m = meta('serial')
     await ctx.sessionPersistence.create(m)
-    const bad = (extra: unknown) => [{ type: 'user/message', seq: 0, time: 1, data: createUserMessage({
-      content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra,
-    }) }] as unknown as SessionEvent[]
+    const bad = (extra: unknown) => [{
+      type: 'user/message',
+      seq: 0,
+      time: 1,
+      data: {
+        id: MessageId('invalid-json'),
+        role: 'user',
+        content: [{ type: 'text', text: 'x' }],
+        source: { kind: 'user' },
+        extra,
+      },
+    }] as unknown as SessionEvent[]
     await expect(ctx.sessionPersistence.append(m.id, bad(1n))).rejects.toThrow(/non-JSON-serializable/)
     await expect(ctx.sessionPersistence.append(m.id, bad(() => 0))).rejects.toThrow(/non-JSON-serializable/)
     await expect(ctx.sessionPersistence.append(m.id, bad(Symbol('s')))).rejects.toThrow(/non-JSON-serializable/)

+ 23 - 7
packages/session-persistence/session-persistence/tests/contract.ts

@@ -11,7 +11,7 @@
 import { describe, expect, it } from 'vitest'
 import { SESSION_FORMAT_VERSION, Session, SessionId, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '@deepseek-ai/dsh-session'
 import type { SessionEvent, SessionHeader, SurfaceEventType, SurfaceIntent } from '@deepseek-ai/dsh-session'
-import { createUserMessage, CallId , createMessage } from '@deepseek-ai/dsh-llm'
+import { CallId, MessageId, createMessage, freezeMessage } from '@deepseek-ai/dsh-llm'
 import type { SessionPersistence } from '../src/index.ts'
 
 /** A backend under test plus its teardown. */
@@ -34,13 +34,16 @@ export function meta(id: string, cwd?: string): SessionHeader {
 export function oneTurnLog(): SessionEvent[] {
   return [
     { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
-    { type: 'user/message', seq: 1, time: 2, data: createUserMessage({
+    { type: 'user/message', seq: 1, time: 2, data: freezeMessage({
+      id: MessageId('one-turn-user'),
+      role: 'user',
       content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
     }), surfaceOp: 'append' },
     { type: 'step/start', seq: 2, time: 3, data: { turn: 1, step: 1 } },
     { type: 'assistant/message', seq: 3, time: 4, data: {
       turn: 1, step: 1,
-      message: createMessage({
+      message: freezeMessage({
+        id: MessageId('one-turn-assistant'),
         role: 'assistant',
         content: [{ type: 'text', text: 'hello' }],
         source: {
@@ -201,7 +204,11 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
         ])
         const synthetic = loaded.events.find(e => e.type === 'tool/result')
         expect(synthetic?.type === 'tool/result' && synthetic.data).toMatchObject({
-          callId: CallId('call-x'), isError: true, error: { code: TOOL_NOT_STARTED },
+          message: {
+            source: { kind: 'tool', callId: CallId('call-x') },
+            content: [{ type: 'tool-result', toolCallId: CallId('call-x'), isError: true }],
+          },
+          error: { code: TOOL_NOT_STARTED },
         })
         // The synthetic result carries the SAME callId as the orphaned tool-call,
         // so deriveMessages() pairs them — no provider-invalid dangling call.
@@ -365,9 +372,18 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
           const mi = meta(`s5-${i}`)
           await persistence.create(mi)
           const events = [
-            { type: 'user/message', seq: 0, time: 1, data: createUserMessage({
-              content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra: bad,
-            }) },
+            {
+              type: 'user/message',
+              seq: 0,
+              time: 1,
+              data: {
+                id: MessageId(`invalid-json-${i}`),
+                role: 'user',
+                content: [{ type: 'text', text: 'x' }],
+                source: { kind: 'user' },
+                extra: bad,
+              },
+            },
           ] as unknown as SessionEvent[]
           await expect(persistence.append(mi.id, events)).rejects.toThrow(/losslessly JSON-serializable/)
         }

+ 8 - 4
packages/session-query/session-query/src/index.ts

@@ -45,6 +45,7 @@ import {
   materializeSessionResultFilters,
 } from './filters.ts'
 import * as tracing from './tracing.ts'
+import { snapshotEvent } from './snapshot.ts'
 
 export type * from './types.ts'
 export { SessionSearchCursor } from './cursor.ts'
@@ -146,7 +147,7 @@ export abstract class SessionQueryService extends Service {
     new Session(sessionId, loaded.events, loaded.header)
     return {
       session: structuredClone(loaded.header),
-      events: loaded.events.map(event => structuredClone(event)),
+      events: loaded.events.map(snapshotEvent),
     }
   }
 
@@ -330,10 +331,13 @@ export abstract class SessionQueryService extends Service {
     }
     const startSeq = Math.max(0, seq - before)
     const endSeq = Math.min(loaded.events.length - 1, seq + after)
+    const targetSnapshot = snapshotEvent(target)
+    const events = loaded.events.slice(startSeq, endSeq + 1)
+      .map(event => event === target ? targetSnapshot : snapshotEvent(event))
     return {
-      session: loaded.header,
-      target,
-      events: loaded.events.slice(startSeq, endSeq + 1),
+      session: structuredClone(loaded.header),
+      target: targetSnapshot,
+      events,
       startSeq,
       endSeq,
     }

+ 27 - 0
packages/session-query/session-query/src/snapshot.ts

@@ -0,0 +1,27 @@
+/** Detached session-query snapshots that preserve message immutability. */
+
+import { deepFreeze } from '@deepseek-ai/dsh-llm'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+
+/**
+ * Clone one event while retaining the invariant that every identified message is frozen.
+ * @param event - source event from one corpus observation.
+ * @returns a detached event whose message value, if any, is deeply frozen.
+ */
+export function snapshotEvent<T extends SessionEvent>(event: T): T {
+  const snapshot = structuredClone(event)
+  switch (snapshot.type) {
+    case 'user/message':
+      deepFreeze(snapshot.data)
+      break
+    case 'assistant/message':
+    case 'tool/result':
+    case 'steering/message':
+      deepFreeze(snapshot.data.message)
+      break
+    default:
+      // SessionEventMap is merge-extensible; plugin-owned log-only events carry no core message.
+      break
+  }
+  return snapshot
+}

+ 2 - 1
packages/session-query/session-query/src/tracing.ts

@@ -10,6 +10,7 @@ import type {
   SessionLineageTrace,
   SessionRecord,
 } from './types.ts'
+import { snapshotEvent } from './snapshot.ts'
 
 interface EventLogAnalysis {
   records: SessionEventRecord[]
@@ -51,7 +52,7 @@ export function currentSurfaceEvents(
         'SESSION_QUERY_INVALID_SURFACE',
       )
     }
-    return structuredClone(event)
+    return snapshotEvent(event)
   })
 }
 

+ 8 - 1
packages/skill/tool-skill/tests/tool-skill.spec.ts

@@ -150,7 +150,9 @@ describe('dsh-tool-skill', () => {
 
     expect(prefix).toEqual([
       {
+        id: expect.any(String) as unknown,
         role: 'user',
+        source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
         content: [{
           type: 'text',
           text: [
@@ -167,7 +169,12 @@ describe('dsh-tool-skill', () => {
           ].join('\n'),
         }],
       },
-      { role: 'user', content: [{ type: 'text', text: 'later contribution' }] },
+      {
+        id: expect.any(String) as unknown,
+        role: 'user',
+        content: [{ type: 'text', text: 'later contribution' }],
+        source: { kind: 'plugin', plugin: 'later-contribution' },
+      },
     ])
     const rendered = JSON.stringify(prefix[0])
     expect(rendered).not.toContain('whenToUse')

+ 1 - 1
packages/subagent/subagent-inprocess/tests/structured.spec.ts

@@ -230,7 +230,7 @@ describe('in-process structured output', () => {
     const child = ctx.agents.get(run.id)!
     const results = child.session.events.filter(e => e.type === 'tool/result')
     expect(results.length).toBe(2)
-    expect((results[0]!.data as { isError?: boolean }).isError).toBe(true)
+    expect(results[0]!.data.message.content[0].isError).toBe(true)
     await run.dispose()
   })
 

+ 4 - 1
packages/support/acp-snapshot/src/suite.ts

@@ -439,9 +439,12 @@ export function unknownToolCallIds(rawLog: string): string[] {
     if (record.type !== 'tool/result') return []
     const data = record.data
     if (data === null || typeof data !== 'object') return []
-    const { source, error } = data as { source?: unknown; error?: unknown }
+    const { message, error } = data as { message?: unknown; error?: unknown }
     if (error === null || typeof error !== 'object') return []
     if ((error as { code?: unknown }).code !== 'UNKNOWN_TOOL') return []
+    const source = typeof message === 'object' && message !== null
+      ? (message as { source?: unknown }).source
+      : undefined
     const callId = typeof source === 'object' && source !== null
       ? (source as { callId?: unknown }).callId
       : undefined

+ 3 - 3
packages/support/acp-snapshot/tests/suite.spec.ts

@@ -432,8 +432,8 @@ describe('tool-schema snapshots', () => {
 describe('unknownToolCallIds', () => {
   it('returns structured UNKNOWN_TOOL call ids and ignores other results', () => {
     const log = [
-      '{"type":"tool/result","data":{"callId":"missing","error":{"code":"UNKNOWN_TOOL"}}}',
-      '{"type":"tool/result","data":{"callId":"failed","error":{"code":"EXECUTION_FAILED"}}}',
+      '{"type":"tool/result","data":{"message":{"source":{"kind":"tool","callId":"missing"}},"error":{"code":"UNKNOWN_TOOL"}}}',
+      '{"type":"tool/result","data":{"message":{"source":{"kind":"tool","callId":"failed"}},"error":{"code":"EXECUTION_FAILED"}}}',
       '{"type":"tool/result","data":null}',
       '{"type":"tool/result","data":"invalid"}',
       '{"type":"tool/result","data":{"error":null}}',
@@ -446,7 +446,7 @@ describe('unknownToolCallIds', () => {
   })
 
   it('returns no failures for ordinary tool results', () => {
-    expect(unknownToolCallIds('{"type":"tool/result","data":{"callId":"ok"}}\n')).toEqual([])
+    expect(unknownToolCallIds('{"type":"tool/result","data":{"message":{"source":{"kind":"tool","callId":"ok"}}}}\n')).toEqual([])
   })
 })
 

+ 4 - 0
packages/tasks/tool-tasks/tests/tool-tasks.spec.ts

@@ -457,6 +457,8 @@ describe('completion notices', () => {
     await tick()
     expect(inject).toHaveBeenCalledTimes(1)
     expect(inject).toHaveBeenCalledWith({
+      id: expect.any(String) as unknown,
+      role: 'user',
       content: [{ type: 'text', text: 'background task bash-1 (bash: pnpm test) finished [status: completed, exit code: 0]. Read its output with task_output.' }],
       source: { kind: 'plugin', plugin: 'tool-tasks' },
     })
@@ -479,6 +481,8 @@ describe('completion notices', () => {
     expect(inject).toHaveBeenNthCalledWith(
       1,
       {
+        id: expect.any(String) as unknown,
+        role: 'user',
         content: [{ type: 'text', text: 'background task subagent-1\n[notice truncated]\nDone; task_output.' }],
         source: { kind: 'plugin', plugin: 'tool-tasks' },
       },

+ 4 - 1
packages/ui/tui/tests/tui.spec.ts

@@ -2365,7 +2365,10 @@ describe('pi-tui chat lifecycle and transcript', () => {
         type: 'user/message',
         seq: 0,
         time: 1,
-        data: { content: [{ type: 'text', text: 'source background' }], source: { kind: 'user' } },
+        data: createUserMessage({
+          content: [{ type: 'text', text: 'source background' }],
+          source: { kind: 'user' },
+        }),
         surfaceOp: 'append',
       },
       {

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff