@namzu/sdk 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/dist/bridge/a2a/mapper.d.ts.map +1 -1
  3. package/dist/bridge/a2a/mapper.js +2 -0
  4. package/dist/bridge/a2a/mapper.js.map +1 -1
  5. package/dist/bridge/sse/mapper.d.ts.map +1 -1
  6. package/dist/bridge/sse/mapper.js +4 -0
  7. package/dist/bridge/sse/mapper.js.map +1 -1
  8. package/dist/{runtime/query → constants}/continuation.d.ts +5 -0
  9. package/dist/constants/continuation.d.ts.map +1 -0
  10. package/dist/{runtime/query → constants}/continuation.js +5 -0
  11. package/dist/constants/continuation.js.map +1 -0
  12. package/dist/constants/index.d.ts +1 -0
  13. package/dist/constants/index.d.ts.map +1 -1
  14. package/dist/constants/index.js +1 -0
  15. package/dist/constants/index.js.map +1 -1
  16. package/dist/manager/run/persistence.d.ts +11 -0
  17. package/dist/manager/run/persistence.d.ts.map +1 -1
  18. package/dist/manager/run/persistence.js +30 -1
  19. package/dist/manager/run/persistence.js.map +1 -1
  20. package/dist/provider/__tests__/registry.test.d.ts +5 -0
  21. package/dist/provider/__tests__/registry.test.d.ts.map +1 -1
  22. package/dist/provider/__tests__/registry.test.js +186 -1
  23. package/dist/provider/__tests__/registry.test.js.map +1 -1
  24. package/dist/provider/capabilities.d.ts +27 -0
  25. package/dist/provider/capabilities.d.ts.map +1 -0
  26. package/dist/provider/capabilities.js +29 -0
  27. package/dist/provider/capabilities.js.map +1 -0
  28. package/dist/provider/index.d.ts +3 -1
  29. package/dist/provider/index.d.ts.map +1 -1
  30. package/dist/provider/index.js +2 -1
  31. package/dist/provider/index.js.map +1 -1
  32. package/dist/provider/registry.d.ts +81 -1
  33. package/dist/provider/registry.d.ts.map +1 -1
  34. package/dist/provider/registry.js +173 -7
  35. package/dist/provider/registry.js.map +1 -1
  36. package/dist/public-runtime.d.ts +3 -2
  37. package/dist/public-runtime.d.ts.map +1 -1
  38. package/dist/public-runtime.js +2 -2
  39. package/dist/public-runtime.js.map +1 -1
  40. package/dist/run/reporter.d.ts.map +1 -1
  41. package/dist/run/reporter.js +8 -0
  42. package/dist/run/reporter.js.map +1 -1
  43. package/dist/runtime/query/__tests__/capability-negotiation.test.d.ts +2 -0
  44. package/dist/runtime/query/__tests__/capability-negotiation.test.d.ts.map +1 -0
  45. package/dist/runtime/query/__tests__/capability-negotiation.test.js +184 -0
  46. package/dist/runtime/query/__tests__/capability-negotiation.test.js.map +1 -0
  47. package/dist/runtime/query/__tests__/checkpoint-store.test.d.ts +2 -0
  48. package/dist/runtime/query/__tests__/checkpoint-store.test.d.ts.map +1 -0
  49. package/dist/runtime/query/__tests__/checkpoint-store.test.js +259 -0
  50. package/dist/runtime/query/__tests__/checkpoint-store.test.js.map +1 -0
  51. package/dist/runtime/query/__tests__/checkpoint.test.js +9 -3
  52. package/dist/runtime/query/__tests__/checkpoint.test.js.map +1 -1
  53. package/dist/runtime/query/__tests__/orphan-task-turn-end.test.d.ts +2 -0
  54. package/dist/runtime/query/__tests__/orphan-task-turn-end.test.d.ts.map +1 -0
  55. package/dist/runtime/query/__tests__/orphan-task-turn-end.test.js +110 -0
  56. package/dist/runtime/query/__tests__/orphan-task-turn-end.test.js.map +1 -0
  57. package/dist/runtime/query/checkpoint.d.ts +16 -2
  58. package/dist/runtime/query/checkpoint.d.ts.map +1 -1
  59. package/dist/runtime/query/checkpoint.js +21 -7
  60. package/dist/runtime/query/checkpoint.js.map +1 -1
  61. package/dist/runtime/query/context.d.ts +7 -0
  62. package/dist/runtime/query/context.d.ts.map +1 -1
  63. package/dist/runtime/query/context.js +1 -0
  64. package/dist/runtime/query/context.js.map +1 -1
  65. package/dist/runtime/query/index.d.ts +20 -0
  66. package/dist/runtime/query/index.d.ts.map +1 -1
  67. package/dist/runtime/query/index.js +75 -6
  68. package/dist/runtime/query/index.js.map +1 -1
  69. package/dist/runtime/query/iteration/index.d.ts +3 -57
  70. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  71. package/dist/runtime/query/iteration/index.js +246 -735
  72. package/dist/runtime/query/iteration/index.js.map +1 -1
  73. package/dist/runtime/query/iteration/phases/checkpoint.d.ts.map +1 -1
  74. package/dist/runtime/query/iteration/phases/checkpoint.js +22 -0
  75. package/dist/runtime/query/iteration/phases/checkpoint.js.map +1 -1
  76. package/dist/runtime/query/iteration/phases/context.d.ts +1 -2
  77. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  78. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  79. package/dist/runtime/query/iteration/stream-turn.d.ts +35 -0
  80. package/dist/runtime/query/iteration/stream-turn.d.ts.map +1 -0
  81. package/dist/runtime/query/iteration/stream-turn.js +341 -0
  82. package/dist/runtime/query/iteration/stream-turn.js.map +1 -0
  83. package/dist/runtime/query/replay/list.d.ts +17 -1
  84. package/dist/runtime/query/replay/list.d.ts.map +1 -1
  85. package/dist/runtime/query/replay/list.js +21 -4
  86. package/dist/runtime/query/replay/list.js.map +1 -1
  87. package/dist/runtime/query/replay/prepare.d.ts +10 -0
  88. package/dist/runtime/query/replay/prepare.d.ts.map +1 -1
  89. package/dist/runtime/query/replay/prepare.js +20 -4
  90. package/dist/runtime/query/replay/prepare.js.map +1 -1
  91. package/dist/store/index.d.ts +1 -0
  92. package/dist/store/index.d.ts.map +1 -1
  93. package/dist/store/index.js +1 -0
  94. package/dist/store/index.js.map +1 -1
  95. package/dist/store/run/checkpoint-disk.d.ts +28 -0
  96. package/dist/store/run/checkpoint-disk.d.ts.map +1 -0
  97. package/dist/store/run/checkpoint-disk.js +55 -0
  98. package/dist/store/run/checkpoint-disk.js.map +1 -0
  99. package/dist/types/provider/config.d.ts +51 -0
  100. package/dist/types/provider/config.d.ts.map +1 -1
  101. package/dist/types/provider/index.d.ts +1 -1
  102. package/dist/types/provider/index.d.ts.map +1 -1
  103. package/dist/types/provider/interface.d.ts +10 -0
  104. package/dist/types/provider/interface.d.ts.map +1 -1
  105. package/dist/types/run/checkpoint-store.d.ts +64 -0
  106. package/dist/types/run/checkpoint-store.d.ts.map +1 -0
  107. package/dist/types/run/checkpoint-store.js +12 -0
  108. package/dist/types/run/checkpoint-store.js.map +1 -0
  109. package/dist/types/run/config.d.ts +27 -0
  110. package/dist/types/run/config.d.ts.map +1 -1
  111. package/dist/types/run/events.d.ts +6 -0
  112. package/dist/types/run/events.d.ts.map +1 -1
  113. package/dist/types/run/events.js.map +1 -1
  114. package/dist/types/run/index.d.ts +1 -0
  115. package/dist/types/run/index.d.ts.map +1 -1
  116. package/dist/types/run/index.js +1 -0
  117. package/dist/types/run/index.js.map +1 -1
  118. package/package.json +1 -1
  119. package/src/bridge/a2a/mapper.ts +3 -0
  120. package/src/bridge/sse/mapper.ts +5 -0
  121. package/src/{runtime/query → constants}/continuation.ts +5 -0
  122. package/src/constants/index.ts +1 -0
  123. package/src/manager/run/persistence.ts +34 -1
  124. package/src/provider/__tests__/registry.test.ts +266 -1
  125. package/src/provider/capabilities.ts +47 -0
  126. package/src/provider/index.ts +12 -1
  127. package/src/provider/registry.ts +202 -7
  128. package/src/public-runtime.ts +6 -0
  129. package/src/run/reporter.ts +9 -0
  130. package/src/runtime/query/__tests__/capability-negotiation.test.ts +248 -0
  131. package/src/runtime/query/__tests__/checkpoint-store.test.ts +322 -0
  132. package/src/runtime/query/__tests__/checkpoint.test.ts +15 -6
  133. package/src/runtime/query/__tests__/orphan-task-turn-end.test.ts +127 -0
  134. package/src/runtime/query/checkpoint.ts +23 -9
  135. package/src/runtime/query/context.ts +9 -0
  136. package/src/runtime/query/index.ts +115 -28
  137. package/src/runtime/query/iteration/index.ts +298 -905
  138. package/src/runtime/query/iteration/phases/checkpoint.ts +25 -0
  139. package/src/runtime/query/iteration/phases/context.ts +1 -3
  140. package/src/runtime/query/iteration/stream-turn.ts +396 -0
  141. package/src/runtime/query/replay/list.ts +38 -4
  142. package/src/runtime/query/replay/prepare.ts +37 -4
  143. package/src/store/index.ts +1 -0
  144. package/src/store/run/checkpoint-disk.ts +67 -0
  145. package/src/types/provider/config.ts +54 -0
  146. package/src/types/provider/index.ts +3 -0
  147. package/src/types/provider/interface.ts +11 -0
  148. package/src/types/run/checkpoint-store.ts +72 -0
  149. package/src/types/run/config.ts +30 -0
  150. package/src/types/run/events.ts +13 -0
  151. package/src/types/run/index.ts +1 -0
  152. package/dist/runtime/query/continuation.d.ts.map +0 -1
  153. package/dist/runtime/query/continuation.js.map +0 -1
