Browse Source

refactor(session): route construction through Session.create

imccyu 1 month ago
parent
commit
8cbdd5b9d0
61 changed files with 305 additions and 292 deletions
  1. 1 1
      apps/web/tests/chat-scroll-fixture.ts
  2. 2 2
      apps/web/tests/complex-history.perf.ts
  3. 1 1
      apps/web/tests/markdown-images.e2e.ts
  4. 1 1
      packages/compact/command-compact/tests/command-compact.spec.ts
  5. 1 1
      packages/compact/command-compact/tests/loader-composition.spec.ts
  6. 14 14
      packages/compact/compact-basic/tests/compact-basic.spec.ts
  7. 1 1
      packages/compact/compact-basic/tests/compact-loop-repro.spec.ts
  8. 6 6
      packages/compact/compact-basic/tests/manual-compact.spec.ts
  9. 4 4
      packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts
  10. 3 3
      packages/compact/compact/tests/compact.spec.ts
  11. 5 5
      packages/compact/compact/tests/invariant.spec.ts
  12. 8 8
      packages/compact/compact/tests/tool-pairing.spec.ts
  13. 1 1
      packages/context/session-reference/tests/session-reference.spec.ts
  14. 2 2
      packages/context/time-context/tests/invariant.spec.ts
  15. 14 14
      packages/context/time-context/tests/time-context.spec.ts
  16. 15 15
      packages/context/tmux-context/tests/tmux-context.spec.ts
  17. 1 1
      packages/context/workspace-context/tests/workspace-context.spec.ts
  18. 1 1
      packages/core/agent-loop/src/invariant.ts
  19. 2 2
      packages/core/agent-loop/tests/contract-regressions.spec.ts
  20. 1 1
      packages/core/agent-loop/tests/request-reconstruction.spec.ts
  21. 1 1
      packages/core/agent-loop/tests/resume.spec.ts
  22. 2 2
      packages/core/agent/tests/agent.spec.ts
  23. 16 3
      packages/core/session/src/index.ts
  24. 7 7
      packages/core/session/tests/derived-cache.spec.ts
  25. 2 2
      packages/core/session/tests/fork.spec.ts
  26. 4 4
      packages/core/session/tests/properties.spec.ts
  27. 10 10
      packages/core/session/tests/request-header.spec.ts
  28. 65 65
      packages/core/session/tests/session.spec.ts
  29. 23 23
      packages/core/session/tests/surface.spec.ts
  30. 2 2
      packages/core/tools/tests/code-mode.spec.ts
  31. 1 1
      packages/fs/tool-str-replace-editor/tests/tools.spec.ts
  32. 12 12
      packages/goal/goal/tests/goal.spec.ts
  33. 2 2
      packages/goal/tool-goal/tests/tool-goal.spec.ts
  34. 8 8
      packages/hooks/hook-protocol/tests/events.spec.ts
  35. 1 1
      packages/hooks/hook-protocol/tests/invariant.spec.ts
  36. 24 24
      packages/llm/token-meter/tests/token-meter.spec.ts
  37. 3 3
      packages/plan/plan-mode/tests/invariant.spec.ts
  38. 3 3
      packages/plan/plan-mode/tests/plan-mode.spec.ts
  39. 1 1
      packages/pty/pty-local/tests/index.spec.ts
  40. 1 1
      packages/pty/pty-local/tests/local.spec.ts
  41. 1 1
      packages/pty/pty/tests/service.spec.ts
  42. 1 1
      packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts
  43. 1 1
      packages/pty/tool-bash-persistent/tests/tools.spec.ts
  44. 1 1
      packages/pty/tool-pty/tests/loader-composition.spec.ts
  45. 1 1
      packages/pty/tool-pty/tests/tools.spec.ts
  46. 4 4
      packages/sandbox/sandbox-policy/tests/policy.spec.ts
  47. 1 1
      packages/session-persistence/session-persistence/tests/contract.ts
  48. 1 1
      packages/session-persistence/session-persistence/tests/coordinator-contract.ts
  49. 1 1
      packages/session-query/session-query/src/index.ts
  50. 1 1
      packages/session-title/session-title-all-messages-llm/tests/provider.spec.ts
  51. 1 1
      packages/session-title/session-title-first-message-llm/tests/provider.spec.ts
  52. 1 1
      packages/session-title/session-title/tests/rename.spec.ts
  53. 2 2
      packages/session-title/session-title/tests/service-contracts.spec.ts
  54. 1 1
      packages/session-title/session-title/tests/session-title.spec.ts
  55. 9 9
      packages/skill/tool-skill/tests/tool-skill.spec.ts
  56. 1 1
      packages/subagent/subagent/src/descriptor-seed.ts
  57. 1 1
      packages/tasks/tasks-local/tests/tasks.spec.ts
  58. 1 1
      packages/todo/tool-todo/tests/tool-todo.spec.ts
  59. 1 1
      packages/ui/permission/tests/permission.spec.ts
  60. 1 1
      packages/ui/user-approval/tests/approval.spec.ts
  61. 1 1
      packages/ui/user-approval/tests/invariant.spec.ts

+ 1 - 1
apps/web/tests/chat-scroll-fixture.ts

