oh-my-opencode-dashboard 0.0.3 → 0.0.4

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.
@@ -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,
@@ -6,7 +6,7 @@ describe('API Routes', () => {
6
6
  const api = createApi({
7
7
  getSnapshot: () => ({
8
8
  mainSession: { agent: "x", currentTool: "-", lastUpdatedLabel: "never", session: "s", statusPill: "idle" },
9
- planProgress: { name: "p", completed: 0, total: 0, path: "", statusPill: "not started" },
9
+ planProgress: { name: "p", completed: 0, total: 0, path: "", statusPill: "not started", steps: [] },
10
10
  backgroundTasks: [],
11
11
  timeSeries: {
12
12
  windowMs: 0,
@@ -29,7 +29,7 @@ describe('API Routes', () => {
29
29
  const api = createApi({
30
30
  getSnapshot: () => ({
31
31
  mainSession: { agent: "x", currentTool: "-", lastUpdatedLabel: "never", session: "s", statusPill: "idle" },
32
- planProgress: { name: "p", completed: 0, total: 0, path: "", statusPill: "not started" },
32
+ planProgress: { name: "p", completed: 0, total: 0, path: "", statusPill: "not started", steps: [] },
33
33
  backgroundTasks: [{ id: "1", description: "d", agent: "a", status: "queued", toolCalls: 0, lastTool: "-", timeline: "" }],
34
34
  timeSeries: {
35
35
  windowMs: 0,
@@ -75,6 +75,7 @@ describe("buildDashboardPayload", () => {
75
75
  expect(payload.mainSession.statusPill).toBe("running tool")
76
76
  expect(payload.mainSession.currentTool).toBe("delegate_task")
77
77
  expect(payload.mainSession.agent).toBe("sisyphus")
78
+ expect(payload.mainSession.currentModel).toBeNull()
78
79
 
79
80
  expect(payload.raw).not.toHaveProperty("prompt")
80
81
  expect(payload.raw).not.toHaveProperty("input")
@@ -147,6 +148,7 @@ describe("buildDashboardPayload", () => {
147
148
 
148
149
  expect(payload).toHaveProperty("timeSeries")
149
150
  expect(payload.raw).toHaveProperty("timeSeries")
151
+ expect(payload.mainSession.currentModel).toBeNull()
150
152
 
151
153
  const sensitiveKeys = ["prompt", "input", "output", "error", "state"]
152
154
 
@@ -175,4 +177,141 @@ describe("buildDashboardPayload", () => {
175
177
  fs.rmSync(projectRoot, { recursive: true, force: true })
176
178
  }
177
179
  })
180
+
181
+ it("includes latest model strings for main and background sessions", () => {
182
+ const storageRoot = mkStorageRoot()
183
+ const storage = getStorageRoots(storageRoot)
184
+ const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omo-project-"))
185
+ const sessionId = "ses_with_models"
186
+ const backgroundSessionId = "ses_bg_1"
187
+ const messageId = "msg_1"
188
+ const projectID = "proj_1"
189
+
190
+ try {
191
+ const sessionMetaDir = path.join(storage.session, projectID)
192
+ fs.mkdirSync(sessionMetaDir, { recursive: true })
193
+ fs.writeFileSync(
194
+ path.join(sessionMetaDir, `${sessionId}.json`),
195
+ JSON.stringify({
196
+ id: sessionId,
197
+ projectID,
198
+ directory: projectRoot,
199
+ time: { created: 1000, updated: 1000 },
200
+ }),
201
+ "utf8"
202
+ )
203
+ fs.writeFileSync(
204
+ path.join(sessionMetaDir, `${backgroundSessionId}.json`),
205
+ JSON.stringify({
206
+ id: backgroundSessionId,
207
+ projectID,
208
+ directory: projectRoot,
209
+ parentID: sessionId,
210
+ title: "Background: model task",
211
+ time: { created: 1000, updated: 1100 },
212
+ }),
213
+ "utf8"
214
+ )
215
+
216
+ const messageDir = path.join(storage.message, sessionId)
217
+ fs.mkdirSync(messageDir, { recursive: true })
218
+ fs.writeFileSync(
219
+ path.join(messageDir, `${messageId}.json`),
220
+ JSON.stringify({
221
+ id: messageId,
222
+ sessionID: sessionId,
223
+ role: "assistant",
224
+ agent: "sisyphus",
225
+ time: { created: 1000 },
226
+ }),
227
+ "utf8"
228
+ )
229
+
230
+ const partDir = path.join(storage.part, messageId)
231
+ fs.mkdirSync(partDir, { recursive: true })
232
+ fs.writeFileSync(
233
+ path.join(partDir, "part_1.json"),
234
+ JSON.stringify({
235
+ id: "part_1",
236
+ sessionID: sessionId,
237
+ messageID: messageId,
238
+ type: "tool",
239
+ callID: "call_1",
240
+ tool: "delegate_task",
241
+ state: {
242
+ status: "completed",
243
+ input: {
244
+ run_in_background: true,
245
+ description: "model task",
246
+ subagent_type: "explore",
247
+ },
248
+ },
249
+ }),
250
+ "utf8"
251
+ )
252
+
253
+ const backgroundMessageDir = path.join(storage.message, backgroundSessionId)
254
+ fs.mkdirSync(backgroundMessageDir, { recursive: true })
255
+ fs.writeFileSync(
256
+ path.join(backgroundMessageDir, "msg_bg_1.json"),
257
+ JSON.stringify({
258
+ id: "msg_bg_1",
259
+ sessionID: backgroundSessionId,
260
+ role: "assistant",
261
+ providerID: "openai",
262
+ modelID: "gpt-4o",
263
+ time: { created: 1100 },
264
+ }),
265
+ "utf8"
266
+ )
267
+
268
+ const payload = buildDashboardPayload({
269
+ projectRoot,
270
+ storage,
271
+ nowMs: 2000,
272
+ })
273
+
274
+ expect(payload.mainSession.currentModel).toBeNull()
275
+ expect(payload.backgroundTasks).toHaveLength(1)
276
+ expect(payload.backgroundTasks[0]?.lastModel).toBe("openai/gpt-4o")
277
+ expect(payload.raw).toHaveProperty("backgroundTasks.0.lastModel", "openai/gpt-4o")
278
+ } finally {
279
+ fs.rmSync(storageRoot, { recursive: true, force: true })
280
+ fs.rmSync(projectRoot, { recursive: true, force: true })
281
+ }
282
+ })
283
+
284
+ it("does not include elapsed time in status pill when status is unknown", () => {
285
+ const storageRoot = mkStorageRoot()
286
+ const storage = getStorageRoots(storageRoot)
287
+ const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omo-project-"))
288
+ const sessionId = "ses_unknown"
289
+ const projectID = "proj_1"
290
+
291
+ try {
292
+ const sessionMetaDir = path.join(storage.session, projectID)
293
+ fs.mkdirSync(sessionMetaDir, { recursive: true })
294
+ fs.writeFileSync(
295
+ path.join(sessionMetaDir, `${sessionId}.json`),
296
+ JSON.stringify({
297
+ id: sessionId,
298
+ projectID,
299
+ directory: projectRoot,
300
+ time: { created: 1000, updated: 1000 },
301
+ }),
302
+ "utf8"
303
+ )
304
+
305
+ const payload = buildDashboardPayload({
306
+ projectRoot,
307
+ storage,
308
+ nowMs: 65_000,
309
+ })
310
+
311
+ expect(payload.mainSession.statusPill).toBe("unknown")
312
+ } finally {
313
+ fs.rmSync(storageRoot, { recursive: true, force: true })
314
+ fs.rmSync(projectRoot, { recursive: true, force: true })
315
+ }
316
+ })
178
317
  })
@@ -3,9 +3,40 @@ import * as path from "node:path"
3
3
  import { readBoulderState, readPlanProgress, readPlanSteps, type PlanStep } from "../ingest/boulder"
4
4
  import { deriveBackgroundTasks } from "../ingest/background-tasks"
5
5
  import { deriveTimeSeriesActivity, type TimeSeriesPayload } from "../ingest/timeseries"
6
- import { getMainSessionView, getStorageRoots, pickActiveSessionId, readMainSessionMetas, type OpenCodeStorageRoots, type SessionMetadata } from "../ingest/session"
6
+ import { getMainSessionView, getStorageRoots, pickActiveSessionId, readMainSessionMetas, type MainSessionView, type OpenCodeStorageRoots, type SessionMetadata } from "../ingest/session"
7
7
 
8
8
  export type DashboardPayload = {
9
+ mainSession: {
10
+ agent: string
11
+ currentModel: string | null
12
+ currentTool: string
13
+ lastUpdatedLabel: string
14
+ session: string
15
+ statusPill: string
16
+ }
17
+ planProgress: {
18
+ name: string
19
+ completed: number
20
+ total: number
21
+ path: string
22
+ statusPill: string
23
+ steps: PlanStep[]
24
+ }
25
+ backgroundTasks: Array<{
26
+ id: string
27
+ description: string
28
+ agent: string
29
+ lastModel: string | null
30
+ status: string
31
+ toolCalls: number
32
+ lastTool: string
33
+ timeline: string
34
+ }>
35
+ timeSeries: TimeSeriesPayload
36
+ raw: unknown
37
+ }
38
+
39
+ export type LegacyDashboardPayload = {
9
40
  mainSession: {
10
41
  agent: string
11
42
  currentTool: string
@@ -35,7 +66,7 @@ export type DashboardPayload = {
35
66
  }
36
67
 
37
68
  export type DashboardStore = {
38
- getSnapshot: () => DashboardPayload
69
+ getSnapshot: () => DashboardPayload | LegacyDashboardPayload
39
70
  }
40
71
 
41
72
  function formatIso(ts: number | null): string {
@@ -101,10 +132,13 @@ export function buildDashboardPayload(opts: {
101
132
  mainSessionId: sessionId ?? null,
102
133
  nowMs,
103
134
  })
104
-
135
+ const mainCurrentModel = "currentModel" in main
136
+ ? (main as MainSessionView).currentModel
137
+ : null
105
138
  const payload: DashboardPayload = {
106
139
  mainSession: {
107
140
  agent: main.agent,
141
+ currentModel: mainCurrentModel,
108
142
  currentTool: main.currentTool ?? "-",
109
143
  lastUpdatedLabel: formatIso(main.lastUpdated),
110
144
  session: main.sessionLabel,
@@ -122,6 +156,7 @@ export function buildDashboardPayload(opts: {
122
156
  id: t.id,
123
157
  description: t.description,
124
158
  agent: t.agent,
159
+ lastModel: t.lastModel ?? null,
125
160
  status: t.status,
126
161
  toolCalls: t.toolCalls ?? 0,
127
162
  lastTool: t.lastTool ?? "-",
@@ -0,0 +1,261 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { computeStackedSegments, AgentCounts, StackedSegment } from "./timeseries-stacked";
3
+
4
+ describe("computeStackedSegments", () => {
5
+ describe("Edge cases", () => {
6
+ it("should return empty array when chartHeight <= 0", () => {
7
+ const counts: AgentCounts = { sisyphus: 10, prometheus: 5, atlas: 3 };
8
+
9
+ expect(computeStackedSegments(counts, 20, 0)).toEqual([]);
10
+ expect(computeStackedSegments(counts, 20, -5)).toEqual([]);
11
+ });
12
+
13
+ it("should return empty array when scaleMax <= 0", () => {
14
+ const counts: AgentCounts = { sisyphus: 10, prometheus: 5, atlas: 3 };
15
+
16
+ expect(computeStackedSegments(counts, 0, 100)).toEqual([]);
17
+ expect(computeStackedSegments(counts, -10, 100)).toEqual([]);
18
+ });
19
+
20
+ it("should return empty array when all counts are zero", () => {
21
+ const counts: AgentCounts = { sisyphus: 0, prometheus: 0, atlas: 0 };
22
+
23
+ const result = computeStackedSegments(counts, 20, 100);
24
+ expect(result).toEqual([]);
25
+ });
26
+
27
+ it("should handle invalid/missing counts gracefully", () => {
28
+ const invalidCounts = {
29
+ sisyphus: NaN,
30
+ prometheus: Infinity,
31
+ atlas: -5,
32
+ } as unknown as AgentCounts;
33
+
34
+ const result = computeStackedSegments(invalidCounts, 20, 100);
35
+ expect(result).toEqual([]);
36
+ });
37
+
38
+ it("should handle mixed valid/invalid counts", () => {
39
+ const mixedCounts = {
40
+ sisyphus: 10,
41
+ prometheus: NaN,
42
+ atlas: -3,
43
+ } as unknown as AgentCounts;
44
+
45
+ const result = computeStackedSegments(mixedCounts, 20, 100);
46
+ expect(result).toHaveLength(1);
47
+ expect(result[0]).toEqual({
48
+ tone: "teal",
49
+ y: 50,
50
+ height: 50,
51
+ });
52
+ });
53
+ });
54
+
55
+ describe("Single agent scenarios", () => {
56
+ it("should return one segment when only sisyphus is non-zero", () => {
57
+ const counts: AgentCounts = { sisyphus: 10, prometheus: 0, atlas: 0 };
58
+
59
+ const result = computeStackedSegments(counts, 20, 100);
60
+ expect(result).toHaveLength(1);
61
+ expect(result[0]).toEqual({
62
+ tone: "teal",
63
+ y: 50,
64
+ height: 50,
65
+ });
66
+ });
67
+
68
+ it("should return one segment when only prometheus is non-zero", () => {
69
+ const counts: AgentCounts = { sisyphus: 0, prometheus: 15, atlas: 0 };
70
+
71
+ const result = computeStackedSegments(counts, 30, 120);
72
+ expect(result).toHaveLength(1);
73
+ expect(result[0]).toEqual({
74
+ tone: "red",
75
+ y: 60,
76
+ height: 60,
77
+ });
78
+ });
79
+
80
+ it("should return one segment when only atlas is non-zero", () => {
81
+ const counts: AgentCounts = { sisyphus: 0, prometheus: 0, atlas: 8 };
82
+
83
+ const result = computeStackedSegments(counts, 16, 80);
84
+ expect(result).toHaveLength(1);
85
+ expect(result[0]).toEqual({
86
+ tone: "green",
87
+ y: 40,
88
+ height: 40,
89
+ });
90
+ });
91
+
92
+ it("should round to at least 1px for non-zero values", () => {
93
+ const counts: AgentCounts = { sisyphus: 1, prometheus: 0, atlas: 0 };
94
+
95
+ const result = computeStackedSegments(counts, 1000, 100);
96
+ expect(result).toHaveLength(1);
97
+ expect(result[0].height).toBeGreaterThanOrEqual(1);
98
+ });
99
+ });
100
+
101
+ describe("Multiple agent scenarios", () => {
102
+ it("should return multiple segments in correct order (bottom to top)", () => {
103
+ const counts: AgentCounts = { sisyphus: 10, prometheus: 20, atlas: 15 };
104
+
105
+ const result = computeStackedSegments(counts, 50, 100);
106
+ expect(result).toHaveLength(3);
107
+
108
+ // Check order: teal (sisyphus) -> red (prometheus) -> green (atlas)
109
+ expect(result[0].tone).toBe("teal");
110
+ expect(result[1].tone).toBe("red");
111
+ expect(result[2].tone).toBe("green");
112
+
113
+ // Check positions (y increases upward, so atlas should have smallest y)
114
+ expect(result[0].y).toBeGreaterThan(result[1].y);
115
+ expect(result[1].y).toBeGreaterThan(result[2].y);
116
+ });
117
+
118
+ it("should correctly calculate heights for all agents", () => {
119
+ const counts: AgentCounts = { sisyphus: 10, prometheus: 20, atlas: 15 };
120
+
121
+ const result = computeStackedSegments(counts, 50, 100);
122
+ const totalHeight = result.reduce((sum, seg) => sum + seg.height, 0);
123
+
124
+ expect(totalHeight).toBeLessThanOrEqual(100);
125
+
126
+ // Expected heights: 20, 40, 30
127
+ expect(result[0].height).toBe(20); // sisyphus
128
+ expect(result[1].height).toBe(40); // prometheus
129
+ expect(result[2].height).toBe(30); // atlas
130
+ });
131
+
132
+ it("should handle zero values mixed with non-zero values", () => {
133
+ const counts: AgentCounts = { sisyphus: 10, prometheus: 0, atlas: 15 };
134
+
135
+ const result = computeStackedSegments(counts, 30, 90);
136
+ expect(result).toHaveLength(2);
137
+
138
+ // Should only have teal (sisyphus) and green (atlas)
139
+ expect(result[0].tone).toBe("teal");
140
+ expect(result[1].tone).toBe("green");
141
+
142
+ // Check positioning
143
+ expect(result[0].y).toBeGreaterThan(result[1].y);
144
+ });
145
+ });
146
+
147
+ describe("Clamping and overflow behavior", () => {
148
+ it("should ensure sum of heights never exceeds chartHeight", () => {
149
+ const counts: AgentCounts = { sisyphus: 100, prometheus: 100, atlas: 100 };
150
+
151
+ const result = computeStackedSegments(counts, 100, 50); // Should overflow
152
+ const totalHeight = result.reduce((sum, seg) => sum + seg.height, 0);
153
+
154
+ expect(totalHeight).toBeLessThanOrEqual(50);
155
+ });
156
+
157
+ it("should preserve at least 1px for non-zero agents when possible", () => {
158
+ const counts: AgentCounts = { sisyphus: 1, prometheus: 1, atlas: 1 };
159
+
160
+ const result = computeStackedSegments(counts, 100, 10);
161
+
162
+ // All agents should be visible with at least 1px each
163
+ expect(result).toHaveLength(3);
164
+ expect(result.every(seg => seg.height >= 1)).toBe(true);
165
+ });
166
+
167
+ it("should distribute overflow reduction fairly", () => {
168
+ const counts: AgentCounts = { sisyphus: 40, prometheus: 35, atlas: 25 };
169
+
170
+ const result = computeStackedSegments(counts, 100, 80);
171
+ const totalHeight = result.reduce((sum, seg) => sum + seg.height, 0);
172
+
173
+ expect(totalHeight).toBeLessThanOrEqual(80);
174
+
175
+ // Larger segments should be reduced more
176
+ const heights = result.map(seg => seg.height);
177
+ expect(Math.max(...heights)).toBeLessThanOrEqual(40); // Original largest was 40
178
+ });
179
+
180
+ it("should handle extreme overflow gracefully", () => {
181
+ const counts: AgentCounts = { sisyphus: 1000, prometheus: 1000, atlas: 1000 };
182
+
183
+ const result = computeStackedSegments(counts, 100, 5);
184
+ const totalHeight = result.reduce((sum, seg) => sum + seg.height, 0);
185
+
186
+ expect(totalHeight).toBeLessThanOrEqual(5);
187
+ // Should still try to show all agents
188
+ expect(result.length).toBeGreaterThanOrEqual(1);
189
+ });
190
+ });
191
+
192
+ describe("Deterministic behavior", () => {
193
+ it("should produce identical results for identical inputs", () => {
194
+ const counts: AgentCounts = { sisyphus: 15, prometheus: 25, atlas: 10 };
195
+
196
+ const result1 = computeStackedSegments(counts, 60, 100);
197
+ const result2 = computeStackedSegments(counts, 60, 100);
198
+
199
+ expect(result1).toEqual(result2);
200
+ });
201
+
202
+ it("should maintain consistent segment order regardless of input magnitudes", () => {
203
+ const testCases = [
204
+ { sisyphus: 100, prometheus: 1, atlas: 1 },
205
+ { sisyphus: 1, prometheus: 100, atlas: 1 },
206
+ { sisyphus: 1, prometheus: 1, atlas: 100 },
207
+ { sisyphus: 50, prometheus: 25, atlas: 75 },
208
+ ] as AgentCounts[];
209
+
210
+ testCases.forEach(counts => {
211
+ const result = computeStackedSegments(counts, 100, 100);
212
+ const tones = result.map(seg => seg.tone);
213
+
214
+ if (result.length === 3) {
215
+ expect(tones).toEqual(["teal", "red", "green"]);
216
+ } else if (result.length === 2) {
217
+ // Should still be in correct order, just missing zero-valued agents
218
+ expect(tones).toEqual(expect.arrayContaining([
219
+ expect.stringMatching(/teal|red|green/)
220
+ ]));
221
+ // Check that order is preserved for present agents
222
+ const toneIndex = (t: string) => ["teal", "red", "green"].indexOf(t);
223
+ for (let i = 1; i < tones.length; i++) {
224
+ expect(toneIndex(tones[i])).toBeGreaterThan(toneIndex(tones[i-1]));
225
+ }
226
+ }
227
+ });
228
+ });
229
+ });
230
+
231
+ describe("Boundary conditions", () => {
232
+ it("should handle very small chartHeight", () => {
233
+ const counts: AgentCounts = { sisyphus: 10, prometheus: 5, atlas: 3 };
234
+
235
+ const result = computeStackedSegments(counts, 20, 1);
236
+ const totalHeight = result.reduce((sum, seg) => sum + seg.height, 0);
237
+
238
+ expect(totalHeight).toBeLessThanOrEqual(1);
239
+ });
240
+
241
+ it("should handle very large scaleMax", () => {
242
+ const counts: AgentCounts = { sisyphus: 10, prometheus: 5, atlas: 3 };
243
+
244
+ const result = computeStackedSegments(counts, 1000000, 100);
245
+
246
+ // Should produce very small but non-zero heights
247
+ expect(result.length).toBeGreaterThan(0);
248
+ expect(result.every(seg => seg.height >= 1)).toBe(true);
249
+ });
250
+
251
+ it("should handle fractional results correctly", () => {
252
+ const counts: AgentCounts = { sisyphus: 1, prometheus: 1, atlas: 1 };
253
+
254
+ const result = computeStackedSegments(counts, 3, 10);
255
+
256
+ // All heights should be integers
257
+ expect(result.every(seg => Number.isInteger(seg.height))).toBe(true);
258
+ expect(result.every(seg => Number.isInteger(seg.y))).toBe(true);
259
+ });
260
+ });
261
+ });