@@ -0,0 +1,322 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+ import { z } from 'zod'
6
+
7
+ import type { RunPersistence } from '../../../manager/run/persistence.js'
8
+ import { ToolRegistry } from '../../../registry/tool/execute.js'
9
+ import type { CheckpointId, IterationCheckpoint } from '../../../types/hitl/index.js'
10
+ import type { RunId, SessionId, TenantId } from '../../../types/ids/index.js'
11
+ import { createUserMessage } from '../../../types/message/index.js'
12
+ import type { LLMProvider, StreamChunk } from '../../../types/provider/index.js'
13
+ import type { CheckpointRunScope, CheckpointStore } from '../../../types/run/checkpoint-store.js'
14
+ import type { RunEvent } from '../../../types/run/index.js'
15
+ import type { ProjectId, ThreadId } from '../../../types/session/ids.js'
16
+ import { CheckpointManager } from '../checkpoint.js'
17
+ import { drainQuery } from '../index.js'
18
+ import { runIterationCheckpoint } from '../iteration/phases/checkpoint.js'
19
+ import type { IterationContext } from '../iteration/phases/context.js'
20
+
21
+ const ZERO_USAGE = {
22
+ promptTokens: 0,
23
+ completionTokens: 0,
24
+ totalTokens: 0,
25
+ cachedTokens: 0,
26
+ cacheWriteTokens: 0,
27
+ }
28
+
29
+ const ZERO_COST = {
30
+ inputCostPer1M: 0,
31
+ outputCostPer1M: 0,
32
+ totalCost: 0,
33
+ cacheDiscount: 0,
34
+ }
35
+
36
+ const SCOPE: CheckpointRunScope = {
37
+ tenantId: 'tnt_cp_store' as TenantId,
38
+ projectId: 'prj_cp_store' as ProjectId,
39
+ sessionId: 'ses_cp_store' as SessionId,
40
+ runId: 'run_cp_store' as RunId,
41
+ }
42
+
43
+ /**
44
+ * In-memory {@link CheckpointStore} used both as the conformance fixture for
45
+ * `CheckpointManager` and as the injected store in the query()-level test.
46
+ * Keys rows by the full scope so the tests can assert the kernel passes the
47
+ * five-layer attribution through, not just the checkpoint payload.
48
+ */
49
+ class InMemoryCheckpointStore implements CheckpointStore {
50
+ readonly rows = new Map<string, IterationCheckpoint>()
51
+ readonly seenScopes: CheckpointRunScope[] = []
52
+
53
+ private key(scope: CheckpointRunScope, checkpointId: CheckpointId): string {
54
+ return [scope.tenantId, scope.projectId, scope.sessionId, scope.runId, checkpointId].join('/')
55
+ }
56
+
57
+ private runPrefix(scope: CheckpointRunScope): string {
58
+ return `${[scope.tenantId, scope.projectId, scope.sessionId, scope.runId].join('/')}/`
59
+ }
60
+
61
+ async writeCheckpoint(scope: CheckpointRunScope, checkpoint: IterationCheckpoint): Promise<void> {
62
+ this.seenScopes.push(scope)
63
+ this.rows.set(this.key(scope, checkpoint.id), checkpoint)
64
+ }
65
+
66
+ async readCheckpoint(
67
+ scope: CheckpointRunScope,
68
+ checkpointId: CheckpointId,
69
+ ): Promise<IterationCheckpoint | null> {
70
+ return this.rows.get(this.key(scope, checkpointId)) ?? null
71
+ }
72
+
73
+ async listCheckpoints(scope: CheckpointRunScope): Promise<IterationCheckpoint[]> {
74
+ const prefix = this.runPrefix(scope)
75
+ return [...this.rows.entries()]
76
+ .filter(([key]) => key.startsWith(prefix))
77
+ .map(([, cp]) => cp)
78
+ .sort((a, b) => a.createdAt - b.createdAt)
79
+ }
80
+
81
+ async deleteCheckpoint(scope: CheckpointRunScope, checkpointId: CheckpointId): Promise<void> {
82
+ this.rows.delete(this.key(scope, checkpointId))
83
+ }
84
+ }
85
+
86
+ function makeRunMgrStub(): RunPersistence {
87
+ return {
88
+ id: SCOPE.runId,
89
+ messages: [{ role: 'user', content: 'hello' }],
90
+ tokenUsage: { ...ZERO_USAGE },
91
+ costInfo: { ...ZERO_COST },
92
+ currentIteration: 1,
93
+ getSession: () => ({ startedAt: Date.now() }),
94
+ } as unknown as RunPersistence
95
+ }
96
+
97
+ describe('CheckpointManager against an injected CheckpointStore', () => {
98
+ it('round-trips create → restore → list → prune through the interface', async () => {
99
+ const store = new InMemoryCheckpointStore()
100
+ const mgr = new CheckpointManager(store, SCOPE)
101
+ const runMgr = makeRunMgrStub()
102
+
103
+ const first = await mgr.create(runMgr, 1)
104
+ const second = await mgr.create(runMgr, 2)
105
+ // Deterministic ordering for prune (createdAt can tie at ms resolution).
106
+ const storedFirst = store.rows.get(
107
+ [SCOPE.tenantId, SCOPE.projectId, SCOPE.sessionId, SCOPE.runId, first.id].join('/'),
108
+ )
109
+ const storedSecond = store.rows.get(
110
+ [SCOPE.tenantId, SCOPE.projectId, SCOPE.sessionId, SCOPE.runId, second.id].join('/'),
111
+ )
112
+ if (storedFirst) storedFirst.createdAt = 1_000
113
+ if (storedSecond) storedSecond.createdAt = 2_000
114
+
115
+ // restore reads back through the interface
116
+ const restored = await mgr.restore(first.id)
117
+ expect(restored.id).toBe(first.id)
118
+ expect(restored.iteration).toBe(1)
119
+
120
+ // list sees both, oldest first
121
+ const listed = await mgr.list()
122
+ expect(listed.map((cp) => cp.id)).toEqual([first.id, second.id])
123
+
124
+ // prune deletes oldest-first through deleteCheckpoint
125
+ await mgr.prune(1)
126
+ const remaining = await mgr.list()
127
+ expect(remaining.map((cp) => cp.id)).toEqual([second.id])
128
+
129
+ // every write carried the full five-layer scope
130
+ expect(store.seenScopes.every((s) => s.tenantId === SCOPE.tenantId)).toBe(true)
131
+ expect(store.seenScopes.every((s) => s.sessionId === SCOPE.sessionId)).toBe(true)
132
+ })
133
+
134
+ it('restore throws a descriptive error for a missing checkpoint', async () => {
135
+ const mgr = new CheckpointManager(new InMemoryCheckpointStore(), SCOPE)
136
+ await expect(mgr.restore('cp_missing' as CheckpointId)).rejects.toThrow(
137
+ 'Checkpoint not found: cp_missing',
138
+ )
139
+ })
140
+ })
141
+
142
+ // ─── cadence + prune via the iteration-checkpoint phase ──────────────────────
143
+
144
+ function makePhaseContext(
145
+ store: InMemoryCheckpointStore,
146
+ runConfig: { checkpointEvery?: number; pruneKeepLast?: number },
147
+ ): { ctx: IterationContext; events: RunEvent[] } {
148
+ const events: RunEvent[] = []
149
+ const ctx = {
150
+ runConfig: {
151
+ model: 'mock-model',
152
+ timeoutMs: 5_000,
153
+ tokenBudget: 100_000,
154
+ ...runConfig,
155
+ },
156
+ runMgr: makeRunMgrStub(),
157
+ checkpointMgr: new CheckpointManager(store, SCOPE),
158
+ emitEvent: async (event: RunEvent) => {
159
+ events.push(event)
160
+ },
161
+ drainPending: function* (): Generator<RunEvent> {},
162
+ resumeHandler: async () => ({ action: 'continue' as const }),
163
+ } as unknown as IterationContext
164
+ return { ctx, events }
165
+ }
166
+
167
+ async function drivePhase(ctx: IterationContext, iterationNum: number): Promise<void> {
168
+ const gen = runIterationCheckpoint(ctx, iterationNum)
169
+ let result = await gen.next()
170
+ while (!result.done) {
171
+ result = await gen.next()
172
+ }
173
+ }
174
+
175
+ describe('iteration checkpoint cadence (checkpointEvery)', () => {
176
+ it('defaults to a checkpoint on every iteration', async () => {
177
+ const store = new InMemoryCheckpointStore()
178
+ const { ctx, events } = makePhaseContext(store, {})
179
+
180
+ await drivePhase(ctx, 1)
181
+ await drivePhase(ctx, 2)
182
+ await drivePhase(ctx, 3)
183
+
184
+ expect(store.rows.size).toBe(3)
185
+ expect(events.filter((e) => e.type === 'checkpoint_created')).toHaveLength(3)
186
+ })
187
+
188
+ it('checkpointEvery: 2 checkpoints iterations 1, 3, 5 and skips the rest', async () => {
189
+ const store = new InMemoryCheckpointStore()
190
+ const { ctx, events } = makePhaseContext(store, { checkpointEvery: 2 })
191
+
192
+ for (const iteration of [1, 2, 3, 4, 5]) {
193
+ await drivePhase(ctx, iteration)
194
+ }
195
+
196
+ const created = events.filter(
197
+ (e): e is Extract<RunEvent, { type: 'checkpoint_created' }> =>
198
+ e.type === 'checkpoint_created',
199
+ )
200
+ expect(created.map((e) => e.iteration)).toEqual([1, 3, 5])
201
+ expect(store.rows.size).toBe(3)
202
+ })
203
+
204
+ it('pruneKeepLast keeps only the newest N checkpoints after each create', async () => {
205
+ const store = new InMemoryCheckpointStore()
206
+ const { ctx } = makePhaseContext(store, { pruneKeepLast: 2 })
207
+
208
+ // Make createdAt strictly increasing so prune order is deterministic.
209
+ let tick = 0
210
+ const originalWrite = store.writeCheckpoint.bind(store)
211
+ store.writeCheckpoint = async (scope, checkpoint) => {
212
+ tick += 1
213
+ await originalWrite(scope, { ...checkpoint, createdAt: tick })
214
+ }
215
+
216
+ await drivePhase(ctx, 1)
217
+ await drivePhase(ctx, 2)
218
+ await drivePhase(ctx, 3)
219
+ await drivePhase(ctx, 4)
220
+
221
+ const remaining = await store.listCheckpoints(SCOPE)
222
+ expect(remaining).toHaveLength(2)
223
+ expect(remaining.map((cp) => cp.iteration)).toEqual([3, 4])
224
+ })
225
+ })
226
+
227
+ // ─── query()-level injection ─────────────────────────────────────────────────
228
+
229
+ class OneToolCallProvider implements LLMProvider {
230
+ readonly id = 'one-tool-call'
231
+ readonly name = 'One Tool Call Provider'
232
+ calls = 0
233
+
234
+ async *chatStream(): AsyncIterable<StreamChunk> {
235
+ this.calls += 1
236
+
237
+ if (this.calls === 1) {
238
+ yield {
239
+ id: 'msg_1',
240
+ delta: {
241
+ toolCalls: [
242
+ {
243
+ index: 0,
244
+ id: 'toolu_echo_1',
245
+ type: 'function',
246
+ function: { name: 'echo', arguments: '{"text":"hi"}' },
247
+ },
248
+ ],
249
+ },
250
+ }
251
+ yield {
252
+ id: 'msg_1',
253
+ delta: { toolCallEnd: { index: 0, id: 'toolu_echo_1' } },
254
+ }
255
+ yield {
256
+ id: 'msg_1',
257
+ delta: {},
258
+ finishReason: 'tool_calls',
259
+ usage: ZERO_USAGE,
260
+ }
261
+ return
262
+ }
263
+
264
+ yield { id: 'msg_2', delta: { content: 'done' } }
265
+ yield { id: 'msg_2', delta: {}, finishReason: 'stop', usage: ZERO_USAGE }
266
+ }
267
+ }
268
+
269
+ describe('query() with an injected checkpointStore', () => {
270
+ let workdirs: string[] = []
271
+
272
+ afterEach(async () => {
273
+ await Promise.all(workdirs.map((dir) => rm(dir, { recursive: true, force: true })))
274
+ workdirs = []
275
+ })
276
+
277
+ it('persists iteration checkpoints into the injected store, keyed by run scope', async () => {
278
+ const provider = new OneToolCallProvider()
279
+ const store = new InMemoryCheckpointStore()
280
+ const tools = new ToolRegistry()
281
+ tools.register({
282
+ name: 'echo',
283
+ description: 'echo the text back',
284
+ inputSchema: z.object({ text: z.string() }),
285
+ execute: async () => ({ success: true, output: 'hi' }),
286
+ })
287
+
288
+ const workingDirectory = await mkdtemp(join(tmpdir(), 'namzu-checkpoint-store-'))
289
+ workdirs.push(workingDirectory)
290
+
291
+ const run = await drainQuery({
292
+ provider,
293
+ tools,
294
+ checkpointStore: store,
295
+ runConfig: {
296
+ model: 'mock-model',
297
+ timeoutMs: 5_000,
298
+ tokenBudget: 100_000,
299
+ maxIterations: 3,
300
+ maxResponseTokens: 256,
301
+ },
302
+ agentId: 'agent_test',
303
+ agentName: 'Test Agent',
304
+ messages: [createUserMessage('use the echo tool')],
305
+ workingDirectory,
306
+ sessionId: 'ses_cp_inject' as SessionId,
307
+ threadId: 'thd_cp_inject' as ThreadId,
308
+ projectId: 'prj_cp_inject' as ProjectId,
309
+ tenantId: 'tnt_cp_inject' as TenantId,
310
+ })
311
+
312
+ expect(run.status).toBe('completed')
313
+ // The tool-call iteration produced at least one checkpoint, and it
314
+ // landed in the injected store — not on disk.
315
+ expect(store.rows.size).toBeGreaterThan(0)
316
+ const scope = store.seenScopes[0]
317
+ expect(scope?.tenantId).toBe('tnt_cp_inject')
318
+ expect(scope?.projectId).toBe('prj_cp_inject')
319
+ expect(scope?.sessionId).toBe('ses_cp_inject')
320
+ expect(scope?.runId).toBe(run.id)
321
+ })
322
+ })
@@ -1,10 +1,18 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import type { RunDiskStore } from '../../../store/run/disk.js'
3
2
  import type { CheckpointId, IterationCheckpoint } from '../../../types/hitl/index.js'
