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
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { toDashboardPayload } from "./App"
|
|
3
|
+
|
|
4
|
+
describe('toDashboardPayload', () => {
|
|
5
|
+
it('should preserve planProgress.steps from server JSON', () => {
|
|
6
|
+
// #given: server JSON with planProgress.steps
|
|
7
|
+
const serverJson = {
|
|
8
|
+
mainSession: {
|
|
9
|
+
agent: "sisyphus",
|
|
10
|
+
currentTool: "dashboard_start",
|
|
11
|
+
currentModel: "anthropic/claude-opus-4-5",
|
|
12
|
+
lastUpdatedLabel: "just now",
|
|
13
|
+
session: "test-session",
|
|
14
|
+
statusPill: "busy",
|
|
15
|
+
},
|
|
16
|
+
planProgress: {
|
|
17
|
+
name: "test-plan",
|
|
18
|
+
completed: 2,
|
|
19
|
+
total: 4,
|
|
20
|
+
path: "/tmp/test-plan.md",
|
|
21
|
+
statusPill: "in progress",
|
|
22
|
+
steps: [
|
|
23
|
+
{ checked: true, text: "First completed task" },
|
|
24
|
+
{ checked: true, text: "Second completed task" },
|
|
25
|
+
{ checked: false, text: "Third pending task" },
|
|
26
|
+
{ checked: false, text: "Fourth pending task" },
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
backgroundTasks: [],
|
|
30
|
+
timeSeries: {
|
|
31
|
+
windowMs: 300000,
|
|
32
|
+
buckets: 150,
|
|
33
|
+
bucketMs: 2000,
|
|
34
|
+
anchorMs: 1640995200000,
|
|
35
|
+
serverNowMs: 1640995500000,
|
|
36
|
+
series: [
|
|
37
|
+
{
|
|
38
|
+
id: "overall-main",
|
|
39
|
+
label: "Overall",
|
|
40
|
+
tone: "muted",
|
|
41
|
+
values: new Array(150).fill(0),
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// #when: converting to dashboard payload
|
|
48
|
+
const payload = toDashboardPayload(serverJson)
|
|
49
|
+
|
|
50
|
+
// #then: planProgress.steps should be preserved with correct structure
|
|
51
|
+
expect(payload.planProgress.steps).toBeDefined()
|
|
52
|
+
expect(payload.planProgress.steps).toEqual([
|
|
53
|
+
{ checked: true, text: "First completed task" },
|
|
54
|
+
{ checked: true, text: "Second completed task" },
|
|
55
|
+
{ checked: false, text: "Third pending task" },
|
|
56
|
+
{ checked: false, text: "Fourth pending task" },
|
|
57
|
+
])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('should handle missing or malformed planProgress.steps defensively', () => {
|
|
61
|
+
// #given: server JSON with malformed planProgress.steps
|
|
62
|
+
const serverJson = {
|
|
63
|
+
mainSession: {
|
|
64
|
+
agent: "sisyphus",
|
|
65
|
+
currentTool: "dashboard_start",
|
|
66
|
+
currentModel: "anthropic/claude-opus-4-5",
|
|
67
|
+
lastUpdatedLabel: "just now",
|
|
68
|
+
session: "test-session",
|
|
69
|
+
statusPill: "busy",
|
|
70
|
+
},
|
|
71
|
+
planProgress: {
|
|
72
|
+
name: "test-plan",
|
|
73
|
+
completed: 0,
|
|
74
|
+
total: 0,
|
|
75
|
+
path: "/tmp/test-plan.md",
|
|
76
|
+
statusPill: "not started",
|
|
77
|
+
steps: [
|
|
78
|
+
{ checked: true, text: "Valid step" },
|
|
79
|
+
{ checked: false }, // missing text
|
|
80
|
+
{ text: "Missing checked" }, // missing checked
|
|
81
|
+
"invalid string", // wrong type
|
|
82
|
+
null, // null value
|
|
83
|
+
{ checked: "not-boolean", text: "Invalid checked type" }, // wrong checked type
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
backgroundTasks: [],
|
|
87
|
+
timeSeries: {
|
|
88
|
+
windowMs: 300000,
|
|
89
|
+
buckets: 150,
|
|
90
|
+
bucketMs: 2000,
|
|
91
|
+
anchorMs: 1640995200000,
|
|
92
|
+
serverNowMs: 1640995500000,
|
|
93
|
+
series: [
|
|
94
|
+
{
|
|
95
|
+
id: "overall-main",
|
|
96
|
+
label: "Overall",
|
|
97
|
+
tone: "muted",
|
|
98
|
+
values: new Array(150).fill(0),
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// #when: converting to dashboard payload
|
|
105
|
+
const payload = toDashboardPayload(serverJson)
|
|
106
|
+
|
|
107
|
+
// #then: should only include valid steps, ignore malformed ones
|
|
108
|
+
expect(payload.planProgress.steps).toEqual([
|
|
109
|
+
{ checked: true, text: "Valid step" },
|
|
110
|
+
{ checked: false, text: "Missing checked" }, // default checked to false
|
|
111
|
+
{ checked: false, text: "Invalid checked type" }, // default checked to false for invalid boolean
|
|
112
|
+
])
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('should handle non-array planProgress.steps', () => {
|
|
116
|
+
// #given: server JSON with non-array planProgress.steps
|
|
117
|
+
const serverJson = {
|
|
118
|
+
mainSession: {
|
|
119
|
+
agent: "sisyphus",
|
|
120
|
+
currentTool: "dashboard_start",
|
|
121
|
+
currentModel: "anthropic/claude-opus-4-5",
|
|
122
|
+
lastUpdatedLabel: "just now",
|
|
123
|
+
session: "test-session",
|
|
124
|
+
statusPill: "busy",
|
|
125
|
+
},
|
|
126
|
+
planProgress: {
|
|
127
|
+
name: "test-plan",
|
|
128
|
+
completed: 0,
|
|
129
|
+
total: 0,
|
|
130
|
+
path: "/tmp/test-plan.md",
|
|
131
|
+
statusPill: "not started",
|
|
132
|
+
steps: "not an array",
|
|
133
|
+
},
|
|
134
|
+
backgroundTasks: [],
|
|
135
|
+
timeSeries: {
|
|
136
|
+
windowMs: 300000,
|
|
137
|
+
buckets: 150,
|
|
138
|
+
bucketMs: 2000,
|
|
139
|
+
anchorMs: 1640995200000,
|
|
140
|
+
serverNowMs: 1640995500000,
|
|
141
|
+
series: [
|
|
142
|
+
{
|
|
143
|
+
id: "overall-main",
|
|
144
|
+
label: "Overall",
|
|
145
|
+
tone: "muted",
|
|
146
|
+
values: new Array(150).fill(0),
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// #when: converting to dashboard payload
|
|
153
|
+
const payload = toDashboardPayload(serverJson)
|
|
154
|
+
|
|
155
|
+
// #then: should handle non-array steps gracefully
|
|
156
|
+
expect(payload.planProgress.steps).toEqual([])
|
|
157
|
+
})
|
|
158
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { formatBackgroundTaskTimelineCell } from "./App"
|
|
3
|
+
|
|
4
|
+
describe('formatBackgroundTaskTimelineCell', () => {
|
|
5
|
+
it('should render "-" for queued regardless of timeline', () => {
|
|
6
|
+
// #given: queued status
|
|
7
|
+
const status = "queued"
|
|
8
|
+
|
|
9
|
+
// #when/#then
|
|
10
|
+
expect(formatBackgroundTaskTimelineCell(status, "")).toBe("-")
|
|
11
|
+
expect(formatBackgroundTaskTimelineCell(status, "2026-01-01T00:00:00Z: 2m")).toBe("-")
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('should render blank for unknown regardless of timeline', () => {
|
|
15
|
+
// #given: unknown status
|
|
16
|
+
const status = "unknown"
|
|
17
|
+
|
|
18
|
+
// #when/#then
|
|
19
|
+
expect(formatBackgroundTaskTimelineCell(status, "")).toBe("")
|
|
20
|
+
expect(formatBackgroundTaskTimelineCell(status, "2026-01-01T00:00:00Z: 2m")).toBe("")
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('should render timeline when present, otherwise "-" for other statuses', () => {
|
|
24
|
+
// #given: a non-queued, non-unknown status
|
|
25
|
+
const status = "running"
|
|
26
|
+
|
|
27
|
+
// #when/#then
|
|
28
|
+
expect(formatBackgroundTaskTimelineCell(status, "2026-01-01T00:00:00Z: 2m")).toBe("2026-01-01T00:00:00Z: 2m")
|
|
29
|
+
expect(formatBackgroundTaskTimelineCell(status, "")).toBe("-")
|
|
30
|
+
expect(formatBackgroundTaskTimelineCell(status, " ")).toBe("-")
|
|
31
|
+
})
|
|
32
|
+
})
|
|
@@ -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
|
+
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import * as fs from "node:fs"
|
|
2
1
|
import * as os from "node:os"
|
|
3
2
|
import * as path from "node:path"
|
|
4
|
-
import
|
|
3
|
+
import * as fs from "node:fs"
|
|
4
|
+
import { describe, expect, it, vi } from "vitest"
|
|
5
5
|
import { deriveBackgroundTasks } from "./background-tasks"
|
|
6
6
|
import { getStorageRoots } from "./session"
|
|
7
7
|
|
|
@@ -602,6 +602,7 @@ describe("deriveBackgroundTasks", () => {
|
|
|
602
602
|
expect(rows[0].toolCalls).toBe(0) // No tool calls in fallback session
|
|
603
603
|
expect(rows[0].lastTool).toBe(null)
|
|
604
604
|
expect(rows[0].status).toBe("unknown") // Should be unknown since session exists but no tool calls
|
|
605
|
+
expect(rows[0].timeline).toBe("") // Unknown status should emit no timeline
|
|
605
606
|
})
|
|
606
607
|
|
|
607
608
|
it("links sync delegate_task rows to Background sessions when forced-to-background but waited", () => {
|
|
@@ -704,4 +705,305 @@ describe("deriveBackgroundTasks", () => {
|
|
|
704
705
|
expect((rows[0] as unknown as Record<string, unknown>).input).toBeUndefined()
|
|
705
706
|
expect((rows[0] as unknown as Record<string, unknown>).state).toBeUndefined()
|
|
706
707
|
})
|
|
708
|
+
|
|
709
|
+
it("derives lastModel from background session assistant metas", () => {
|
|
710
|
+
// #given
|
|
711
|
+
const storageRoot = mkStorageRoot()
|
|
712
|
+
const storage = getStorageRoots(storageRoot)
|
|
713
|
+
const mainSessionId = "ses_main"
|
|
714
|
+
|
|
715
|
+
const msgDir = path.join(storage.message, mainSessionId)
|
|
716
|
+
fs.mkdirSync(msgDir, { recursive: true })
|
|
717
|
+
const messageID = "msg_1"
|
|
718
|
+
fs.writeFileSync(
|
|
719
|
+
path.join(msgDir, `${messageID}.json`),
|
|
720
|
+
JSON.stringify({
|
|
721
|
+
id: messageID,
|
|
722
|
+
sessionID: mainSessionId,
|
|
723
|
+
role: "assistant",
|
|
724
|
+
time: { created: 1000 },
|
|
725
|
+
}),
|
|
726
|
+
"utf8"
|
|
727
|
+
)
|
|
728
|
+
const partDir = path.join(storage.part, messageID)
|
|
729
|
+
fs.mkdirSync(partDir, { recursive: true })
|
|
730
|
+
fs.writeFileSync(
|
|
731
|
+
path.join(partDir, "part_1.json"),
|
|
732
|
+
JSON.stringify({
|
|
733
|
+
id: "part_1",
|
|
734
|
+
sessionID: mainSessionId,
|
|
735
|
+
messageID,
|
|
736
|
+
type: "tool",
|
|
737
|
+
callID: "call_1",
|
|
738
|
+
tool: "delegate_task",
|
|
739
|
+
state: {
|
|
740
|
+
status: "completed",
|
|
741
|
+
input: {
|
|
742
|
+
run_in_background: true,
|
|
743
|
+
description: "Model scan",
|
|
744
|
+
subagent_type: "explore",
|
|
745
|
+
},
|
|
746
|
+
},
|
|
747
|
+
}),
|
|
748
|
+
"utf8"
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
const projectID = "proj"
|
|
752
|
+
const sessDir = path.join(storage.session, projectID)
|
|
753
|
+
fs.mkdirSync(sessDir, { recursive: true })
|
|
754
|
+
fs.writeFileSync(
|
|
755
|
+
path.join(sessDir, "ses_child.json"),
|
|
756
|
+
JSON.stringify({
|
|
757
|
+
id: "ses_child",
|
|
758
|
+
projectID,
|
|
759
|
+
directory: "/tmp/project",
|
|
760
|
+
title: "Background: Model scan",
|
|
761
|
+
parentID: mainSessionId,
|
|
762
|
+
time: { created: 1500, updated: 1500 },
|
|
763
|
+
}),
|
|
764
|
+
"utf8"
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
const childMsgDir = path.join(storage.message, "ses_child")
|
|
768
|
+
fs.mkdirSync(childMsgDir, { recursive: true })
|
|
769
|
+
const childMsgId = "msg_child"
|
|
770
|
+
fs.writeFileSync(
|
|
771
|
+
path.join(childMsgDir, `${childMsgId}.json`),
|
|
772
|
+
JSON.stringify({
|
|
773
|
+
id: childMsgId,
|
|
774
|
+
sessionID: "ses_child",
|
|
775
|
+
role: "assistant",
|
|
776
|
+
time: { created: 2000 },
|
|
777
|
+
providerID: "openai",
|
|
778
|
+
modelID: "gpt-5.2",
|
|
779
|
+
}),
|
|
780
|
+
"utf8"
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
// #when
|
|
784
|
+
const rows = deriveBackgroundTasks({ storage, mainSessionId })
|
|
785
|
+
|
|
786
|
+
// #then
|
|
787
|
+
expect(rows.length).toBe(1)
|
|
788
|
+
expect(rows[0].lastModel).toBe("openai/gpt-5.2")
|
|
789
|
+
})
|
|
790
|
+
|
|
791
|
+
it("orders lastModel by time.created over file mtime", () => {
|
|
792
|
+
// #given
|
|
793
|
+
const storageRoot = mkStorageRoot()
|
|
794
|
+
const storage = getStorageRoots(storageRoot)
|
|
795
|
+
const mainSessionId = "ses_main"
|
|
796
|
+
|
|
797
|
+
const msgDir = path.join(storage.message, mainSessionId)
|
|
798
|
+
fs.mkdirSync(msgDir, { recursive: true })
|
|
799
|
+
const messageID = "msg_1"
|
|
800
|
+
fs.writeFileSync(
|
|
801
|
+
path.join(msgDir, `${messageID}.json`),
|
|
802
|
+
JSON.stringify({
|
|
803
|
+
id: messageID,
|
|
804
|
+
sessionID: mainSessionId,
|
|
805
|
+
role: "assistant",
|
|
806
|
+
time: { created: 1000 },
|
|
807
|
+
}),
|
|
808
|
+
"utf8"
|
|
809
|
+
)
|
|
810
|
+
const partDir = path.join(storage.part, messageID)
|
|
811
|
+
fs.mkdirSync(partDir, { recursive: true })
|
|
812
|
+
fs.writeFileSync(
|
|
813
|
+
path.join(partDir, "part_1.json"),
|
|
814
|
+
JSON.stringify({
|
|
815
|
+
id: "part_1",
|
|
816
|
+
sessionID: mainSessionId,
|
|
817
|
+
messageID,
|
|
818
|
+
type: "tool",
|
|
819
|
+
callID: "call_1",
|
|
820
|
+
tool: "delegate_task",
|
|
821
|
+
state: {
|
|
822
|
+
status: "completed",
|
|
823
|
+
input: {
|
|
824
|
+
run_in_background: true,
|
|
825
|
+
description: "Ordering scan",
|
|
826
|
+
subagent_type: "explore",
|
|
827
|
+
},
|
|
828
|
+
},
|
|
829
|
+
}),
|
|
830
|
+
"utf8"
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
const projectID = "proj"
|
|
834
|
+
const sessDir = path.join(storage.session, projectID)
|
|
835
|
+
fs.mkdirSync(sessDir, { recursive: true })
|
|
836
|
+
fs.writeFileSync(
|
|
837
|
+
path.join(sessDir, "ses_child.json"),
|
|
838
|
+
JSON.stringify({
|
|
839
|
+
id: "ses_child",
|
|
840
|
+
projectID,
|
|
841
|
+
directory: "/tmp/project",
|
|
842
|
+
title: "Background: Ordering scan",
|
|
843
|
+
parentID: mainSessionId,
|
|
844
|
+
time: { created: 1500, updated: 1500 },
|
|
845
|
+
}),
|
|
846
|
+
"utf8"
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
const childMsgDir = path.join(storage.message, "ses_child")
|
|
850
|
+
fs.mkdirSync(childMsgDir, { recursive: true })
|
|
851
|
+
const olderMsgId = "msg_older"
|
|
852
|
+
const newerMsgId = "msg_newer"
|
|
853
|
+
|
|
854
|
+
fs.writeFileSync(
|
|
855
|
+
path.join(childMsgDir, `${newerMsgId}.json`),
|
|
856
|
+
JSON.stringify({
|
|
857
|
+
id: newerMsgId,
|
|
858
|
+
sessionID: "ses_child",
|
|
859
|
+
role: "assistant",
|
|
860
|
+
time: { created: 3000 },
|
|
861
|
+
providerID: "openai",
|
|
862
|
+
modelID: "gpt-newer",
|
|
863
|
+
}),
|
|
864
|
+
"utf8"
|
|
865
|
+
)
|
|
866
|
+
fs.writeFileSync(
|
|
867
|
+
path.join(childMsgDir, `${olderMsgId}.json`),
|
|
868
|
+
JSON.stringify({
|
|
869
|
+
id: olderMsgId,
|
|
870
|
+
sessionID: "ses_child",
|
|
871
|
+
role: "assistant",
|
|
872
|
+
time: { created: 1000 },
|
|
873
|
+
providerID: "openai",
|
|
874
|
+
modelID: "gpt-older",
|
|
875
|
+
}),
|
|
876
|
+
"utf8"
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
// #when
|
|
880
|
+
const rows = deriveBackgroundTasks({ storage, mainSessionId })
|
|
881
|
+
|
|
882
|
+
// #then
|
|
883
|
+
expect(rows.length).toBe(1)
|
|
884
|
+
expect(rows[0].lastModel).toBe("openai/gpt-newer")
|
|
885
|
+
})
|
|
886
|
+
|
|
887
|
+
it("memoizes background session reads for shared sessionId", () => {
|
|
888
|
+
// #given
|
|
889
|
+
const storageRoot = mkStorageRoot()
|
|
890
|
+
const storage = getStorageRoots(storageRoot)
|
|
891
|
+
const mainSessionId = "ses_main"
|
|
892
|
+
|
|
893
|
+
// Clear any previous mock state
|
|
894
|
+
vi.clearAllMocks()
|
|
895
|
+
|
|
896
|
+
const msgDir = path.join(storage.message, mainSessionId)
|
|
897
|
+
fs.mkdirSync(msgDir, { recursive: true })
|
|
898
|
+
const messageID = "msg_1"
|
|
899
|
+
fs.writeFileSync(
|
|
900
|
+
path.join(msgDir, `${messageID}.json`),
|
|
901
|
+
JSON.stringify({
|
|
902
|
+
id: messageID,
|
|
903
|
+
sessionID: mainSessionId,
|
|
904
|
+
role: "assistant",
|
|
905
|
+
time: { created: 1000 },
|
|
906
|
+
}),
|
|
907
|
+
"utf8"
|
|
908
|
+
)
|
|
909
|
+
const partDir = path.join(storage.part, messageID)
|
|
910
|
+
fs.mkdirSync(partDir, { recursive: true })
|
|
911
|
+
fs.writeFileSync(
|
|
912
|
+
path.join(partDir, "part_1.json"),
|
|
913
|
+
JSON.stringify({
|
|
914
|
+
id: "part_1",
|
|
915
|
+
sessionID: mainSessionId,
|
|
916
|
+
messageID,
|
|
917
|
+
type: "tool",
|
|
918
|
+
callID: "call_1",
|
|
919
|
+
tool: "delegate_task",
|
|
920
|
+
state: {
|
|
921
|
+
status: "completed",
|
|
922
|
+
input: {
|
|
923
|
+
run_in_background: true,
|
|
924
|
+
description: "Memo scan",
|
|
925
|
+
subagent_type: "explore",
|
|
926
|
+
},
|
|
927
|
+
},
|
|
928
|
+
}),
|
|
929
|
+
"utf8"
|
|
930
|
+
)
|
|
931
|
+
fs.writeFileSync(
|
|
932
|
+
path.join(partDir, "part_2.json"),
|
|
933
|
+
JSON.stringify({
|
|
934
|
+
id: "part_2",
|
|
935
|
+
sessionID: mainSessionId,
|
|
936
|
+
messageID,
|
|
937
|
+
type: "tool",
|
|
938
|
+
callID: "call_2",
|
|
939
|
+
tool: "delegate_task",
|
|
940
|
+
state: {
|
|
941
|
+
status: "completed",
|
|
942
|
+
input: {
|
|
943
|
+
run_in_background: true,
|
|
944
|
+
description: "Memo scan",
|
|
945
|
+
subagent_type: "explore",
|
|
946
|
+
},
|
|
947
|
+
},
|
|
948
|
+
}),
|
|
949
|
+
"utf8"
|
|
950
|
+
)
|
|
951
|
+
|
|
952
|
+
const projectID = "proj"
|
|
953
|
+
const sessDir = path.join(storage.session, projectID)
|
|
954
|
+
fs.mkdirSync(sessDir, { recursive: true })
|
|
955
|
+
fs.writeFileSync(
|
|
956
|
+
path.join(sessDir, "ses_child.json"),
|
|
957
|
+
JSON.stringify({
|
|
958
|
+
id: "ses_child",
|
|
959
|
+
projectID,
|
|
960
|
+
directory: "/tmp/project",
|
|
961
|
+
title: "Background: Memo scan",
|
|
962
|
+
parentID: mainSessionId,
|
|
963
|
+
time: { created: 1500, updated: 1500 },
|
|
964
|
+
}),
|
|
965
|
+
"utf8"
|
|
966
|
+
)
|
|
967
|
+
|
|
968
|
+
const childMsgDir = path.join(storage.message, "ses_child")
|
|
969
|
+
fs.mkdirSync(childMsgDir, { recursive: true })
|
|
970
|
+
const childMsgId = "msg_child"
|
|
971
|
+
fs.writeFileSync(
|
|
972
|
+
path.join(childMsgDir, `${childMsgId}.json`),
|
|
973
|
+
JSON.stringify({
|
|
974
|
+
id: childMsgId,
|
|
975
|
+
sessionID: "ses_child",
|
|
976
|
+
role: "assistant",
|
|
977
|
+
time: { created: 2000 },
|
|
978
|
+
providerID: "openai",
|
|
979
|
+
modelID: "gpt-5.2",
|
|
980
|
+
}),
|
|
981
|
+
"utf8"
|
|
982
|
+
)
|
|
983
|
+
|
|
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
|
+
|
|
994
|
+
const fsLike = {
|
|
995
|
+
readFileSync: fs.readFileSync,
|
|
996
|
+
readdirSync,
|
|
997
|
+
existsSync: fs.existsSync,
|
|
998
|
+
statSync: fs.statSync,
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// #when
|
|
1002
|
+
const rows = deriveBackgroundTasks({ storage, mainSessionId, fs: fsLike })
|
|
1003
|
+
|
|
1004
|
+
// #then
|
|
1005
|
+
const backgroundReads = readdirCalls.filter((call) => call[0] === childMsgDir)
|
|
1006
|
+
expect(rows.length).toBe(2)
|
|
1007
|
+
expect(backgroundReads.length).toBe(1)
|
|
1008
|
+
})
|
|
707
1009
|
})
|