@@ -179,7 +179,7 @@ function fixtureLog(session: Session): string {
 export function createChatScrollFixture(options: ChatScrollFixtureOptions): ChatScrollFixture {
   const turns = options.turns ?? DEFAULT_TURNS
   const markers = markerHelpers(options.markerPrefix)
-  const session = new Session(SessionId(`chat-scroll-${options.markerPrefix.toLowerCase()}-template`))
+  const session = Session.create(SessionId(`chat-scroll-${options.markerPrefix.toLowerCase()}-template`))
 
   for (let turn = 1; turn <= turns; turn += 1) {
     session.append('turn/start', {

+ 2 - 2
apps/web/tests/complex-history.perf.ts

@@ -319,7 +319,7 @@ function fixtureLog(session: Session): string {
 }
 
 function smallSidebarFixture(): string {
-  const session = new Session(SessionId('perf-small-template'))
+  const session = Session.create(SessionId('perf-small-template'))
   session.append('turn/start', {
     turn: 1,
     trigger: { kind: 'message', source: { kind: 'user' } },
@@ -342,7 +342,7 @@ function smallSidebarFixture(): string {
 }
 
 function longHistoryFixture(): string {
-  const session = new Session(SessionId(LONG_SESSION_ID))
+  const session = Session.create(SessionId(LONG_SESSION_ID))
   for (let turn = 1; turn <= LONG_HISTORY_TURNS; turn += 1) {
     session.append('turn/start', {
       turn,

+ 1 - 1
apps/web/tests/markdown-images.e2e.ts

@@ -82,7 +82,7 @@ async function stopServer(server: Server): Promise<void> {
 
 /** Build one closed, invariant-checked session fixture with remote and local image Markdown. */
 function markdownImageFixture(remoteUrl: string): string {
-  const session = new Session(SessionId('markdown-image-source'))
+  const session = Session.create(SessionId('markdown-image-source'))
   session.append('turn/start', {
     turn: 1,
     trigger: { kind: 'message', source: { kind: 'user' } },

+ 1 - 1
packages/compact/command-compact/tests/command-compact.spec.ts

@@ -67,7 +67,7 @@ async function harness(): Promise<Harness> {
   await ctx.plugin(CommandService)
   const compact = new StubCompactService(ctx)
   const plugin = await ctx.plugin(commandCompact)
-  const session = new Session(SessionId('command-compact'))
+  const session = Session.create(SessionId('command-compact'))
   const agent = {
     session,
     status: 'idle',

+ 1 - 1
packages/compact/command-compact/tests/loader-composition.spec.ts

@@ -92,7 +92,7 @@ describe('command-compact real Loader composition', () => {
     })
     await context.loader.await()
 
-    const session = new Session(SessionId('loader-command-compact'))
+    const session = Session.create(SessionId('loader-command-compact'))
     const agent = {
       session,
       status: 'idle',

+ 14 - 14
packages/compact/compact-basic/tests/compact-basic.spec.ts

@@ -103,7 +103,7 @@ function promptInput(text: string): SummarizationInput {
 
 /** Closed two-message turns followed by one open turn for durable compaction events. */
 function conversation(turns = 4, text = 'fixture '.repeat(40).trim()): Session {
-  const session = new Session(SessionId(`conversation-${turns}`))
+  const session = Session.create(SessionId(`conversation-${turns}`))
   for (let turn = 1; turn <= turns; turn += 1) {
     session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
     session.append('user/message', createUserMessage({
@@ -140,7 +140,7 @@ function conversation(turns = 4, text = 'fixture '.repeat(40).trim()): Session {
 }
 
 function toolConversation(): Session {
-  const session = new Session(SessionId('tools'))
+  const session = Session.create(SessionId('tools'))
   for (let turn = 1; turn <= 3; turn += 1) {
     const callId = CallId(`call-${turn}`)
     session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -189,7 +189,7 @@ function toolConversation(): Session {
 
 /** One closed routed tool step followed by an open turn for rewrite events. */
 function oversizedToolResult(chars = 3_000, withCompactablePrompt = false): Session {
-  const session = new Session(SessionId(`oversized-tool-${chars}`))
+  const session = Session.create(SessionId(`oversized-tool-${chars}`))
   const callId = CallId('oversized')
   session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
   if (withCompactablePrompt) {
@@ -485,7 +485,7 @@ describe('pressure measurement and retention', () => {
 
   it('skips when no durable routed model exists instead of using AgentOptions fallback', async () => {
     const compact = service(compactConfig)
-    const session = new Session(SessionId('headerless'))
+    const session = Session.create(SessionId('headerless'))
     session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
     await expect(compact.compactIfNeeded(agent(session, MODEL), 'pressure', SIGNAL))
       .resolves.toBeNull()
@@ -567,7 +567,7 @@ describe('pressure measurement and retention', () => {
 
   it('declines forced overflow when the whole surface is one indivisible tool pair', async () => {
     const compact = service(compactConfig)
-    const session = new Session(SessionId('single-tool-pair'))
+    const session = Session.create(SessionId('single-tool-pair'))
     const callId = CallId('single-call')
     session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
     session.append('step/start', { turn: 1, step: 1 })
@@ -658,7 +658,7 @@ describe('pressure measurement and retention', () => {
 
   it('declines when envelope pressure is high but the surface has no compactable range', async () => {
     const compact = service(compactConfig)
-    const empty = new Session(SessionId('empty'))
+    const empty = Session.create(SessionId('empty'))
     empty.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
     empty.append('request/header', {
       header: { config: { provider: MODEL, model: MODEL }, system: 'x'.repeat(100_000) },
@@ -733,7 +733,7 @@ describe('pressure measurement and retention', () => {
 
   it('declines when rounding a cut would consume the only tool pair', () => {
     const ctx = createContext()
-    const session = new Session(SessionId('one-tool-pair'))
+    const session = Session.create(SessionId('one-tool-pair'))
     const callId = CallId('only')
     session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
     session.append('step/start', { turn: 1, step: 1 })
@@ -874,7 +874,7 @@ describe('compaction region transaction', () => {
     expect(head.content[0]?.type === 'text' ? head.content[0].text : '').toContain('<compacted-summary>')
     expect(head.content.at(-1)).toEqual({ type: 'text', text: '</compacted-summary>' })
 
-    const replay = new Session(SessionId('replay'), [...session.events])
+    const replay = Session.create(SessionId('replay'), [...session.events])
     expect(replay.deriveMessages()).toEqual(session.deriveMessages())
   })
 
@@ -956,7 +956,7 @@ describe('compaction region transaction', () => {
 
   it('rejects a session with no turn boundary at all', async () => {
     const compact = service()
-    const session = new Session(SessionId('turnless'))
+    const session = Session.create(SessionId('turnless'))
     session.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'orphan' }],
       source: { kind: 'user' },
@@ -1076,7 +1076,7 @@ describe('compaction region transaction', () => {
 
   it('lets a model-independent custom summarizer compact without a conversation model', async () => {
     const compact = service()
-    const session = new Session(SessionId('model-less-region'))
+    const session = Session.create(SessionId('model-less-region'))
     session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
     session.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'history '.repeat(100) }],
@@ -1313,13 +1313,13 @@ describe('default one-shot summarizer', () => {
     await ctx.plugin(LlmService)
     void new TokenMeterService(ctx)
     const compact = new ExposedCompactService(ctx, { auto: false })
-    await expect(compact.runSummarize(promptInput('history'), agent(new Session(SessionId('model-less')))))
+    await expect(compact.runSummarize(promptInput('history'), agent(Session.create(SessionId('model-less')))))
       .rejects.toThrow(/no provider\/model available for summarization/)
   })
 
   it('uses a complete AgentOptions target when no durable route exists', async () => {
     const { adapter, compact } = await summarizerHarness([{ type: 'text', text: 'summary' }])
-    const session = new Session(SessionId('headerless-summary'))
+    const session = Session.create(SessionId('headerless-summary'))
 
     await expect(compact.runSummarize(promptInput('history'), agent(session, MODEL))).resolves.toMatchObject({
       provider: MODEL,
@@ -1335,7 +1335,7 @@ describe('default one-shot summarizer', () => {
   ])('rejects incomplete AgentOptions target %#', async (options) => {
     const { compact } = await summarizerHarness([{ type: 'text', text: 'unused' }])
     const owner = {
-      session: new Session(SessionId(`incomplete-${String(options.model)}`)),
+      session: Session.create(SessionId(`incomplete-${String(options.model)}`)),
       options,
     } as Agent
     await expect(compact.runSummarize(promptInput('history'), owner))
@@ -1698,7 +1698,7 @@ describe('automatic listener and loader composition', () => {
   it('delegates canonical overflow when no durable routed target exists', async () => {
     const ctx = createContext()
     void new TestCompactService(ctx)
-    const session = new Session(SessionId('headerless-overflow'))
+    const session = Session.create(SessionId('headerless-overflow'))
     session.append('turn/start', {
       turn: 1,
       trigger: { kind: 'message', source: { kind: 'user' } },

+ 1 - 1
packages/compact/compact-basic/tests/compact-loop-repro.spec.ts

@@ -185,7 +185,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
 }
 
 function overflowHistorySeed(): SessionEvent[] {
-  const session = new Session(SessionId('overflow-history-seed'))
+  const session = Session.create(SessionId('overflow-history-seed'))
   for (let turn = 1; turn <= 2; turn += 1) {
     const sentinel = turn === 1 ? 'OLD HISTORY SENTINEL' : 'RECENT HISTORY'
     session.append('turn/start', {

+ 6 - 6
packages/compact/compact-basic/tests/manual-compact.spec.ts

@@ -166,7 +166,7 @@ function deferred(): { promise: Promise<undefined>; resolve: () => void } {
 
 /** A closed-tail session with compactable exchanges and no live agent. */
 function closedConversation(turns = 2, lastTurnNumber = turns): Session {
-  const session = new Session(SessionId(`closed-${turns}-${lastTurnNumber}`))
+  const session = Session.create(SessionId(`closed-${turns}-${lastTurnNumber}`))
   for (let index = 1; index <= turns; index += 1) {
     const turn = index === turns ? lastTurnNumber : index
     session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -368,7 +368,7 @@ describe('compactNow through the real loop', () => {
 describe('compactNow transaction and failure classification', () => {
   it('returns null without writing a bracket for history that cannot be compacted', async () => {
     const { compact } = detachedService()
-    const session = new Session(SessionId('empty'))
+    const session = Session.create(SessionId('empty'))
     let released = 0
     const agent = fakeAgent(session, () => () => { released += 1 })
 
@@ -410,7 +410,7 @@ describe('compactNow transaction and failure classification', () => {
     const { compact } = detachedService()
     const original = closedConversation(2)
     original.append('compact/start', { turn: null })
-    const reloaded = new Session(SessionId('stale-orphan'), [...original.events])
+    const reloaded = Session.create(SessionId('stale-orphan'), [...original.events])
     const boundary = reloaded.events.findLast(event => event.type === 'session/end-seed')
     const orphan = reloaded.events.find(event => event.type === 'compact/start')
     const agent = fakeAgent(reloaded, () => () => undefined)
@@ -426,7 +426,7 @@ describe('compactNow transaction and failure classification', () => {
     original.append('compact/start', { turn: null })
     original.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
     original.append('turn/end', { turn: 3, reason: { kind: 'interrupted' } })
-    const reloaded = new Session(SessionId('reloaded-orphan'), [...original.events])
+    const reloaded = Session.create(SessionId('reloaded-orphan'), [...original.events])
     const agent = fakeAgent(reloaded, () => () => undefined)
 
     await expect(compact.compactNow(agent, SIGNAL)).resolves.not.toBeNull()
@@ -638,7 +638,7 @@ describe('compactNow transaction and failure classification', () => {
 
   it('compacts a session with no durable turn boundary without creating one', async () => {
     const { compact } = detachedService()
-    const session = new Session(SessionId('turnless'))
+    const session = Session.create(SessionId('turnless'))
     for (const text of [PROMPT, 'recent tail']) {
       session.append('user/message', createUserMessage({
         content: [{ type: 'text', text }],
@@ -671,7 +671,7 @@ describe('compactNow transaction and failure classification', () => {
   it('lets a pre-aborted signal win before reservation, measurement, or summarization', async () => {
     const cases = [
       { name: 'busy', session: closedConversation(2), release: undefined },
-      { name: 'empty', session: new Session(SessionId('pre-aborted-empty')), release: () => undefined },
+      { name: 'empty', session: Session.create(SessionId('pre-aborted-empty')), release: () => undefined },
       { name: 'compactable', session: closedConversation(2, 9), release: () => undefined },
     ] as const
 

+ 4 - 4
packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts

@@ -153,7 +153,7 @@ describe('ToolResultPruneService content transform', () => {
 
 describe('ToolResultPruneService session transaction', () => {
   it('prunes a stable snapshot, preserves all data, and records provenance', () => {
-    const session = new Session(SessionId('preserve'))
+    const session = Session.create(SessionId('preserve'))
     const originalSeq = appendToolStep(session, 1, 'one', [{
       type: 'text',
       text: 'x'.repeat(100),
@@ -208,7 +208,7 @@ describe('ToolResultPruneService session transaction', () => {
   })
 
   it('prunes multiple results, skips short ones, and converges in one pass', () => {
-    const session = new Session(SessionId('multiple'))
+    const session = Session.create(SessionId('multiple'))
     appendToolStep(session, 1, 'a', [{ type: 'text', text: 'A'.repeat(100) }])
     appendToolStep(session, 2, 'b', [{ type: 'text', text: 'short' }])
     appendToolStep(session, 3, 'c', [{ type: 'text', text: 'C'.repeat(80) }])
@@ -227,14 +227,14 @@ describe('ToolResultPruneService session transaction', () => {
   })
 
   it('replays to the identical pruned model messages', () => {
-    const session = new Session(SessionId('replay'))
+    const session = Session.create(SessionId('replay'))
     appendToolStep(session, 1, 'a', [{ type: 'text', text: 'A'.repeat(100) }])
     session.append('turn/start', {
       turn: 2,
       trigger: { kind: 'message', source: { kind: 'user' } },
     })
     service().pruneSession(session)
-    const replay = new Session(session.id, [...session.events])
+    const replay = Session.create(session.id, [...session.events])
     expect(replay.deriveMessages()).toEqual(session.deriveMessages())
     expect(replay.surface.replaceGeneration).toBe(session.surface.replaceGeneration)
   })

+ 3 - 3
packages/compact/compact/tests/compact.spec.ts

@@ -105,7 +105,7 @@ describe('CompactService seam', () => {
   it('exposes the abstract contract methods', async () => {
     const ctx = new Context()
     const svc = new StubCompactService(ctx)
-    const session = new Session(SessionId('s'))
+    const session = Session.create(SessionId('s'))
     expect(await svc.compactIfNeeded(stubAgent(session), 'pressure', new AbortController().signal)).toBeNull()
     const signal = new AbortController().signal
     expect(await svc.compactNow({
@@ -118,7 +118,7 @@ describe('CompactService seam', () => {
   it('compact/* events merge into SessionEventMap and are log-only', async () => {
     const ctx = new Context()
     const svc = new StubCompactService(ctx)
-    const session = new Session(SessionId('s'))
+    const session = Session.create(SessionId('s'))
     const original = session.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'original' }],
       source: { kind: 'user' },
@@ -149,7 +149,7 @@ describe('CompactService seam', () => {
   it('threads the cancellation signal through to the backend', async () => {
     const ctx = new Context()
     const svc = new StubCompactService(ctx)
-    const session = new Session(SessionId('s'))
+    const session = Session.create(SessionId('s'))
     const controller = new AbortController()
     const original = session.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'original' }],

+ 5 - 5
packages/compact/compact/tests/invariant.spec.ts

@@ -56,7 +56,7 @@ describe('compaction invariants', () => {
   it('clears an inherited open compaction trace at end-seed during replay', async () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)
-    const source = new Session(SessionId('stale-compaction-source'))
+    const source = Session.create(SessionId('stale-compaction-source'))
     source.append('compact/start', { turn: null })
     const replayed = ctx.sessions.create(SessionId('stale-compaction-replay'), {
       seed: source.events,
@@ -76,7 +76,7 @@ describe('compaction invariants', () => {
   it('allows repair turn boundaries after end-seed clears a seeded numbered orphan', async () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)
-    const source = new Session(SessionId('stale-numbered-compaction-source'))
+    const source = Session.create(SessionId('stale-numbered-compaction-source'))
     startTurn(source)
     source.append('compact/start', { turn: 1 })
     const replayed = ctx.sessions.create(SessionId('stale-numbered-compaction-replay'), {
@@ -97,7 +97,7 @@ describe('compaction invariants', () => {
   it('accepts inherited repair boundaries before the end-seed that clears a standalone orphan', async () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)
-    const source = new Session(SessionId('stale-repaired-compaction-source'))
+    const source = Session.create(SessionId('stale-repaired-compaction-source'))
     source.append('compact/start', { turn: null })
     startTurn(source)
     source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
@@ -123,7 +123,7 @@ describe('compaction invariants', () => {
   it('rejects a closed standalone bracket that contains a turn before end-seed', async () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)
-    const source = new Session(SessionId('closed-nested-compaction-source'))
+    const source = Session.create(SessionId('closed-nested-compaction-source'))
     source.append('compact/start', { turn: null })
     startTurn(source)
     source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
@@ -152,7 +152,7 @@ describe('compaction invariants', () => {
 
   it('adopts a bare session and ignores unrelated committed events', async () => {
     const ctx = await setup()
-    const session = new Session(SessionId('bare-compaction-session'))
+    const session = Session.create(SessionId('bare-compaction-session'))
     expect(() => {
       ctx.emit('session/event', session, {
         type: 'turn/start', seq: 0, time: 0,

+ 8 - 8
packages/compact/compact/tests/tool-pairing.spec.ts

@@ -25,7 +25,7 @@ function after(session: Session, type: SessionEvent['type'], nth = 0): boolean {
 }
 
 function closedToolStep(): Session {
-  const session = new Session(SessionId('closed-tool-step'))
+  const session = Session.create(SessionId('closed-tool-step'))
   session.append('user/message', createUserMessage({
     content: [{ type: 'text', text: 'go' }],
     source: { kind: 'user' },
@@ -64,7 +64,7 @@ describe('tool-pairing boundaries', () => {
     expect(before(closed, 'tool/result')).toBe(false)
     expect(after(closed, 'tool/result')).toBe(true)
 
-    const open = new Session(SessionId('open-tool-step'))
+    const open = Session.create(SessionId('open-tool-step'))
     open.append('assistant/message', {
       turn: 1,
       step: 1,
@@ -81,7 +81,7 @@ describe('tool-pairing boundaries', () => {
   })
 
   it('requires every result from a multiple-call assistant message', () => {
-    const session = new Session(SessionId('multiple-calls'))
+    const session = Session.create(SessionId('multiple-calls'))
     session.append('assistant/message', {
       turn: 1,
       step: 1,
@@ -119,7 +119,7 @@ describe('tool-pairing boundaries', () => {
   })
 
   it('keeps neutral nodes inside an open pair unbalanced and free nodes balanced', () => {
-    const midStep = new Session(SessionId('neutral-mid-step'))
+    const midStep = Session.create(SessionId('neutral-mid-step'))
     midStep.append('assistant/message', {
       turn: 1,
       step: 1,
@@ -147,7 +147,7 @@ describe('tool-pairing boundaries', () => {
     expect(before(midStep, 'user/message')).toBe(false)
     expect(after(midStep, 'user/message')).toBe(false)
 
-    const free = new Session(SessionId('neutral-free'))
+    const free = Session.create(SessionId('neutral-free'))
     free.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'idle injection' }],
       source: { kind: 'user' },
@@ -187,7 +187,7 @@ describe('tool-pairing surface identity', () => {
   })
 
   it('rejects missing seqs before and after, including an empty surface', () => {
-    const session = new Session(SessionId('missing-membership'))
+    const session = Session.create(SessionId('missing-membership'))
     const missing = 999
     expect(() => toolPairingBalancedBefore(session, missing)).toThrow(/surface seq 999 not found/)
     expect(() => toolPairingBalancedAfter(session, missing)).toThrow(/surface seq 999 not found/)
@@ -367,7 +367,7 @@ describe('tool-pairing cache refresh', () => {
 
 describe('tool-pairing corrupt surfaces', () => {
   it('throws for an orphan result during a rebuild', () => {
-    const session = new Session(SessionId('orphan-rebuild'))
+    const session = Session.create(SessionId('orphan-rebuild'))
     session.append('tool/result', {
       turn: 1, step: 1,
       message: createToolResultMessage({
@@ -380,7 +380,7 @@ describe('tool-pairing corrupt surfaces', () => {
   })
 
   it('retries an orphan result in an appended tail without committing partial cache state', () => {
-    const session = new Session(SessionId('orphan-tail'))
+    const session = Session.create(SessionId('orphan-tail'))
     session.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'safe head' }], source: { kind: 'user' },
     }), SURFACE)

+ 1 - 1
packages/context/session-reference/tests/session-reference.spec.ts

@@ -639,7 +639,7 @@ describe('session reference discovery and preparation', () => {
     expect(JSON.stringify(before)).toContain('durable referenced fact')
     expect(JSON.stringify(before)).toContain('use @source')
     expect(JSON.stringify(before)).not.toContain('later source mutation')
-    expect(new Session(SessionId('replayed-target'), target.events).deriveMessages()).toEqual(before)
+    expect(Session.create(SessionId('replayed-target'), target.events).deriveMessages()).toEqual(before)
   })
 
   it('rejects direct invalid configuration before service publication', async () => {

+ 2 - 2
packages/context/time-context/tests/invariant.spec.ts

@@ -44,7 +44,7 @@ function reading(
 }
 
 function preparing(turn: number, step: number): Session {
-  const session = new Session(SessionId(`time-invariant-${turn}-${step}`))
+  const session = Session.create(SessionId(`time-invariant-${turn}-${step}`))
   for (let priorTurn = 1; priorTurn < turn; priorTurn += 1) {
     session.append('turn/start', { turn: priorTurn, trigger: { kind: 'message', source: { kind: 'user' } } })
     session.append('turn/end', { turn: priorTurn, reason: { kind: 'completed' } })
@@ -136,7 +136,7 @@ describe('time-context invariants', () => {
     started.append('step/start', { turn: 1, step: 1 })
     expect(() => { ctx.emit('session/event', started, event(reading())) }).toThrow(/must precede step\/start/)
     expect(() => {
-      ctx.emit('session/event', new Session(SessionId('time-invariant-empty')), event(reading()))
+      ctx.emit('session/event', Session.create(SessionId('time-invariant-empty')), event(reading()))
     }).toThrow(/inside an open turn/)
   })
 

+ 14 - 14
packages/context/time-context/tests/time-context.spec.ts

@@ -141,7 +141,7 @@ function requestText(request: GenerateOptions): string {
 describe('durable step context', () => {
   it('records turn, step, zoned time, and the preceding model-visible message baseline', async () => {
     const { ctx } = await mount({ timeZone: 'Asia/Shanghai' })
-    const session = new Session(SessionId('first'))
+    const session = Session.create(SessionId('first'))
     openMessageTurn(session, 1)
     vi.setSystemTime(BASE + 90_061_000)
 
@@ -160,7 +160,7 @@ describe('durable step context', () => {
 
   it('reports an unavailable first-step baseline when no model-visible message precedes it', async () => {
     const { ctx } = await mount()
-    const session = new Session(SessionId('unavailable'))
+    const session = Session.create(SessionId('unavailable'))
     session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
 
     await fire(ctx, sessionAgent(session), 1, 1)
@@ -175,7 +175,7 @@ describe('durable step context', () => {
     ['zero interval', { refreshIntervalMs: 0 }],
   ] as const)('uses the preceding durable step-context timestamp after step one with %s', async (_label, config) => {
     const { ctx } = await mount(config)
-    const session = new Session(SessionId('later-step'))
+    const session = Session.create(SessionId('later-step'))
     const agent = sessionAgent(session)
     openMessageTurn(session, 3)
     await fire(ctx, agent, 3, 1)
@@ -191,7 +191,7 @@ describe('durable step context', () => {
 
   it('reports an unavailable later-step baseline at the matching turn boundary', async () => {
     const { ctx } = await mount()
-    const session = new Session(SessionId('later-step-boundary'))
+    const session = Session.create(SessionId('later-step-boundary'))
     openMessageTurn(session, 4)
 
     await fire(ctx, sessionAgent(session), 4, 2)
@@ -203,7 +203,7 @@ describe('durable step context', () => {
 
   it('reports an unavailable later-step baseline when event lookup is exhausted', async () => {
     const { ctx } = await mount()
-    const session = new Session(SessionId('later-step-exhausted'))
+    const session = Session.create(SessionId('later-step-exhausted'))
 
     await fire(ctx, sessionAgent(session), 1, 2)
 
@@ -214,7 +214,7 @@ describe('durable step context', () => {
 
   it('injects after backward wall-clock movement and clamps elapsed time to zero', async () => {
     const { ctx } = await mount({ refreshIntervalMs: 60_000 })
-    const session = new Session(SessionId('backward'))
+    const session = Session.create(SessionId('backward'))
     const agent = sessionAgent(session)
     openMessageTurn(session, 1)
     await fire(ctx, agent, 1, 1)
@@ -228,7 +228,7 @@ describe('durable step context', () => {
 
   it('uses a shadowed durable injection after resume and injects at the exact threshold', async () => {
     const { ctx } = await mount({ refreshIntervalMs: 1_000 })
-    const original = new Session(SessionId('seed-source'))
+    const original = Session.create(SessionId('seed-source'))
     openMessageTurn(original, 1)
     await fire(ctx, sessionAgent(original), 1, 1)
     const user = original.events.find(event => event.type === 'user/message' && event.data.source.kind === 'user')
@@ -244,7 +244,7 @@ describe('durable step context', () => {
     original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
     expect(JSON.stringify(original.deriveMessages())).not.toContain('Time sampled while preparing')
 
-    const resumed = new Session(SessionId('resumed'), [...original.events])
+    const resumed = Session.create(SessionId('resumed'), [...original.events])
     const resumedAgent = sessionAgent(resumed)
     vi.setSystemTime(BASE + 999)
     openMessageTurn(resumed, 2)
@@ -266,7 +266,7 @@ describe('durable step context', () => {
 
   it('applies a positive interval across turns without sharing state between sessions', async () => {
     const { ctx } = await mount({ refreshIntervalMs: 1_000 })
-    const first = new Session(SessionId('interval-first'))
+    const first = Session.create(SessionId('interval-first'))
     const firstAgent = sessionAgent(first, 'first-agent')
     openMessageTurn(first, 1)
     await fire(ctx, firstAgent, 1, 1)
@@ -277,7 +277,7 @@ describe('durable step context', () => {
     const beforeSkip = first.events.length
     await fire(ctx, firstAgent, 2, 1)
 
-    const independent = new Session(SessionId('interval-independent'))
+    const independent = Session.create(SessionId('interval-independent'))
     openMessageTurn(independent, 1)
     await fire(ctx, sessionAgent(independent, 'independent-agent'), 1, 1)
 
@@ -288,7 +288,7 @@ describe('durable step context', () => {
 
   it('runs before ordinary pre-step listeners and skips an already-aborted step', async () => {
     const { ctx } = await mount()
-    const session = new Session(SessionId('ordering'))
+    const session = Session.create(SessionId('ordering'))
     const agent = sessionAgent(session)
     openMessageTurn(session, 1)
     let ordinarySawContext = false
@@ -311,7 +311,7 @@ describe('configuration and lifecycle', () => {
     process.env['TZ'] = 'Asia/Shanghai'
     const { ctx } = await mount()
     process.env['TZ'] = 'America/New_York'
-    const session = new Session(SessionId('system-zone'))
+    const session = Session.create(SessionId('system-zone'))
     openMessageTurn(session, 1)
 
     await fire(ctx, sessionAgent(session), 1, 1)
@@ -345,7 +345,7 @@ describe('configuration and lifecycle', () => {
 
   it('removes its listener when the plugin fiber disposes', async () => {
     const { ctx, fiber } = await mount()
-    const session = new Session(SessionId('dispose'))
+    const session = Session.create(SessionId('dispose'))
     const agent = sessionAgent(session)
     openMessageTurn(session, 1)
     await fire(ctx, agent, 1, 1)
@@ -445,7 +445,7 @@ describe('real Loader export path', () => {
     await ctx.plugin(AgentRegistry)
     const plugin = loader.unwrapExports(timeContext) as Parameters<Context['plugin']>[0]
     await ctx.plugin(plugin)
-    const session = new Session(SessionId('loader'))
+    const session = Session.create(SessionId('loader'))
     openMessageTurn(session, 1)
     await fire(ctx, sessionAgent(session), 1, 1)
     expect(contextTexts(session)[0]).toContain('Time sampled while preparing turn 1, step 1:')

+ 15 - 15
packages/context/tmux-context/tests/tmux-context.spec.ts

@@ -150,7 +150,7 @@ afterEach(() => {
 describe('tmux-context injection', () => {
   it('injects the tmux location on the first step of a turn', async () => {
     const { ctx } = await mount({}, true)
-    const session = new Session(SessionId('first'))
+    const session = Session.create(SessionId('first'))
     openMessageTurn(session, 1)
 
     await fire(ctx, sessionAgent(session), 1, 1)
@@ -169,7 +169,7 @@ describe('tmux-context injection', () => {
 
   it('queries the pane this process runs in and matches its controlling tty', async () => {
     const { ctx, bash } = await mount({}, true)
-    const session = new Session(SessionId('command'))
+    const session = Session.create(SessionId('command'))
     openMessageTurn(session, 1)
 
     await fire(ctx, sessionAgent(session), 1, 1)
@@ -187,7 +187,7 @@ describe('tmux-context injection', () => {
 
   it('does not run on later steps of a turn', async () => {
     const { ctx, bash } = await mount({}, true)
-    const session = new Session(SessionId('later-step'))
+    const session = Session.create(SessionId('later-step'))
     openMessageTurn(session, 1)
 
     await fire(ctx, sessionAgent(session), 1, 2)
@@ -198,7 +198,7 @@ describe('tmux-context injection', () => {
 
   it('re-injects a new turn only when tmux state changed', async () => {
     const { ctx, bash } = await mount({}, true)
-    const session = new Session(SessionId('change'))
+    const session = Session.create(SessionId('change'))
     const agent = sessionAgent(session)
 
     openMessageTurn(session, 1)
@@ -226,7 +226,7 @@ describe('tmux-context injection', () => {
     vi.useFakeTimers()
     vi.setSystemTime(1_000)
     const { ctx, bash } = await mount({ refreshIntervalMs: 10_000 }, true)
-    const session = new Session(SessionId('interval'))
+    const session = Session.create(SessionId('interval'))
     const agent = sessionAgent(session)
 
     openMessageTurn(session, 1)
@@ -253,7 +253,7 @@ describe('tmux-context injection', () => {
 describe('tmux-context prior-reading resilience', () => {
   it('treats a prior non-text plugin reading as absent and injects afresh', async () => {
     const { ctx, bash } = await mount({}, true)
-    const session = new Session(SessionId('prior-non-text'))
+    const session = Session.create(SessionId('prior-non-text'))
     const agent = sessionAgent(session)
     openMessageTurn(session, 1)
     session.append('user/message', createUserMessage({
@@ -269,7 +269,7 @@ describe('tmux-context prior-reading resilience', () => {
 
   it('treats a prior single-line plugin reading (no newline) as empty state', async () => {
     const { ctx, bash } = await mount({}, true)
-    const session = new Session(SessionId('prior-single-line'))
+    const session = Session.create(SessionId('prior-single-line'))
     const agent = sessionAgent(session)
     openMessageTurn(session, 1)
     session.append('user/message', createUserMessage({
@@ -288,7 +288,7 @@ describe('tmux-context prior-reading resilience', () => {
 describe('tmux-context no-op paths', () => {
   it('is a no-op when no bash executor is mounted', async () => {
     const { ctx } = await mount()
-    const session = new Session(SessionId('no-bash'))
+    const session = Session.create(SessionId('no-bash'))
     openMessageTurn(session, 1)
 
     await fire(ctx, sessionAgent(session), 1, 1)
@@ -299,7 +299,7 @@ describe('tmux-context no-op paths', () => {
   it('is a no-op when the tmux query exits nonzero (outside tmux, or an inherited env whose tty does not match the pane)', async () => {
     const { ctx, bash } = await mount({}, true)
     bash.result = runResult('', { exitCode: 1 })
-    const session = new Session(SessionId('outside-tmux'))
+    const session = Session.create(SessionId('outside-tmux'))
     openMessageTurn(session, 1)
 
     await fire(ctx, sessionAgent(session), 1, 1)
@@ -310,7 +310,7 @@ describe('tmux-context no-op paths', () => {
   it('is a no-op when the reading has the wrong field count', async () => {
     const { ctx, bash } = await mount({}, true)
     bash.result = runResult('0\\t1\\tnode\n')
-    const session = new Session(SessionId('malformed'))
+    const session = Session.create(SessionId('malformed'))
     openMessageTurn(session, 1)
 
     await fire(ctx, sessionAgent(session), 1, 1)
@@ -321,7 +321,7 @@ describe('tmux-context no-op paths', () => {
   it('is a no-op when the pane id is empty', async () => {
     const { ctx, bash } = await mount({}, true)
     bash.result = runResult(`${tmuxLine({ paneId: '' })}\n`)
-    const session = new Session(SessionId('empty-pane'))
+    const session = Session.create(SessionId('empty-pane'))
     openMessageTurn(session, 1)
 
     await fire(ctx, sessionAgent(session), 1, 1)
@@ -333,7 +333,7 @@ describe('tmux-context no-op paths', () => {
     const { ctx, bash } = await mount({}, true)
     bash.runError = new Error('bash executor unavailable')
     const warn = vi.spyOn(ctx.logger, 'warn')
-    const session = new Session(SessionId('run-rejected'))
+    const session = Session.create(SessionId('run-rejected'))
     openMessageTurn(session, 1)
 
     await fire(ctx, sessionAgent(session), 1, 1)
@@ -346,7 +346,7 @@ describe('tmux-context no-op paths', () => {
     const { ctx, bash } = await mount({}, true)
     bash.resolveError = new Error('command denied by policy')
     const warn = vi.spyOn(ctx.logger, 'warn')
-    const session = new Session(SessionId('resolve-rejected'))
+    const session = Session.create(SessionId('resolve-rejected'))
     openMessageTurn(session, 1)
 
     await fire(ctx, sessionAgent(session), 1, 1)
@@ -360,7 +360,7 @@ describe('tmux-context no-op paths', () => {
     // Non-Error throw: the executor seam is typed, but a bad impl can reject with anything.
     bash.runError = 'spawn refused' as unknown as Error
     const warn = vi.spyOn(ctx.logger, 'warn')
-    const session = new Session(SessionId('non-error-rejection'))
+    const session = Session.create(SessionId('non-error-rejection'))
     openMessageTurn(session, 1)
 
     await fire(ctx, sessionAgent(session), 1, 1)
@@ -371,7 +371,7 @@ describe('tmux-context no-op paths', () => {
 
   it('skips an already-aborted step and runs before ordinary agent/step listeners', async () => {
     const { ctx } = await mount({}, true)
-    const session = new Session(SessionId('ordering'))
+    const session = Session.create(SessionId('ordering'))
     const agent = sessionAgent(session)
     openMessageTurn(session, 1)
     let ordinarySawContext = false

+ 1 - 1
packages/context/workspace-context/tests/workspace-context.spec.ts

@@ -170,7 +170,7 @@ async function mountFileToolsAndWorkspaceContext(ctx: Context, config: workspace
 
 function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
   const id = SessionId('s1')
-  const session = new Session(id, seed, cwd === undefined ? undefined : { version: SESSION_FORMAT_VERSION, id, createdAt: 0, cwd })
+  const session = Session.create(id, seed, cwd === undefined ? undefined : { version: SESSION_FORMAT_VERSION, id, createdAt: 0, cwd })
   return {
     ctx: new Context(),
     id: SessionId('a1'),

+ 1 - 1
packages/core/agent-loop/src/invariant.ts

@@ -44,7 +44,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
     if (header === undefined) {
       return fail('a loop-built request with no request/header event in its session log')
     }
-    const rebuilt = new Session(
+    const rebuilt = Session.create(
       SessionId(`${String(session.id)}-invariant-rebuild`),
       structuredClone(events.slice(0, boundary)),
     )

+ 2 - 2
packages/core/agent-loop/tests/contract-regressions.spec.ts

@@ -203,7 +203,7 @@ describe('addressable inbox operations', () => {
 
   it('keeps a queued occurrence when the next-step window is closed', () => {
     const ctx = new Context()
-    const session = new Session(SessionId('queue-to-steer-closed'))
+    const session = Session.create(SessionId('queue-to-steer-closed'))
     const agent = new ReactLoopAgent(ctx, session.id, {}, session)
     const enqueued: InboxItem[] = []
     const discarded: InboxItem[] = []
@@ -772,7 +772,7 @@ describe('turn numbering continues across seeded sessions', () => {
 
 describe('discriminated SessionEvent narrows without casts', () => {
   it('narrows event.data from event.type', () => {
-    const session = new Session(SessionId('s'))
+    const session = Session.create(SessionId('s'))
     const appended: SessionEvent = session.append('tool/call', {
       turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}',
     })

+ 1 - 1
packages/core/agent-loop/tests/request-reconstruction.spec.ts

@@ -597,7 +597,7 @@ describe('request stability across the loop', () => {
       const stepStart = stepStarts[index]!
       // Messages: the derivation over the log prefix strictly before this
       // step's step/start — rebuilt here through a completely fresh Session.
-      const rebuilt = new Session(SessionId(`rebuild-${index}`), structuredClone(events.slice(0, stepStart.seq)))
+      const rebuilt = Session.create(SessionId(`rebuild-${index}`), structuredClone(events.slice(0, stepStart.seq)))
       expect(structuredClone(request.messages)).toEqual(rebuilt.deriveMessages())
 
       // Header: the latest request/header snapshot up to this step's dispatch

+ 1 - 1
packages/core/agent-loop/tests/resume.spec.ts

@@ -625,7 +625,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
     expect(a2.session.events.length).toBe(events1.length + 1)
     expect(a2.session.firstLiveSeq).toBe(events1.length)
     expect(a2.session.events.at(-1)?.type).toBe('session/end-seed')
-    const replay = new Session(SessionId('replay'), events1)
+    const replay = Session.create(SessionId('replay'), events1)
     expect(a2.session.deriveMessages()).toEqual(replay.deriveMessages())
 
     // …and a new turn continues numbering (turn 2) with contiguous seqs.

+ 2 - 2
packages/core/agent/tests/agent.spec.ts

@@ -19,7 +19,7 @@ function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
   const agent: Agent = {
     id,
     options: {},
-    session: new Session(id),
+    session: Session.create(id),
     status: 'idle',
     acceptsNextStep: false,
     ctx: new Context(),
@@ -58,7 +58,7 @@ describe('AgentRegistry', () => {
   it('rejects an agent whose registry and session identities differ', async () => {
     const ctx = new Context()
     await ctx.plugin(AgentRegistry)
-    const agent = stubAgent('agent-id', { session: new Session(SessionId('session-id')) })
+    const agent = stubAgent('agent-id', { session: Session.create(SessionId('session-id')) })
 
     expect(() => ctx.agents.enter(agent, undefined))
       .toThrow('agent id "agent-id" does not match session id "session-id"')

+ 16 - 3
packages/core/session/src/index.ts

@@ -378,7 +378,8 @@ const attachments = new WeakMap<Session, SessionEntry>()
 /**
  * An event-sourced session: an append-only log of {@link SessionEvent}s.
  *
- * Plain class (not a Service) — create instances via `ctx.sessions.create()`.
+ * Plain class (not a Service) — create live instances via
+ * `ctx.sessions.create()` and detached instances via {@link create}.
  * Seeding with an existing event log replays/forks a session.
  * @typert object
  */
@@ -433,7 +434,19 @@ export class Session {
    */
   readonly firstLiveSeq: number
 
-  constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) {
+  /**
+   * Create a detached session with the same validation and snapshot semantics
+   * formerly provided by direct construction.
+   * @param id - session identity.
+   * @param seed - optional borrowed replay or fork events.
+   * @param header - optional borrowed storage metadata.
+   * @returns a detached session.
+   */
+  static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session {
+    return new Session(id, seed, header)
+  }
+
+  private constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) {
     if (seed !== undefined) {
       // Validate the seed to the SAME invariants `append` enforces, so a
       // replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a
@@ -843,7 +856,7 @@ export class SessionStore extends Service {
       ...meta?.origin === undefined ? {} : { origin: meta.origin },
       ...meta?.delegationDepth === undefined ? {} : { delegationDepth: meta.delegationDepth },
     }
-    return new Session(sessionId, seed, header)
+    return Session.create(sessionId, seed, header)
   }
 
   /**

+ 7 - 7
packages/core/session/tests/derived-cache.spec.ts

@@ -16,12 +16,12 @@ function userText(session: Session, text: string): void {
 
 /** From-scratch oracle: replay the log into a fresh session and derive. */
 function scratch(session: Session): unknown {
-  return new Session(SessionId(`${session.id}-scratch-${session.seq}`), [...session.events]).deriveMessages()
+  return Session.create(SessionId(`${session.id}-scratch-${session.seq}`), [...session.events]).deriveMessages()
 }
 
 describe('derived-message cache', () => {
   it('stays deep-equal to a from-scratch replay derivation as the log grows', () => {
-    const session = new Session(SessionId('cache-grow'))
+    const session = Session.create(SessionId('cache-grow'))
     session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
     userText(session, 'one')
     expect(session.deriveMessages()).toEqual(scratch(session))
@@ -54,7 +54,7 @@ describe('derived-message cache', () => {
   })
 
   it('rebuilds on a surface replace and still matches scratch', () => {
-    const session = new Session(SessionId('cache-replace'))
+    const session = Session.create(SessionId('cache-replace'))
     session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
     userText(session, 'one')
     userText(session, 'two')
@@ -72,7 +72,7 @@ describe('derived-message cache', () => {
   })
 
   it('returns a fresh array per call: later appends never grow a held snapshot', () => {
-    const session = new Session(SessionId('cache-snapshot'))
+    const session = Session.create(SessionId('cache-snapshot'))
     session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
     userText(session, 'one')
     const first = session.deriveMessages()
@@ -89,7 +89,7 @@ describe('derived-message cache', () => {
 
 describe('Session.deriveEventMessage — the per-event projection', () => {
   it('projects one appended event exactly as the full derivation projects its node', () => {
-    const session = new Session(SessionId('per-event'))
+    const session = Session.create(SessionId('per-event'))
     session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
     const event = session.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
@@ -99,7 +99,7 @@ describe('Session.deriveEventMessage — the per-event projection', () => {
   })
 
   it('reuses the logged event\'s already frozen content', () => {
-    const session = new Session(SessionId('per-event-clone'))
+    const session = Session.create(SessionId('per-event-clone'))
     session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
     const event = session.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'orig' }], source: { kind: 'user' },
@@ -113,7 +113,7 @@ describe('Session.deriveEventMessage — the per-event projection', () => {
   })
 
   it('projects null for events that produce no message (boundaries, empty assistant)', () => {
-    const session = new Session(SessionId('per-event-null'))
+    const session = Session.create(SessionId('per-event-null'))
     session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
     const boundary = session.append('step/start', { turn: 1, step: 1 })
     expect(session.deriveEventMessage(boundary)).toBeNull()

+ 2 - 2
packages/core/session/tests/fork.spec.ts

@@ -217,7 +217,7 @@ describe('SessionStore.fork', () => {
 
   it('rejects a detached Session object that is not live in ctx.sessions', async () => {
     const { sessions } = await setup()
-    const detached = new Session(SessionId('detached'))
+    const detached = Session.create(SessionId('detached'))
 
     expect(() => sessions.fork(detached))
       .toThrow(new SessionForkError('session "detached" not found', 'SESSION_NOT_FOUND'))
@@ -226,7 +226,7 @@ describe('SessionStore.fork', () => {
   it('rejects a stale Session object whose id is live on a different instance', async () => {
     const { ctx, sessions } = await setup()
     ctx.sessions.create(SessionId('same-id'))
-    const stale = new Session(SessionId('same-id'))
+    const stale = Session.create(SessionId('same-id'))
 
     expect(() => sessions.fork(stale))
       .toThrow(new SessionForkError('session "same-id" is not the live store instance', 'SESSION_NOT_LIVE'))

+ 4 - 4
packages/core/session/tests/properties.spec.ts

@@ -82,7 +82,7 @@ const logArb = fc.array(anyEventArb, { maxLength: 25 })
 
 let counter = 0
 function build(events: Appendable[]): Session {
-  const session = new Session(SessionId(`prop-${counter++}`))
+  const session = Session.create(SessionId(`prop-${counter++}`))
   for (const e of events) {
     // Forward the generated intent verbatim; non-surface events carry none.
     if (e.intent !== undefined) session.append(e.type, e.data, e.intent)
@@ -110,7 +110,7 @@ describe('Session properties', () => {
   it('replay-from-seed reproduces the derivation identically', () => {
     fc.assert(fc.property(logArb, (events) => {
       const original = build(events)
-      const replayed = new Session(SessionId(`replay-${counter++}`), [...original.events])
+      const replayed = Session.create(SessionId(`replay-${counter++}`), [...original.events])
       expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
       // Every explicit replay grows by exactly one log-only boundary.
       expect(replayed.events.slice(0, original.seq)).toEqual(original.events)
@@ -121,8 +121,8 @@ describe('Session properties', () => {
   it('replaying a log that already ends in end-seed adds no further marker', () => {
     fc.assert(fc.property(logArb, (events) => {
       const original = build(events)
-      const once = new Session(SessionId(`idem-a-${counter++}`), [...original.events])
-      const twice = new Session(SessionId(`idem-b-${counter++}`), [...once.events])
+      const once = Session.create(SessionId(`idem-a-${counter++}`), [...original.events])
+      const twice = Session.create(SessionId(`idem-b-${counter++}`), [...once.events])
       // Lazy resume makes browsing a pickup, so this must not grow per open.
       expect(twice.events).toEqual(once.events)
     }))

+ 10 - 10
packages/core/session/tests/request-header.spec.ts

@@ -75,7 +75,7 @@ describe('foldRequestHeader', () => {
   })
 
   it('takes the latest full snapshot and skips unrelated events', () => {
-    const session = new Session(SessionId('fold'))
+    const session = Session.create(SessionId('fold'))
     session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
     session.append('request/header', { header: { config: CONFIG, system: 'first' }, reason: 'initial' })
     session.append('user/message', createUserMessage({
@@ -91,9 +91,9 @@ describe('legacy request-header format', () => {
     const legacy = [{
       type: 'request/header-delta', seq: 0, time: 1, data: { config: CONFIG },
     }] as unknown as SessionEvent[]
-    expect(() => new Session(SessionId('legacy'), legacy)).toThrow(/unsupported legacy request\/header-delta/)
+    expect(() => Session.create(SessionId('legacy'), legacy)).toThrow(/unsupported legacy request\/header-delta/)
 
-    const session = new Session(SessionId('legacy-append-delta'))
+    const session = Session.create(SessionId('legacy-append-delta'))
     const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent
     expect(() => appendLegacy('request/header-delta', { config: CONFIG }))
       .toThrow(/unsupported legacy request\/header-delta/)
@@ -104,10 +104,10 @@ describe('legacy request-header format', () => {
     const legacy = [{
       type: 'request/header', seq: 0, time: 1, data: { header: { config: CONFIG }, reason: 'fallback' },
     }] as unknown as SessionEvent[]
-    expect(() => new Session(SessionId('legacy-seed-reason'), legacy))
+    expect(() => Session.create(SessionId('legacy-seed-reason'), legacy))
       .toThrow('unsupported legacy request/header reason "fallback"')
 
-    const session = new Session(SessionId('legacy-append-reason'))
+    const session = Session.create(SessionId('legacy-append-reason'))
     const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent
     expect(() => appendLegacy('request/header', { header: { config: CONFIG }, reason: 'fallback' }))
       .toThrow('unsupported legacy request/header reason "fallback"')
@@ -130,13 +130,13 @@ describe('Session.requestContext', () => {
   }
 
   it('reads undefined before any record exists', () => {
-    expect(new Session(SessionId('no-capacity')).requestContext()).toBeUndefined()
+    expect(Session.create(SessionId('no-capacity')).requestContext()).toBeUndefined()
   })
 
   it('folds a seeded log on first read, taking the last record', () => {
     // The fold watermark starts at 0 with the seed already in the log, so the
     // first read must consume the whole seed rather than skip it.
-    const session = new Session(SessionId('seeded-capacity'), seedWith(
+    const session = Session.create(SessionId('seeded-capacity'), seedWith(
       CAPACITY,
       { ...CAPACITY, model: 'later', contextWindow: 256_000 },
     ))
@@ -144,7 +144,7 @@ describe('Session.requestContext', () => {
   })
 
   it('advances incrementally across appends and skips unrelated events', () => {
-    const session = new Session(SessionId('incremental-capacity'), seedWith(CAPACITY))
+    const session = Session.create(SessionId('incremental-capacity'), seedWith(CAPACITY))
     expect(session.requestContext()).toEqual(CAPACITY)
     session.append('todo/write', { todos: [] })
     expect(session.requestContext()).toEqual(CAPACITY)
@@ -155,7 +155,7 @@ describe('Session.requestContext', () => {
   })
 
   it('folds a batch appended between two reads', () => {
-    const session = new Session(SessionId('batched-capacity'), seedWith(CAPACITY))
+    const session = Session.create(SessionId('batched-capacity'), seedWith(CAPACITY))
     expect(session.requestContext()).toEqual(CAPACITY)
     session.append('request/context', { ...CAPACITY, contextWindow: 200_000 })
     session.append('todo/write', { todos: [] })
@@ -164,7 +164,7 @@ describe('Session.requestContext', () => {
   })
 
   it('exposes a frozen record so a reader cannot desync later comparisons', () => {
-    const session = new Session(SessionId('frozen-capacity'), seedWith(CAPACITY))
+    const session = Session.create(SessionId('frozen-capacity'), seedWith(CAPACITY))
     const held = session.requestContext()
     if (held === undefined) throw new Error('expected a folded capacity record')
     expect(Object.isFrozen(held)).toBe(true)

+ 65 - 65
packages/core/session/tests/session.spec.ts

@@ -13,7 +13,7 @@ import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurf
 
 describe('Session', () => {
   it('exposes one stable readonly surface view', () => {
-    const session = new Session(SessionId('surface-view'))
+    const session = Session.create(SessionId('surface-view'))
     const surface = session.surface
 
     expectTypeOf(surface).toEqualTypeOf<SessionSurface>()
@@ -21,7 +21,7 @@ describe('Session', () => {
   })
 
   it('derives message history from the event log', () => {
-    const session = new Session(SessionId('s1'))
+    const session = Session.create(SessionId('s1'))
     session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
     session.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
@@ -61,7 +61,7 @@ describe('Session', () => {
   it('accepts and round-trips a max-tokens turn/end reason', () => {
     // The max-tokens TurnEndReason variant carries no extra data, so it must
     // append and persist like any other reason (JSON-serializable, no fields).
-    const session = new Session(SessionId('s1'))
+    const session = Session.create(SessionId('s1'))
     session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
     session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } })
 
@@ -72,7 +72,7 @@ describe('Session', () => {
   })
 
   it('finds the latest message-turn outcome past later non-message turns', () => {
-    const session = new Session(SessionId('message-turn-outcome'))
+    const session = Session.create(SessionId('message-turn-outcome'))
     expect(findLastMessageTurnEnd(session.events)).toBeUndefined()
     session.append('turn/start', {
       turn: 1,
@@ -108,10 +108,10 @@ describe('Session', () => {
   })
 
   it('round-trips the coarse aborted turn outcome', () => {
-    const session = new Session(SessionId('aborted'))
+    const session = Session.create(SessionId('aborted'))
     session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
     session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } })
-    const replayed = new Session(SessionId('aborted-replay'), structuredClone(session.events))
+    const replayed = Session.create(SessionId('aborted-replay'), structuredClone(session.events))
     expect(replayed.events.slice(0, -1)).toEqual(session.events)
     const turnEnd = replayed.events.findLast(event => event.type === 'turn/end')
     expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
@@ -129,12 +129,12 @@ describe('Session', () => {
       },
     ] as unknown as SessionEvent[]
 
-    expect(() => new Session(SessionId('legacy-aborted'), legacy))
+    expect(() => Session.create(SessionId('legacy-aborted'), legacy))
       .toThrow('seed turn/end at index 1 uses unsupported reason-bearing aborted format')
   })
 
   it('renders injected-context and steering messages as plain user content', () => {
-    const session = new Session(SessionId('s2'))
+    const session = Session.create(SessionId('s2'))
     session.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'file changed: a.ts' }],
       source: { kind: 'plugin', plugin: 'watcher' },
@@ -155,7 +155,7 @@ describe('Session', () => {
   })
 
   it('keeps the exact identified context message in durable history and projection', () => {
-    const session = new Session(SessionId('s2-raw'))
+    const session = Session.create(SessionId('s2-raw'))
     const message = createUserMessage({
       content: [{ type: 'text', text: '<system-reminder>Additional instructions from: pkg/AGENTS.md</system-reminder>' }],
       source: { kind: 'plugin', plugin: 'workspace-context' },
@@ -168,7 +168,7 @@ describe('Session', () => {
   })
 
   it('replays identically from a seeded event log', () => {
-    const original = new Session(SessionId('s3'))
+    const original = Session.create(SessionId('s3'))
     original.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
     original.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'q' }], source: { kind: 'user' },
@@ -186,7 +186,7 @@ describe('Session', () => {
     }, { surfaceOp: 'append' })
     original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
 
-    const replayed = new Session(SessionId('s3-replay'), [...original.events])
+    const replayed = Session.create(SessionId('s3-replay'), [...original.events])
     expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
     // The seed verbatim, plus the end-seed event the constructor appends.
     expect(replayed.events.slice(0, original.seq)).toEqual(original.events)
@@ -195,16 +195,16 @@ describe('Session', () => {
   })
 
   it('marks an explicitly empty seed without marking a fresh session', () => {
-    const fresh = new Session(SessionId('fresh-empty'))
+    const fresh = Session.create(SessionId('fresh-empty'))
     expect(fresh.events).toEqual([])
 
-    const resumed = new Session(SessionId('resumed-empty'), [])
+    const resumed = Session.create(SessionId('resumed-empty'), [])
     expect(resumed.firstLiveSeq).toBe(0)
     expect(resumed.events).toMatchObject([
       { type: 'session/end-seed', seq: 0, data: {} },
     ])
 
-    const reopened = new Session(SessionId('reopened-empty'), resumed.events)
+    const reopened = Session.create(SessionId('reopened-empty'), resumed.events)
     expect(reopened.firstLiveSeq).toBe(1)
     expect(reopened.events).toEqual(resumed.events)
   })
@@ -214,7 +214,7 @@ describe('Session', () => {
       type: 'request/header', seq: 0, time: 1,
       data: { header: { config: { model: 'old-model' } }, reason: 'initial' },
     } as unknown as SessionEvent
-    expect(() => new Session(SessionId('old-header'), [requestHeader]))
+    expect(() => Session.create(SessionId('old-header'), [requestHeader]))
       .toThrow('seed request/header at index 0 lacks provider/model')
 
     const assistantMessage = {
@@ -222,20 +222,20 @@ describe('Session', () => {
       data: { turn: 1, step: 1, content: [{ type: 'text', text: 'old' }] },
       surfaceOp: 'append',
     } as unknown as SessionEvent
-    expect(() => new Session(SessionId('old-assistant'), [assistantMessage]))
+    expect(() => Session.create(SessionId('old-assistant'), [assistantMessage]))
       .toThrow('seed assistant/message at index 0 lacks an identified message')
 
     const malformedHeader = {
       type: 'request/header', seq: 0, time: 1,
       data: { header: 'old-header' },
     } as unknown as SessionEvent
-    expect(() => new Session(SessionId('malformed-header'), [malformedHeader]))
+    expect(() => Session.create(SessionId('malformed-header'), [malformedHeader]))
       .toThrow('seed request/header at index 0 lacks provider/model')
 
     const unrelatedPrimitiveData = {
       type: 'plugin/event', seq: 0, time: 1, data: null,
     } as unknown as SessionEvent
-    expect(new Session(SessionId('primitive-plugin-data'), [unrelatedPrimitiveData]).events.slice(0, 1))
+    expect(Session.create(SessionId('primitive-plugin-data'), [unrelatedPrimitiveData]).events.slice(0, 1))
       .toEqual([unrelatedPrimitiveData])
   })
 
@@ -353,7 +353,7 @@ describe('Session', () => {
 
     for (const { name, event, message } of invalid) {
       expect(
-        () => new Session(SessionId(`invalid-${name}`), [event as unknown as SessionEvent]),
+        () => Session.create(SessionId(`invalid-${name}`), [event as unknown as SessionEvent]),
         name,
       ).toThrow(message)
     }
@@ -405,7 +405,7 @@ describe('Session', () => {
         reason: 'initial',
       },
     } as const
-    expect(new Session(SessionId('reasoning-effort'), [valid]).events[0])
+    expect(Session.create(SessionId('reasoning-effort'), [valid]).events[0])
       .toEqual(valid)
 
     for (const reasoningEffort of ['', 1]) {
@@ -413,7 +413,7 @@ describe('Session', () => {
       if (invalid.type !== 'request/header') throw new Error('test fixture must be a request header')
       const config = invalid.data.header.config as unknown as Record<string, unknown>
       config.reasoningEffort = reasoningEffort
-      expect(() => new Session(SessionId('invalid-reasoning-effort'), [invalid]))
+      expect(() => Session.create(SessionId('invalid-reasoning-effort'), [invalid]))
         .toThrow('seed request/header at index 0 has an invalid reasoningEffort')
     }
   })
@@ -435,7 +435,7 @@ describe('Session', () => {
         reason: 'initial',
       },
     } as const
-    expect(new Session(SessionId('adapter-defaults'), [valid]).events[0]).toEqual(valid)
+    expect(Session.create(SessionId('adapter-defaults'), [valid]).events[0]).toEqual(valid)
 
     for (const adapterDefaults of [
       null,
@@ -447,13 +447,13 @@ describe('Session', () => {
       const invalid = structuredClone(valid) as unknown as SessionEvent
       if (invalid.type !== 'request/header') throw new Error('test fixture must be a request header')
       invalid.data.header.adapterDefaults = adapterDefaults as never
-      expect(() => new Session(SessionId('invalid-adapter-defaults'), [invalid]))
+      expect(() => Session.create(SessionId('invalid-adapter-defaults'), [invalid]))
         .toThrow('seed request/header at index 0 has invalid adapterDefaults')
     }
   })
 
   it('isolates the log from mutation through a derived message (append-only contract)', () => {
-    const session = new Session(SessionId('s4'))
+    const session = Session.create(SessionId('s4'))
     session.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'original' }], source: { kind: 'user' },
     }), { surfaceOp: 'append' })
@@ -487,7 +487,7 @@ describe('Session', () => {
   })
 
   it('rejects non-JSON-serializable event data at the source (incl. sparse arrays)', () => {
-    const session = new Session(SessionId('s5'))
+    const session = Session.create(SessionId('s5'))
     const bad = (extra: unknown) => () => session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra } as never, { surfaceOp: 'append' })
     expect(bad(1n)).toThrow(/non-JSON-serializable/)
     expect(bad(() => 0)).toThrow(/non-JSON-serializable/)
@@ -514,7 +514,7 @@ describe('Session', () => {
   })
 
   it('rejects a surface-eligible append with no surfaceOp marker (runtime guard for the union-widening loophole)', () => {
-    const session = new Session(SessionId('s5b'))
+    const session = Session.create(SessionId('s5b'))
     session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
     // A widened SessionEventType bypasses the overload's conditional requirement,
     // so the runtime guard must still reject the missing surface marker.
@@ -528,7 +528,7 @@ describe('Session', () => {
   })
 
   it('accepts dense arrays and nested plain objects', () => {
-    const session = new Session(SessionId('s6'))
+    const session = Session.create(SessionId('s6'))
     expect(() => session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra: [1, 2, [3, { a: null, b: true }]] } as never, { surfaceOp: 'append' })).not.toThrow()
     expect(session.events).toHaveLength(1)
   })
@@ -539,7 +539,7 @@ describe('Session', () => {
     const badSeed = [
       { type: 'user/message' as const, seq: 0, time: 1, data: { content: [{ type: 'text' as const, text: 'x' }], source: { kind: 'user' as const }, bad: 1n } },
     ] as unknown as SessionEvent[]
-    expect(() => new Session(SessionId('seed-bad'), badSeed)).toThrow(/losslessly JSON-serializable/)
+    expect(() => Session.create(SessionId('seed-bad'), badSeed)).toThrow(/losslessly JSON-serializable/)
   })
 
   it('validates seed events: rejects a non-contiguous seq', () => {
@@ -547,7 +547,7 @@ describe('Session', () => {
       { type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
       { type: 'turn/end' as const, seq: 5, time: 2, data: { turn: 1, reason: { kind: 'completed' as const } } }, // gap: expected seq 1
     ] as SessionEvent[]
-    expect(() => new Session(SessionId('seed-gap'), gapSeed)).toThrow(/contiguous|seq/)
+    expect(() => Session.create(SessionId('seed-gap'), gapSeed)).toThrow(/contiguous|seq/)
   })
 
   it('validates seed events: rejects a surface-eligible event missing its surfaceOp marker', () => {
@@ -562,7 +562,7 @@ describe('Session', () => {
       }) },
       { type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
     ] as SessionEvent[]
-    expect(() => new Session(SessionId('seed-no-marker'), markerlessSeed)).toThrow(/requires a surfaceOp marker/)
+    expect(() => Session.create(SessionId('seed-no-marker'), markerlessSeed)).toThrow(/requires a surfaceOp marker/)
   })
 
   it('accepts a well-formed contiguous serializable seed', () => {
@@ -573,7 +573,7 @@ describe('Session', () => {
       }), surfaceOp: 'append' as const },
       { type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
     ] as SessionEvent[]
-    const session = new Session(SessionId('seed-ok'), goodSeed)
+    const session = Session.create(SessionId('seed-ok'), goodSeed)
     expect(session.events.slice(0, 3)).toEqual(goodSeed)
     expect(session.firstLiveSeq).toBe(3)
   })
@@ -596,7 +596,7 @@ describe('Session', () => {
       },
     })
 
-    const session = new Session(SessionId('seed-entry-snapshot'), seed)
+    const session = Session.create(SessionId('seed-entry-snapshot'), seed)
 
     expect(reads).toBe(1)
     expect(session.events.slice(0, 1)).toEqual([accepted])
@@ -613,7 +613,7 @@ describe('Session', () => {
     })
     const seed = [{ type: 'test/unstable', seq: 0, time: 1, data }] as unknown as SessionEvent[]
 
-    const session = new Session(SessionId('seed-nested-drift'), seed)
+    const session = Session.create(SessionId('seed-nested-drift'), seed)
 
     expect(reads).toBe(1)
     expect(session.events[0]!.data).toEqual({ value: 'accepted' })
@@ -630,7 +630,7 @@ describe('Session', () => {
       surfaceOp: { op: 'replace', start: 1n, end: 2 },
     }] as unknown as SessionEvent[]
 
-    expect(() => new Session(SessionId('seed-bad-metadata'), seed))
+    expect(() => Session.create(SessionId('seed-bad-metadata'), seed))
       .toThrow(/losslessly JSON-serializable/)
   })
 
@@ -650,7 +650,7 @@ describe('Session', () => {
       surfaceOp: new ReplaceOp(),
     }] as unknown as SessionEvent[]
 
-    expect(() => new Session(SessionId('seed-exotic-metadata'), seed))
+    expect(() => Session.create(SessionId('seed-exotic-metadata'), seed))
       .toThrow(/losslessly JSON-serializable/)
   })
 
@@ -663,7 +663,7 @@ describe('Session', () => {
     }
     const seed: SessionEvent[] = [new SeedEvent()]
 
-    expect(() => new Session(SessionId('seed-exotic-shell'), seed))
+    expect(() => Session.create(SessionId('seed-exotic-shell'), seed))
       .toThrow(/not losslessly JSON-serializable/)
   })
 
@@ -675,7 +675,7 @@ describe('Session', () => {
       data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } },
     }) as unknown as SessionEvent
 
-    const session = new Session(SessionId('seed-null-prototype'), [event])
+    const session = Session.create(SessionId('seed-null-prototype'), [event])
 
     expect(session.events.slice(0, 1)).toEqual([{ ...event }])
   })
@@ -708,7 +708,7 @@ describe('Session', () => {
       sourceEventSeqs: [0],
     }] as unknown as SessionEvent[]
 
-    const session = new Session(SessionId('seed-unstable-metadata'), seed)
+    const session = Session.create(SessionId('seed-unstable-metadata'), seed)
     const event = session.events[1]!
     if (event.type !== 'user/message') throw new Error('test fixture must remain a user/message')
 
@@ -745,7 +745,7 @@ describe('Session', () => {
     }] as unknown as SessionEvent[]
 
     try {
-      expect(() => new Session(SessionId('seed-non-error-metadata-failure'), seed))
+      expect(() => Session.create(SessionId('seed-non-error-metadata-failure'), seed))
         .toThrow(`invalid seed event at index 1: ${expected}`)
     } finally {
       hasOwn.mockRestore()
@@ -762,7 +762,7 @@ describe('Session', () => {
       }, surfaceOp: 'append' as const },
       { type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
     ] as SessionEvent[]
-    const session = new Session(SessionId('seed-snapshot'), seed)
+    const session = Session.create(SessionId('seed-snapshot'), seed)
     // Mutate the ORIGINAL seed objects after construction: a shared reference
     // would let this rewrite the forked log (or reintroduce non-serializable
     // data past validation). The snapshot must shield session.events.
@@ -775,7 +775,7 @@ describe('Session', () => {
   })
 
   it('snapshots append data: mutating the passed object after append does not affect session.events', () => {
-    const session = new Session(SessionId('append-snapshot'))
+    const session = Session.create(SessionId('append-snapshot'))
     const data = {
       id: MessageId('append-input'),
       role: 'user' as const,
@@ -795,7 +795,7 @@ describe('Session', () => {
   })
 
   it('reads a nested append-data getter once and stores its first JSON value', () => {
-    const session = new Session(SessionId('append-nested-drift'))
+    const session = Session.create(SessionId('append-nested-drift'))
     let reads = 0
     const data = Object.defineProperty({}, 'value', {
       enumerable: true,
@@ -813,7 +813,7 @@ describe('Session', () => {
   })
 
   it('rejects non-JSON surface metadata before appending the event', () => {
-    const session = new Session(SessionId('append-bad-metadata'))
+    const session = Session.create(SessionId('append-bad-metadata'))
 
     expect(() => session.append(
       'user/message',
@@ -831,7 +831,7 @@ describe('Session', () => {
       readonly start = 0
       readonly end = 0
     }
-    const session = new Session(SessionId('append-exotic-metadata'))
+    const session = Session.create(SessionId('append-exotic-metadata'))
 
     expect(() => session.append(
       'user/message',
@@ -844,7 +844,7 @@ describe('Session', () => {
   })
 
   it('reads a nested append-metadata getter once and stores its first JSON value', () => {
-    const session = new Session(SessionId('append-unstable-metadata'))
+    const session = Session.create(SessionId('append-unstable-metadata'))
     const source = session.append(
       'user/message',
       createUserMessage({
@@ -875,7 +875,7 @@ describe('Session', () => {
   })
 
   it('rejects invalid plain surface metadata shapes at append', () => {
-    const session = new Session(SessionId('append-invalid-surface-shape'))
+    const session = Session.create(SessionId('append-invalid-surface-shape'))
     const appendRaw = session.append.bind(session) as unknown as (
       type: SessionEventType,
       data: unknown,
@@ -896,7 +896,7 @@ describe('Session', () => {
   })
 
   it('rejects surface metadata on non-surface append and seed events', () => {
-    const session = new Session(SessionId('non-surface-metadata'))
+    const session = Session.create(SessionId('non-surface-metadata'))
     const appendRaw = session.append.bind(session) as unknown as (
       type: SessionEventType,
       data: unknown,
@@ -908,7 +908,7 @@ describe('Session', () => {
       { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
       { surfaceOp: 'append' },
     )).toThrow(/not surface-eligible and cannot carry surfaceOp/)
-    expect(() => new Session(SessionId('non-surface-metadata-seed'), [{
+    expect(() => Session.create(SessionId('non-surface-metadata-seed'), [{
       type: 'turn/start',
       seq: 0,
       time: 1,
@@ -919,7 +919,7 @@ describe('Session', () => {
   })
 
   it('deep-freezes seeded and appended event snapshots', () => {
-    const seeded = new Session(SessionId('seed-frozen'), [{
+    const seeded = Session.create(SessionId('seed-frozen'), [{
       type: 'turn/start',
       seq: 0,
       time: 1,
@@ -932,7 +932,7 @@ describe('Session', () => {
     expect(Object.isFrozen(seededEvent.data.trigger)).toBe(true)
     expect(() => { seededEvent.data.turn = 99 }).toThrow(TypeError)
 
-    const appended = new Session(SessionId('append-frozen'))
+    const appended = Session.create(SessionId('append-frozen'))
     const appendedEvent = appended.append('todo/write', {
       todos: [{ content: 'first', status: 'pending' }],
     })
@@ -944,7 +944,7 @@ describe('Session', () => {
   })
 
   it('returns cached frozen event-array snapshots that do not grow after append', () => {
-    const session = new Session(SessionId('events-snapshot'))
+    const session = Session.create(SessionId('events-snapshot'))
     session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
     const before = session.events
     const beforeEvent = before[0]!
@@ -973,7 +973,7 @@ describe('Session', () => {
       seedLength: 2,
     }
 
-    const session = new Session(SessionId('header-owned'), undefined, input)
+    const session = Session.create(SessionId('header-owned'), undefined, input)
     input.cwd = '/caller-mutated'
 
     expect(session.header).toEqual({
@@ -998,15 +998,15 @@ describe('Session', () => {
       readonly createdAt = 123
     }
 
-    expect(() => new Session(SessionId('header-invalid'), undefined, new ExoticHeader()))
+    expect(() => Session.create(SessionId('header-invalid'), undefined, new ExoticHeader()))
       .toThrow(/not losslessly JSON-serializable/)
-    expect(() => new Session(SessionId('header-invalid'), undefined, {
+    expect(() => Session.create(SessionId('header-invalid'), undefined, {
       version: SESSION_FORMAT_VERSION,
       id: SessionId('header-invalid'),
       createdAt: 123,
       parentSession: 1n,
     } as unknown as SessionHeader)).toThrow(/not losslessly JSON-serializable/)
-    expect(() => new Session(SessionId('header-invalid'), undefined, {
+    expect(() => Session.create(SessionId('header-invalid'), undefined, {
       version: SESSION_FORMAT_VERSION,
       id: SessionId('other'),
       createdAt: 123,
@@ -1033,7 +1033,7 @@ describe('Session', () => {
     ]
 
     for (const { header, error } of cases) {
-      expect(() => new Session(SessionId('header-shape'), undefined, header as SessionHeader)).toThrow(error)
+      expect(() => Session.create(SessionId('header-shape'), undefined, header as SessionHeader)).toThrow(error)
     }
   })
 
@@ -1057,7 +1057,7 @@ describe('Session', () => {
     ]
 
     for (const [index, event] of cases.entries()) {
-      expect(() => new Session(SessionId(`bad-envelope-${index}`), [event as SessionEvent]))
+      expect(() => Session.create(SessionId(`bad-envelope-${index}`), [event as SessionEvent]))
         .toThrow(/invalid event envelope/)
     }
   })
@@ -1145,7 +1145,7 @@ describe('SessionStore', () => {
     const secondCtx = new Context()
     await firstCtx.plugin(SessionStore)
     await secondCtx.plugin(SessionStore)
-    const session = new Session(SessionId('owned-key'))
+    const session = Session.create(SessionId('owned-key'))
     const detachFirst = firstCtx.sessions.enter(session)
 
     expect(() => secondCtx.sessions.enter(session)).toThrow(/already attached to a store/)
@@ -1302,7 +1302,7 @@ describe('SessionStore', () => {
   })
 
   it('a bare Session() constructed without the store still exposes a current-version header', () => {
-    const session = new Session(SessionId('bare'))
+    const session = Session.create(SessionId('bare'))
     expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'bare' })
     expect(typeof session.header.createdAt).toBe('number')
   })
@@ -1626,7 +1626,7 @@ describe('SessionStore', () => {
   it('does not let internal dispatch replace the disposed callback tuple', async () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)
-    const replacement = new Session(SessionId('replacement-disposed'))
+    const replacement = Session.create(SessionId('replacement-disposed'))
     const heard: Session[] = []
     ctx.on('internal/dispatch', (_mode, name, args) => {
       if (name === 'session/disposed') args[0] = replacement
@@ -1644,7 +1644,7 @@ describe('SessionStore', () => {
 
 describe('todo/write event', () => {
   it('appends the whole-list snapshot and isolates the log from later mutation', () => {
-    const session = new Session(SessionId('t1'))
+    const session = Session.create(SessionId('t1'))
     const todos: TodoItem[] = [
       { content: 'plan the work', status: 'in_progress' },
       { content: 'write the code', status: 'pending' },
@@ -1666,7 +1666,7 @@ describe('todo/write event', () => {
   })
 
   it('is last-write-wins: the current list is the most recent todo/write', () => {
-    const session = new Session(SessionId('t2'))
+    const session = Session.create(SessionId('t2'))
     session.append('todo/write', { todos: [{ content: 'first', status: 'pending' }] })
     session.append('todo/write', { todos: [
       { content: 'first', status: 'completed' },
@@ -1681,7 +1681,7 @@ describe('todo/write event', () => {
   })
 
   it('is NOT a surface event: it produces no derived message and joins no surface node', () => {
-    const session = new Session(SessionId('t3'))
+    const session = Session.create(SessionId('t3'))
     session.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'q' }], source: { kind: 'user' },
     }), { surfaceOp: 'append' })
@@ -1694,12 +1694,12 @@ describe('todo/write event', () => {
   })
 
   it('round-trips through a seeded replay identically (durable, no surfaceOp needed)', () => {
-    const original = new Session(SessionId('t4'))
+    const original = Session.create(SessionId('t4'))
     original.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
     original.append('todo/write', { todos: [{ content: 'only', status: 'completed' }] })
     original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
     // Seeding a non-surface event with no surfaceOp must not throw.
-    const replayed = new Session(SessionId('t4-replay'), [...original.events])
+    const replayed = Session.create(SessionId('t4-replay'), [...original.events])
     expect(replayed.events.findLast(e => e.type === 'todo/write')!.data.todos)
       .toEqual([{ content: 'only', status: 'completed' }])
     expect(replayed.events.slice(0, original.seq)).toEqual(original.events)

+ 23 - 23
packages/core/session/tests/surface.spec.ts

@@ -20,7 +20,7 @@ import {
 
 /** Build a minimal session with turn boundaries and a single user message. */
 function surfaceSession(): Session {
-  const s = new Session(SessionId('ss'))
+  const s = Session.create(SessionId('ss'))
   s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
   s.append('user/message', createUserMessage({
     content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
@@ -240,7 +240,7 @@ describe('foldSurface tool-result rewrites', () => {
 
 describe('SurfaceManager', () => {
   it('shares ordered entries and nested replacement ranges with foldSurface', () => {
-    const s = new Session(SessionId('shared-fold'))
+    const s = Session.create(SessionId('shared-fold'))
     s.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
     }), { surfaceOp: 'append' })
@@ -284,7 +284,7 @@ describe('SurfaceManager', () => {
   })
 
   it('does not retain fold-only replacement history in incremental state', () => {
-    const s = new Session(SessionId('incremental-state'))
+    const s = Session.create(SessionId('incremental-state'))
     s.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
     }), { surfaceOp: 'append' })
@@ -315,12 +315,12 @@ describe('SurfaceManager', () => {
     ] as SessionEvent[]
 
     expect(() => foldSurface(events)).toThrow(/start seq 42 not found/)
-    expect(() => new Session(SessionId('shared-fold-invalid'), events))
+    expect(() => Session.create(SessionId('shared-fold-invalid'), events))
       .toThrow(/start seq 42 not found/)
   })
 
   it('leaves incremental state unchanged when candidate validation fails', () => {
-    const s = new Session(SessionId('atomic-validation'))
+    const s = Session.create(SessionId('atomic-validation'))
     s.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
     }), { surfaceOp: 'append' })
@@ -397,7 +397,7 @@ describe('SurfaceManager', () => {
   })
 
   it('empty surface yields empty nodes', () => {
-    const s = new Session(SessionId('empty'))
+    const s = Session.create(SessionId('empty'))
     s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
     s.append('step/start', { turn: 1, step: 1 })
     s.append('step/end', { turn: 1, step: 1 })
@@ -431,7 +431,7 @@ describe('SurfaceManager', () => {
         isError: false,
       }),
     }, { surfaceOp: 'append' })
-    const replayed = new Session(SessionId('replay'), [...original.events])
+    const replayed = Session.create(SessionId('replay'), [...original.events])
     expect(replayed.surface.nodes).toEqual([1, 2, 4])
     expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
   })
@@ -456,7 +456,7 @@ describe('SurfaceManager', () => {
   })
 
   it('replace with both ends at real nodes splices only the range', () => {
-    const s = new Session(SessionId('range'))
+    const s = Session.create(SessionId('range'))
     s.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
     }), { surfaceOp: 'append' }) // seq 0
@@ -485,7 +485,7 @@ describe('SurfaceManager', () => {
   })
 
   it('single-node replacement (start === end)', () => {
-    const s = new Session(SessionId('single'))
+    const s = Session.create(SessionId('single'))
     s.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
     }), { surfaceOp: 'append' }) // seq 0
@@ -511,7 +511,7 @@ describe('SurfaceManager', () => {
   })
 
   it('throws when replace start is not found', () => {
-    const s = new Session(SessionId('bad-start'))
+    const s = Session.create(SessionId('bad-start'))
     s.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
     }), { surfaceOp: 'append' }) // seq 0
@@ -532,7 +532,7 @@ describe('SurfaceManager', () => {
   })
 
   it('throws when replace end is not found', () => {
-    const s = new Session(SessionId('bad-end'))
+    const s = Session.create(SessionId('bad-end'))
     s.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
     }), { surfaceOp: 'append' }) // seq 0
@@ -553,7 +553,7 @@ describe('SurfaceManager', () => {
   })
 
   it('throws when start is after end', () => {
-    const s = new Session(SessionId('reversed'))
+    const s = Session.create(SessionId('reversed'))
     s.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
     }), { surfaceOp: 'append' }) // seq 0
@@ -578,7 +578,7 @@ describe('SurfaceManager', () => {
   })
 
   it('sourceEventSeqs is snapshot so caller mutation does not affect logged event', () => {
-    const s = new Session(SessionId('immutable'))
+    const s = Session.create(SessionId('immutable'))
     s.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'source' }], source: { kind: 'user' },
     }), { surfaceOp: 'append' })
@@ -602,7 +602,7 @@ describe('SurfaceManager', () => {
   })
 
   it('replace starting at non-head position preserves surrounding order', () => {
-    const s = new Session(SessionId('mid-replace'))
+    const s = Session.create(SessionId('mid-replace'))
     s.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
     }), { surfaceOp: 'append' }) // seq 0
@@ -631,7 +631,7 @@ describe('SurfaceManager', () => {
   })
 
   it('surfaceOp replace object is snapshot so caller mutation is isolated', () => {
-    const s = new Session(SessionId('immutable-op'))
+    const s = Session.create(SessionId('immutable-op'))
     s.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
     }), { surfaceOp: 'append' })
@@ -666,7 +666,7 @@ describe('deriveMessages with surface', () => {
   })
 
   it('surface path skips non-surface events (chunks, boundaries)', () => {
-    const s = new Session(SessionId('filter'))
+    const s = Session.create(SessionId('filter'))
     s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
     s.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } })
     s.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'i' } })
@@ -690,7 +690,7 @@ describe('deriveMessages with surface', () => {
   })
 
   it('deriveMessages via surface respects replace (shadowed nodes are excluded)', () => {
-    const s = new Session(SessionId('compacted'))
+    const s = Session.create(SessionId('compacted'))
     s.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'original' }], source: { kind: 'user' },
     }), { surfaceOp: 'append' })
@@ -712,7 +712,7 @@ describe('deriveMessages with surface', () => {
   })
 
   it('injected-context and steering/message appear on surface', () => {
-    const s = new Session(SessionId('ctx'))
+    const s = Session.create(SessionId('ctx'))
     s.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'file changed' }], source: { kind: 'plugin', plugin: 'watcher' },
     }), { surfaceOp: 'append' })
@@ -732,7 +732,7 @@ describe('deriveMessages with surface', () => {
 
 describe('Session.append surface opts', () => {
   it('records sourceEventSeqs and surfaceOp on the event', () => {
-    const s = new Session(SessionId('opts'))
+    const s = Session.create(SessionId('opts'))
     s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
     s.append('step/start', { turn: 1, step: 1 })
     const event = s.append('assistant/message',
@@ -777,20 +777,20 @@ describe('Session.append surface opts', () => {
       { type: 'step/end', seq: 3, time: 4, data: { turn: 1, step: 1 } },
       { type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } },
     ]
-    const s = new Session(SessionId('nomessage'), seed)
+    const s = Session.create(SessionId('nomessage'), seed)
     // The empty assistant/message is on the surface but _deriveOneMessage returns null for it.
     expect(s.deriveMessages()).toHaveLength(0)
   })
 
   it('a non-surface event carries no surface fields', () => {
-    const s = new Session(SessionId('noopts'))
+    const s = Session.create(SessionId('noopts'))
     s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
     expect((s.events[0] as SessionEvent<SurfaceEventType>).sourceEventSeqs).toBeUndefined()
     expect((s.events[0] as SessionEvent<SurfaceEventType>).surfaceOp).toBeUndefined()
   })
 
   it('surfaceOp primitives are not cloned (they are immutable)', () => {
-    const s = new Session(SessionId('prim'))
+    const s = Session.create(SessionId('prim'))
     const event = s.append('assistant/message', {
       turn: 1, step: 1,
       message: createMessage({
@@ -901,7 +901,7 @@ describe('surface type guards', () => {
 
 describe('SurfaceManager.replaceGeneration', () => {
   it('folds the pending log delta on access and counts replaces', () => {
-    const s = new Session(SessionId('gen'))
+    const s = Session.create(SessionId('gen'))
     s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
     s.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'one' }], source: { kind: 'user' },

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

@@ -1278,7 +1278,7 @@ describe('the run_code dispatch bridge', () => {
         return Promise.resolve(observedDepth)
       },
     }))
-    const session = new Session(SessionId('deep-code-arguments'))
+    const session = Session.create(SessionId('deep-code-arguments'))
     const agent = { session } as Agent
     runtime.behavior = async (request) => {
       let nested: JsonValue = 'leaf'
@@ -1441,7 +1441,7 @@ describe('the run_code dispatch bridge', () => {
   })
 
   it('a tool/code-dispatch event never derives a model message', () => {
-    const session = new Session(SessionId('code-mode-derive'))
+    const session = Session.create(SessionId('code-mode-derive'))
     session.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
     }), { surfaceOp: 'append' })

+ 1 - 1
packages/fs/tool-str-replace-editor/tests/tools.spec.ts

@@ -31,7 +31,7 @@ function agent(ctx: Context, cwd: string): Agent {
   const value: Agent = {
     id,
     options: {},
-    session: new Session(id, [], { version: 0, id, createdAt: 0, cwd }),
+    session: Session.create(id, [], { version: 0, id, createdAt: 0, cwd }),
     status: 'idle',
     acceptsNextStep: false,
     ctx: scope.ctx,

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

@@ -74,7 +74,7 @@ function stubAgentForSession(session: Session): StubAgent {
 
 /** Build a registry-compatible agent with controllable context deferral. */
 function stubAgent(rawId: string, seed?: readonly import('@deepseek-ai/dsh-session').SessionEvent[]): StubAgent {
-  return stubAgentForSession(new Session(SessionId(rawId), seed))
+  return stubAgentForSession(Session.create(SessionId(rawId), seed))
 }
 
 async function harness(config: { defaultMaxGoalRounds?: number } = {}) {
@@ -256,7 +256,7 @@ describe('GoalService creation and replay', () => {
     const { ctx, agent } = await harness()
     // A same-id agent backed by a different session object — the live-instance
     // check must reject it even though the ids match.
-    const impostor = stubAgentForSession(new Session(agent.id)).agent
+    const impostor = stubAgentForSession(Session.create(agent.id)).agent
     expect(() => ctx.goals.get(impostor)).toThrow(expect.objectContaining({ code: 'GOAL_AGENT_NOT_LIVE' }))
     expect(() => ctx.goals.create(impostor, { objective: 'no' })).toThrow(expect.objectContaining({
       code: 'GOAL_AGENT_NOT_LIVE',
@@ -614,7 +614,7 @@ describe('goal replay validation', () => {
   }
 
   function oneChange(change: GoalChangeMeta, overrides: { content?: ContentBlock[]; source?: MessageSource } = {}) {
-    const session = new Session(SessionId(`validation-${Math.random()}`))
+    const session = Session.create(SessionId(`validation-${Math.random()}`))
     appendChange(session, change, overrides)
     return session.events
   }
@@ -644,7 +644,7 @@ describe('goal replay validation', () => {
   }
 
   function foldPair(first: GoalSnapshotChangeMeta, second: GoalChangeMeta): ReturnType<typeof foldGoal> {
-    const session = new Session(SessionId(`validation-pair-${Math.random()}`))
+    const session = Session.create(SessionId(`validation-pair-${Math.random()}`))
     appendChange(session, first)
     appendChange(session, second)
     return foldGoal(session.events)
@@ -653,7 +653,7 @@ describe('goal replay validation', () => {
   it('ignores unrelated metadata and non-goal round sources', () => {
     expect(decodeGoalChange(undefined)).toBeUndefined()
     expect(decodeGoalChange({ kind: 'other' })).toBeUndefined()
-    const session = new Session(SessionId('unrelated'))
+    const session = Session.create(SessionId('unrelated'))
     appendInjection(session, createUserMessage({
       content: [{ type: 'text', text: 'other' }],
       source: { kind: 'plugin', plugin: 'test' },
@@ -671,7 +671,7 @@ describe('goal replay validation', () => {
 
   it('rejects rounds attributed to another goal', () => {
     const change = snapshotChange()
-    const session = new Session(SessionId('other-goal-round'), oneChange(change))
+    const session = Session.create(SessionId('other-goal-round'), oneChange(change))
     appendRound(session, { id: GoalId('goal-other'), revision: 1 }, 1)
     expect(() => foldGoal(session.events)).toThrow('not the next admitted round')
   })
@@ -743,7 +743,7 @@ describe('goal replay validation', () => {
       roundsStarted: 2,
       goal: { ...paused.goal, revision: 3, phase: 'active', maxGoalRounds: 2 },
     })
-    const session = new Session(SessionId('exhausted-resume'))
+    const session = Session.create(SessionId('exhausted-resume'))
     appendChange(session, base)
     appendRound(session, base.goal, 1)
     appendRound(session, base.goal, 2)
@@ -769,7 +769,7 @@ describe('goal replay validation', () => {
       createdAt: 20,
       updatedAt: 20,
     })
-    const completedSession = new Session(SessionId('reuse-complete'))
+    const completedSession = Session.create(SessionId('reuse-complete'))
     appendChange(completedSession, base)
     appendChange(completedSession, complete)
     appendChange(completedSession, sameCurrentId)
@@ -781,7 +781,7 @@ describe('goal replay validation', () => {
       updatedAt: 20,
     })
     const secondComplete = mutation(second, 'complete', 'complete')
-    const nonAdjacentReuse = new Session(SessionId('reuse-non-adjacent'))
+    const nonAdjacentReuse = Session.create(SessionId('reuse-non-adjacent'))
     appendChange(nonAdjacentReuse, base)
     appendChange(nonAdjacentReuse, complete)
     appendChange(nonAdjacentReuse, second)
@@ -792,7 +792,7 @@ describe('goal replay validation', () => {
     const clear: GoalChangeMeta = {
       kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: base.goal.id, revision: 2 }, clearedAt: 11,
     }
-    const clearedSession = new Session(SessionId('reuse-clear'))
+    const clearedSession = Session.create(SessionId('reuse-clear'))
     appendChange(clearedSession, base)
     appendChange(clearedSession, clear)
     appendChange(clearedSession, sameCurrentId)
@@ -800,7 +800,7 @@ describe('goal replay validation', () => {
   })
 
   it('rejects goal-source context without matching durable metadata', () => {
-    const session = new Session(SessionId('goal-source-without-meta'))
+    const session = Session.create(SessionId('goal-source-without-meta'))
     const source = { kind: 'goal', goalId: GoalId('goal-missing-meta'), revision: 1, round: 0 } as const
     const turn = nextTurn(session)
     session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
@@ -861,7 +861,7 @@ describe('goal replay validation', () => {
 
   it('folds a clear tombstone after a snapshot', () => {
     const change = snapshotChange()
-    const session = new Session(SessionId('fold-clear'), oneChange(change))
+    const session = Session.create(SessionId('fold-clear'), oneChange(change))
     const clear: GoalChangeMeta = {
       kind: 'goal/change',
       version: 1,

+ 2 - 2
packages/goal/tool-goal/tests/tool-goal.spec.ts

@@ -23,7 +23,7 @@ interface StubAgent {
 
 /** Build one registry-compatible live agent whose injections append in place. */
 function stubAgent(rawId: string, supplied?: Session): StubAgent {
-  const session = supplied ?? new Session(SessionId(rawId))
+  const session = supplied ?? Session.create(SessionId(rawId))
   let status: AgentStatus = 'running'
   const agent: Agent = {
     id: session.id,
@@ -253,7 +253,7 @@ describe('goal tool execution authority', () => {
     const created = ctx.goals.create(root.agent, { objective: 'resume the fork' })
     closeTurn(root, originalTurn)
     const forkId = SessionId('goal-tool-resumed-fork')
-    const forkSession = new Session(forkId, root.session.events, {
+    const forkSession = Session.create(forkId, root.session.events, {
       version: SESSION_FORMAT_VERSION,
       id: forkId,
       createdAt: Date.now(),

+ 8 - 8
packages/hooks/hook-protocol/tests/events.spec.ts

@@ -9,7 +9,7 @@ function output(over: Partial<HookOutput> = {}): HookOutput {
 
 describe('hook/* session events', () => {
   it('appendHookInvoked records a log-only hook/invoked (with matcher when present)', () => {
-    const session = new Session(SessionId('s'))
+    const session = Session.create(SessionId('s'))
     appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude', handlerId: 'h1', matcher: 'Bash' })
 
     const ev = [...session.events].find(e => e.type === 'hook/invoked')
@@ -22,7 +22,7 @@ describe('hook/* session events', () => {
   })
 
   it('omits matcher when absent (match-all hook)', () => {
-    const session = new Session(SessionId('s'))
+    const session = Session.create(SessionId('s'))
     appendHookInvoked(session, { turn: 2, point: 'Stop', dialect: 'codex', handlerId: 'h2' })
 
     const ev = [...session.events].find(e => e.type === 'hook/invoked')
@@ -32,7 +32,7 @@ describe('hook/* session events', () => {
   })
 
   it('appendHookResult derives decision/exitCode/stderrSummary from the output', () => {
-    const session = new Session(SessionId('s'))
+    const session = Session.create(SessionId('s'))
     appendHookResult(session, {
       turn: 1, point: 'PreToolUse', handlerId: 'h1',
       stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: 'blocked', decision: 'deny' }),
@@ -43,7 +43,7 @@ describe('hook/* session events', () => {
     }
 
     // A result with no exit code / no stderr (e.g. a hook that could not run) omits both keys.
-    const session2 = new Session(SessionId('s2'))
+    const session2 = Session.create(SessionId('s2'))
     appendHookResult(session2, {
       turn: 1, point: 'Stop', handlerId: 'h3',
       stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: undefined, decision: 'allow' }),
@@ -57,7 +57,7 @@ describe('hook/* session events', () => {
   })
 
   it('the decision falls back to stop on continue:false, else pass', () => {
-    const session = new Session(SessionId('s'))
+    const session = Session.create(SessionId('s'))
     appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'halt', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ continue: false }) })
     appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'noop', stderrSummaryMaxChars: 500, durationMs: 5, output: output() })
     // An explicit decision wins over the continue:false fallback.
@@ -70,7 +70,7 @@ describe('hook/* session events', () => {
   })
 
   it('stderrSummary is trimmed and truncated to 500 characters with an ellipsis', () => {
-    const session = new Session(SessionId('s'))
+    const session = Session.create(SessionId('s'))
     appendHookResult(session, {
       turn: 1, point: 'PreToolUse', handlerId: 'long',
       stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: `  ${'x'.repeat(600)}  ` }),
@@ -82,7 +82,7 @@ describe('hook/* session events', () => {
   })
 
   it('a 500-character stderr is kept verbatim (the cap is exclusive)', () => {
-    const session = new Session(SessionId('s'))
+    const session = Session.create(SessionId('s'))
     appendHookResult(session, {
       turn: 1, point: 'PreToolUse', handlerId: 'edge',
       stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: 'y'.repeat(500) }),
@@ -94,7 +94,7 @@ describe('hook/* session events', () => {
   })
 
   it('an invoked/result pair correlates by handlerId', () => {
-    const session = new Session(SessionId('s'))
+    const session = Session.create(SessionId('s'))
     appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude', handlerId: 'pair-1' })
     appendHookResult(session, { turn: 1, point: 'PreToolUse', handlerId: 'pair-1', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ decision: 'allow' }) })
 

+ 1 - 1
packages/hooks/hook-protocol/tests/invariant.spec.ts

@@ -59,7 +59,7 @@ describe('hook-protocol invariants', () => {
 
   it('adopts a bare session first observed through publication', async () => {
     const ctx = await setup()
-    const session = new Session(SessionId('bare-hook-session'))
+    const session = Session.create(SessionId('bare-hook-session'))
     expect(() => {
       ctx.emit('session/event', session, {
         type: 'turn/start', seq: 0, time: 0,

+ 24 - 24
packages/llm/token-meter/tests/token-meter.spec.ts

@@ -147,7 +147,7 @@ describe('TokenMeterService pricing', () => {
 
   it('returns a detached deeply immutable empty measurement', () => {
     const service = meter()
-    const session = new Session(SessionId('empty'))
+    const session = Session.create(SessionId('empty'))
     const result = service.measure(session)
     expect(result).toEqual({
       logRevision: 0,
@@ -168,7 +168,7 @@ describe('TokenMeterService pricing', () => {
 
   it('keeps an earlier unified snapshot detached from later replay', () => {
     const service = meter()
-    const session = new Session(SessionId('detached'))
+    const session = Session.create(SessionId('detached'))
     session.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'first' }],
       source: { kind: 'user' },
@@ -200,7 +200,7 @@ describe('TokenMeterService pricing', () => {
 
   it('prices header, tools, and surface when no reusable usage exists', () => {
     const service = meter()
-    const session = new Session(SessionId('heuristic'))
+    const session = Session.create(SessionId('heuristic'))
     session.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'question' }],
       source: { kind: 'user' },
@@ -218,7 +218,7 @@ describe('TokenMeterService pricing', () => {
 
   it('keeps request-header overrides out of the returned surface', () => {
     const service = meter()
-    const session = new Session(SessionId('override-surface'))
+    const session = Session.create(SessionId('override-surface'))
     session.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'question' }],
       source: { kind: 'user' },
@@ -246,7 +246,7 @@ describe('replay anchors and surface folds', () => {
 
   it('uses disjoint provider usage and signed durable-output rewrites', () => {
     const service = meter()
-    const session = new Session(SessionId('usage'))
+    const session = Session.create(SessionId('usage'))
     session.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'before' }],
       source: { kind: 'user' },
@@ -267,7 +267,7 @@ describe('replay anchors and surface folds', () => {
 
   it('selects a heuristic anchor when provider usage would undercut its scale', () => {
     const service = meter()
-    const session = new Session(SessionId('low-usage-anchor'))
+    const session = Session.create(SessionId('low-usage-anchor'))
     const system = 'system context'
     const requestHeader = header('deepseek-v4-flash', { system })
     appendSuccessfulCall(session, requestHeader, {
@@ -297,7 +297,7 @@ describe('replay anchors and surface folds', () => {
 
   it('uses an estimated anchor when provider usage is absent', () => {
     const service = meter()
-    const session = new Session(SessionId('missing-usage'))
+    const session = Session.create(SessionId('missing-usage'))
     appendSuccessfulCall(session, header('deepseek-v4-flash', { system: 's' }), {
       providerText: 'provider',
       durableText: 'rewritten',
@@ -314,8 +314,8 @@ describe('replay anchors and surface folds', () => {
   })
 
   it('distinguishes explicit empty provenance from absent legacy provenance', () => {
-    const explicit = new Session(SessionId('explicit-empty'))
-    const legacy = new Session(SessionId('legacy-absent'))
+    const explicit = Session.create(SessionId('explicit-empty'))
+    const legacy = Session.create(SessionId('legacy-absent'))
     appendSuccessfulCall(explicit, header('deepseek-v4-flash'), {
       durableText: 'listener injected text',
       providerText: '',
@@ -335,7 +335,7 @@ describe('replay anchors and surface folds', () => {
 
   it('keeps only the latest successful request anchor across model switches', () => {
     const service = meter()
-    const session = new Session(SessionId('switch'))
+    const session = Session.create(SessionId('switch'))
     const alphaHeader = header('alpha', { system: 'same envelope' })
     appendSuccessfulCall(session, alphaHeader, { usage: USAGE, providerText: 'alpha' })
     expect(service.measure(session).baseline).toMatchObject({ kind: 'usage', tokens: 34 })
@@ -356,7 +356,7 @@ describe('replay anchors and surface folds', () => {
 
   it('invalidates usage for any canonical envelope change or explicit override', () => {
     const service = meter()
-    const session = new Session(SessionId('envelope'))
+    const session = Session.create(SessionId('envelope'))
     const anchoredHeader = header('deepseek-v4-flash', { system: 'one' })
     appendSuccessfulCall(session, anchoredHeader, { usage: USAGE })
     expect(service.measure(session, { ...anchoredHeader, tools: [] }).baseline.kind).toBe('usage')
@@ -375,7 +375,7 @@ describe('replay anchors and surface folds', () => {
   })
 
   it('folds the latest full header snapshot into the effective envelope', () => {
-    const session = new Session(SessionId('header-snapshot'))
+    const session = Session.create(SessionId('header-snapshot'))
     appendHeader(session, header('deepseek-v4-flash'))
     session.append('request/header', {
       header: header('deepseek-v4-pro'),
@@ -388,7 +388,7 @@ describe('replay anchors and surface folds', () => {
 
   it('replays seeded append and replace operations with signed deltas', () => {
     const service = meter()
-    const original = new Session(SessionId('surface-original'))
+    const original = Session.create(SessionId('surface-original'))
     appendSuccessfulCall(original, header('deepseek-v4-flash'), {
       usage: USAGE,
       providerText: 'long provider answer '.repeat(100),
@@ -397,7 +397,7 @@ describe('replay anchors and surface folds', () => {
       content: [{ type: 'text', text: 'new tail' }],
       source: { kind: 'user' },
     }), { surfaceOp: 'append' })
-    const seeded = new Session(SessionId('surface-seeded'), original.events)
+    const seeded = Session.create(SessionId('surface-seeded'), original.events)
     const before = service.measure(seeded)
     expect(before.nodes).toHaveLength(2)
     expect(before.surfaceDeltaTokens).toBeGreaterThan(0)
@@ -423,7 +423,7 @@ describe('replay anchors and surface folds', () => {
   })
 
   it('prices an empty assistant surface anchor as zero', () => {
-    const session = new Session(SessionId('empty-assistant'))
+    const session = Session.create(SessionId('empty-assistant'))
     appendSuccessfulCall(session, header('deepseek-v4-flash'), {
       providerText: '',
       durableText: '',
@@ -444,7 +444,7 @@ describe('malformed replay and listener lifecycle', () => {
   }
 
   it('rejects an assistant without its step boundary transactionally', () => {
-    const session = new Session(SessionId('bad-step'))
+    const session = Session.create(SessionId('bad-step'))
     appendHeader(session, header('deepseek-v4-flash'))
     session.append('assistant/message', {
       turn: 1,
@@ -462,7 +462,7 @@ describe('malformed replay and listener lifecycle', () => {
   })
 
   it('clears completed step boundaries and rejects overlapping or late step events', () => {
-    const overlapping = new Session(SessionId('overlapping-step'))
+    const overlapping = Session.create(SessionId('overlapping-step'))
     overlapping.append('step/start', { turn: 1, step: 1 })
     overlapping.append('step/start', { turn: 1, step: 2 })
     expectRepeatedFailure(
@@ -471,7 +471,7 @@ describe('malformed replay and listener lifecycle', () => {
       /arrived before turn 1\/step 1 ended/,
     )
 
-    const late = new Session(SessionId('late-assistant'))
+    const late = Session.create(SessionId('late-assistant'))
     late.append('step/start', { turn: 1, step: 1 })
     appendHeader(late, header('deepseek-v4-flash'))
     late.append('step/end', { turn: 1, step: 1 })
@@ -493,7 +493,7 @@ describe('malformed replay and listener lifecycle', () => {
       /no matching step\/start/,
     )
 
-    const mismatchedEnd = new Session(SessionId('mismatched-end'))
+    const mismatchedEnd = Session.create(SessionId('mismatched-end'))
     mismatchedEnd.append('step/start', { turn: 1, step: 1 })
     mismatchedEnd.append('step/end', { turn: 1, step: 2 })
     expectRepeatedFailure(
@@ -532,7 +532,7 @@ describe('malformed replay and listener lifecycle', () => {
       },
     ]
     for (const testCase of cases) {
-      const session = new Session(SessionId(`bad-source-${testCase.name}`))
+      const session = Session.create(SessionId(`bad-source-${testCase.name}`))
       session.append('step/start', { turn: 1, step: 1 })
       appendHeader(session, header('deepseek-v4-flash'))
       const sourceEventSeqs = testCase.appendSource(session)
@@ -554,7 +554,7 @@ describe('malformed replay and listener lifecycle', () => {
   })
 
   it('rejects repeated and non-earlier assistant provenance', () => {
-    const duplicate = new Session(SessionId('duplicate-source'))
+    const duplicate = Session.create(SessionId('duplicate-source'))
     duplicate.append('step/start', { turn: 1, step: 1 })
     appendHeader(duplicate, header('deepseek-v4-flash'))
     const source = duplicate.append('assistant/chunk', {
@@ -584,7 +584,7 @@ describe('malformed replay and listener lifecycle', () => {
     })
     expect(() => meter().measure(duplicate)).toThrow(/repeats source seq/)
 
-    const future = new Session(SessionId('future-source'))
+    const future = Session.create(SessionId('future-source'))
     future.append('step/start', { turn: 1, step: 1 })
     appendHeader(future, header('deepseek-v4-flash'))
     appendUnchecked(future, {
@@ -611,7 +611,7 @@ describe('malformed replay and listener lifecycle', () => {
   })
 
   it('does not partially apply a malformed assistant replacement', () => {
-    const session = new Session(SessionId('transactional-replace'))
+    const session = Session.create(SessionId('transactional-replace'))
     session.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'head' }],
       source: { kind: 'user' },
@@ -638,7 +638,7 @@ describe('malformed replay and listener lifecycle', () => {
   })
 
   it('rejects corrupt replacement ranges without advancing the replay cursor', () => {
-    const session = new Session(SessionId('bad-replace'))
+    const session = Session.create(SessionId('bad-replace'))
     const head = session.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'head' }],
       source: { kind: 'user' },

+ 3 - 3
packages/plan/plan-mode/tests/invariant.spec.ts

@@ -26,7 +26,7 @@ function emitTurnStart(ctx: Context, session: Session): void {
 describe('plan-mode stream invariants', () => {
   it('accepts either boolean state', async () => {
     const ctx = await setup()
-    const session = new Session(SessionId('plan-state'))
+    const session = Session.create(SessionId('plan-state'))
     emitTurnStart(ctx, session)
     expect(() => { ctx.emit('session/event', session, event(true)) }).not.toThrow()
     expect(() => { ctx.emit('session/event', session, event(false)) }).not.toThrow()
@@ -38,7 +38,7 @@ describe('plan-mode stream invariants', () => {
 
   it.each([42, 'plan', undefined])('rejects invalid durable plan state %j', async (active) => {
     const ctx = await setup()
-    const session = new Session(SessionId(`invalid-${String(active)}`))
+    const session = Session.create(SessionId(`invalid-${String(active)}`))
     emitTurnStart(ctx, session)
     expect(() => { ctx.emit('session/event', session, event(active)) })
       .toThrow(/expected a boolean/)
@@ -52,7 +52,7 @@ describe('plan-mode stream invariants', () => {
 
   it('ignores unrelated dispatches and session events', async () => {
     const ctx = await setup()
-    const session = new Session(SessionId('unrelated'))
+    const session = Session.create(SessionId('unrelated'))
     expect(() => {
       ctx.emit('tools/change')
       ctx.emit('session/event', session, {

+ 3 - 3
packages/plan/plan-mode/tests/plan-mode.spec.ts

@@ -28,7 +28,7 @@ const PLAN_CONFIG = { section: TEST_PLAN_SECTION } satisfies PlanModeConfig
 async function agentWithSession(ctx: Context, id = 'agent-1', { active }: { active?: boolean } = {}): Promise<Agent & { session: Session }> {
   // A live store session when a store is mounted (the command executor logs
   // lifecycle events through it); bare otherwise (fold/tool-only benches).
-  const session = new Session(SessionId(id))
+  const session = Session.create(SessionId(id))
   const agent = { id: SessionId(id), session, options: {} } as unknown as Agent & { session: Session }
   let scoped!: Context
   await ctx.plugin(Object.assign((inner: Context) => { scoped = createScope(inner, agent).ctx }, {
@@ -153,7 +153,7 @@ describe('resolveConfig', () => {
 
 describe('foldPlanMode', () => {
   it('folds an empty log to inactive and takes the last plan/mode otherwise', () => {
-    const session = new Session(SessionId('fold'))
+    const session = Session.create(SessionId('fold'))
     expect(foldPlanMode(session.events)).toBe(false)
     session.append('plan/mode', { active: true })
     session.append('plan/mode', { active: false })
@@ -162,7 +162,7 @@ describe('foldPlanMode', () => {
   })
 
   it('folds a prefix when `end` is given', () => {
-    const session = new Session(SessionId('fold-prefix'))
+    const session = Session.create(SessionId('fold-prefix'))
     session.append('plan/mode', { active: true })
     session.append('plan/mode', { active: false })
     expect(foldPlanMode(session.events, 1)).toBe(true)

+ 1 - 1
packages/pty/pty-local/tests/index.spec.ts

@@ -43,7 +43,7 @@ function agent(ctx: Context, cwd?: string): Agent {
   return {
     id,
     options: {},
-    session: new Session(id, undefined, { version: 0, id, createdAt: 0, ...cwd === undefined ? {} : { cwd } }),
+    session: Session.create(id, undefined, { version: 0, id, createdAt: 0, ...cwd === undefined ? {} : { cwd } }),
     status: 'idle', acceptsNextStep: false, ctx,
     followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
   }

+ 1 - 1
packages/pty/pty-local/tests/local.spec.ts

@@ -34,7 +34,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
   const id = SessionId(rawId)
   const scope = ctx.plugin(() => {})
   return {
-    id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx,
+    id, options: {}, session: Session.create(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx,
     followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
   }
 }

+ 1 - 1
packages/pty/pty/tests/service.spec.ts

@@ -24,7 +24,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
   const agent: Agent = {
     id,
     options: {},
-    session: new Session(id),
+    session: Session.create(id),
     status: 'idle',
     acceptsNextStep: false,
     ctx: scopeFiber.ctx,

+ 1 - 1
packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts

@@ -41,7 +41,7 @@ function agent(ctx: Context, cwd: string): Agent {
   const value: Agent = {
     id,
     options: {},
-    session: new Session(id, [], { version: 0, id, createdAt: 0, cwd }),
+    session: Session.create(id, [], { version: 0, id, createdAt: 0, cwd }),
     status: 'idle',
     acceptsNextStep: false,
     ctx: scope.ctx,

+ 1 - 1
packages/pty/tool-bash-persistent/tests/tools.spec.ts

@@ -32,7 +32,7 @@ function agent(ctx: Context, cwd: string | undefined): Agent {
   const value: Agent = {
     id,
     options: {},
-    session: new Session(id, [], {
+    session: Session.create(id, [], {
       version: 0,
       id,
       createdAt: 0,

+ 1 - 1
packages/pty/tool-pty/tests/loader-composition.spec.ts

@@ -39,7 +39,7 @@ function agent(ctx: Context): Agent {
   const scope = ctx.plugin(() => {})
   const id = SessionId('pty-loader-agent')
   const value: Agent = {
-    id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx,
+    id, options: {}, session: Session.create(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx,
     followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
   }
   ctx.agents.register(value)

+ 1 - 1
packages/pty/tool-pty/tests/tools.spec.ts

@@ -17,7 +17,7 @@ function fakeAgent(ctx: Context, rawId: string): Agent {
   const scope = ctx.plugin(() => {})
   const id = SessionId(rawId)
   const agent: Agent = {
-    id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx,
+    id, options: {}, session: Session.create(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx,
     followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
   }
   ctx.agents.register(agent)

+ 4 - 4
packages/sandbox/sandbox-policy/tests/policy.spec.ts

@@ -22,7 +22,7 @@ async function mounted(config: { mode?: 'read-only' | 'workspace-write' | 'dange
 
 function session(id: string, cwd?: string): Session {
   const sessionId = SessionId(id)
-  return new Session(sessionId, undefined, {
+  return Session.create(sessionId, undefined, {
     version: 0,
     id: sessionId,
     createdAt: 0,
@@ -195,7 +195,7 @@ describe('sandbox:policy request context', () => {
   it('reconstructs resumed policy from the session log and omits diagnostics without an agent', async () => {
     const active = session('sess-resume', '/projects/current')
     setSandboxMode(active, 'workspace-write')
-    const resumed = new Session(active.id, active.events, active.header)
+    const resumed = Session.create(active.id, active.events, active.header)
     const ctx = await promptMounted({ mode: 'read-only' })
 
     expect(await policyContext(ctx, resumed)).toContain('workspace-write')
@@ -209,7 +209,7 @@ describe('the sandbox/mode session kit', () => {
   })
 
   it('effectiveSandboxMode folds to the last switch, or undefined without one', () => {
-    const session = new Session(SessionId('sess-fold'))
+    const session = Session.create(SessionId('sess-fold'))
     expect(effectiveSandboxMode(session.events)).toBeUndefined()
     setSandboxMode(session, 'workspace-write')
     setSandboxMode(session, 'read-only')
@@ -217,7 +217,7 @@ describe('the sandbox/mode session kit', () => {
   })
 
   it('setSandboxMode appends exactly one sandbox/mode event per switch', () => {
-    const session = new Session(SessionId('sess-write'))
+    const session = Session.create(SessionId('sess-write'))
     setSandboxMode(session, 'danger-full-access')
     const modeEvents = session.events.filter(e => e.type === 'sandbox/mode')
     expect(modeEvents).toHaveLength(1)

+ 1 - 1
packages/session-persistence/session-persistence/tests/contract.ts

@@ -255,7 +255,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
         }
         expect(synthetic.data.message.content[0].content[0].text).toContain('retry only if the operation is read-only or idempotent')
         expect(synthetic.data.message.content[0].content[0].text).toContain('if it may have side effects, first verify external state or ask the user')
-        const resumed = new Session(m.id, loaded.events, loaded.meta)
+        const resumed = Session.create(m.id, loaded.events, loaded.meta)
         const resumedResult = resumed.deriveMessages().find(message => message.content.some(block => block.type === 'tool-result'))
         expect(resumedResult?.content[0]).toMatchObject({
           type: 'tool-result', toolCallId: CallId('call-risk'), isError: true,

+ 1 - 1
packages/session-persistence/session-persistence/tests/coordinator-contract.ts

@@ -379,7 +379,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
           ])
           expect(messages.every(message => Object.isFrozen(message))).toBe(true)
 
-          const resumed = new Session(id, snapshot.events, snapshot.meta)
+          const resumed = Session.create(id, snapshot.events, snapshot.meta)
           expect(resumed.deriveMessages().map(message => message.id)).toEqual([
             `legacy-message:${id}:1`,
             `legacy-message:${id}:3`,

+ 1 - 1
packages/session-query/session-query/src/index.ts

@@ -143,7 +143,7 @@ export abstract class SessionQueryService extends Service {
    */
   async readSession(sessionId: SessionId): Promise<SessionLogSnapshot> {
     const loaded = await this._corpus.load(sessionId)
-    new Session(sessionId, loaded.events, loaded.header)
+    Session.create(sessionId, loaded.events, loaded.header)
     return {
       session: structuredClone(loaded.header),
       events: loaded.events.map(snapshotSessionEvent),

+ 1 - 1
packages/session-title/session-title-all-messages-llm/tests/provider.spec.ts

@@ -31,7 +31,7 @@ async function settle(): Promise<void> {
 
 describe('all-messages LLM title provider', () => {
   it('includes seeded history and the latest prompt while inheriting the logged request route', async () => {
-    const seeded = new Session(SessionId('seed-source'))
+    const seeded = Session.create(SessionId('seed-source'))
     seeded.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
     const inherited = seeded.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'inherited prompt' }], source: { kind: 'user' },

+ 1 - 1
packages/session-title/session-title-first-message-llm/tests/provider.spec.ts

@@ -45,7 +45,7 @@ describe('first-message LLM title provider', () => {
     providerPlugin.apply(ctx, LLM_CONFIG)
 
     await expect(registered!.generate({
-      session: new Session(SessionId('empty-first-provider')),
+      session: Session.create(SessionId('empty-first-provider')),
       messages: [],
       signal: new AbortController().signal,
     })).rejects.toThrow(/requires one human message/)

+ 1 - 1
packages/session-title/session-title/tests/rename.spec.ts

@@ -61,7 +61,7 @@ describe('SessionTitleService.rename', () => {
     const session = ctx.sessions.create(SessionId('rename-reject'))
     expect(() => ctx.sessionTitle.rename(session, '    ')).toThrow(/visible characters/)
 
-    expect(() => ctx.sessionTitle.rename(new Session(SessionId('detached')), 'name'))
+    expect(() => ctx.sessionTitle.rename(Session.create(SessionId('detached')), 'name'))
       .toThrow(/not live in this store/)
   })
 

+ 2 - 2
packages/session-title/session-title/tests/service-contracts.spec.ts

@@ -82,7 +82,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => {
     await expect(withProvider.sessionTitle.refresh(providerEmpty)).resolves.toBeUndefined()
     expect(generate).not.toHaveBeenCalled()
 
-    await expect(withProvider.sessionTitle.refresh(new Session(SessionId('detached'))))
+    await expect(withProvider.sessionTitle.refresh(Session.create(SessionId('detached'))))
       .rejects.toThrow(/not live in this store/)
     const controller = new AbortController()
     controller.abort(new Error('already cancelled'))
@@ -165,7 +165,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => {
 
   it('shares one fallback across concurrent refreshes', async () => {
     const ctx = await setup()
-    const seed = new Session(SessionId('fallback-concurrency-seed'))
+    const seed = Session.create(SessionId('fallback-concurrency-seed'))
     seed.append('turn/start', {
       turn: 1,
       trigger: { kind: 'message', source: { kind: 'user' } },

+ 1 - 1
packages/session-title/session-title/tests/session-title.spec.ts

@@ -134,7 +134,7 @@ describe('SessionTitleService', () => {
   })
 
   it('folds the latest title event during replay', () => {
-    const seed = new Session(SessionId('source'))
+    const seed = Session.create(SessionId('source'))
     seed.append('session/title', {
       title: 'Earlier',
       messageSeqs: [1],

+ 9 - 9
packages/skill/tool-skill/tests/tool-skill.spec.ts

@@ -38,7 +38,7 @@ async function setup(home: string, config: toolSkill.Config = {}): Promise<Conte
 
 function agentForCwd(cwd: string): Agent {
   const id = SessionId(`tool-skill-${cwd}`)
-  const session = new Session(id, [], { version: 0, id, createdAt: 0, cwd })
+  const session = Session.create(id, [], { version: 0, id, createdAt: 0, cwd })
   return {
     ctx: new Context(),
     id,
@@ -284,7 +284,7 @@ describe('dsh-tool-skill', () => {
       invalidate = control.invalidate
       return provider
     })
-    const session = new Session(SessionId('incomplete-prefix'))
+    const session = Session.create(SessionId('incomplete-prefix'))
     const agent = sessionAgent(session)
     openMessageTurn(session)
 
@@ -300,7 +300,7 @@ describe('dsh-tool-skill', () => {
   it('records an empty baseline across repeated step observations', async () => {
     const home = await tempDir('tool-empty-step')
     const ctx = await setup(home)
-    const session = new Session(SessionId('empty-step'))
+    const session = Session.create(SessionId('empty-step'))
     const agent = sessionAgent(session)
     openMessageTurn(session)
 
@@ -319,7 +319,7 @@ describe('dsh-tool-skill', () => {
       source: 'runtime',
       content: 'First body.',
     })
-    const session = new Session(SessionId('dynamic-catalog'))
+    const session = Session.create(SessionId('dynamic-catalog'))
     const agent = sessionAgent(session)
     openMessageTurn(session)
 
@@ -363,7 +363,7 @@ describe('dsh-tool-skill', () => {
       source: 'runtime',
       content: 'Resumed body.',
     })
-    const session = new Session(SessionId('catalog-resume'))
+    const session = Session.create(SessionId('catalog-resume'))
     const agent = sessionAgent(session)
     openMessageTurn(session)
     session.append('user/message', createUserMessage({
@@ -402,7 +402,7 @@ describe('dsh-tool-skill', () => {
       source: 'runtime',
       content: 'First body.',
     })
-    const session = new Session(SessionId('catalog-compaction'))
+    const session = Session.create(SessionId('catalog-compaction'))
     const agent = sessionAgent(session)
     openMessageTurn(session)
     expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('first-skill')
@@ -427,7 +427,7 @@ describe('dsh-tool-skill', () => {
     const root = join(home, '.dsh/skills')
     await writeSkill(root, 'body-skill', 'Stable description', 'First body.')
     const ctx = await setup(home)
-    const session = new Session(SessionId('body-refresh'))
+    const session = Session.create(SessionId('body-refresh'))
     const agent = sessionAgent(session)
     openMessageTurn(session)
 
@@ -457,7 +457,7 @@ describe('dsh-tool-skill', () => {
       source: 'runtime',
       content: 'Stable body.',
     })
-    const session = new Session(SessionId('incomplete-catalog'))
+    const session = Session.create(SessionId('incomplete-catalog'))
     const agent = sessionAgent(session)
     openMessageTurn(session)
     expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('stable-skill')
@@ -481,7 +481,7 @@ describe('dsh-tool-skill', () => {
     const home = await tempDir('tool-restricted-catalog')
     const ctx = await setup(home)
     ctx.skills.register({ name: 'listed-skill', description: 'Listed', source: 'runtime', content: 'body' })
-    const session = new Session(SessionId('restricted-catalog'))
+    const session = Session.create(SessionId('restricted-catalog'))
     const agent = sessionAgent(session)
     openMessageTurn(session)
     const { scope } = await mintAgentScope(ctx, agent)

+ 1 - 1
packages/subagent/subagent/src/descriptor-seed.ts

@@ -25,7 +25,7 @@ export function seedDescriptorTurn(
   seed: readonly SessionEvent[] | undefined,
   descriptor: SubagentDescriptorData,
 ): SessionEvent[] {
-  const staged = new Session(childId, seed)
+  const staged = Session.create(childId, seed)
   staged.append('subagent/descriptor', descriptor)
   return [...staged.events]
 }

+ 1 - 1
packages/tasks/tasks-local/tests/tasks.spec.ts

@@ -21,7 +21,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
   const agent = {
     id,
     options: {},
-    session: new Session(id),
+    session: Session.create(id),
     status: 'idle' as const,
     acceptsNextStep: false,
     ctx: scopeFiber.ctx,

+ 1 - 1
packages/todo/tool-todo/tests/tool-todo.spec.ts

@@ -22,7 +22,7 @@ const testToolSignal = new AbortController().signal
 
 /** A parent Agent backed by a real Session — the tool reads `agent.session`. */
 function agentWithSession(id = 'parent-1'): Agent & { session: Session } {
-  const session = new Session(SessionId(id))
+  const session = Session.create(SessionId(id))
   return { id: SessionId(id), session } as unknown as Agent & { session: Session }
 }
 

+ 1 - 1
packages/ui/permission/tests/permission.spec.ts

@@ -44,7 +44,7 @@ async function mounted(options: {
 }
 
 function freshSession(id: string): Session {
-  return new Session(SessionId(id))
+  return Session.create(SessionId(id))
 }
 
 async function mountedStore(options: { approvalDefault?: ApprovalPolicy | undefined } = {}): Promise<Context> {

+ 1 - 1
packages/ui/user-approval/tests/approval.spec.ts

@@ -355,7 +355,7 @@ describe('approval policy (the approval/policy fold)', () => {
 
   /** Agent stand-in over a real Session; the opened turn satisfies request()'s enclosure precondition. */
   function sessionAgent(id: string): { agent: Agent; session: Session } {
-    const session = new Session(SessionId(id))
+    const session = Session.create(SessionId(id))
     session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
     const agent = {
       id,

+ 1 - 1
packages/ui/user-approval/tests/invariant.spec.ts

@@ -43,7 +43,7 @@ describe('approval invariants', () => {
 
   it('adopts a bare session first observed through publication', async () => {
     const ctx = await setup()
-    const session = new Session(SessionId('bare-approval-session'))
+    const session = Session.create(SessionId('bare-approval-session'))
     const id = ApprovalRequestId('bare-ask')
     const asked = {
       type: 'approval/asked', seq: 0, time: 0, data: { id, toolName: 'bash' },