oh-my-opencode-dashboard 0.0.4 → 0.1.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.
- package/README.md +15 -4
- package/dist/assets/index-BFRahC0d.css +1 -0
- package/dist/assets/index-BsLpOGvG.js +40 -0
- package/dist/index.html +3 -3
- package/package.json +1 -1
- package/src/App.tsx +512 -14
- package/src/app-payload.test.ts +108 -1
- package/src/background-task-toolcalls-policy.test.ts +191 -0
- package/src/ingest/background-tasks.test.ts +11 -2
- package/src/ingest/tool-calls.test.ts +161 -0
- package/src/ingest/tool-calls.ts +157 -0
- package/src/server/api.test.ts +175 -53
- package/src/server/api.ts +39 -2
- package/src/server/dashboard.test.ts +41 -0
- package/src/server/dashboard.ts +81 -0
- package/src/server/dev.ts +4 -2
- package/src/server/start.ts +4 -2
- package/src/styles.css +189 -0
- package/dist/assets/index-CZM2MUUs.js +0 -40
- package/dist/assets/index-RAZRO3YN.css +0 -1
package/src/app-payload.test.ts
CHANGED
|
@@ -155,4 +155,111 @@ describe('toDashboardPayload', () => {
|
|
|
155
155
|
// #then: should handle non-array steps gracefully
|
|
156
156
|
expect(payload.planProgress.steps).toEqual([])
|
|
157
157
|
})
|
|
158
|
-
|
|
158
|
+
|
|
159
|
+
it('should parse mainSession.sessionId from camel or snake keys', () => {
|
|
160
|
+
// #given: server JSON with main session id in camel and snake case
|
|
161
|
+
const camelJson = {
|
|
162
|
+
mainSession: {
|
|
163
|
+
agent: "sisyphus",
|
|
164
|
+
currentTool: "dashboard_start",
|
|
165
|
+
currentModel: "anthropic/claude-opus-4-5",
|
|
166
|
+
lastUpdatedLabel: "just now",
|
|
167
|
+
session: "test-session",
|
|
168
|
+
sessionId: "ses_main",
|
|
169
|
+
statusPill: "busy",
|
|
170
|
+
},
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const snakeJson = {
|
|
174
|
+
main_session: {
|
|
175
|
+
agent: "sisyphus",
|
|
176
|
+
current_tool: "dashboard_start",
|
|
177
|
+
current_model: "anthropic/claude-opus-4-5",
|
|
178
|
+
last_updated: "just now",
|
|
179
|
+
session: "test-session",
|
|
180
|
+
session_id: "ses_snake",
|
|
181
|
+
status: "busy",
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// #when: converting to dashboard payload
|
|
186
|
+
const camelPayload = toDashboardPayload(camelJson)
|
|
187
|
+
const snakePayload = toDashboardPayload(snakeJson)
|
|
188
|
+
|
|
189
|
+
// #then: sessionId should be preserved
|
|
190
|
+
expect(camelPayload.mainSession.sessionId).toBe("ses_main")
|
|
191
|
+
expect(snakePayload.mainSession.sessionId).toBe("ses_snake")
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('should preserve mainSessionTasks from server JSON', () => {
|
|
195
|
+
// #given: server JSON with mainSessionTasks
|
|
196
|
+
const serverJson = {
|
|
197
|
+
mainSession: {
|
|
198
|
+
agent: "sisyphus",
|
|
199
|
+
currentTool: "dashboard_start",
|
|
200
|
+
currentModel: "anthropic/claude-opus-4-5",
|
|
201
|
+
lastUpdatedLabel: "just now",
|
|
202
|
+
session: "test-session",
|
|
203
|
+
sessionId: "ses_main",
|
|
204
|
+
statusPill: "busy",
|
|
205
|
+
},
|
|
206
|
+
planProgress: {
|
|
207
|
+
name: "test-plan",
|
|
208
|
+
completed: 0,
|
|
209
|
+
total: 0,
|
|
210
|
+
path: "/tmp/test-plan.md",
|
|
211
|
+
statusPill: "not started",
|
|
212
|
+
steps: [],
|
|
213
|
+
},
|
|
214
|
+
mainSessionTasks: [
|
|
215
|
+
{
|
|
216
|
+
id: "main-session",
|
|
217
|
+
description: "Main session",
|
|
218
|
+
subline: "ses_main",
|
|
219
|
+
agent: "sisyphus",
|
|
220
|
+
lastModel: "anthropic/claude-opus-4-5",
|
|
221
|
+
sessionId: "ses_main",
|
|
222
|
+
status: "running",
|
|
223
|
+
toolCalls: 3,
|
|
224
|
+
lastTool: "delegate_task",
|
|
225
|
+
timeline: "2026-01-01T00:00:00Z: 2m",
|
|
226
|
+
},
|
|
227
|
+
],
|
|
228
|
+
backgroundTasks: [],
|
|
229
|
+
timeSeries: {
|
|
230
|
+
windowMs: 300000,
|
|
231
|
+
buckets: 150,
|
|
232
|
+
bucketMs: 2000,
|
|
233
|
+
anchorMs: 1640995200000,
|
|
234
|
+
serverNowMs: 1640995500000,
|
|
235
|
+
series: [
|
|
236
|
+
{
|
|
237
|
+
id: "overall-main",
|
|
238
|
+
label: "Overall",
|
|
239
|
+
tone: "muted",
|
|
240
|
+
values: new Array(150).fill(0),
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
},
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// #when
|
|
247
|
+
const payload = toDashboardPayload(serverJson)
|
|
248
|
+
|
|
249
|
+
// #then
|
|
250
|
+
expect(payload.mainSessionTasks).toEqual([
|
|
251
|
+
{
|
|
252
|
+
id: "main-session",
|
|
253
|
+
description: "Main session",
|
|
254
|
+
subline: "ses_main",
|
|
255
|
+
agent: "sisyphus",
|
|
256
|
+
lastModel: "anthropic/claude-opus-4-5",
|
|
257
|
+
sessionId: "ses_main",
|
|
258
|
+
status: "running",
|
|
259
|
+
toolCalls: 3,
|
|
260
|
+
lastTool: "delegate_task",
|
|
261
|
+
timeline: "2026-01-01T00:00:00Z: 2m",
|
|
262
|
+
},
|
|
263
|
+
])
|
|
264
|
+
})
|
|
265
|
+
})
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { computeToolCallsFetchPlan, toggleIdInSet } from "./App"
|
|
3
|
+
|
|
4
|
+
describe('computeToolCallsFetchPlan', () => {
|
|
5
|
+
it('should not fetch when sessionId is missing', () => {
|
|
6
|
+
// #given: no sessionId
|
|
7
|
+
const params = {
|
|
8
|
+
sessionId: null,
|
|
9
|
+
status: "running",
|
|
10
|
+
cachedState: "idle" as const,
|
|
11
|
+
cachedDataOk: false,
|
|
12
|
+
isExpanded: true
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// #when
|
|
16
|
+
const result = computeToolCallsFetchPlan(params)
|
|
17
|
+
|
|
18
|
+
// #then
|
|
19
|
+
expect(result.shouldFetch).toBe(false)
|
|
20
|
+
expect(result.force).toBe(false)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('should not fetch when not expanded', () => {
|
|
24
|
+
// #given: expanded is false
|
|
25
|
+
const params = {
|
|
26
|
+
sessionId: "session-123",
|
|
27
|
+
status: "done",
|
|
28
|
+
cachedState: "idle" as const,
|
|
29
|
+
cachedDataOk: false,
|
|
30
|
+
isExpanded: false
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// #when
|
|
34
|
+
const result = computeToolCallsFetchPlan(params)
|
|
35
|
+
|
|
36
|
+
// #then
|
|
37
|
+
expect(result.shouldFetch).toBe(false)
|
|
38
|
+
expect(result.force).toBe(false)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('should force fetch when status is "running" and expanded', () => {
|
|
42
|
+
// #given: running status
|
|
43
|
+
const params = {
|
|
44
|
+
sessionId: "session-123",
|
|
45
|
+
status: "running",
|
|
46
|
+
cachedState: "ok" as const,
|
|
47
|
+
cachedDataOk: true,
|
|
48
|
+
isExpanded: true
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// #when
|
|
52
|
+
const result = computeToolCallsFetchPlan(params)
|
|
53
|
+
|
|
54
|
+
// #then
|
|
55
|
+
expect(result.shouldFetch).toBe(true)
|
|
56
|
+
expect(result.force).toBe(true)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('should not fetch when non-running status and cached data is ok', () => {
|
|
60
|
+
// #given: done status with good cache
|
|
61
|
+
const params = {
|
|
62
|
+
sessionId: "session-123",
|
|
63
|
+
status: "done",
|
|
64
|
+
cachedState: "ok" as const,
|
|
65
|
+
cachedDataOk: true,
|
|
66
|
+
isExpanded: true
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// #when
|
|
70
|
+
const result = computeToolCallsFetchPlan(params)
|
|
71
|
+
|
|
72
|
+
// #then
|
|
73
|
+
expect(result.shouldFetch).toBe(false)
|
|
74
|
+
expect(result.force).toBe(false)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('should not fetch when already loading', () => {
|
|
78
|
+
// #given: loading state
|
|
79
|
+
const params = {
|
|
80
|
+
sessionId: "session-123",
|
|
81
|
+
status: "done",
|
|
82
|
+
cachedState: "loading" as const,
|
|
83
|
+
cachedDataOk: false,
|
|
84
|
+
isExpanded: true
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// #when
|
|
88
|
+
const result = computeToolCallsFetchPlan(params)
|
|
89
|
+
|
|
90
|
+
// #then
|
|
91
|
+
expect(result.shouldFetch).toBe(false)
|
|
92
|
+
expect(result.force).toBe(false)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('should fetch when non-running, not cached, not loading, and expanded', () => {
|
|
96
|
+
// #given: done status with no cache
|
|
97
|
+
const params = {
|
|
98
|
+
sessionId: "session-123",
|
|
99
|
+
status: "done",
|
|
100
|
+
cachedState: "idle" as const,
|
|
101
|
+
cachedDataOk: false,
|
|
102
|
+
isExpanded: true
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// #when
|
|
106
|
+
const result = computeToolCallsFetchPlan(params)
|
|
107
|
+
|
|
108
|
+
// #then
|
|
109
|
+
expect(result.shouldFetch).toBe(true)
|
|
110
|
+
expect(result.force).toBe(false)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('should handle case-insensitive status values', () => {
|
|
114
|
+
// #given: RUNNING in uppercase
|
|
115
|
+
const params = {
|
|
116
|
+
sessionId: "session-123",
|
|
117
|
+
status: "RUNNING",
|
|
118
|
+
cachedState: "idle" as const,
|
|
119
|
+
cachedDataOk: false,
|
|
120
|
+
isExpanded: true
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// #when
|
|
124
|
+
const result = computeToolCallsFetchPlan(params)
|
|
125
|
+
|
|
126
|
+
// #then
|
|
127
|
+
expect(result.shouldFetch).toBe(true)
|
|
128
|
+
expect(result.force).toBe(true)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('should handle whitespace in status values', () => {
|
|
132
|
+
// #given: running with whitespace
|
|
133
|
+
const params = {
|
|
134
|
+
sessionId: "session-123",
|
|
135
|
+
status: " running ",
|
|
136
|
+
cachedState: "idle" as const,
|
|
137
|
+
cachedDataOk: false,
|
|
138
|
+
isExpanded: true
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// #when
|
|
142
|
+
const result = computeToolCallsFetchPlan(params)
|
|
143
|
+
|
|
144
|
+
// #then
|
|
145
|
+
expect(result.shouldFetch).toBe(true)
|
|
146
|
+
expect(result.force).toBe(true)
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
describe('toggleIdInSet', () => {
|
|
151
|
+
it('should add id when not present in set', () => {
|
|
152
|
+
// #given: empty set
|
|
153
|
+
const currentSet = new Set<string>()
|
|
154
|
+
|
|
155
|
+
// #when
|
|
156
|
+
const result = toggleIdInSet("task-1", currentSet)
|
|
157
|
+
|
|
158
|
+
// #then
|
|
159
|
+
expect(result.has("task-1")).toBe(true)
|
|
160
|
+
expect(result.size).toBe(1)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('should remove id when already present in set', () => {
|
|
164
|
+
// #given: set with id already present
|
|
165
|
+
const currentSet = new Set(["task-1", "task-2"])
|
|
166
|
+
|
|
167
|
+
// #when
|
|
168
|
+
const result = toggleIdInSet("task-1", currentSet)
|
|
169
|
+
|
|
170
|
+
// #then
|
|
171
|
+
expect(result.has("task-1")).toBe(false)
|
|
172
|
+
expect(result.has("task-2")).toBe(true)
|
|
173
|
+
expect(result.size).toBe(1)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('should not modify original set', () => {
|
|
177
|
+
// #given: original set
|
|
178
|
+
const originalSet = new Set(["task-1"])
|
|
179
|
+
|
|
180
|
+
// #when
|
|
181
|
+
const result = toggleIdInSet("task-2", originalSet)
|
|
182
|
+
|
|
183
|
+
// #then
|
|
184
|
+
expect(originalSet.has("task-1")).toBe(true)
|
|
185
|
+
expect(originalSet.has("task-2")).toBe(false)
|
|
186
|
+
expect(originalSet.size).toBe(1)
|
|
187
|
+
expect(result.has("task-1")).toBe(true)
|
|
188
|
+
expect(result.has("task-2")).toBe(true)
|
|
189
|
+
expect(result.size).toBe(2)
|
|
190
|
+
})
|
|
191
|
+
})
|
|
@@ -981,7 +981,16 @@ describe("deriveBackgroundTasks", () => {
|
|
|
981
981
|
"utf8"
|
|
982
982
|
)
|
|
983
983
|
|
|
984
|
-
|
|
984
|
+
// Create typed wrapper that records calls and delegates to fs.readdirSync
|
|
985
|
+
const readdirCalls: Array<[fs.PathLike]> = []
|
|
986
|
+
const readdirSync = ((path: fs.PathLike, options?: any): any => {
|
|
987
|
+
if (typeof options === 'object' && options.withFileTypes) {
|
|
988
|
+
return fs.readdirSync(path, options)
|
|
989
|
+
}
|
|
990
|
+
readdirCalls.push([path])
|
|
991
|
+
return fs.readdirSync(path, 'utf8')
|
|
992
|
+
}) as typeof fs.readdirSync
|
|
993
|
+
|
|
985
994
|
const fsLike = {
|
|
986
995
|
readFileSync: fs.readFileSync,
|
|
987
996
|
readdirSync,
|
|
@@ -993,7 +1002,7 @@ describe("deriveBackgroundTasks", () => {
|
|
|
993
1002
|
const rows = deriveBackgroundTasks({ storage, mainSessionId, fs: fsLike })
|
|
994
1003
|
|
|
995
1004
|
// #then
|
|
996
|
-
const backgroundReads =
|
|
1005
|
+
const backgroundReads = readdirCalls.filter((call) => call[0] === childMsgDir)
|
|
997
1006
|
expect(rows.length).toBe(2)
|
|
998
1007
|
expect(backgroundReads.length).toBe(1)
|
|
999
1008
|
})
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import * as fs from "node:fs"
|
|
2
|
+
import * as path from "node:path"
|
|
3
|
+
import type { OpenCodeStorageRoots, StoredMessageMeta } from "./session"
|
|
4
|
+
import { getMessageDir } from "./session"
|
|
5
|
+
import { assertAllowedPath } from "./paths"
|
|
6
|
+
|
|
7
|
+
type FsLike = Pick<typeof fs, "readFileSync" | "readdirSync" | "existsSync" | "statSync">
|
|
8
|
+
|
|
9
|
+
export const MAX_TOOL_CALL_MESSAGES = 200
|
|
10
|
+
export const MAX_TOOL_CALLS = 300
|
|
11
|
+
|
|
12
|
+
export type ToolCallSummary = {
|
|
13
|
+
sessionId: string
|
|
14
|
+
messageId: string
|
|
15
|
+
callId: string
|
|
16
|
+
tool: string
|
|
17
|
+
status: "pending" | "running" | "completed" | "error" | "unknown"
|
|
18
|
+
createdAtMs: number | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type ToolCallSummaryResult = {
|
|
22
|
+
toolCalls: ToolCallSummary[]
|
|
23
|
+
truncated: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type StoredToolPartMeta = {
|
|
27
|
+
type?: string
|
|
28
|
+
callID?: string
|
|
29
|
+
tool?: string
|
|
30
|
+
state?: { status?: string }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readJsonFile<T>(filePath: string, fsLike: FsLike): T | null {
|
|
34
|
+
try {
|
|
35
|
+
const content = fsLike.readFileSync(filePath, "utf8")
|
|
36
|
+
return JSON.parse(content) as T
|
|
37
|
+
} catch {
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function listJsonFiles(dir: string, fsLike: FsLike): string[] {
|
|
43
|
+
try {
|
|
44
|
+
return fsLike.readdirSync(dir).filter((f) => f.endsWith(".json"))
|
|
45
|
+
} catch {
|
|
46
|
+
return []
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readRecentMessageMetas(
|
|
51
|
+
messageDir: string,
|
|
52
|
+
maxMessages: number,
|
|
53
|
+
fsLike: FsLike
|
|
54
|
+
): { metas: StoredMessageMeta[]; totalMessages: number } {
|
|
55
|
+
if (!messageDir || !fsLike.existsSync(messageDir)) return { metas: [], totalMessages: 0 }
|
|
56
|
+
const files = listJsonFiles(messageDir, fsLike)
|
|
57
|
+
const ranked = files
|
|
58
|
+
.map((f) => ({
|
|
59
|
+
f,
|
|
60
|
+
mtime: (() => {
|
|
61
|
+
try {
|
|
62
|
+
return fsLike.statSync(path.join(messageDir, f)).mtimeMs
|
|
63
|
+
} catch {
|
|
64
|
+
return 0
|
|
65
|
+
}
|
|
66
|
+
})(),
|
|
67
|
+
}))
|
|
68
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
69
|
+
.slice(0, maxMessages)
|
|
70
|
+
|
|
71
|
+
const metas: StoredMessageMeta[] = []
|
|
72
|
+
for (const item of ranked) {
|
|
73
|
+
const meta = readJsonFile<StoredMessageMeta>(path.join(messageDir, item.f), fsLike)
|
|
74
|
+
if (meta && typeof meta.id === "string") metas.push(meta)
|
|
75
|
+
}
|
|
76
|
+
return { metas, totalMessages: files.length }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function readToolPartsForMessage(
|
|
80
|
+
partStorage: string,
|
|
81
|
+
messageId: string,
|
|
82
|
+
fsLike: FsLike,
|
|
83
|
+
allowedRoots?: string[]
|
|
84
|
+
): StoredToolPartMeta[] {
|
|
85
|
+
const partDir = path.join(partStorage, messageId)
|
|
86
|
+
if (allowedRoots && allowedRoots.length > 0) {
|
|
87
|
+
assertAllowedPath({ candidatePath: partDir, allowedRoots })
|
|
88
|
+
}
|
|
89
|
+
if (!fsLike.existsSync(partDir)) return []
|
|
90
|
+
|
|
91
|
+
const files = listJsonFiles(partDir, fsLike).sort()
|
|
92
|
+
const parts: StoredToolPartMeta[] = []
|
|
93
|
+
for (const file of files) {
|
|
94
|
+
const part = readJsonFile<StoredToolPartMeta>(path.join(partDir, file), fsLike)
|
|
95
|
+
if (part && part.type === "tool" && typeof part.tool === "string" && typeof part.callID === "string") {
|
|
96
|
+
parts.push(part)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return parts
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function readStatus(value: StoredToolPartMeta["state"]): ToolCallSummary["status"] {
|
|
103
|
+
const status = value?.status
|
|
104
|
+
if (status === "pending" || status === "running" || status === "completed" || status === "error") {
|
|
105
|
+
return status
|
|
106
|
+
}
|
|
107
|
+
return "unknown"
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function deriveToolCalls(opts: {
|
|
111
|
+
storage: OpenCodeStorageRoots
|
|
112
|
+
sessionId: string
|
|
113
|
+
fs?: FsLike
|
|
114
|
+
allowedRoots?: string[]
|
|
115
|
+
}): ToolCallSummaryResult {
|
|
116
|
+
const fsLike: FsLike = opts.fs ?? fs
|
|
117
|
+
const messageDir = getMessageDir(opts.storage.message, opts.sessionId)
|
|
118
|
+
if (messageDir && opts.allowedRoots && opts.allowedRoots.length > 0) {
|
|
119
|
+
assertAllowedPath({ candidatePath: messageDir, allowedRoots: opts.allowedRoots })
|
|
120
|
+
}
|
|
121
|
+
const { metas, totalMessages } = readRecentMessageMetas(messageDir, MAX_TOOL_CALL_MESSAGES, fsLike)
|
|
122
|
+
const truncatedByMessages = totalMessages > MAX_TOOL_CALL_MESSAGES
|
|
123
|
+
|
|
124
|
+
const calls: Array<ToolCallSummary & { createdSortKey: number }> = []
|
|
125
|
+
for (const meta of metas) {
|
|
126
|
+
const createdAtMs = typeof meta.time?.created === "number" ? meta.time.created : null
|
|
127
|
+
const createdSortKey = createdAtMs ?? -Infinity
|
|
128
|
+
const parts = readToolPartsForMessage(opts.storage.part, meta.id, fsLike, opts.allowedRoots)
|
|
129
|
+
for (const part of parts) {
|
|
130
|
+
calls.push({
|
|
131
|
+
sessionId: opts.sessionId,
|
|
132
|
+
messageId: meta.id,
|
|
133
|
+
callId: part.callID ?? "",
|
|
134
|
+
tool: part.tool ?? "",
|
|
135
|
+
status: readStatus(part.state),
|
|
136
|
+
createdAtMs,
|
|
137
|
+
createdSortKey,
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const truncatedByCalls = calls.length > MAX_TOOL_CALLS
|
|
143
|
+
const toolCalls = calls
|
|
144
|
+
.sort((a, b) => {
|
|
145
|
+
if (a.createdSortKey !== b.createdSortKey) return b.createdSortKey - a.createdSortKey
|
|
146
|
+
const messageCompare = String(a.messageId).localeCompare(String(b.messageId))
|
|
147
|
+
if (messageCompare !== 0) return messageCompare
|
|
148
|
+
return String(a.callId).localeCompare(String(b.callId))
|
|
149
|
+
})
|
|
150
|
+
.slice(0, MAX_TOOL_CALLS)
|
|
151
|
+
.map(({ createdSortKey, ...row }) => row)
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
toolCalls,
|
|
155
|
+
truncated: truncatedByMessages || truncatedByCalls,
|
|
156
|
+
}
|
|
157
|
+
}
|