4
- import type { EmergencySaveId, RunId } from '../../../types/ids/index.js'
3
+ import type { EmergencySaveId, RunId, SessionId, TenantId } from '../../../types/ids/index.js'
4
+ import type { CheckpointRunScope, CheckpointStore } from '../../../types/run/checkpoint-store.js'
5
5
  import type { EmergencySaveData } from '../../../types/run/emergency.js'
6
+ import type { ProjectId } from '../../../types/session/ids.js'
6
7
  import { CheckpointManager, projectEmergencyToCheckpoint } from '../checkpoint.js'
7
8
 
9
+ const TEST_SCOPE: CheckpointRunScope = {
10
+ tenantId: 'tnt_test' as TenantId,
11
+ projectId: 'prj_test' as ProjectId,
12
+ sessionId: 'ses_test' as SessionId,
13
+ runId: 'run_test' as RunId,
14
+ }
15
+
8
16
  function makeCheckpoint(overrides: Partial<IterationCheckpoint> = {}): IterationCheckpoint {
9
17
  return {
10
18
  id: 'cp_test_a' as CheckpointId,
@@ -30,10 +38,10 @@ function makeCheckpoint(overrides: Partial<IterationCheckpoint> = {}): Iteration
30
38
  }
31
39
  }
32
40
 
