oh-my-opencode-dashboard 0.0.3 → 0.0.5

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.
@@ -2,6 +2,9 @@ import * as fs from "node:fs"
2
2
  import * as path from "node:path"
3
3
  import type { OpenCodeStorageRoots, SessionMetadata, StoredMessageMeta, StoredToolPart } from "./session"
4
4
  import { getMessageDir } from "./session"
5
+ import { pickLatestModelString } from "./model"
6
+
7
+ type FsLike = Pick<typeof fs, "readFileSync" | "readdirSync" | "existsSync" | "statSync">
5
8
 
6
9
  export type BackgroundTaskRow = {
7
10
  id: string
@@ -10,6 +13,7 @@ export type BackgroundTaskRow = {
10
13
  status: "queued" | "running" | "completed" | "error" | "unknown"
11
14
  toolCalls: number | null
12
15
  lastTool: string | null
16
+ lastModel: string | null
13
17
  timeline: string
14
18
  sessionId: string | null
15
19
  }
@@ -24,31 +28,31 @@ function clampString(value: unknown, maxLen: number): string | null {
24
28
  return s.length <= maxLen ? s : s.slice(0, maxLen)
25
29
  }
26
30
 
27
- function readJsonFile<T>(filePath: string): T | null {
31
+ function readJsonFile<T>(filePath: string, fsLike: FsLike): T | null {
28
32
  try {
29
- const content = fs.readFileSync(filePath, "utf8")
33
+ const content = fsLike.readFileSync(filePath, "utf8")
30
34
  return JSON.parse(content) as T
31
35
  } catch {
32
36
  return null
33
37
  }
34
38
  }
35
39
 
36
- function listJsonFiles(dir: string): string[] {
40
+ function listJsonFiles(dir: string, fsLike: FsLike): string[] {
37
41
  try {
38
- return fs.readdirSync(dir).filter((f) => f.endsWith(".json"))
42
+ return fsLike.readdirSync(dir).filter((f) => f.endsWith(".json"))
39
43
  } catch {
40
44
  return []
41
45
  }
42
46
  }
43
47
 
44
- function readToolPartsForMessage(storage: OpenCodeStorageRoots, messageID: string): StoredToolPart[] {
48
+ function readToolPartsForMessage(storage: OpenCodeStorageRoots, messageID: string, fsLike: FsLike): StoredToolPart[] {
45
49
  const partDir = path.join(storage.part, messageID)
46
- if (!fs.existsSync(partDir)) return []
50
+ if (!fsLike.existsSync(partDir)) return []
47
51
 
48
- const files = listJsonFiles(partDir).sort()
52
+ const files = listJsonFiles(partDir, fsLike).sort()
49
53
  const parts: StoredToolPart[] = []
50
54
  for (const f of files) {
51
- const p = readJsonFile<StoredToolPart>(path.join(partDir, f))
55
+ const p = readJsonFile<StoredToolPart>(path.join(partDir, f), fsLike)
52
56
  if (p && p.type === "tool" && typeof p.tool === "string" && p.state && typeof p.state === "object") {
53
57
  parts.push(p)
54
58
  }
@@ -56,14 +60,14 @@ function readToolPartsForMessage(storage: OpenCodeStorageRoots, messageID: strin
56
60
  return parts
57
61
  }
58
62
 
59
- function readRecentMessageMetas(messageDir: string, maxMessages: number): StoredMessageMeta[] {
60
- if (!messageDir || !fs.existsSync(messageDir)) return []
61
- const files = listJsonFiles(messageDir)
63
+ function readRecentMessageMetas(messageDir: string, maxMessages: number, fsLike: FsLike): StoredMessageMeta[] {
64
+ if (!messageDir || !fsLike.existsSync(messageDir)) return []
65
+ const files = listJsonFiles(messageDir, fsLike)
62
66
  .map((f) => ({
63
67
  f,
64
68
  mtime: (() => {
65
69
  try {
66
- return fs.statSync(path.join(messageDir, f)).mtimeMs
70
+ return fsLike.statSync(path.join(messageDir, f)).mtimeMs
67
71
  } catch {
68
72
  return 0
69
73
  }
@@ -74,22 +78,22 @@ function readRecentMessageMetas(messageDir: string, maxMessages: number): Stored
74
78
 
75
79
  const metas: StoredMessageMeta[] = []
76
80
  for (const item of files) {
77
- const meta = readJsonFile<StoredMessageMeta>(path.join(messageDir, item.f))
81
+ const meta = readJsonFile<StoredMessageMeta>(path.join(messageDir, item.f), fsLike)
78
82
  if (meta && typeof meta.id === "string") metas.push(meta)
79
83
  }
80
84
  return metas
81
85
  }
82
86
 
83
- export function readAllSessionMetas(sessionStorage: string): SessionMetadata[] {
84
- if (!fs.existsSync(sessionStorage)) return []
87
+ export function readAllSessionMetas(sessionStorage: string, fsLike: FsLike = fs): SessionMetadata[] {
88
+ if (!fsLike.existsSync(sessionStorage)) return []
85
89
  const metas: SessionMetadata[] = []
86
90
  try {
87
- const projectDirs = fs.readdirSync(sessionStorage, { withFileTypes: true })
91
+ const projectDirs = fsLike.readdirSync(sessionStorage, { withFileTypes: true })
88
92
  for (const d of projectDirs) {
89
93
  if (!d.isDirectory()) continue
90
94
  const projectPath = path.join(sessionStorage, d.name)
91
- for (const file of listJsonFiles(projectPath)) {
92
- const meta = readJsonFile<SessionMetadata>(path.join(projectPath, file))
95
+ for (const file of listJsonFiles(projectPath, fsLike)) {
96
+ const meta = readJsonFile<SessionMetadata>(path.join(projectPath, file), fsLike)
93
97
  if (meta && typeof meta.id === "string") metas.push(meta)
94
98
  }
95
99
  }
@@ -152,9 +156,11 @@ function findTaskSessionId(opts: {
152
156
  return candidates[0]?.id ?? null
153
157
  }
154
158
 
155
- function deriveBackgroundSessionStats(storage: OpenCodeStorageRoots, sessionId: string): { toolCalls: number; lastTool: string | null; lastUpdateAt: number | null } {
156
- const messageDir = getMessageDir(storage.message, sessionId)
157
- const metas = readRecentMessageMetas(messageDir, 200)
159
+ function deriveBackgroundSessionStats(
160
+ storage: OpenCodeStorageRoots,
161
+ metas: StoredMessageMeta[],
162
+ fsLike: FsLike
163
+ ): { toolCalls: number; lastTool: string | null; lastUpdateAt: number | null } {
158
164
  let toolCalls = 0
159
165
  let lastTool: string | null = null
160
166
  let lastUpdateAt: number | null = null
@@ -170,7 +176,7 @@ function deriveBackgroundSessionStats(storage: OpenCodeStorageRoots, sessionId:
170
176
  for (const meta of ordered) {
171
177
  const created = meta.time?.created
172
178
  if (typeof created === "number") lastUpdateAt = created
173
- const parts = readToolPartsForMessage(storage, meta.id)
179
+ const parts = readToolPartsForMessage(storage, meta.id, fsLike)
174
180
  for (const p of parts) {
175
181
  toolCalls += 1
176
182
  lastTool = p.tool
@@ -211,11 +217,42 @@ export function deriveBackgroundTasks(opts: {
211
217
  storage: OpenCodeStorageRoots
212
218
  mainSessionId: string
213
219
  nowMs?: number
220
+ fs?: FsLike
214
221
  }): BackgroundTaskRow[] {
222
+ const fsLike: FsLike = opts.fs ?? fs
215
223
  const nowMs = opts.nowMs ?? Date.now()
216
224
  const messageDir = getMessageDir(opts.storage.message, opts.mainSessionId)
217
- const metas = readRecentMessageMetas(messageDir, 200)
218
- const allSessionMetas = readAllSessionMetas(opts.storage.session)
225
+ const metas = readRecentMessageMetas(messageDir, 200, fsLike)
226
+ const allSessionMetas = readAllSessionMetas(opts.storage.session, fsLike)
227
+ const backgroundMessageCache = new Map<string, StoredMessageMeta[]>()
228
+ const backgroundStatsCache = new Map<string, { toolCalls: number; lastTool: string | null; lastUpdateAt: number | null }>()
229
+ const backgroundModelCache = new Map<string, string | null>()
230
+
231
+ const readBackgroundMetas = (sessionId: string): StoredMessageMeta[] => {
232
+ const cached = backgroundMessageCache.get(sessionId)
233
+ if (cached) return cached
234
+ const backgroundMessageDir = getMessageDir(opts.storage.message, sessionId)
235
+ const recent = readRecentMessageMetas(backgroundMessageDir, 200, fsLike)
236
+ backgroundMessageCache.set(sessionId, recent)
237
+ return recent
238
+ }
239
+
240
+ const readBackgroundStats = (sessionId: string) => {
241
+ const cached = backgroundStatsCache.get(sessionId)
242
+ if (cached) return cached
243
+ const recent = readBackgroundMetas(sessionId)
244
+ const stats = deriveBackgroundSessionStats(opts.storage, recent, fsLike)
245
+ backgroundStatsCache.set(sessionId, stats)
246
+ return stats
247
+ }
248
+
249
+ const readBackgroundModel = (sessionId: string): string | null => {
250
+ if (backgroundModelCache.has(sessionId)) return backgroundModelCache.get(sessionId) ?? null
251
+ const recent = readBackgroundMetas(sessionId)
252
+ const model = pickLatestModelString(recent as unknown[])
253
+ backgroundModelCache.set(sessionId, model)
254
+ return model
255
+ }
219
256
 
220
257
  const rows: BackgroundTaskRow[] = []
221
258
 
@@ -225,7 +262,7 @@ export function deriveBackgroundTasks(opts: {
225
262
  const startedAt = meta.time?.created ?? null
226
263
  if (typeof startedAt !== "number") continue
227
264
 
228
- const parts = readToolPartsForMessage(opts.storage, meta.id)
265
+ const parts = readToolPartsForMessage(opts.storage, meta.id, fsLike)
229
266
  for (const part of parts) {
230
267
  if (part.tool !== "delegate_task") continue
231
268
  if (!part.state || typeof part.state !== "object") continue
@@ -258,7 +295,7 @@ export function deriveBackgroundTasks(opts: {
258
295
  if (typeof resume === "string" && resume.trim() !== "") {
259
296
  // Check if resumed session exists (has readable messages dir)
260
297
  const resumeMessageDir = getMessageDir(opts.storage.message, resume.trim())
261
- if (fs.existsSync(resumeMessageDir) && fs.readdirSync(resumeMessageDir).length > 0) {
298
+ if (fsLike.existsSync(resumeMessageDir) && fsLike.readdirSync(resumeMessageDir).length > 0) {
262
299
  backgroundSessionId = resume.trim()
263
300
  }
264
301
  }
@@ -283,8 +320,9 @@ export function deriveBackgroundTasks(opts: {
283
320
  }
284
321
 
285
322
  const stats = backgroundSessionId
286
- ? deriveBackgroundSessionStats(opts.storage, backgroundSessionId)
323
+ ? readBackgroundStats(backgroundSessionId)
287
324
  : { toolCalls: 0, lastTool: null, lastUpdateAt: startedAt }
325
+ const lastModel = backgroundSessionId ? readBackgroundModel(backgroundSessionId) : null
288
326
 
289
327
  // Best-effort status: if background session exists and has any tool calls, treat as running unless idle.
290
328
  let status: BackgroundTaskRow["status"] = "unknown"
@@ -305,7 +343,8 @@ export function deriveBackgroundTasks(opts: {
305
343
  status,
306
344
  toolCalls: backgroundSessionId ? stats.toolCalls : null,
307
345
  lastTool: stats.lastTool,
308
- timeline: formatTimeline(startedAt, timelineEndMs),
346
+ lastModel,
347
+ timeline: status === "unknown" ? "" : formatTimeline(startedAt, timelineEndMs),
309
348
  sessionId: backgroundSessionId,
310
349
  })
311
350
  }
@@ -0,0 +1,79 @@
1
+ type ModelParts = {
2
+ providerID?: string
3
+ modelID?: string
4
+ }
5
+
6
+ function isRecord(value: unknown): value is Record<string, unknown> {
7
+ return typeof value === "object" && value !== null
8
+ }
9
+
10
+ function readString(value: unknown): string | null {
11
+ if (typeof value !== "string") return null
12
+ const trimmed = value.trim()
13
+ return trimmed.length > 0 ? trimmed : null
14
+ }
15
+
16
+ function readModelParts(value: Record<string, unknown>): ModelParts {
17
+ const providerID =
18
+ readString(value.providerID) ??
19
+ readString(value.providerId) ??
20
+ readString(value.provider_id)
21
+ const modelID =
22
+ readString(value.modelID) ??
23
+ readString(value.modelId) ??
24
+ readString(value.model_id)
25
+
26
+ return { providerID: providerID ?? undefined, modelID: modelID ?? undefined }
27
+ }
28
+
29
+ export function extractModelString(meta: unknown): string | null {
30
+ if (!isRecord(meta)) return null
31
+
32
+ const direct = readModelParts(meta)
33
+ if (direct.providerID && direct.modelID) return `${direct.providerID}/${direct.modelID}`
34
+
35
+ const nested = meta.model
36
+ if (isRecord(nested)) {
37
+ const nestedParts = readModelParts(nested)
38
+ if (nestedParts.providerID && nestedParts.modelID) {
39
+ return `${nestedParts.providerID}/${nestedParts.modelID}`
40
+ }
41
+ }
42
+
43
+ return null
44
+ }
45
+
46
+ type ModelCandidate = {
47
+ created: number
48
+ id: string
49
+ role: string | null
50
+ model: string
51
+ }
52
+
53
+ export function pickLatestModelString(metas: Array<unknown>): string | null {
54
+ const candidates: ModelCandidate[] = []
55
+
56
+ for (const meta of metas) {
57
+ if (!isRecord(meta)) continue
58
+ const model = extractModelString(meta)
59
+ if (!model) continue
60
+
61
+ const created = typeof meta.time === "object" && meta.time !== null && typeof (meta.time as { created?: unknown }).created === "number"
62
+ ? (meta.time as { created: number }).created
63
+ : 0
64
+ const id = readString(meta.id) ?? ""
65
+ const role = readString(meta.role)
66
+
67
+ candidates.push({ created, id, role, model })
68
+ }
69
+
70
+ if (candidates.length === 0) return null
71
+
72
+ candidates.sort((a, b) => {
73
+ if (b.created !== a.created) return b.created - a.created
74
+ return b.id.localeCompare(a.id)
75
+ })
76
+
77
+ const assistant = candidates.find((candidate) => candidate.role === "assistant")
78
+ return assistant?.model ?? candidates[0]?.model ?? null
79
+ }
@@ -8,6 +8,7 @@ import {
8
8
  pickActiveSessionId,
9
9
  readMainSessionMetas,
10
10
  } from "./session"
11
+ import { extractModelString, pickLatestModelString } from "./model"
11
12
 
12
13
  function mkStorageRoot(): string {
13
14
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "omo-storage-"))
@@ -217,4 +218,122 @@ describe("getMainSessionView", () => {
217
218
 
218
219
  expect(view.status).toBe("thinking")
219
220
  })
221
+
222
+ it("returns null currentModel when no model metadata exists", () => {
223
+ const storageRoot = mkStorageRoot()
224
+ const storage = getStorageRoots(storageRoot)
225
+ const projectRoot = "/tmp/project"
226
+ const sessionId = "ses_1"
227
+
228
+ const messageDir = path.join(storage.message, sessionId)
229
+ fs.mkdirSync(messageDir, { recursive: true })
230
+
231
+ const messageID = "msg_1"
232
+ fs.writeFileSync(
233
+ path.join(messageDir, `${messageID}.json`),
234
+ JSON.stringify({
235
+ id: messageID,
236
+ sessionID: sessionId,
237
+ role: "assistant",
238
+ agent: "sisyphus",
239
+ time: { created: 1000 },
240
+ }),
241
+ "utf8"
242
+ )
243
+
244
+ // #given no model metadata in messages
245
+ // #when deriving main session view
246
+ const view = getMainSessionView({
247
+ projectRoot,
248
+ sessionId,
249
+ storage,
250
+ sessionMeta: null,
251
+ nowMs: 50_000,
252
+ })
253
+
254
+ // #then currentModel is null
255
+ expect(view.currentModel).toBeNull()
256
+ })
257
+ })
258
+
259
+ describe("extractModelString", () => {
260
+ it("supports assistant flat and user nested shapes", () => {
261
+ // #given assistant flat model
262
+ const assistantMeta = {
263
+ role: "assistant",
264
+ providerID: "openai",
265
+ modelID: "gpt-5.2",
266
+ }
267
+
268
+ // #when extracting model string
269
+ const assistantModel = extractModelString(assistantMeta)
270
+
271
+ // #then uses provider/model format
272
+ expect(assistantModel).toBe("openai/gpt-5.2")
273
+
274
+ // #given user nested model
275
+ const userMeta = {
276
+ role: "user",
277
+ model: { providerID: "anthropic", modelID: "claude-opus-4.5" },
278
+ }
279
+
280
+ // #when extracting model string
281
+ const userModel = extractModelString(userMeta)
282
+
283
+ // #then uses provider/model format
284
+ expect(userModel).toBe("anthropic/claude-opus-4.5")
285
+ })
286
+ })
287
+
288
+ describe("pickLatestModelString", () => {
289
+ it("prefers latest assistant model over newer user model", () => {
290
+ const metas = [
291
+ {
292
+ id: "msg_1",
293
+ role: "assistant",
294
+ time: { created: 1000 },
295
+ providerID: "openai",
296
+ modelID: "gpt-5.2",
297
+ },
298
+ {
299
+ id: "msg_2",
300
+ role: "user",
301
+ time: { created: 2000 },
302
+ model: { providerID: "anthropic", modelID: "claude-opus-4.5" },
303
+ },
304
+ ]
305
+
306
+ // #given assistant with model exists but user is newer
307
+ // #when picking latest model string
308
+ const picked = pickLatestModelString(metas)
309
+
310
+ // #then assistant model is preferred
311
+ expect(picked).toBe("openai/gpt-5.2")
312
+ })
313
+
314
+ it("uses deterministic ordering by created then id", () => {
315
+ const metas = [
316
+ {
317
+ id: "msg_a",
318
+ role: "assistant",
319
+ time: { created: 3000 },
320
+ providerID: "openai",
321
+ modelID: "gpt-5.2",
322
+ },
323
+ {
324
+ id: "msg_b",
325
+ role: "assistant",
326
+ time: { created: 3000 },
327
+ providerID: "openai",
328
+ modelID: "gpt-5.2-turbo",
329
+ },
330
+ ]
331
+
332
+ // #given same created time, higher id wins
333
+ // #when picking latest model string
334
+ const picked = pickLatestModelString(metas)
335
+
336
+ // #then picks model from msg_b
337
+ expect(picked).toBe("openai/gpt-5.2-turbo")
338
+ })
220
339
  })
@@ -1,5 +1,6 @@
1
1
  import * as fs from "node:fs"
2
2
  import * as path from "node:path"
3
+ import { pickLatestModelString } from "./model"
3
4
  import { getOpenCodeStorageDir, realpathSafe } from "./paths"
4
5
 
5
6
  export type SessionMetadata = {
@@ -32,6 +33,7 @@ export type StoredToolPart = {
32
33
  export type MainSessionView = {
33
34
  agent: string
34
35
  currentTool: string | null
36
+ currentModel: string | null
35
37
  lastUpdated: number | null
36
38
  sessionLabel: string
37
39
  status: "busy" | "idle" | "unknown" | "running_tool" | "thinking"
@@ -253,6 +255,7 @@ export function getMainSessionView(opts: {
253
255
  // Scan recent messages for any in-flight tool parts
254
256
  let activeTool: { tool: string; status: string } | null = null
255
257
  const recentMetas = readRecentMessageMetas(messageDir, 200)
258
+ const currentModel = pickLatestModelString(recentMetas)
256
259
 
257
260
  // Iterate newest → oldest, early-exit on first tool part with pending/running status
258
261
  for (const meta of recentMetas) {
@@ -276,6 +279,7 @@ export function getMainSessionView(opts: {
276
279
  return {
277
280
  agent,
278
281
  currentTool: activeTool?.tool ?? null,
282
+ currentModel,
279
283
  lastUpdated,
280
284
  sessionLabel,
281
285
  status,
@@ -0,0 +1,161 @@
1
+ import * as os from "node:os"
2
+ import * as path from "node:path"
3
+ import * as fs from "node:fs"
4
+ import { describe, expect, it } from "vitest"
5
+ import { deriveToolCalls, MAX_TOOL_CALL_MESSAGES, MAX_TOOL_CALLS } from "./tool-calls"
6
+ import { getStorageRoots } from "./session"
7
+
8
+ function mkStorageRoot(): string {
9
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "omo-storage-"))
10
+ fs.mkdirSync(path.join(root, "session"), { recursive: true })
11
+ fs.mkdirSync(path.join(root, "message"), { recursive: true })
12
+ fs.mkdirSync(path.join(root, "part"), { recursive: true })
13
+ return root
14
+ }
15
+
16
+ function writeMessageMeta(opts: {
17
+ storageRoot: string
18
+ sessionId: string
19
+ messageId: string
20
+ created?: number
21
+ }): void {
22
+ const storage = getStorageRoots(opts.storageRoot)
23
+ const msgDir = path.join(storage.message, opts.sessionId)
24
+ fs.mkdirSync(msgDir, { recursive: true })
25
+ const meta: Record<string, unknown> = {
26
+ id: opts.messageId,
27
+ sessionID: opts.sessionId,
28
+ role: "assistant",
29
+ }
30
+ if (typeof opts.created === "number") {
31
+ meta.time = { created: opts.created }
32
+ }
33
+ fs.writeFileSync(path.join(msgDir, `${opts.messageId}.json`), JSON.stringify(meta), "utf8")
34
+ }
35
+
36
+ function writeToolPart(opts: {
37
+ storageRoot: string
38
+ sessionId: string
39
+ messageId: string
40
+ callId: string
41
+ tool: string
42
+ state?: Record<string, unknown>
43
+ }): void {
44
+ const storage = getStorageRoots(opts.storageRoot)
45
+ const partDir = path.join(storage.part, opts.messageId)
46
+ fs.mkdirSync(partDir, { recursive: true })
47
+ fs.writeFileSync(
48
+ path.join(partDir, `${opts.callId}.json`),
49
+ JSON.stringify({
50
+ id: `part_${opts.callId}`,
51
+ sessionID: opts.sessionId,
52
+ messageID: opts.messageId,
53
+ type: "tool",
54
+ callID: opts.callId,
55
+ tool: opts.tool,
56
+ state: opts.state ?? { status: "completed", input: {} },
57
+ }),
58
+ "utf8"
59
+ )
60
+ }
61
+
62
+ function hasBannedKeys(value: unknown, banned: Set<string>): boolean {
63
+ if (!value || typeof value !== "object") return false
64
+ if (Array.isArray(value)) {
65
+ return value.some((item) => hasBannedKeys(item, banned))
66
+ }
67
+ for (const [key, child] of Object.entries(value)) {
68
+ if (banned.has(key)) return true
69
+ if (hasBannedKeys(child, banned)) return true
70
+ }
71
+ return false
72
+ }
73
+
74
+ describe("deriveToolCalls", () => {
75
+ it("orders tool calls deterministically and sorts null timestamps last", () => {
76
+ const storageRoot = mkStorageRoot()
77
+ const storage = getStorageRoots(storageRoot)
78
+ const sessionId = "ses_main"
79
+
80
+ writeMessageMeta({ storageRoot, sessionId, messageId: "msg_0", created: 500 })
81
+ writeToolPart({ storageRoot, sessionId, messageId: "msg_0", callId: "call_a", tool: "read" })
82
+
83
+ writeMessageMeta({ storageRoot, sessionId, messageId: "msg_1", created: 1000 })
84
+ writeToolPart({ storageRoot, sessionId, messageId: "msg_1", callId: "call_a", tool: "bash" })
85
+
86
+ writeMessageMeta({ storageRoot, sessionId, messageId: "msg_2", created: 1000 })
87
+ writeToolPart({ storageRoot, sessionId, messageId: "msg_2", callId: "call_b", tool: "grep" })
88
+ writeToolPart({ storageRoot, sessionId, messageId: "msg_2", callId: "call_a", tool: "grep" })
89
+
90
+ writeMessageMeta({ storageRoot, sessionId, messageId: "msg_3" })
91
+ writeToolPart({ storageRoot, sessionId, messageId: "msg_3", callId: "call_z", tool: "read" })
92
+
93
+ const result = deriveToolCalls({ storage, sessionId })
94
+ expect(result.toolCalls.map((row) => `${row.messageId}:${row.callId}`)).toEqual([
95
+ "msg_1:call_a",
96
+ "msg_2:call_a",
97
+ "msg_2:call_b",
98
+ "msg_0:call_a",
99
+ "msg_3:call_z",
100
+ ])
101
+ expect(result.toolCalls[0].createdAtMs).toBe(1000)
102
+ expect(result.toolCalls[4].createdAtMs).toBe(null)
103
+ expect(result.truncated).toBe(false)
104
+ })
105
+
106
+ it("caps message scan and tool call output", () => {
107
+ const storageRoot = mkStorageRoot()
108
+ const storage = getStorageRoots(storageRoot)
109
+ const sessionId = "ses_main"
110
+
111
+ const totalMessages = MAX_TOOL_CALL_MESSAGES + 5
112
+ for (let i = 0; i < totalMessages; i += 1) {
113
+ const suffix = String(i).padStart(3, "0")
114
+ const messageId = `msg_${suffix}`
115
+ writeMessageMeta({ storageRoot, sessionId, messageId, created: i })
116
+ writeToolPart({ storageRoot, sessionId, messageId, callId: `call_${suffix}_a`, tool: "bash" })
117
+ writeToolPart({ storageRoot, sessionId, messageId, callId: `call_${suffix}_b`, tool: "read" })
118
+ }
119
+
120
+ const result = deriveToolCalls({ storage, sessionId })
121
+ expect(result.toolCalls.length).toBe(MAX_TOOL_CALLS)
122
+ expect(result.truncated).toBe(true)
123
+
124
+ const messageIds = new Set(result.toolCalls.map((row) => row.messageId))
125
+ for (let i = 0; i < 5; i += 1) {
126
+ const suffix = String(i).padStart(3, "0")
127
+ expect(messageIds.has(`msg_${suffix}`)).toBe(false)
128
+ }
129
+ for (let i = totalMessages - 5; i < totalMessages; i += 1) {
130
+ const suffix = String(i).padStart(3, "0")
131
+ expect(messageIds.has(`msg_${suffix}`)).toBe(true)
132
+ }
133
+ })
134
+
135
+ it("redacts tool call payload fields", () => {
136
+ const storageRoot = mkStorageRoot()
137
+ const storage = getStorageRoots(storageRoot)
138
+ const sessionId = "ses_main"
139
+
140
+ writeMessageMeta({ storageRoot, sessionId, messageId: "msg_1", created: 1000 })
141
+ writeToolPart({
142
+ storageRoot,
143
+ sessionId,
144
+ messageId: "msg_1",
145
+ callId: "call_secret",
146
+ tool: "bash",
147
+ state: {
148
+ status: "completed",
149
+ input: { prompt: "SECRET", nested: { output: "HIDDEN" } },
150
+ output: "NOPE",
151
+ error: "NOPE",
152
+ },
153
+ })
154
+
155
+ const result = deriveToolCalls({ storage, sessionId })
156
+ expect(result.toolCalls.length).toBe(1)
157
+
158
+ const banned = new Set(["prompt", "input", "output", "error", "state"])
159
+ expect(hasBannedKeys(result.toolCalls[0], banned)).toBe(false)
160
+ })
161
+ })