forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathagentThreadCards.test.ts
More file actions
330 lines (300 loc) · 11.3 KB
/
Copy pathagentThreadCards.test.ts
File metadata and controls
330 lines (300 loc) · 11.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
// Tests for agentThreadCards.ts (B4, INV-CHAT-1). The one guarantee: a background
// agent spawned from a surface leaves EXACTLY TWO durable artifacts on that
// producing conversation — an agentSpawn card at launch and one agentCompletion
// card at terminal — idempotent under repeat + terminal retry, never live
// in-between chatter. Runs against the real store (node:sqlite seam).
import { mkdtempSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { DatabaseSync } from 'node:sqlite'
import { afterEach, describe, expect, it } from 'vitest'
import { SqliteAgentStore, type DatabaseFactory } from './store'
import { resolveSurfaceSession, type SurfaceRef } from './surfaceSession'
import { appendConversationTurn } from './conversationTurns'
import {
agentCardStampMetadata,
hasAgentCompletionCard,
hasAgentSpawnCard,
listAgentThreadCards,
materializeAgentCompletionCard,
materializeAgentSpawnCard,
normalizeCompletionStatus,
readAgentCardStamp,
sweepOrphanedAgentCompletionCards,
type AgentCardStamp
} from './agentThreadCards'
const nodeSqliteFactory = DatabaseSync as unknown as DatabaseFactory
const createdDirs: string[] = []
const openStores: SqliteAgentStore[] = []
afterEach(() => {
for (const store of openStores.splice(0)) {
try {
store.close()
} catch {
// already closed
}
}
for (const dir of createdDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})
function newStore(): SqliteAgentStore {
const dir = mkdtempSync(join(tmpdir(), 'omi-cards-'))
createdDirs.push(dir)
const store = new SqliteAgentStore({
databaseFactory: nodeSqliteFactory,
databasePath: join(dir, 'omi-agentd.sqlite3'),
reconcileOnOpen: false
})
openStores.push(store)
return store
}
const ref: SurfaceRef = {
surfaceKind: 'main_chat',
externalRefKind: 'chat',
externalRefId: 'default'
}
function newConversation(store: SqliteAgentStore): string {
return resolveSurfaceSession(store, { ownerId: 'owner', surfaceRef: ref }, () => 1000)
.conversationId
}
function stampFor(conversationId: string): AgentCardStamp {
return {
producingConversationId: conversationId,
producingChatId: 'default',
producingSurfaceKind: 'main_chat',
pillId: 'pill-1',
title: 'Build X',
objective: 'Build feature X end to end'
}
}
describe('agent thread cards — the exactly-two boundary', () => {
it('writes a spawn card at launch and one completion card at terminal', () => {
const store = newStore()
const conversationId = newConversation(store)
const stamp = stampFor(conversationId)
const spawn = materializeAgentSpawnCard(store, {
runId: 'run_1',
sessionId: 'sess_1',
stamp,
nowMs: 1000
})
const completion = materializeAgentCompletionCard(store, {
run: {
runId: 'run_1',
sessionId: 'sess_1',
status: 'succeeded',
finalText: 'done',
errorMessage: null
},
stamp,
nowMs: 1001
})
expect(spawn?.block.type).toBe('agentSpawn')
expect(completion?.block.type).toBe('agentCompletion')
const cards = listAgentThreadCards(store, conversationId)
expect(cards).toHaveLength(2)
expect(cards.map((c) => c.block.type)).toEqual(['agentSpawn', 'agentCompletion'])
expect(cards[0].block).toMatchObject({ runId: 'run_1', title: 'Build X', pillId: 'pill-1' })
if (cards[1].block.type === 'agentCompletion') {
expect(cards[1].block).toMatchObject({ runId: 'run_1', status: 'succeeded', output: 'done' })
}
})
it('is idempotent: a repeat spawn and a retried terminal never add a second card', () => {
const store = newStore()
const conversationId = newConversation(store)
const stamp = stampFor(conversationId)
materializeAgentSpawnCard(store, { runId: 'run_1', sessionId: 'sess_1', stamp, nowMs: 1000 })
// Duplicate spawn (e.g. a re-delivered run.queued) — no second card.
const dupSpawn = materializeAgentSpawnCard(store, {
runId: 'run_1',
sessionId: 'sess_1',
stamp,
nowMs: 1002
})
expect(dupSpawn).toBeNull()
const first = materializeAgentCompletionCard(store, {
run: {
runId: 'run_1',
sessionId: 'sess_1',
status: 'failed',
finalText: null,
errorMessage: 'boom'
},
stamp,
nowMs: 1003
})
// Terminal retry / duplicate terminal event — exactly one completion.
const retry = materializeAgentCompletionCard(store, {
run: {
runId: 'run_1',
sessionId: 'sess_1',
status: 'failed',
finalText: null,
errorMessage: 'boom again'
},
stamp,
nowMs: 1004
})
expect(first?.block.type).toBe('agentCompletion')
expect(retry).toBeNull()
expect(listAgentThreadCards(store, conversationId)).toHaveLength(2)
expect(hasAgentSpawnCard(store, conversationId, 'run_1')).toBe(true)
expect(hasAgentCompletionCard(store, conversationId, 'run_1')).toBe(true)
})
it('keeps exactly two cards even with normal turns interleaved between launch and terminal', () => {
const store = newStore()
const conversationId = newConversation(store)
const stamp = stampFor(conversationId)
materializeAgentSpawnCard(store, { runId: 'run_1', sessionId: 'sess_1', stamp, nowMs: 1000 })
// A normal user + assistant exchange lands between the two card writes — no
// live agent chatter is a card; only the two markers are.
appendConversationTurn(store, {
conversationId,
role: 'user',
surfaceKind: 'main_chat',
content: 'how is it going?',
createdAtMs: 1001,
metadataJson: JSON.stringify({ runId: 'run_x' })
})
appendConversationTurn(store, {
conversationId,
role: 'assistant',
surfaceKind: 'main_chat',
content: 'still working on it',
createdAtMs: 1002,
metadataJson: JSON.stringify({ runId: 'run_x' })
})
materializeAgentCompletionCard(store, {
run: {
runId: 'run_1',
sessionId: 'sess_1',
status: 'cancelled',
finalText: null,
errorMessage: null
},
stamp,
nowMs: 1003
})
const cards = listAgentThreadCards(store, conversationId)
expect(cards).toHaveLength(2)
expect(cards.map((c) => c.block.type)).toEqual(['agentSpawn', 'agentCompletion'])
if (cards[1].block.type === 'agentCompletion') {
expect(cards[1].block.status).toBe('stopped') // cancelled → stopped
}
})
it('maps terminal status to the card vocabulary and picks the right output source', () => {
expect(normalizeCompletionStatus('succeeded')).toBe('succeeded')
expect(normalizeCompletionStatus('cancelled')).toBe('stopped')
expect(normalizeCompletionStatus('failed')).toBe('failed')
expect(normalizeCompletionStatus('timedOut')).toBe('failed')
const store = newStore()
const conversationId = newConversation(store)
const stamp = stampFor(conversationId)
const completion = materializeAgentCompletionCard(store, {
run: {
runId: 'run_1',
sessionId: 'sess_1',
status: 'failed',
finalText: 'partial',
errorMessage: 'the reason'
},
stamp,
nowMs: 1000
})
// A failed run shows the error, not the partial final text.
if (completion?.block.type === 'agentCompletion') {
expect(completion.block.output).toBe('the reason')
}
})
it('round-trips the producing-surface stamp through run metadata', () => {
const stamp = stampFor('conv_abc')
const metadata = agentCardStampMetadata(stamp)
// The stamp lives under a namespaced key inside a run's inputJson.metadata.
const readBack = readAgentCardStamp(metadata)
expect(readBack).toEqual(stamp)
// A run with no stamp (a trusted-direct-control spawn with no originating chat)
// resolves to null → no shared-thread cards.
expect(readAgentCardStamp(undefined)).toBeNull()
expect(readAgentCardStamp({ visible: true })).toBeNull()
})
})
describe('agent thread cards — orphan/terminal load-time sweep', () => {
function seedRun(
store: SqliteAgentStore,
sessionId: string,
stamp: AgentCardStamp,
status: string
): string {
const run = store.insertRun({
sessionId,
clientId: 'c',
requestId: 'r',
status: status as never,
mode: 'act',
inputJson: JSON.stringify({
prompt: 'build X',
systemPrompt: '',
metadata: agentCardStampMetadata(stamp)
})
})
return run.runId
}
it('heals a run that went terminal holding a spawn card but no completion card', () => {
const store = newStore()
const resolved = resolveSurfaceSession(store, { ownerId: 'owner', surfaceRef: ref }, () => 1000)
const stamp = stampFor(resolved.conversationId)
// A background run launched (spawn card written) then the app crashed / the run
// was orphaned by startup reconciliation — terminal in the db, no completion card.
const runId = seedRun(store, resolved.agentSessionId, stamp, 'orphaned')
materializeAgentSpawnCard(store, {
runId,
sessionId: resolved.agentSessionId,
stamp,
nowMs: 1000
})
const healed = sweepOrphanedAgentCompletionCards(store, { nowMs: () => 2000 })
expect(healed).toHaveLength(1)
expect(healed[0].chatId).toBe('default')
const block = healed[0].record.block
expect(block.type).toBe('agentCompletion')
if (block.type === 'agentCompletion') {
expect(block.runId).toBe(runId)
expect(block.status).toBe('failed') // orphaned → failed
expect(block.output).toMatch(/interrupted/i) // clear reason, never an empty body
}
// Exactly two cards, and a second sweep heals nothing (idempotent).
expect(listAgentThreadCards(store, resolved.conversationId).map((c) => c.block.type)).toEqual([
'agentSpawn',
'agentCompletion'
])
expect(sweepOrphanedAgentCompletionCards(store, { nowMs: () => 3000 })).toHaveLength(0)
expect(listAgentThreadCards(store, resolved.conversationId)).toHaveLength(2)
})
it('does NOT fabricate a completion for a terminal run that never got a spawn card', () => {
const store = newStore()
const resolved = resolveSurfaceSession(store, { ownerId: 'owner', surfaceRef: ref }, () => 1000)
const stamp = stampFor(resolved.conversationId)
// Terminal + stamped, but no spawn card was ever written → out of scope.
seedRun(store, resolved.agentSessionId, stamp, 'failed')
expect(sweepOrphanedAgentCompletionCards(store)).toHaveLength(0)
expect(listAgentThreadCards(store, resolved.conversationId)).toHaveLength(0)
})
it('leaves a still-running card-producing run alone (only terminal runs are swept)', () => {
const store = newStore()
const resolved = resolveSurfaceSession(store, { ownerId: 'owner', surfaceRef: ref }, () => 1000)
const stamp = stampFor(resolved.conversationId)
const runId = seedRun(store, resolved.agentSessionId, stamp, 'running')
materializeAgentSpawnCard(store, {
runId,
sessionId: resolved.agentSessionId,
stamp,
nowMs: 1000
})
expect(sweepOrphanedAgentCompletionCards(store)).toHaveLength(0)
// Just the spawn card — the completion waits for a real terminal.
expect(listAgentThreadCards(store, resolved.conversationId).map((c) => c.block.type)).toEqual([
'agentSpawn'
])
})
})