33
- function makeStoreStub(checkpoints: IterationCheckpoint[]): RunDiskStore {
41
+ function makeStoreStub(checkpoints: IterationCheckpoint[]): CheckpointStore {
34
42
  return {
35
43
  listCheckpoints: async () => checkpoints,
36
- } as unknown as RunDiskStore
44
+ } as unknown as CheckpointStore
37
45
  }
38
46
 
39
47
  describe('CheckpointManager.listEntries', () => {
@@ -60,7 +68,7 @@ describe('CheckpointManager.listEntries', () => {
60
68
  }),
61
69
  ])
62
70
 
63
- const mgr = new CheckpointManager(store)
71
+ const mgr = new CheckpointManager(store, TEST_SCOPE)
64
72
  const entries = await mgr.listEntries()
65
73
 
66
74
  expect(entries).toHaveLength(2)
@@ -81,7 +89,7 @@ describe('CheckpointManager.listEntries', () => {
81
89
  })
82
90
 
83
91
  it('returns empty array when no checkpoints exist', async () => {
84
- const mgr = new CheckpointManager(makeStoreStub([]))
92
+ const mgr = new CheckpointManager(makeStoreStub([]), TEST_SCOPE)
85
93
  expect(await mgr.listEntries()).toEqual([])
86
94
  })
