@dxos/react-ui-trace 0.11.1 → 0.13.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 +1 -1
- package/dist/lib/index.mjs +1722 -561
- package/dist/lib/index.mjs.map +1 -1
- package/dist/lib/testing.mjs +3 -3
- package/dist/lib/testing.mjs.map +1 -1
- package/dist/lib/translations.mjs +5 -0
- package/dist/lib/translations.mjs.map +1 -1
- package/dist/types/src/components/Gantt/Gantt.d.ts +109 -24
- package/dist/types/src/components/Gantt/Gantt.d.ts.map +1 -1
- package/dist/types/src/components/Gantt/Gantt.stories.d.ts +73 -3
- package/dist/types/src/components/Gantt/Gantt.stories.d.ts.map +1 -1
- package/dist/types/src/components/Gantt/gantt-rows.d.ts +26 -0
- package/dist/types/src/components/Gantt/gantt-rows.d.ts.map +1 -0
- package/dist/types/src/components/Gantt/gantt-rows.test.d.ts +2 -0
- package/dist/types/src/components/Gantt/gantt-rows.test.d.ts.map +1 -0
- package/dist/types/src/components/Gantt/gantt-scale.d.ts +57 -0
- package/dist/types/src/components/Gantt/gantt-scale.d.ts.map +1 -0
- package/dist/types/src/components/Gantt/gantt-scale.test.d.ts +2 -0
- package/dist/types/src/components/Gantt/gantt-scale.test.d.ts.map +1 -0
- package/dist/types/src/components/Gantt/index.d.ts +2 -0
- package/dist/types/src/components/Gantt/index.d.ts.map +1 -1
- package/dist/types/src/components/Gantt/useEnter.d.ts +13 -0
- package/dist/types/src/components/Gantt/useEnter.d.ts.map +1 -0
- package/dist/types/src/components/Gantt/useRangeTween.d.ts +12 -0
- package/dist/types/src/components/Gantt/useRangeTween.d.ts.map +1 -0
- package/dist/types/src/components/ProcessTree/ProcessTree.d.ts +3 -3
- package/dist/types/src/components/ProcessTree/ProcessTree.d.ts.map +1 -1
- package/dist/types/src/components/ProcessTree/ProcessTree.stories.d.ts.map +1 -1
- package/dist/types/src/components/Timeline/Timeline.d.ts +10 -2
- package/dist/types/src/components/Timeline/Timeline.d.ts.map +1 -1
- package/dist/types/src/components/Timeline/Timeline.stories.d.ts +8 -4
- package/dist/types/src/components/Timeline/Timeline.stories.d.ts.map +1 -1
- package/dist/types/src/components/Timeline/timeline-layout.d.ts +35 -0
- package/dist/types/src/components/Timeline/timeline-layout.d.ts.map +1 -0
- package/dist/types/src/components/Timeline/timeline-layout.test.d.ts +2 -0
- package/dist/types/src/components/Timeline/timeline-layout.test.d.ts.map +1 -0
- package/dist/types/src/components/TracePanel/SpanTreeView.d.ts +15 -0
- package/dist/types/src/components/TracePanel/SpanTreeView.d.ts.map +1 -0
- package/dist/types/src/components/TracePanel/TracePanel.d.ts +3 -3
- package/dist/types/src/components/TracePanel/TracePanel.d.ts.map +1 -1
- package/dist/types/src/components/TracePanel/TracePanel.stories.d.ts +46 -0
- package/dist/types/src/components/TracePanel/TracePanel.stories.d.ts.map +1 -0
- package/dist/types/src/components/TracePanel/trace-filter.d.ts +7 -2
- package/dist/types/src/components/TracePanel/trace-filter.d.ts.map +1 -1
- package/dist/types/src/components/TracePanel/useTraceMenu.d.ts.map +1 -1
- package/dist/types/src/execution-graph/execution-graph.d.ts +1 -1
- package/dist/types/src/execution-graph/execution-graph.d.ts.map +1 -1
- package/dist/types/src/hooks/useExecutionGraph.d.ts +2 -2
- package/dist/types/src/hooks/useExecutionGraph.d.ts.map +1 -1
- package/dist/types/src/hooks/useSessionTimeline.d.ts +1 -1
- package/dist/types/src/hooks/useSessionTimeline.d.ts.map +1 -1
- package/dist/types/src/hooks/useTraceMessages.d.ts +1 -1
- package/dist/types/src/hooks/useTraceMessages.d.ts.map +1 -1
- package/dist/types/src/session-timeline/TaskHistory.stories.d.ts +12 -0
- package/dist/types/src/session-timeline/TaskHistory.stories.d.ts.map +1 -0
- package/dist/types/src/session-timeline/gantt-mapping.d.ts +14 -0
- package/dist/types/src/session-timeline/gantt-mapping.d.ts.map +1 -0
- package/dist/types/src/session-timeline/gantt-mapping.test.d.ts +2 -0
- package/dist/types/src/session-timeline/gantt-mapping.test.d.ts.map +1 -0
- package/dist/types/src/session-timeline/index.d.ts +1 -0
- package/dist/types/src/session-timeline/index.d.ts.map +1 -1
- package/dist/types/src/session-timeline/session-timeline.d.ts +17 -2
- package/dist/types/src/session-timeline/session-timeline.d.ts.map +1 -1
- package/dist/types/src/session-timeline/types.d.ts +32 -0
- package/dist/types/src/session-timeline/types.d.ts.map +1 -1
- package/dist/types/src/testing/simulated-agent.d.ts +3 -2
- package/dist/types/src/testing/simulated-agent.d.ts.map +1 -1
- package/dist/types/src/testing/test-trace.d.ts +2 -2
- package/dist/types/src/testing/test-trace.d.ts.map +1 -1
- package/dist/types/src/translations.d.ts +5 -0
- package/dist/types/src/translations.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +30 -26
- package/src/components/Gantt/Gantt.stories.tsx +914 -96
- package/src/components/Gantt/Gantt.tsx +964 -261
- package/src/components/Gantt/gantt-rows.test.ts +123 -0
- package/src/components/Gantt/gantt-rows.ts +71 -0
- package/src/components/Gantt/gantt-scale.test.ts +105 -0
- package/src/components/Gantt/gantt-scale.ts +143 -0
- package/src/components/Gantt/index.ts +2 -0
- package/src/components/Gantt/useEnter.ts +33 -0
- package/src/components/Gantt/useRangeTween.ts +61 -0
- package/src/components/ProcessTree/ProcessTree.stories.tsx +2 -3
- package/src/components/ProcessTree/ProcessTree.tsx +99 -90
- package/src/components/Timeline/Timeline.stories.tsx +110 -22
- package/src/components/Timeline/Timeline.tsx +319 -271
- package/src/components/Timeline/timeline-layout.test.ts +177 -0
- package/src/components/Timeline/timeline-layout.ts +153 -0
- package/src/components/TracePanel/SpanTreeView.tsx +39 -0
- package/src/components/TracePanel/TracePanel.stories.tsx +106 -0
- package/src/components/TracePanel/TracePanel.tsx +113 -106
- package/src/components/TracePanel/trace-filter.test.ts +31 -2
- package/src/components/TracePanel/trace-filter.ts +28 -3
- package/src/components/TracePanel/useTraceMenu.ts +0 -1
- package/src/execution-graph/SPEC.md +10 -10
- package/src/execution-graph/execution-graph.test.ts +5 -6
- package/src/execution-graph/execution-graph.ts +108 -60
- package/src/execution-graph/pending-block-status.ts +1 -1
- package/src/execution-graph/sub-agent-delegation.test.ts +1 -1
- package/src/hooks/useExecutionGraph.ts +14 -11
- package/src/hooks/useSessionTimeline.ts +35 -9
- package/src/hooks/useTraceMessages.ts +1 -1
- package/src/session-timeline/TaskHistory.stories.tsx +142 -0
- package/src/session-timeline/gantt-mapping.test.ts +118 -0
- package/src/session-timeline/gantt-mapping.ts +158 -0
- package/src/session-timeline/index.ts +1 -0
- package/src/session-timeline/session-timeline.test.ts +567 -19
- package/src/session-timeline/session-timeline.ts +433 -62
- package/src/session-timeline/types.ts +14 -0
- package/src/testing/simulated-agent.ts +3 -2
- package/src/testing/test-trace.ts +2 -2
- package/src/translations.ts +8 -2
- package/dist/types/src/execution-graph/remote-snapshot.test.d.ts +0 -2
- package/dist/types/src/execution-graph/remote-snapshot.test.d.ts.map +0 -1
- package/src/execution-graph/remote-snapshot.test.ts +0 -116
|
@@ -10,12 +10,14 @@ import { describe, test } from 'vitest';
|
|
|
10
10
|
import * as Process from '@dxos/compute/Process';
|
|
11
11
|
import { TestTraceService } from '@dxos/compute/testing';
|
|
12
12
|
import * as Trace from '@dxos/compute/Trace';
|
|
13
|
-
import { Annotation, Feed, Obj, Ref } from '@dxos/echo';
|
|
13
|
+
import { Annotation, Database, Feed, Obj, Ref } from '@dxos/echo';
|
|
14
|
+
import { TestDatabaseLayer } from '@dxos/echo-client/testing';
|
|
14
15
|
import { EID, EntityId, URI } from '@dxos/keys';
|
|
15
|
-
import { Task } from '@dxos/types';
|
|
16
|
+
import { Milestone, Task } from '@dxos/types';
|
|
17
|
+
import { getHashHue } from '@dxos/ui-theme';
|
|
16
18
|
|
|
17
19
|
import subAgentFixture from '../execution-graph/testing/sub-agent-delegation.json';
|
|
18
|
-
import { buildSessionTimeline } from './session-timeline.ts';
|
|
20
|
+
import { buildSessionTimeline, readTaskStatusChanges } from './session-timeline.ts';
|
|
19
21
|
import { type Session } from './types.ts';
|
|
20
22
|
|
|
21
23
|
/** The plain-JSON shape the fixture was captured in, before being replayed as real trace messages. */
|
|
@@ -257,7 +259,11 @@ describe('buildSessionTimeline', () => {
|
|
|
257
259
|
);
|
|
258
260
|
|
|
259
261
|
const messages = yield* TestTraceService.messages;
|
|
260
|
-
const timeline = buildSessionTimeline({
|
|
262
|
+
const timeline = buildSessionTimeline({
|
|
263
|
+
traceMessages: messages,
|
|
264
|
+
sessions: [chat.session],
|
|
265
|
+
tasks: [first, second],
|
|
266
|
+
});
|
|
261
267
|
const firstLane = timeline.lanes.find((lane) => lane.id === `task:${first.id}`);
|
|
262
268
|
const secondLane = timeline.lanes.find((lane) => lane.id === `task:${second.id}`);
|
|
263
269
|
expect(firstLane).toMatchObject({ start: 1, end: 3 });
|
|
@@ -295,7 +301,11 @@ describe('buildSessionTimeline', () => {
|
|
|
295
301
|
);
|
|
296
302
|
|
|
297
303
|
const messages = yield* TestTraceService.messages;
|
|
298
|
-
const timeline = buildSessionTimeline({
|
|
304
|
+
const timeline = buildSessionTimeline({
|
|
305
|
+
traceMessages: messages,
|
|
306
|
+
sessions: [chat.session],
|
|
307
|
+
tasks: [mine, other, later],
|
|
308
|
+
});
|
|
299
309
|
const laneId = `task:${mine.id}`;
|
|
300
310
|
expect(timeline.lanes.find((lane) => lane.id === laneId)).toMatchObject({ start: 2, end: 6 });
|
|
301
311
|
const lanesByLabel = new Map(timeline.markers.map((marker) => [marker.label, marker.laneId]));
|
|
@@ -305,7 +315,7 @@ describe('buildSessionTimeline', () => {
|
|
|
305
315
|
);
|
|
306
316
|
|
|
307
317
|
it.effect(
|
|
308
|
-
|
|
318
|
+
'a task started over an open one runs alongside it, and work while both are open stays with the session',
|
|
309
319
|
Effect.fnUntraced(function* ({ expect }) {
|
|
310
320
|
const first = Task.make({ title: 'First', status: 'started' });
|
|
311
321
|
const second = Task.make({ title: 'Second', status: 'started' });
|
|
@@ -316,25 +326,35 @@ describe('buildSessionTimeline', () => {
|
|
|
316
326
|
yield* Trace.write(Trace.AgentRequestBegin, {}); // 1.
|
|
317
327
|
yield* Trace.write(Trace.TaskStatusChanged, { taskId: first.id, title: 'First', status: 'started' }); // 2.
|
|
318
328
|
yield* toolCall('Read file'); // 3.
|
|
319
|
-
// No close for the first:
|
|
329
|
+
// No close for the first: the agent holds both.
|
|
320
330
|
yield* Trace.write(Trace.TaskStatusChanged, { taskId: second.id, title: 'Second', status: 'started' }); // 4.
|
|
321
331
|
yield* toolCall('Write file'); // 5.
|
|
322
332
|
}),
|
|
323
333
|
);
|
|
324
334
|
|
|
325
335
|
const messages = yield* TestTraceService.messages;
|
|
326
|
-
const timeline = buildSessionTimeline({
|
|
327
|
-
|
|
336
|
+
const timeline = buildSessionTimeline({
|
|
337
|
+
traceMessages: messages,
|
|
338
|
+
sessions: [chat.session],
|
|
339
|
+
tasks: [first, second],
|
|
340
|
+
});
|
|
341
|
+
expect(timeline.lanes.find((lane) => lane.id === `task:${first.id}`)).toMatchObject({
|
|
342
|
+
start: 2,
|
|
343
|
+
end: undefined,
|
|
344
|
+
});
|
|
328
345
|
expect(timeline.lanes.find((lane) => lane.id === `task:${second.id}`)).toMatchObject({
|
|
329
346
|
start: 4,
|
|
330
347
|
end: undefined,
|
|
331
348
|
});
|
|
332
|
-
// The two segments share the boundary at 4; the second task's own start node sits on its lane.
|
|
333
349
|
const taskMarkers = timeline.markers.filter((marker) => marker.kind === 'task');
|
|
334
350
|
expect(taskMarkers.map(({ laneId, timestamp }) => ({ laneId, timestamp }))).toEqual([
|
|
335
351
|
{ laneId: `task:${first.id}`, timestamp: 2 },
|
|
336
352
|
{ laneId: `task:${second.id}`, timestamp: 4 },
|
|
337
353
|
]);
|
|
354
|
+
// One task open: its work. Two open: the session's, since it cannot be told which it was for.
|
|
355
|
+
const lanesByLabel = new Map(timeline.markers.map((marker) => [marker.label, marker.laneId]));
|
|
356
|
+
expect(lanesByLabel.get('Read file')).toBe(`task:${first.id}`);
|
|
357
|
+
expect(lanesByLabel.get('Write file')).toBe(`session:${chat.session.id}`);
|
|
338
358
|
}, Effect.provide(TestTraceService.layer)),
|
|
339
359
|
);
|
|
340
360
|
|
|
@@ -361,6 +381,109 @@ describe('buildSessionTimeline', () => {
|
|
|
361
381
|
}, Effect.provide(TestTraceService.layer)),
|
|
362
382
|
);
|
|
363
383
|
|
|
384
|
+
it.effect(
|
|
385
|
+
'a folded lane begins no later than its task, and a traced move takes the trace instant over the history second',
|
|
386
|
+
Effect.fnUntraced(function* ({ expect }) {
|
|
387
|
+
const task = Task.make({ title: 'Only', status: 'started' });
|
|
388
|
+
const chat = makeChat('Only', [task]);
|
|
389
|
+
yield* TestTraceService.withMeta(
|
|
390
|
+
{ pid: 'agent', conversation: chat.feed },
|
|
391
|
+
Effect.gen(function* () {
|
|
392
|
+
yield* Trace.write(Trace.AgentRequestBegin, {}); // 1.
|
|
393
|
+
yield* Trace.write(Trace.TaskStatusChanged, { taskId: task.id, title: 'Only', status: 'started' }); // 2.
|
|
394
|
+
}),
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
const messages = yield* TestTraceService.messages;
|
|
398
|
+
const timeline = buildSessionTimeline({
|
|
399
|
+
traceMessages: messages,
|
|
400
|
+
sessions: [chat.session],
|
|
401
|
+
tasks: [task],
|
|
402
|
+
// Delegation started the task before the first request, recorded at whole-second precision.
|
|
403
|
+
taskStatusChanges: new Map([[task.id, [{ timestamp: 0, status: 'started', previousStatus: 'todo' }]]]),
|
|
404
|
+
});
|
|
405
|
+
expect(timeline.lanes).toHaveLength(1);
|
|
406
|
+
// The history's rounded-down second is replaced by the traced instant, so the lane begins at the
|
|
407
|
+
// session's first request (1) rather than before any node.
|
|
408
|
+
expect(timeline.lanes[0]).toMatchObject({ kind: 'session', taskId: task.id, start: 1 });
|
|
409
|
+
const started = timeline.markers.find((marker) => marker.label === 'Task started');
|
|
410
|
+
expect(started?.timestamp).toBe(2);
|
|
411
|
+
}, Effect.provide(TestTraceService.layer)),
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
it.effect(
|
|
415
|
+
'a task lane starts at its first node, not at the rounded-down second its history records',
|
|
416
|
+
Effect.fnUntraced(function* ({ expect }) {
|
|
417
|
+
const first = Task.make({ title: 'First', status: 'started' });
|
|
418
|
+
const second = Task.make({ title: 'Second', status: 'todo' });
|
|
419
|
+
const chat = makeChat('Precise', [first, second]);
|
|
420
|
+
yield* TestTraceService.withMeta(
|
|
421
|
+
{ pid: 'agent', conversation: chat.feed },
|
|
422
|
+
Effect.gen(function* () {
|
|
423
|
+
yield* Trace.write(Trace.AgentRequestBegin, {}); // 1.
|
|
424
|
+
yield* Trace.write(Trace.TaskStatusChanged, { taskId: first.id, title: 'First', status: 'started' }); // 2.
|
|
425
|
+
}),
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
const messages = yield* TestTraceService.messages;
|
|
429
|
+
const timeline = buildSessionTimeline({
|
|
430
|
+
traceMessages: messages,
|
|
431
|
+
sessions: [chat.session],
|
|
432
|
+
tasks: [first, second],
|
|
433
|
+
taskStatusChanges: new Map([[first.id, [{ timestamp: 0, status: 'started', previousStatus: 'todo' }]]]),
|
|
434
|
+
});
|
|
435
|
+
const lane = timeline.lanes.find((lane) => lane.id === `task:${first.id}`);
|
|
436
|
+
const node = timeline.markers.find((marker) => marker.laneId === lane?.id);
|
|
437
|
+
expect(lane?.start).toBe(2);
|
|
438
|
+
expect(node?.timestamp).toBe(lane?.start);
|
|
439
|
+
}, Effect.provide(TestTraceService.layer)),
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
it.effect(
|
|
443
|
+
"a parent task's lane carries each sub-task's start and finish",
|
|
444
|
+
Effect.fnUntraced(function* ({ expect }) {
|
|
445
|
+
const parent = Task.make({ title: 'Parent', status: 'started' });
|
|
446
|
+
const child = Task.make({ [Obj.Parent]: parent, title: 'Child', status: 'done' });
|
|
447
|
+
const chat = makeChat('Tree', [parent, child]);
|
|
448
|
+
yield* TestTraceService.withMeta(
|
|
449
|
+
{ pid: 'agent', conversation: chat.feed },
|
|
450
|
+
Effect.gen(function* () {
|
|
451
|
+
yield* Trace.write(Trace.AgentRequestBegin, {}); // 1.
|
|
452
|
+
yield* Trace.write(Trace.TaskStatusChanged, { taskId: child.id, title: 'Child', status: 'started' }); // 2.
|
|
453
|
+
yield* Trace.write(Trace.TaskStatusChanged, {
|
|
454
|
+
taskId: child.id,
|
|
455
|
+
title: 'Child',
|
|
456
|
+
status: 'done',
|
|
457
|
+
previousStatus: 'started',
|
|
458
|
+
}); // 3.
|
|
459
|
+
}),
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
const messages = yield* TestTraceService.messages;
|
|
463
|
+
const timeline = buildSessionTimeline({
|
|
464
|
+
traceMessages: messages,
|
|
465
|
+
sessions: [chat.session],
|
|
466
|
+
tasks: [parent, child],
|
|
467
|
+
taskStatusChanges: new Map([
|
|
468
|
+
[
|
|
469
|
+
child.id,
|
|
470
|
+
[
|
|
471
|
+
{ timestamp: 0, status: 'started', previousStatus: 'todo' },
|
|
472
|
+
{ timestamp: 0, status: 'done', previousStatus: 'started' },
|
|
473
|
+
],
|
|
474
|
+
],
|
|
475
|
+
]),
|
|
476
|
+
});
|
|
477
|
+
const onParent = timeline.markers
|
|
478
|
+
.filter((marker) => marker.laneId === `task:${parent.id}`)
|
|
479
|
+
.map(({ label, timestamp }) => ({ label, timestamp }));
|
|
480
|
+
expect(onParent).toEqual([
|
|
481
|
+
{ label: 'Child: started', timestamp: 2 },
|
|
482
|
+
{ label: 'Child: done', timestamp: 3 },
|
|
483
|
+
]);
|
|
484
|
+
}, Effect.provide(TestTraceService.layer)),
|
|
485
|
+
);
|
|
486
|
+
|
|
364
487
|
it.effect(
|
|
365
488
|
'a run whose process ended without a request end closes at its last event, and the axis stops following now',
|
|
366
489
|
Effect.fnUntraced(function* ({ expect }) {
|
|
@@ -419,7 +542,11 @@ describe('buildSessionTimeline', () => {
|
|
|
419
542
|
);
|
|
420
543
|
|
|
421
544
|
const messages = yield* TestTraceService.messages;
|
|
422
|
-
const timeline = buildSessionTimeline({
|
|
545
|
+
const timeline = buildSessionTimeline({
|
|
546
|
+
traceMessages: messages,
|
|
547
|
+
sessions: [chat.session],
|
|
548
|
+
tasks: [task, later],
|
|
549
|
+
});
|
|
423
550
|
expect(timeline.lanes.find((lane) => lane.id === `task:${task.id}`)).toMatchObject({ start: 2, end: 4 });
|
|
424
551
|
const lanesByLabel = new Map(timeline.markers.map((marker) => [marker.label, marker.laneId]));
|
|
425
552
|
expect(lanesByLabel.get('Read file')).toBe(`task:${task.id}`);
|
|
@@ -464,7 +591,11 @@ describe('buildSessionTimeline', () => {
|
|
|
464
591
|
);
|
|
465
592
|
|
|
466
593
|
const messages = yield* TestTraceService.messages;
|
|
467
|
-
const timeline = buildSessionTimeline({
|
|
594
|
+
const timeline = buildSessionTimeline({
|
|
595
|
+
traceMessages: messages,
|
|
596
|
+
sessions: [chat.session],
|
|
597
|
+
tasks: [dismissed, worked],
|
|
598
|
+
});
|
|
468
599
|
expect(timeline.lanes.find((lane) => lane.id === `task:${worked.id}`)).toMatchObject({ start: 3, end: 4 });
|
|
469
600
|
expect(timeline.lanes.find((lane) => lane.id === `task:${dismissed.id}`)).toMatchObject({
|
|
470
601
|
start: 5,
|
|
@@ -476,7 +607,7 @@ describe('buildSessionTimeline', () => {
|
|
|
476
607
|
);
|
|
477
608
|
|
|
478
609
|
it.effect(
|
|
479
|
-
'a close arriving after the next task started
|
|
610
|
+
'a close arriving after the next task started ends the first where it was closed',
|
|
480
611
|
Effect.fnUntraced(function* ({ expect }) {
|
|
481
612
|
const first = Task.make({ title: 'First', status: 'done' });
|
|
482
613
|
const second = Task.make({ title: 'Second', status: 'started' });
|
|
@@ -488,8 +619,8 @@ describe('buildSessionTimeline', () => {
|
|
|
488
619
|
yield* Trace.write(Trace.TaskStatusChanged, { taskId: first.id, title: 'First', status: 'started' }); // 2.
|
|
489
620
|
yield* Trace.write(Trace.TaskStatusChanged, { taskId: second.id, title: 'Second', status: 'started' }); // 3.
|
|
490
621
|
yield* toolCall('Write file'); // 4.
|
|
491
|
-
// The first task's close lands after the second one is under way
|
|
492
|
-
//
|
|
622
|
+
// The first task's close lands after the second one is under way: both were in progress
|
|
623
|
+
// until then.
|
|
493
624
|
yield* Trace.write(Trace.TaskStatusChanged, {
|
|
494
625
|
taskId: first.id,
|
|
495
626
|
title: 'First',
|
|
@@ -501,14 +632,18 @@ describe('buildSessionTimeline', () => {
|
|
|
501
632
|
);
|
|
502
633
|
|
|
503
634
|
const messages = yield* TestTraceService.messages;
|
|
504
|
-
const timeline = buildSessionTimeline({
|
|
505
|
-
|
|
635
|
+
const timeline = buildSessionTimeline({
|
|
636
|
+
traceMessages: messages,
|
|
637
|
+
sessions: [chat.session],
|
|
638
|
+
tasks: [first, second],
|
|
639
|
+
});
|
|
640
|
+
expect(timeline.lanes.find((lane) => lane.id === `task:${first.id}`)).toMatchObject({ start: 2, end: 5 });
|
|
506
641
|
expect(timeline.lanes.find((lane) => lane.id === `task:${second.id}`)).toMatchObject({
|
|
507
642
|
start: 3,
|
|
508
643
|
end: undefined,
|
|
509
644
|
});
|
|
510
645
|
const lanesByLabel = new Map(timeline.markers.map((marker) => [marker.label, marker.laneId]));
|
|
511
|
-
expect(lanesByLabel.get('Write file')).toBe(`
|
|
646
|
+
expect(lanesByLabel.get('Write file')).toBe(`session:${chat.session.id}`);
|
|
512
647
|
expect(lanesByLabel.get('Read file')).toBe(`task:${second.id}`);
|
|
513
648
|
}, Effect.provide(TestTraceService.layer)),
|
|
514
649
|
);
|
|
@@ -603,9 +738,419 @@ describe('buildSessionTimeline', () => {
|
|
|
603
738
|
expect(subSession?.delegatedFrom).toEqual({ laneId: session?.id, markerId: spawn?.id });
|
|
604
739
|
}, Effect.provide(TestTraceService.layer)),
|
|
605
740
|
);
|
|
741
|
+
it.effect(
|
|
742
|
+
'pairs a sub-agent with the node its result returned into',
|
|
743
|
+
Effect.fnUntraced(function* ({ expect }) {
|
|
744
|
+
const task = Task.make({ title: 'Compute', status: 'done' });
|
|
745
|
+
const chat = makeChat('Return', [task]);
|
|
746
|
+
yield* TestTraceService.withMeta(
|
|
747
|
+
{ pid: 'agent', conversation: chat.feed },
|
|
748
|
+
Effect.gen(function* () {
|
|
749
|
+
yield* Trace.write(Trace.AgentRequestBegin, {});
|
|
750
|
+
yield* Trace.write(Trace.DelegationSpawned, { taskId: task.id, pid: 'sub' });
|
|
751
|
+
yield* TestTraceService.withMeta(
|
|
752
|
+
{ pid: 'sub', parentPid: 'agent' },
|
|
753
|
+
Effect.gen(function* () {
|
|
754
|
+
yield* Trace.write(Trace.OperationStart, { key: 'run', name: 'Run Instructions' });
|
|
755
|
+
yield* Trace.write(Trace.OperationEnd, { key: 'run', outcome: 'success' });
|
|
756
|
+
}),
|
|
757
|
+
);
|
|
758
|
+
yield* Trace.write(Trace.DelegationCompleted, {
|
|
759
|
+
taskId: task.id,
|
|
760
|
+
pid: 'sub',
|
|
761
|
+
status: 'success',
|
|
762
|
+
result: '3628800',
|
|
763
|
+
});
|
|
764
|
+
yield* Trace.write(Trace.AgentRequestEnd, { status: 'success' });
|
|
765
|
+
}),
|
|
766
|
+
);
|
|
767
|
+
|
|
768
|
+
const messages = yield* TestTraceService.messages;
|
|
769
|
+
const timeline = buildSessionTimeline({ traceMessages: messages, sessions: [chat.session], tasks: [task] });
|
|
770
|
+
const session = timeline.lanes.find((lane) => lane.id === `session:${chat.id}`);
|
|
771
|
+
const subSession = timeline.lanes.find((lane) => lane.id === 'session:sub');
|
|
772
|
+
|
|
773
|
+
// Both edges land on the supervisor's lane, on different nodes: handing the work over and
|
|
774
|
+
// hearing back are separate moments, and the chart draws them as separate connectors.
|
|
775
|
+
const [spawn, returned] = timeline.markers.filter((marker) => marker.kind === 'delegation');
|
|
776
|
+
expect(returned?.laneId).toBe(session?.id);
|
|
777
|
+
expect(returned?.label).toBe('Returned: 3628800');
|
|
778
|
+
expect(subSession?.delegatedFrom).toEqual({ laneId: session?.id, markerId: spawn?.id });
|
|
779
|
+
expect(subSession?.returnedTo).toEqual({ laneId: session?.id, markerId: returned?.id });
|
|
780
|
+
}, Effect.provide(TestTraceService.layer)),
|
|
781
|
+
);
|
|
782
|
+
|
|
783
|
+
it.effect(
|
|
784
|
+
'leaves a sub-agent that never reported back with no return edge',
|
|
785
|
+
Effect.fnUntraced(function* ({ expect }) {
|
|
786
|
+
// Deliberately not inferred from the supervisor's next node: a child can end without ever
|
|
787
|
+
// answering, and guessing would draw a causal edge that does not exist.
|
|
788
|
+
const task = Task.make({ title: 'Compute', status: 'done' });
|
|
789
|
+
const chat = makeChat('No return', [task]);
|
|
790
|
+
yield* TestTraceService.withMeta(
|
|
791
|
+
{ pid: 'agent', conversation: chat.feed },
|
|
792
|
+
Effect.gen(function* () {
|
|
793
|
+
yield* Trace.write(Trace.AgentRequestBegin, {});
|
|
794
|
+
yield* Trace.write(Trace.DelegationSpawned, { taskId: task.id, pid: 'sub' });
|
|
795
|
+
yield* TestTraceService.withMeta(
|
|
796
|
+
{ pid: 'sub', parentPid: 'agent' },
|
|
797
|
+
Effect.gen(function* () {
|
|
798
|
+
yield* Trace.write(Trace.OperationStart, { key: 'run', name: 'Run Instructions' });
|
|
799
|
+
yield* Trace.write(Trace.OperationEnd, { key: 'run', outcome: 'success' });
|
|
800
|
+
}),
|
|
801
|
+
);
|
|
802
|
+
yield* Trace.write(Trace.AgentRequestEnd, { status: 'success' });
|
|
803
|
+
}),
|
|
804
|
+
);
|
|
805
|
+
|
|
806
|
+
const messages = yield* TestTraceService.messages;
|
|
807
|
+
const timeline = buildSessionTimeline({ traceMessages: messages, sessions: [chat.session], tasks: [task] });
|
|
808
|
+
const subSession = timeline.lanes.find((lane) => lane.id === 'session:sub');
|
|
809
|
+
expect(subSession?.delegatedFrom).toBeDefined();
|
|
810
|
+
expect(subSession?.returnedTo).toBeUndefined();
|
|
811
|
+
}, Effect.provide(TestTraceService.layer)),
|
|
812
|
+
);
|
|
813
|
+
|
|
814
|
+
test('bounds task lanes and threads their nodes from the edit history alone', ({ expect }) => {
|
|
815
|
+
const first = Task.make({ title: 'First', status: 'done' });
|
|
816
|
+
const second = Task.make({ title: 'Second', status: 'started' });
|
|
817
|
+
Task.ask(first, { text: 'Which schema?', date: at(1_500) });
|
|
818
|
+
const chat = makeChat('History', [first, second]);
|
|
819
|
+
|
|
820
|
+
const timeline = buildSessionTimeline({
|
|
821
|
+
traceMessages: [],
|
|
822
|
+
sessions: [chat.session],
|
|
823
|
+
tasks: [first, second],
|
|
824
|
+
taskStatusChanges: new Map([
|
|
825
|
+
[
|
|
826
|
+
first.id,
|
|
827
|
+
[
|
|
828
|
+
{ timestamp: 1_000, status: 'started', previousStatus: 'todo' },
|
|
829
|
+
{ timestamp: 2_000, status: 'done', previousStatus: 'started' },
|
|
830
|
+
],
|
|
831
|
+
],
|
|
832
|
+
[second.id, [{ timestamp: 3_000, status: 'started', previousStatus: 'todo' }]],
|
|
833
|
+
]),
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
const firstLane = timeline.lanes.find((lane) => lane.id === `task:${first.id}`);
|
|
837
|
+
const secondLane = timeline.lanes.find((lane) => lane.id === `task:${second.id}`);
|
|
838
|
+
expect(firstLane).toMatchObject({ status: 'done', start: 1_000, end: 2_000 });
|
|
839
|
+
expect(secondLane).toMatchObject({ status: 'running', start: 3_000, end: undefined });
|
|
840
|
+
expect(
|
|
841
|
+
timeline.markers
|
|
842
|
+
.filter((marker) => marker.laneId === firstLane?.id)
|
|
843
|
+
.toSorted((a, b) => a.timestamp - b.timestamp)
|
|
844
|
+
.map(({ label, timestamp, level }) => ({ label, timestamp, level })),
|
|
845
|
+
).toEqual([
|
|
846
|
+
{ label: 'Task started', timestamp: 1_000, level: undefined },
|
|
847
|
+
{ label: 'Which schema?', timestamp: 1_500, level: 'warn' },
|
|
848
|
+
{ label: 'Task done', timestamp: 2_000, level: undefined },
|
|
849
|
+
]);
|
|
850
|
+
expect(timeline.range).toEqual({ start: 1_000, end: 3_000 });
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
test('draws no node for the wait before a task was first picked up', ({ expect }) => {
|
|
854
|
+
const task = Task.make({ title: 'Queued', status: 'todo' });
|
|
855
|
+
const chat = makeChat('Queue', [task]);
|
|
856
|
+
const timeline = buildSessionTimeline({
|
|
857
|
+
traceMessages: [],
|
|
858
|
+
sessions: [chat.session],
|
|
859
|
+
tasks: [task],
|
|
860
|
+
taskStatusChanges: new Map([
|
|
861
|
+
[
|
|
862
|
+
task.id,
|
|
863
|
+
[
|
|
864
|
+
{ timestamp: 1_000, status: 'todo' },
|
|
865
|
+
{ timestamp: 2_000, status: 'started', previousStatus: 'todo' },
|
|
866
|
+
// Put back: a move of its own, unlike the wait before the first pick-up.
|
|
867
|
+
{ timestamp: 3_000, status: 'todo', previousStatus: 'started' },
|
|
868
|
+
],
|
|
869
|
+
],
|
|
870
|
+
]),
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
expect(
|
|
874
|
+
timeline.markers.filter((marker) => marker.kind === 'task').map(({ label, timestamp }) => ({ label, timestamp })),
|
|
875
|
+
).toEqual([
|
|
876
|
+
{ label: 'Task started', timestamp: 2_000 },
|
|
877
|
+
{ label: 'Task todo', timestamp: 3_000 },
|
|
878
|
+
]);
|
|
879
|
+
expect(timeline.range.start).toBe(2_000);
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
test('nests a sub-task under its parent task when both are on the checklist', ({ expect }) => {
|
|
883
|
+
const parent = Task.make({ title: 'Parent', status: 'started' });
|
|
884
|
+
const child = Task.make({ [Obj.Parent]: parent, title: 'Child', status: 'todo' });
|
|
885
|
+
const chat = makeChat('Tree', [parent, child]);
|
|
886
|
+
const timeline = buildSessionTimeline({ traceMessages: [], sessions: [chat.session], tasks: [parent, child] });
|
|
887
|
+
|
|
888
|
+
expect(timeline.lanes.find((lane) => lane.id === `task:${parent.id}`)?.parentId).toBe(`session:${chat.session.id}`);
|
|
889
|
+
expect(timeline.lanes.find((lane) => lane.id === `task:${child.id}`)?.parentId).toBe(`task:${parent.id}`);
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
test("a parent task's lane begins no later than its sub-tasks', even when they are not on one checklist", ({
|
|
893
|
+
expect,
|
|
894
|
+
}) => {
|
|
895
|
+
const parent = Task.make({ title: 'Parent', status: 'started' });
|
|
896
|
+
const child = Task.make({ [Obj.Parent]: parent, title: 'Child', status: 'done' });
|
|
897
|
+
const grandchild = Task.make({ [Obj.Parent]: child, title: 'Grandchild', status: 'done' });
|
|
898
|
+
// The parent is not on the checklist, so it is drawn from its edit history alone, unnested.
|
|
899
|
+
const chat = makeChat('Run', [child, grandchild]);
|
|
900
|
+
const timeline = buildSessionTimeline({
|
|
901
|
+
traceMessages: [],
|
|
902
|
+
sessions: [chat.session],
|
|
903
|
+
tasks: [parent, child, grandchild],
|
|
904
|
+
taskStatusChanges: new Map([
|
|
905
|
+
[parent.id, [{ timestamp: 3_000, status: 'started', previousStatus: 'todo' }]],
|
|
906
|
+
[
|
|
907
|
+
child.id,
|
|
908
|
+
[
|
|
909
|
+
{ timestamp: 2_000, status: 'started', previousStatus: 'todo' },
|
|
910
|
+
{ timestamp: 4_000, status: 'done', previousStatus: 'started' },
|
|
911
|
+
],
|
|
912
|
+
],
|
|
913
|
+
[
|
|
914
|
+
grandchild.id,
|
|
915
|
+
[
|
|
916
|
+
{ timestamp: 1_000, status: 'started', previousStatus: 'todo' },
|
|
917
|
+
{ timestamp: 2_500, status: 'done', previousStatus: 'started' },
|
|
918
|
+
],
|
|
919
|
+
],
|
|
920
|
+
]),
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
const laneOf = (task: Task.Task) => timeline.lanes.find((lane) => lane.taskId === task.id);
|
|
924
|
+
expect(laneOf(parent)?.parentId).toBeUndefined();
|
|
925
|
+
expect(laneOf(parent)?.start).toBe(1_000);
|
|
926
|
+
expect(laneOf(child)?.start).toBe(1_000);
|
|
927
|
+
expect(laneOf(grandchild)?.start).toBe(1_000);
|
|
928
|
+
// It carries every descendant's start and finish beside its own move, so its first node is where the work began.
|
|
929
|
+
const nodesOf = (task: Task.Task) =>
|
|
930
|
+
timeline.markers
|
|
931
|
+
.filter((marker) => marker.laneId === laneOf(task)?.id)
|
|
932
|
+
.map(({ timestamp }) => timestamp)
|
|
933
|
+
.sort((left, right) => left - right);
|
|
934
|
+
expect(nodesOf(parent)).toEqual([1_000, 2_000, 2_500, 3_000, 4_000]);
|
|
935
|
+
expect(nodesOf(child)).toEqual([1_000, 2_000, 2_500, 4_000]);
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
it.effect(
|
|
939
|
+
'draws a status move once when both the trace and the edit history record it',
|
|
940
|
+
Effect.fnUntraced(function* ({ expect }) {
|
|
941
|
+
const first = Task.make({ title: 'First', status: 'started' });
|
|
942
|
+
const second = Task.make({ title: 'Second', status: 'todo' });
|
|
943
|
+
const chat = makeChat('Both', [first, second]);
|
|
944
|
+
yield* TestTraceService.withMeta(
|
|
945
|
+
{ pid: 'agent', conversation: chat.feed },
|
|
946
|
+
Effect.gen(function* () {
|
|
947
|
+
yield* Trace.write(Trace.AgentRequestBegin, {}); // 1.
|
|
948
|
+
yield* Trace.write(Trace.TaskStatusChanged, { taskId: first.id, title: 'First', status: 'started' }); // 2.
|
|
949
|
+
}),
|
|
950
|
+
);
|
|
951
|
+
|
|
952
|
+
const messages = yield* TestTraceService.messages;
|
|
953
|
+
const timeline = buildSessionTimeline({
|
|
954
|
+
traceMessages: messages,
|
|
955
|
+
sessions: [chat.session],
|
|
956
|
+
tasks: [first, second],
|
|
957
|
+
taskStatusChanges: new Map([[first.id, [{ timestamp: 2, status: 'started', previousStatus: 'todo' }]]]),
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
const markers = timeline.markers.filter((marker) => marker.kind === 'task');
|
|
961
|
+
// The trace event is drawn through the history, which takes its pid.
|
|
962
|
+
expect(markers.map(({ laneId, label, pid }) => ({ laneId, label, pid }))).toEqual([
|
|
963
|
+
{ laneId: `task:${first.id}`, label: 'Task started', pid: 'agent' },
|
|
964
|
+
]);
|
|
965
|
+
}, Effect.provide(TestTraceService.layer)),
|
|
966
|
+
);
|
|
967
|
+
|
|
968
|
+
test('closes a finished task whose history is missing its closing move', ({ expect }) => {
|
|
969
|
+
const first = Task.make({ title: 'First', status: 'done' });
|
|
970
|
+
const second = Task.make({ title: 'Second', status: 'todo' });
|
|
971
|
+
const chat = makeChat('Unrecorded', [first, second]);
|
|
972
|
+
|
|
973
|
+
const timeline = buildSessionTimeline({
|
|
974
|
+
traceMessages: [],
|
|
975
|
+
sessions: [chat.session],
|
|
976
|
+
tasks: [first, second],
|
|
977
|
+
taskStatusChanges: new Map([[first.id, [{ timestamp: 1_000, status: 'started', previousStatus: 'todo' }]]]),
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
expect(timeline.lanes.find((lane) => lane.id === `task:${first.id}`)).toMatchObject({ start: 1_000, end: 1_000 });
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
it.effect(
|
|
984
|
+
'draws a recorded status move the trace recorded off the chart',
|
|
985
|
+
Effect.fnUntraced(function* ({ expect }) {
|
|
986
|
+
const first = Task.make({ title: 'First', status: 'started' });
|
|
987
|
+
const second = Task.make({ title: 'Second', status: 'todo' });
|
|
988
|
+
const chat = makeChat('Elsewhere', [first, second]);
|
|
989
|
+
// A process belonging to no session on this chart moved the task.
|
|
990
|
+
yield* TestTraceService.withMeta(
|
|
991
|
+
{ pid: 'stranger' },
|
|
992
|
+
Trace.write(Trace.TaskStatusChanged, { taskId: first.id, title: 'First', status: 'started' }), // 1.
|
|
993
|
+
);
|
|
994
|
+
|
|
995
|
+
const messages = yield* TestTraceService.messages;
|
|
996
|
+
const timeline = buildSessionTimeline({
|
|
997
|
+
traceMessages: messages,
|
|
998
|
+
sessions: [chat.session],
|
|
999
|
+
tasks: [first, second],
|
|
1000
|
+
taskStatusChanges: new Map([[first.id, [{ timestamp: 1, status: 'started', previousStatus: 'todo' }]]]),
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
expect(
|
|
1004
|
+
timeline.markers
|
|
1005
|
+
.filter((marker) => marker.laneId === `task:${first.id}`)
|
|
1006
|
+
.map(({ label, pid }) => ({ label, pid })),
|
|
1007
|
+
).toEqual([{ label: 'Task started', pid: undefined }]);
|
|
1008
|
+
}, Effect.provide(TestTraceService.layer)),
|
|
1009
|
+
);
|
|
1010
|
+
|
|
1011
|
+
test('records the stretch a task was put down between two runs as a gap', ({ expect }) => {
|
|
1012
|
+
const task = Task.make({ title: 'Cup the samples', status: 'done' });
|
|
1013
|
+
const timeline = buildSessionTimeline({
|
|
1014
|
+
traceMessages: [],
|
|
1015
|
+
sessions: [],
|
|
1016
|
+
tasks: [task],
|
|
1017
|
+
taskStatusChanges: new Map([
|
|
1018
|
+
[
|
|
1019
|
+
task.id,
|
|
1020
|
+
[
|
|
1021
|
+
{ timestamp: 1_000, status: 'started', previousStatus: 'todo' },
|
|
1022
|
+
{ timestamp: 2_000, status: 'blocked', previousStatus: 'started' },
|
|
1023
|
+
{ timestamp: 5_000, status: 'started', previousStatus: 'blocked' },
|
|
1024
|
+
{ timestamp: 6_000, status: 'done', previousStatus: 'started' },
|
|
1025
|
+
],
|
|
1026
|
+
],
|
|
1027
|
+
]),
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
expect(timeline.lanes).toMatchObject([{ start: 1_000, end: 6_000, gaps: [{ start: 2_000, end: 5_000 }] }]);
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
test('a task still blocked has no gap: its lane ends where it was put down', ({ expect }) => {
|
|
1034
|
+
const task = Task.make({ title: 'Cup the samples', status: 'blocked' });
|
|
1035
|
+
const timeline = buildSessionTimeline({
|
|
1036
|
+
traceMessages: [],
|
|
1037
|
+
sessions: [],
|
|
1038
|
+
tasks: [task],
|
|
1039
|
+
taskStatusChanges: new Map([
|
|
1040
|
+
[
|
|
1041
|
+
task.id,
|
|
1042
|
+
[
|
|
1043
|
+
{ timestamp: 1_000, status: 'started', previousStatus: 'todo' },
|
|
1044
|
+
{ timestamp: 2_000, status: 'blocked', previousStatus: 'started' },
|
|
1045
|
+
],
|
|
1046
|
+
],
|
|
1047
|
+
]),
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
expect(timeline.lanes[0]).toMatchObject({ start: 1_000, end: 2_000 });
|
|
1051
|
+
expect(timeline.lanes[0].gaps).toBeUndefined();
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
test('draws a task no session works as a lane of its own, from its edit history', ({ expect }) => {
|
|
1055
|
+
const done = Task.make({ title: 'Done elsewhere', status: 'done' });
|
|
1056
|
+
const running = Task.make({ title: 'Running elsewhere', status: 'started', dependsOn: [Ref.make(done)] });
|
|
1057
|
+
const untouched = Task.make({ title: 'Never started', status: 'todo' });
|
|
1058
|
+
|
|
1059
|
+
const timeline = buildSessionTimeline({
|
|
1060
|
+
traceMessages: [],
|
|
1061
|
+
sessions: [],
|
|
1062
|
+
tasks: [done, running, untouched],
|
|
1063
|
+
taskStatusChanges: new Map([
|
|
1064
|
+
[
|
|
1065
|
+
done.id,
|
|
1066
|
+
[
|
|
1067
|
+
{ timestamp: 1_000, status: 'started', previousStatus: 'todo' },
|
|
1068
|
+
{ timestamp: 2_000, status: 'done', previousStatus: 'started' },
|
|
1069
|
+
],
|
|
1070
|
+
],
|
|
1071
|
+
[running.id, [{ timestamp: 3_000, status: 'started', previousStatus: 'todo' }]],
|
|
1072
|
+
]),
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
expect(timeline.lanes).toEqual([
|
|
1076
|
+
{
|
|
1077
|
+
id: `task:${done.id}`,
|
|
1078
|
+
kind: 'task',
|
|
1079
|
+
label: 'Done elsewhere',
|
|
1080
|
+
status: 'done',
|
|
1081
|
+
start: 1_000,
|
|
1082
|
+
end: 2_000,
|
|
1083
|
+
taskId: done.id,
|
|
1084
|
+
hue: getHashHue(Obj.getMnemonic(done)),
|
|
1085
|
+
},
|
|
1086
|
+
{
|
|
1087
|
+
id: `task:${running.id}`,
|
|
1088
|
+
kind: 'task',
|
|
1089
|
+
label: 'Running elsewhere',
|
|
1090
|
+
status: 'running',
|
|
1091
|
+
start: 3_000,
|
|
1092
|
+
end: undefined,
|
|
1093
|
+
taskId: running.id,
|
|
1094
|
+
hue: getHashHue(Obj.getMnemonic(running)),
|
|
1095
|
+
blockedOn: [`task:${done.id}`],
|
|
1096
|
+
},
|
|
1097
|
+
]);
|
|
1098
|
+
expect(timeline.markers.filter((marker) => marker.laneId === `task:${done.id}`).map(({ label }) => label)).toEqual([
|
|
1099
|
+
'Task started',
|
|
1100
|
+
'Task done',
|
|
1101
|
+
]);
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
test('reads no status moves for a task outside a database, which keeps no history', ({ expect }) => {
|
|
1105
|
+
expect(readTaskStatusChanges(Task.make({ title: 'Loose', status: 'done' }))).toEqual([]);
|
|
1106
|
+
});
|
|
606
1107
|
});
|
|
607
1108
|
|
|
608
|
-
|
|
1109
|
+
describe('readTaskStatusChanges', () => {
|
|
1110
|
+
it.effect(
|
|
1111
|
+
"reads the status moves a stored task's edit history records, and the chart bounds its lane by them",
|
|
1112
|
+
Effect.fnUntraced(
|
|
1113
|
+
function* ({ expect }) {
|
|
1114
|
+
const first = yield* Database.add(Task.make({ title: 'First', status: 'todo' }));
|
|
1115
|
+
const second = yield* Database.add(Task.make({ title: 'Second', status: 'todo' }));
|
|
1116
|
+
yield* Database.flush();
|
|
1117
|
+
Task.setStatus(first, 'started');
|
|
1118
|
+
// An edit to another field is no status move.
|
|
1119
|
+
Task.update(first, { priority: 'high' });
|
|
1120
|
+
Task.setStatus(first, 'done');
|
|
1121
|
+
yield* Database.flush();
|
|
1122
|
+
|
|
1123
|
+
const changes = readTaskStatusChanges(first);
|
|
1124
|
+
expect(changes.map(({ status, previousStatus }) => ({ status, previousStatus }))).toEqual([
|
|
1125
|
+
{ status: 'todo', previousStatus: undefined },
|
|
1126
|
+
{ status: 'started', previousStatus: 'todo' },
|
|
1127
|
+
{ status: 'done', previousStatus: 'started' },
|
|
1128
|
+
]);
|
|
1129
|
+
expect(readTaskStatusChanges(second).map(({ status }) => status)).toEqual(['todo']);
|
|
1130
|
+
|
|
1131
|
+
const chat = makeChat('Stored', [first, second]);
|
|
1132
|
+
const timeline = buildSessionTimeline({
|
|
1133
|
+
traceMessages: [],
|
|
1134
|
+
sessions: [chat.session],
|
|
1135
|
+
tasks: [first, second],
|
|
1136
|
+
taskStatusChanges: new Map([
|
|
1137
|
+
[first.id, changes],
|
|
1138
|
+
[second.id, readTaskStatusChanges(second)],
|
|
1139
|
+
]),
|
|
1140
|
+
});
|
|
1141
|
+
expect(timeline.lanes.find((lane) => lane.id === `task:${first.id}`)).toMatchObject({
|
|
1142
|
+
status: 'done',
|
|
1143
|
+
start: changes[1]?.timestamp,
|
|
1144
|
+
end: changes[2]?.timestamp,
|
|
1145
|
+
});
|
|
1146
|
+
expect(timeline.lanes.find((lane) => lane.id === `task:${second.id}`)?.start).toBeUndefined();
|
|
1147
|
+
},
|
|
1148
|
+
Effect.provide(TestDatabaseLayer({ types: [Milestone.Milestone, Task.Task] })),
|
|
1149
|
+
),
|
|
1150
|
+
);
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
const agentProcess = (pid: string, chat: TestChat, state: Process.State): Process.Process => ({
|
|
609
1154
|
pid: Process.ID.make(pid),
|
|
610
1155
|
parentPid: null,
|
|
611
1156
|
key: 'agent',
|
|
@@ -643,6 +1188,9 @@ const makeChat = (name: string, tasks: readonly Task.Task[]): TestChat => {
|
|
|
643
1188
|
};
|
|
644
1189
|
};
|
|
645
1190
|
|
|
1191
|
+
/** An ISO date `ms` after the epoch, for history entries set against the trace's clock. */
|
|
1192
|
+
const at = (ms: number): string => new Date(ms).toISOString();
|
|
1193
|
+
|
|
646
1194
|
const toolCall = (name: string) =>
|
|
647
1195
|
Trace.write(Trace.CompleteBlock, {
|
|
648
1196
|
messageId: MESSAGE_ID,
|