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.
- package/dist/assets/index--GqzhA4-.css +1 -0
- package/dist/assets/index-CiC6k4Yg.js +40 -0
- package/dist/index.html +3 -3
- package/package.json +1 -1
- package/src/App.tsx +443 -56
- package/src/app-payload.test.ts +158 -0
- package/src/background-task-timeline.test.ts +32 -0
- package/src/background-task-toolcalls-policy.test.ts +191 -0
- package/src/ingest/background-tasks.test.ts +304 -2
- package/src/ingest/background-tasks.ts +67 -28
- package/src/ingest/model.ts +79 -0
- package/src/ingest/session.test.ts +119 -0
- package/src/ingest/session.ts +4 -0
- package/src/ingest/tool-calls.test.ts +161 -0
- package/src/ingest/tool-calls.ts +157 -0
- package/src/server/api.test.ts +162 -53
- package/src/server/api.ts +39 -2
- package/src/server/dashboard.test.ts +139 -0
- package/src/server/dashboard.ts +40 -3
- package/src/server/dev.ts +4 -2
- package/src/server/start.ts +4 -2
- package/src/styles.css +131 -0
- package/src/timeseries-stacked.test.ts +261 -0
- package/src/timeseries-stacked.ts +145 -0
- package/dist/assets/index-Cs5xePn_.js +0 -40
- package/dist/assets/index-RAZRO3YN.css +0 -1
|
@@ -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 =
|
|
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
|
|
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 (!
|
|
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 || !
|
|
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
|
|
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 (!
|
|
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 =
|
|
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(
|
|
156
|
-
|
|
157
|
-
|
|
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 (
|
|
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
|
-
?
|
|
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
|
-
|
|
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
|
})
|
package/src/ingest/session.ts
CHANGED
|
@@ -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
|
+
})
|