87
95
 
@@ -93,6 +101,7 @@ describe('CheckpointManager.listEntries', () => {
93
101
  branchStack: [{ agentId: 'a', decision: 'd', confidence: 1, timestamp: 0 }],
94
102
  }),
95
103
  ]),
104
+ TEST_SCOPE,
96
105
  )
97
106
  const [entry] = await mgr.listEntries()
98
107
  expect(entry).not.toHaveProperty('tokenUsage')
@@ -0,0 +1,127 @@
1
+ import { rm } from 'node:fs/promises'
2
+ import { mkdtemp } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { afterEach, describe, expect, it } from 'vitest'
6
+
7
+ import { ToolRegistry } from '../../../registry/tool/execute.js'
8
+ import type { TaskGateway, TaskHandle } from '../../../types/agent/gateway.js'
9
+ import type { SessionId, TaskId, TenantId } from '../../../types/ids/index.js'
10
+ import { createUserMessage } from '../../../types/message/index.js'
11
+ import type { LLMProvider, StreamChunk } from '../../../types/provider/index.js'
12
+ import type { RunEvent } from '../../../types/run/index.js'
13
+ import type { ProjectId, ThreadId } from '../../../types/session/ids.js'
14
+ import { drainQuery } from '../index.js'
15
+
16
+ const ZERO_USAGE = {
17
+ promptTokens: 0,
18
+ completionTokens: 0,
19
+ totalTokens: 0,
20
+ cachedTokens: 0,
21
+ cacheWriteTokens: 0,
22
+ }
23
+
24
+ /** Ends its turn with plain text on the first (and only) call. */
25
+ class SingleTurnProvider implements LLMProvider {
26
+ readonly id = 'single-turn'
27
+ readonly name = 'Single Turn Provider'
28
+ calls = 0
29
+
30
+ async *chatStream(): AsyncIterable<StreamChunk> {
31
+ this.calls += 1
32
+ yield {
33
+ id: `msg_${this.calls}`,
34
+ delta: { content: 'Final answer.' },
35
+ }
36
+ yield {
37
+ id: `msg_${this.calls}`,
38
+ delta: {},
39
+ finishReason: 'stop',
40
+ usage: ZERO_USAGE,
41
+ }
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Gateway that permanently reports one running task. Every dispatch
47
+ * tool is blocking, so a running task at end-of-turn is an orphan —
48
+ * there is no notification producer that could ever deliver its
49
+ * result (the listener was removed in dc16d58). The run must NOT
50
+ * busy-wait on it.
51
+ */
52
+ function orphanTaskGateway(): TaskGateway {
53
+ const handle: TaskHandle = {
54
+ taskId: 'task_orphan' as TaskId,
55
+ agentId: 'agent_worker',
56
+ state: 'running',
57
+ createdAt: Date.now(),
58
+ }
59
+ return {
60
+ createTask: async () => handle,
61
+ waitForTask: () => new Promise<TaskHandle>(() => {}),
62
+ continueTask: async () => {},
63
+ cancelTask: () => {},
64
+ getTask: () => handle,
65
+ listTasks: () => [handle],
66
+ onTaskCompleted: () => () => {},
67
+ }
68
+ }
69
+
70
+ describe('end of turn with running agent tasks', () => {
71
+ let workdirs: string[] = []
72
+
73
+ afterEach(async () => {
74
+ await Promise.all(workdirs.map((dir) => rm(dir, { recursive: true, force: true })))
75
+ workdirs = []
76
+ })
77
+
78
+ // Regression: the loop used to poll `pendingNotifications` every
79
+ // 250ms for up to `runConfig.timeoutMs` (120s default) whenever the
80
+ // turn ended while the gateway still listed a running task — but
81
+ // nothing has pushed onto that queue since dc16d58 removed the
82
+ // onTaskCompleted producer, so the wait always injected nothing and
83
+ // the run hung for minutes. The vitest per-test timeout is the
84
+ // hang detector here: with the busy-wait present this test times
85
+ // out instead of completing.
86
+ it('ends the run promptly instead of busy-waiting on orphan tasks', async () => {
87
+ const provider = new SingleTurnProvider()
88
+ const workingDirectory = await mkdtemp(join(tmpdir(), 'namzu-orphan-task-'))
89
+ workdirs.push(workingDirectory)
90
+ const events: RunEvent[] = []
91
+
92
+ const run = await drainQuery(
93
+ {
94
+ provider,
95
+ tools: new ToolRegistry(),
96
+ runConfig: {
97
+ model: 'mock-model',
98
+ // Deliberately longer than the vitest timeout: the old
99
+ // code waited min(timeoutMs, …) polling the dead queue.
100
+ timeoutMs: 120_000,
101
+ tokenBudget: 100_000,
102
+ maxIterations: 3,
103
+ maxResponseTokens: 256,
104
+ },
105
+ agentId: 'agent_test',
106
+ agentName: 'Test Agent',
107
+ messages: [createUserMessage('do the thing')],
108
+ workingDirectory,
109
+ taskGateway: orphanTaskGateway(),
110
+ sessionId: 'ses_orphan_task' as SessionId,
111
+ threadId: 'thd_orphan_task' as ThreadId,
112
+ projectId: 'prj_orphan_task' as ProjectId,
113
+ tenantId: 'tnt_orphan_task' as TenantId,
114
+ },
115
+ (event) => {
116
+ events.push(event)
117
+ },
118
+ )
119
+
120
+ expect(run.status).toBe('completed')
121
+ expect(run.stopReason).toBe('end_turn')
122
+ expect(run.result).toBe('Final answer.')
123
+ // One turn only — no futile re-invocation loop on the orphan.
124
+ expect(provider.calls).toBe(1)
125
+ expect(events.some((event) => event.type === 'run_failed')).toBe(false)
126
+ }, 10_000)
127
+ })
@@ -1,5 +1,4 @@
1
1
  import type { RunPersistence } from '../../manager/run/persistence.js'
2
- import type { RunDiskStore } from '../../store/run/disk.js'
3
2
  import type {
4
3
  ActiveNodeInfo,
5
4
  BranchStackEntry,
@@ -8,13 +7,19 @@ import type {
8
7
  IterationCheckpoint,
9
8
  } from '../../types/hitl/index.js'
10
9
  import type { AssistantMessage } from '../../types/message/index.js'
10
+ import type { CheckpointRunScope, CheckpointStore } from '../../types/run/checkpoint-store.js'
11
11
  import type { EmergencySaveData } from '../../types/run/emergency.js'
12
12
  import type { CheckpointListEntry } from '../../types/run/replay.js'
13
13
  import { ZERO_COST } from '../../utils/cost.js'
14
14
  import { buildToolResultHashes } from '../../utils/hash.js'
15
15
  import { generateCheckpointId } from '../../utils/id.js'
16
16
 
17
- function toCheckpointListEntry(cp: IterationCheckpoint): CheckpointListEntry {
17
+ /**
18
+ * Projection from a full checkpoint payload to the public listing entry.
19
+ * Exported for the replay `listCheckpoints` entry point, which projects
20
+ * store results without constructing a `CheckpointManager`.
21
+ */
22
+ export function toCheckpointListEntry(cp: IterationCheckpoint): CheckpointListEntry {
18
23
  return {
19
24
  id: cp.id,
20
25
  runId: cp.runId,
@@ -55,10 +60,19 @@ export function projectEmergencyToCheckpoint(dump: EmergencySaveData): Iteration
55
60
  }
56
61
 
57
62
  export class CheckpointManager {
58
- private store: RunDiskStore
63
+ private store: CheckpointStore
64
+ private scope: CheckpointRunScope
59
65
 
60
- constructor(store: RunDiskStore) {
66
+ /**
67
+ * @param store scope-keyed checkpoint persistence. The default query
68
+ * pipeline passes the run's disk-backed store
69
+ * ({@link import('../../store/run/checkpoint-disk.js').DiskCheckpointStore});
70
+ * hosts inject their own via `QueryParams.checkpointStore`.
71
+ * @param scope the run every operation of this manager is keyed to.
72
+ */
73
+ constructor(store: CheckpointStore, scope: CheckpointRunScope) {
61
74
  this.store = store
75
+ this.scope = scope
62
76
  }
63
77
 
64
78
  async create(
@@ -87,12 +101,12 @@ export class CheckpointManager {
87
101
  activeNode: extra?.activeNode,
88
102
  }
89
103
 
90
- await this.store.writeCheckpoint(checkpoint)
104
+ await this.store.writeCheckpoint(this.scope, checkpoint)
91
105
  return checkpoint
92
106
  }
93
107
 
94
108
  async restore(checkpointId: CheckpointId): Promise<IterationCheckpoint> {
95
- const checkpoint = await this.store.readCheckpoint(checkpointId)
109
+ const checkpoint = await this.store.readCheckpoint(this.scope, checkpointId)
96
110
  if (!checkpoint) {
97
111
  throw new Error(`Checkpoint not found: ${checkpointId}`)
98
112
  }
@@ -100,7 +114,7 @@ export class CheckpointManager {
100
114
  }
101
115
 
102
116
  async list(): Promise<IterationCheckpoint[]> {
103
- return this.store.listCheckpoints()
117
+ return this.store.listCheckpoints(this.scope)
104
118
  }
105
119
 
106
120
  /**
@@ -110,7 +124,7 @@ export class CheckpointManager {
110
124
  * full checkpoint payload. See ses_005-deterministic-replay design §3.1.
111
125
  */
112
126
  async listEntries(): Promise<CheckpointListEntry[]> {
113
- const checkpoints = await this.store.listCheckpoints()
127
+ const checkpoints = await this.store.listCheckpoints(this.scope)
114
128
  return checkpoints.map(toCheckpointListEntry)
115
129
  }
116
130
 
@@ -121,7 +135,7 @@ export class CheckpointManager {
121
135
  const toDelete = all.sort((a, b) => a.createdAt - b.createdAt).slice(0, all.length - keepLast)
122
136
 
123
137
  for (const cp of toDelete) {
124
- await this.store.deleteCheckpoint(cp.id)
138
+ await this.store.deleteCheckpoint(this.scope, cp.id)
125
139
  }
126
140
  }
127
141
 
@@ -15,6 +15,7 @@ import type { RunId, SessionId, TenantId } from '../../types/ids/index.js'
15
15
  import type { Message } from '../../types/message/index.js'
16
16
  import type { PermissionMode } from '../../types/permission/index.js'
17
17
  import type { LLMProvider } from '../../types/provider/index.js'
18
+ import type { CheckpointStore } from '../../types/run/checkpoint-store.js'
18
19
  import type { AgentRunConfig } from '../../types/run/index.js'
19
20
  import type { ProjectId, ThreadId } from '../../types/session/ids.js'
20
21
  import type { ModelPricing } from '../../utils/cost.js'
@@ -55,6 +56,13 @@ export interface RunContextConfig {
55
56
 
56
57
  pathBuilder?: PathBuilder
57
58
 
59
+ /**
60
+ * Optional checkpoint persistence override, threaded through to
61
+ * {@link RunPersistence}. Absent ⇒ disk default under the run's
62
+ * output directory.
63
+ */
64
+ checkpointStore?: CheckpointStore
65
+
58
66
  /**
59
67
  * Optional injected migrator — tests pass a stub; production code relies
60
68
  * on the {@link DefaultFilesystemMigrator}. See session-hierarchy.md
@@ -162,6 +170,7 @@ export class RunContextFactory {
162
170
  projectId: config.projectId,
163
171
  parentRunId: config.parentRunId,
164
172
  depth: config.depth,
173
+ checkpointStore: config.checkpointStore,
165
174
  })
166
175
 
167
176
  const trackingConfig = resolveActivityTracking(permissionMode, config.enableActivityTracking)