@dxos/react-ui-trace 0.11.1
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/LICENSE +105 -0
- package/README.md +3 -0
- package/dist/lib/index.mjs +2868 -0
- package/dist/lib/index.mjs.map +1 -0
- package/dist/lib/testing.mjs +338 -0
- package/dist/lib/testing.mjs.map +1 -0
- package/dist/lib/translations.mjs +19 -0
- package/dist/lib/translations.mjs.map +1 -0
- package/dist/types/src/components/Gantt/Gantt.d.ts +68 -0
- package/dist/types/src/components/Gantt/Gantt.d.ts.map +1 -0
- package/dist/types/src/components/Gantt/Gantt.stories.d.ts +30 -0
- package/dist/types/src/components/Gantt/Gantt.stories.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 -0
- package/dist/types/src/components/ProcessTree/ProcessTree.d.ts +32 -0
- package/dist/types/src/components/ProcessTree/ProcessTree.d.ts.map +1 -0
- package/dist/types/src/components/ProcessTree/ProcessTree.stories.d.ts +8 -0
- package/dist/types/src/components/ProcessTree/ProcessTree.stories.d.ts.map +1 -0
- package/dist/types/src/components/ProcessTree/index.d.ts +2 -0
- package/dist/types/src/components/ProcessTree/index.d.ts.map +1 -0
- package/dist/types/src/components/Timeline/Timeline.d.ts +50 -0
- package/dist/types/src/components/Timeline/Timeline.d.ts.map +1 -0
- package/dist/types/src/components/Timeline/Timeline.stories.d.ts +24 -0
- package/dist/types/src/components/Timeline/Timeline.stories.d.ts.map +1 -0
- package/dist/types/src/components/Timeline/index.d.ts +4 -0
- package/dist/types/src/components/Timeline/index.d.ts.map +1 -0
- package/dist/types/src/components/Timeline/timeline-options.d.ts +9 -0
- package/dist/types/src/components/Timeline/timeline-options.d.ts.map +1 -0
- package/dist/types/src/components/Timeline/timeline-printer.d.ts +7 -0
- package/dist/types/src/components/Timeline/timeline-printer.d.ts.map +1 -0
- package/dist/types/src/components/Timeline/timeline-printer.test.d.ts +2 -0
- package/dist/types/src/components/Timeline/timeline-printer.test.d.ts.map +1 -0
- package/dist/types/src/components/TracePanel/TracePanel.d.ts +28 -0
- package/dist/types/src/components/TracePanel/TracePanel.d.ts.map +1 -0
- package/dist/types/src/components/TracePanel/dxn-extractor.d.ts +19 -0
- package/dist/types/src/components/TracePanel/dxn-extractor.d.ts.map +1 -0
- package/dist/types/src/components/TracePanel/dxn-extractor.test.d.ts +2 -0
- package/dist/types/src/components/TracePanel/dxn-extractor.test.d.ts.map +1 -0
- package/dist/types/src/components/TracePanel/index.d.ts +4 -0
- package/dist/types/src/components/TracePanel/index.d.ts.map +1 -0
- package/dist/types/src/components/TracePanel/trace-filter.d.ts +39 -0
- package/dist/types/src/components/TracePanel/trace-filter.d.ts.map +1 -0
- package/dist/types/src/components/TracePanel/trace-filter.test.d.ts +2 -0
- package/dist/types/src/components/TracePanel/trace-filter.test.d.ts.map +1 -0
- package/dist/types/src/components/TracePanel/useTraceMenu.d.ts +15 -0
- package/dist/types/src/components/TracePanel/useTraceMenu.d.ts.map +1 -0
- package/dist/types/src/components/index.d.ts +5 -0
- package/dist/types/src/components/index.d.ts.map +1 -0
- package/dist/types/src/execution-graph/execution-graph.d.ts +139 -0
- package/dist/types/src/execution-graph/execution-graph.d.ts.map +1 -0
- package/dist/types/src/execution-graph/execution-graph.test.d.ts +2 -0
- package/dist/types/src/execution-graph/execution-graph.test.d.ts.map +1 -0
- package/dist/types/src/execution-graph/index.d.ts +4 -0
- package/dist/types/src/execution-graph/index.d.ts.map +1 -0
- package/dist/types/src/execution-graph/pending-block-status.d.ts +26 -0
- package/dist/types/src/execution-graph/pending-block-status.d.ts.map +1 -0
- package/dist/types/src/execution-graph/pending-block-status.test.d.ts +2 -0
- package/dist/types/src/execution-graph/pending-block-status.test.d.ts.map +1 -0
- package/dist/types/src/execution-graph/remote-snapshot.test.d.ts +2 -0
- package/dist/types/src/execution-graph/remote-snapshot.test.d.ts.map +1 -0
- package/dist/types/src/execution-graph/span-tree.d.ts +106 -0
- package/dist/types/src/execution-graph/span-tree.d.ts.map +1 -0
- package/dist/types/src/execution-graph/span-tree.test.d.ts +2 -0
- package/dist/types/src/execution-graph/span-tree.test.d.ts.map +1 -0
- package/dist/types/src/execution-graph/sub-agent-delegation.test.d.ts +2 -0
- package/dist/types/src/execution-graph/sub-agent-delegation.test.d.ts.map +1 -0
- package/dist/types/src/execution-graph/testing/collect-trace-events.d.ts +40 -0
- package/dist/types/src/execution-graph/testing/collect-trace-events.d.ts.map +1 -0
- package/dist/types/src/execution-graph/testing/index.d.ts +2 -0
- package/dist/types/src/execution-graph/testing/index.d.ts.map +1 -0
- package/dist/types/src/hooks/index.d.ts +4 -0
- package/dist/types/src/hooks/index.d.ts.map +1 -0
- package/dist/types/src/hooks/useExecutionGraph.d.ts +17 -0
- package/dist/types/src/hooks/useExecutionGraph.d.ts.map +1 -0
- package/dist/types/src/hooks/useSessionTimeline.d.ts +17 -0
- package/dist/types/src/hooks/useSessionTimeline.d.ts.map +1 -0
- package/dist/types/src/hooks/useTraceMessages.d.ts +17 -0
- package/dist/types/src/hooks/useTraceMessages.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +5 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/session-timeline/index.d.ts +3 -0
- package/dist/types/src/session-timeline/index.d.ts.map +1 -0
- package/dist/types/src/session-timeline/session-timeline.d.ts +19 -0
- package/dist/types/src/session-timeline/session-timeline.d.ts.map +1 -0
- package/dist/types/src/session-timeline/session-timeline.test.d.ts +2 -0
- package/dist/types/src/session-timeline/session-timeline.test.d.ts.map +1 -0
- package/dist/types/src/session-timeline/types.d.ts +129 -0
- package/dist/types/src/session-timeline/types.d.ts.map +1 -0
- package/dist/types/src/testing/index.d.ts +5 -0
- package/dist/types/src/testing/index.d.ts.map +1 -0
- package/dist/types/src/testing/simulated-agent.d.ts +63 -0
- package/dist/types/src/testing/simulated-agent.d.ts.map +1 -0
- package/dist/types/src/testing/snapshot-playback.d.ts +12 -0
- package/dist/types/src/testing/snapshot-playback.d.ts.map +1 -0
- package/dist/types/src/testing/test-trace.d.ts +5 -0
- package/dist/types/src/testing/test-trace.d.ts.map +1 -0
- package/dist/types/src/translations.d.ts +19 -0
- package/dist/types/src/translations.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +77 -0
- package/src/components/Gantt/Gantt.stories.tsx +241 -0
- package/src/components/Gantt/Gantt.tsx +589 -0
- package/src/components/Gantt/index.ts +5 -0
- package/src/components/ProcessTree/ProcessTree.stories.tsx +95 -0
- package/src/components/ProcessTree/ProcessTree.tsx +265 -0
- package/src/components/ProcessTree/index.ts +5 -0
- package/src/components/Timeline/Timeline.stories.tsx +326 -0
- package/src/components/Timeline/Timeline.tsx +534 -0
- package/src/components/Timeline/git.drawio.svg +821 -0
- package/src/components/Timeline/index.ts +7 -0
- package/src/components/Timeline/timeline-options.ts +27 -0
- package/src/components/Timeline/timeline-printer.test.ts +76 -0
- package/src/components/Timeline/timeline-printer.ts +223 -0
- package/src/components/TracePanel/TracePanel.tsx +248 -0
- package/src/components/TracePanel/dxn-extractor.test.ts +153 -0
- package/src/components/TracePanel/dxn-extractor.ts +166 -0
- package/src/components/TracePanel/index.ts +7 -0
- package/src/components/TracePanel/trace-filter.test.ts +137 -0
- package/src/components/TracePanel/trace-filter.ts +120 -0
- package/src/components/TracePanel/useTraceMenu.ts +95 -0
- package/src/components/index.ts +8 -0
- package/src/execution-graph/SPEC.md +749 -0
- package/src/execution-graph/execution-graph.test.ts +1205 -0
- package/src/execution-graph/execution-graph.ts +1076 -0
- package/src/execution-graph/index.ts +35 -0
- package/src/execution-graph/pending-block-status.test.ts +118 -0
- package/src/execution-graph/pending-block-status.ts +88 -0
- package/src/execution-graph/remote-snapshot.test.ts +116 -0
- package/src/execution-graph/span-tree.test.ts +370 -0
- package/src/execution-graph/span-tree.ts +404 -0
- package/src/execution-graph/sub-agent-delegation.test.ts +65 -0
- package/src/execution-graph/testing/collect-trace-events.ts +76 -0
- package/src/execution-graph/testing/index.ts +5 -0
- package/src/execution-graph/testing/sub-agent-delegation.json +1285 -0
- package/src/hooks/index.ts +7 -0
- package/src/hooks/useExecutionGraph.ts +107 -0
- package/src/hooks/useSessionTimeline.ts +53 -0
- package/src/hooks/useTraceMessages.ts +58 -0
- package/src/index.ts +8 -0
- package/src/session-timeline/index.ts +6 -0
- package/src/session-timeline/session-timeline.test.ts +662 -0
- package/src/session-timeline/session-timeline.ts +605 -0
- package/src/session-timeline/types.ts +100 -0
- package/src/testing/index.ts +8 -0
- package/src/testing/simulated-agent.ts +293 -0
- package/src/testing/snapshot-playback.ts +45 -0
- package/src/testing/test-trace.ts +22 -0
- package/src/translations.ts +29 -0
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { it } from '@effect/vitest';
|
|
6
|
+
import * as Effect from 'effect/Effect';
|
|
7
|
+
import * as Option from 'effect/Option';
|
|
8
|
+
import { describe, test } from 'vitest';
|
|
9
|
+
|
|
10
|
+
import * as Process from '@dxos/compute/Process';
|
|
11
|
+
import { TestTraceService } from '@dxos/compute/testing';
|
|
12
|
+
import * as Trace from '@dxos/compute/Trace';
|
|
13
|
+
import { Annotation, Feed, Obj, Ref } from '@dxos/echo';
|
|
14
|
+
import { EID, EntityId, URI } from '@dxos/keys';
|
|
15
|
+
import { Task } from '@dxos/types';
|
|
16
|
+
|
|
17
|
+
import subAgentFixture from '../execution-graph/testing/sub-agent-delegation.json';
|
|
18
|
+
import { buildSessionTimeline } from './session-timeline.ts';
|
|
19
|
+
import { type Session } from './types.ts';
|
|
20
|
+
|
|
21
|
+
/** The plain-JSON shape the fixture was captured in, before being replayed as real trace messages. */
|
|
22
|
+
interface RawTraceMessage {
|
|
23
|
+
readonly meta: {
|
|
24
|
+
readonly pid?: string;
|
|
25
|
+
readonly parentPid?: string;
|
|
26
|
+
readonly processName?: string;
|
|
27
|
+
readonly space?: string;
|
|
28
|
+
};
|
|
29
|
+
readonly isEphemeral: boolean;
|
|
30
|
+
readonly events: readonly { readonly timestamp: number; readonly type: string; readonly data: unknown }[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// External JSON → typed at this boundary as a real (if partial) shape, then replayed as the
|
|
34
|
+
// genuine ECHO objects `buildSpanTree` requires, rather than laundered through `unknown`.
|
|
35
|
+
const fixtureMessages: Trace.Message[] = (subAgentFixture as readonly RawTraceMessage[]).map((raw) =>
|
|
36
|
+
Obj.make(Trace.Message, {
|
|
37
|
+
meta: {
|
|
38
|
+
pid: raw.meta.pid,
|
|
39
|
+
parentPid: raw.meta.parentPid,
|
|
40
|
+
processName: raw.meta.processName,
|
|
41
|
+
space: raw.meta.space,
|
|
42
|
+
},
|
|
43
|
+
isEphemeral: raw.isEphemeral,
|
|
44
|
+
events: raw.events,
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const SUPERVISOR_PID = '2433a0a1-7c09-46e6-bc6f-893f104d1691';
|
|
49
|
+
const SUB_AGENT_PID = 'cf8f7243-5b1d-4902-b158-70d9107d5f43';
|
|
50
|
+
|
|
51
|
+
const MESSAGE_ID = '01HQ0000000000000000000000';
|
|
52
|
+
|
|
53
|
+
describe('buildSessionTimeline', () => {
|
|
54
|
+
test('fixture: attaches the sub-agent span to the supervisor session without a task join', ({ expect }) => {
|
|
55
|
+
const task = Task.make({ title: 'Create a haiku in a new document', status: 'done' });
|
|
56
|
+
const chat = makeChat('Haiku', [task]);
|
|
57
|
+
const timeline = buildSessionTimeline({
|
|
58
|
+
traceMessages: fixtureMessages,
|
|
59
|
+
sessions: [chat.session],
|
|
60
|
+
tasks: [task],
|
|
61
|
+
processes: [agentProcess(SUPERVISOR_PID, chat, Process.State.SUCCEEDED)],
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const session = timeline.lanes.find((lane) => lane.id === `session:${chat.id}`);
|
|
65
|
+
expect(session).toMatchObject({ kind: 'session', label: 'Haiku', status: 'done', pid: SUPERVISOR_PID });
|
|
66
|
+
expect(session?.start).toBe(1780800819470);
|
|
67
|
+
expect(session?.end).toBe(1780800827765);
|
|
68
|
+
expect(session?.tokens).toEqual({ input: 5, output: 207, total: 212 });
|
|
69
|
+
expect(session?.toolCalls).toBe(2);
|
|
70
|
+
|
|
71
|
+
// The fixture predates `delegationSpawned`, so the task stays unjoined.
|
|
72
|
+
const taskLane = timeline.lanes.find((lane) => lane.id === `task:${task.id}`);
|
|
73
|
+
expect(taskLane).toMatchObject({ kind: 'task', status: 'done', parentId: session?.id });
|
|
74
|
+
expect(taskLane?.start).toBeUndefined();
|
|
75
|
+
expect(taskLane?.pid).toBeUndefined();
|
|
76
|
+
|
|
77
|
+
const subSession = timeline.lanes.find((lane) => lane.id === `session:${SUB_AGENT_PID}`);
|
|
78
|
+
expect(subSession).toMatchObject({
|
|
79
|
+
kind: 'session',
|
|
80
|
+
label: 'Run Routine',
|
|
81
|
+
status: 'done',
|
|
82
|
+
parentId: session?.id,
|
|
83
|
+
start: 1780800827912,
|
|
84
|
+
end: 1780800838870,
|
|
85
|
+
tokens: { input: 6, output: 407, total: 413 },
|
|
86
|
+
toolCalls: 3,
|
|
87
|
+
});
|
|
88
|
+
expect(subSession?.taskId).toBeUndefined();
|
|
89
|
+
expect(subSession?.delegatedFrom?.laneId).toBe(session?.id);
|
|
90
|
+
const source = timeline.markers.find((marker) => marker.id === subSession?.delegatedFrom?.markerId);
|
|
91
|
+
expect(source?.laneId).toBe(session?.id);
|
|
92
|
+
expect(source?.timestamp).toBeLessThanOrEqual(1780800827912);
|
|
93
|
+
|
|
94
|
+
const subMarkers = timeline.markers.filter((marker) => marker.laneId === subSession?.id);
|
|
95
|
+
expect(subMarkers.map((marker) => marker.kind)).toContain('tool');
|
|
96
|
+
expect(subMarkers.find((marker) => marker.label === 'Add artifact' && marker.level === 'error')).toBeDefined();
|
|
97
|
+
expect(subMarkers.find((marker) => marker.kind === 'message')?.label).toBe(
|
|
98
|
+
'Complete the following task and report the result concisely.',
|
|
99
|
+
);
|
|
100
|
+
// Tool operations under the supervisor are markers on its lane, not child sessions.
|
|
101
|
+
expect(timeline.lanes.filter((lane) => lane.kind === 'session')).toHaveLength(2);
|
|
102
|
+
expect(timeline.markers.find((marker) => marker.label === 'Delegate task')?.laneId).toBe(session?.id);
|
|
103
|
+
// The unrelated `Update Chat Name` operation has no session pid and is dropped.
|
|
104
|
+
expect(timeline.markers.find((marker) => marker.label === 'Update Chat Name')).toBeUndefined();
|
|
105
|
+
expect(timeline.range).toEqual({ start: 1780800819470, end: 1780800838870 });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('fixture: groups by agent pid when no chats are supplied', ({ expect }) => {
|
|
109
|
+
const timeline = buildSessionTimeline({ traceMessages: fixtureMessages });
|
|
110
|
+
expect(timeline.lanes.map((lane) => lane.id)).toEqual([`session:${SUPERVISOR_PID}`, `session:${SUB_AGENT_PID}`]);
|
|
111
|
+
expect(timeline.lanes[0]?.label).toBe('Agent');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it.effect(
|
|
115
|
+
'running session with a blocked and a running task',
|
|
116
|
+
Effect.fnUntraced(function* ({ expect }) {
|
|
117
|
+
const first = Task.make({ title: 'First', status: 'started' });
|
|
118
|
+
const second = Task.make({ title: 'Second', status: 'todo', dependsOn: [Ref.make(first)] });
|
|
119
|
+
const chat = makeChat('Work', [first, second]);
|
|
120
|
+
yield* TestTraceService.withMeta(
|
|
121
|
+
{ pid: 'agent', conversation: chat.feed },
|
|
122
|
+
Effect.gen(function* () {
|
|
123
|
+
yield* Trace.write(Trace.AgentRequestBegin, {});
|
|
124
|
+
yield* Trace.write(Trace.CompleteBlock, {
|
|
125
|
+
messageId: MESSAGE_ID,
|
|
126
|
+
role: 'user',
|
|
127
|
+
block: { _tag: 'text', text: 'go' },
|
|
128
|
+
});
|
|
129
|
+
yield* Trace.write(Trace.DelegationSpawned, { taskId: first.id, pid: 'sub' });
|
|
130
|
+
yield* TestTraceService.withMeta(
|
|
131
|
+
{ pid: 'sub', parentPid: 'agent' },
|
|
132
|
+
Trace.write(Trace.OperationStart, { key: 'run', name: 'Run Instructions' }),
|
|
133
|
+
);
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const messages = yield* TestTraceService.messages;
|
|
138
|
+
const timeline = buildSessionTimeline({
|
|
139
|
+
traceMessages: messages,
|
|
140
|
+
sessions: [chat.session],
|
|
141
|
+
tasks: [first, second],
|
|
142
|
+
processes: [
|
|
143
|
+
agentProcess('agent', chat, Process.State.RUNNING),
|
|
144
|
+
{ ...agentProcess('sub', chat, Process.State.RUNNING), key: 'run', parentPid: Process.ID.make('agent') },
|
|
145
|
+
],
|
|
146
|
+
now: 100,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// The delegated task is represented by its session alone — no task lane doubles it — and the
|
|
150
|
+
// dependency on it follows to that session.
|
|
151
|
+
const [session, subSession, secondLane] = timeline.lanes;
|
|
152
|
+
expect(timeline.lanes).toHaveLength(3);
|
|
153
|
+
expect(session).toMatchObject({ kind: 'session', status: 'running', start: 1, end: undefined });
|
|
154
|
+
expect(subSession).toMatchObject({
|
|
155
|
+
kind: 'session',
|
|
156
|
+
label: 'First',
|
|
157
|
+
status: 'running',
|
|
158
|
+
taskId: first.id,
|
|
159
|
+
pid: 'sub',
|
|
160
|
+
start: 4,
|
|
161
|
+
end: undefined,
|
|
162
|
+
delegatedFrom: {
|
|
163
|
+
laneId: session?.id,
|
|
164
|
+
markerId: timeline.markers.find((marker) => marker.kind === 'delegation')?.id,
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
expect(secondLane).toMatchObject({ kind: 'task', status: 'blocked', blockedOn: [subSession?.id] });
|
|
168
|
+
expect(timeline.range).toEqual({ start: 1, end: 100 });
|
|
169
|
+
}, Effect.provide(TestTraceService.layer)),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
it.effect(
|
|
173
|
+
'cuts the session into task segments from the status events',
|
|
174
|
+
Effect.fnUntraced(function* ({ expect }) {
|
|
175
|
+
const first = Task.make({ title: 'First', status: 'done' });
|
|
176
|
+
const second = Task.make({ title: 'Second', status: 'started' });
|
|
177
|
+
const chat = makeChat('Segmented', [first, second]);
|
|
178
|
+
yield* TestTraceService.withMeta(
|
|
179
|
+
{ pid: 'agent', conversation: chat.feed },
|
|
180
|
+
Effect.gen(function* () {
|
|
181
|
+
yield* Trace.write(Trace.AgentRequestBegin, {}); // 1.
|
|
182
|
+
yield* toolCall('Search'); // 2 — before any task started, stays on the session.
|
|
183
|
+
yield* Trace.write(Trace.TaskStatusChanged, { taskId: first.id, title: 'First', status: 'started' }); // 3.
|
|
184
|
+
yield* toolCall('Read file'); // 4.
|
|
185
|
+
yield* Trace.write(Trace.TaskStatusChanged, {
|
|
186
|
+
taskId: first.id,
|
|
187
|
+
title: 'First',
|
|
188
|
+
status: 'done',
|
|
189
|
+
previousStatus: 'started',
|
|
190
|
+
}); // 5.
|
|
191
|
+
yield* toolCall('Think'); // 6 — between segments.
|
|
192
|
+
yield* Trace.write(Trace.TaskStatusChanged, { taskId: second.id, title: 'Second', status: 'started' }); // 7.
|
|
193
|
+
yield* toolCall('Write file'); // 8.
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const messages = yield* TestTraceService.messages;
|
|
198
|
+
const timeline = buildSessionTimeline({
|
|
199
|
+
traceMessages: messages,
|
|
200
|
+
sessions: [chat.session],
|
|
201
|
+
tasks: [first, second],
|
|
202
|
+
now: 20,
|
|
203
|
+
});
|
|
204
|
+
const sessionId = `session:${chat.id}`;
|
|
205
|
+
const firstLane = timeline.lanes.find((lane) => lane.id === `task:${first.id}`);
|
|
206
|
+
const secondLane = timeline.lanes.find((lane) => lane.id === `task:${second.id}`);
|
|
207
|
+
// The first task's lane is bounded by its status events; the second one is still open.
|
|
208
|
+
expect(firstLane).toMatchObject({ kind: 'task', status: 'done', start: 3, end: 5 });
|
|
209
|
+
expect(secondLane).toMatchObject({ kind: 'task', status: 'running', start: 7, end: undefined });
|
|
210
|
+
|
|
211
|
+
// Every event inside a segment is that task's; the rest stay on the session.
|
|
212
|
+
const lanesByLabel = new Map(timeline.markers.map((marker) => [marker.label, marker.laneId]));
|
|
213
|
+
expect(lanesByLabel.get('Search')).toBe(sessionId);
|
|
214
|
+
expect(lanesByLabel.get('Read file')).toBe(firstLane?.id);
|
|
215
|
+
expect(lanesByLabel.get('Think')).toBe(sessionId);
|
|
216
|
+
expect(lanesByLabel.get('Write file')).toBe(secondLane?.id);
|
|
217
|
+
expect(lanesByLabel.get('Request started')).toBe(sessionId);
|
|
218
|
+
|
|
219
|
+
// Start and finish are drawn as nodes on the task's own lane.
|
|
220
|
+
const taskMarkers = timeline.markers.filter((marker) => marker.kind === 'task');
|
|
221
|
+
expect(taskMarkers.map(({ laneId, label, timestamp }) => ({ laneId, label, timestamp }))).toEqual([
|
|
222
|
+
{ laneId: firstLane?.id, label: 'Task started', timestamp: 3 },
|
|
223
|
+
{ laneId: firstLane?.id, label: 'Task done', timestamp: 5 },
|
|
224
|
+
{ laneId: secondLane?.id, label: 'Task started', timestamp: 7 },
|
|
225
|
+
]);
|
|
226
|
+
}, Effect.provide(TestTraceService.layer)),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
it.effect(
|
|
230
|
+
'cuts a delegated run, where a task is only ever closed',
|
|
231
|
+
Effect.fnUntraced(function* ({ expect }) {
|
|
232
|
+
// Delegation marks every task it hands over `started`, so the agent's only event per task is
|
|
233
|
+
// the one closing it — the cut comes from the previous boundary instead.
|
|
234
|
+
const first = Task.make({ title: 'First', status: 'done' });
|
|
235
|
+
const second = Task.make({ title: 'Second', status: 'done' });
|
|
236
|
+
const chat = makeChat('Delegated', [first, second]);
|
|
237
|
+
yield* TestTraceService.withMeta(
|
|
238
|
+
{ pid: 'agent', conversation: chat.feed },
|
|
239
|
+
Effect.gen(function* () {
|
|
240
|
+
yield* Trace.write(Trace.AgentRequestBegin, {}); // 1.
|
|
241
|
+
yield* toolCall('Read file'); // 2.
|
|
242
|
+
yield* Trace.write(Trace.TaskStatusChanged, {
|
|
243
|
+
taskId: first.id,
|
|
244
|
+
title: 'First',
|
|
245
|
+
status: 'done',
|
|
246
|
+
previousStatus: 'started',
|
|
247
|
+
}); // 3.
|
|
248
|
+
yield* toolCall('Write file'); // 4.
|
|
249
|
+
yield* Trace.write(Trace.TaskStatusChanged, {
|
|
250
|
+
taskId: second.id,
|
|
251
|
+
title: 'Second',
|
|
252
|
+
status: 'done',
|
|
253
|
+
previousStatus: 'started',
|
|
254
|
+
}); // 5.
|
|
255
|
+
yield* Trace.write(Trace.AgentRequestEnd, { status: 'success' }); // 6.
|
|
256
|
+
}),
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
const messages = yield* TestTraceService.messages;
|
|
260
|
+
const timeline = buildSessionTimeline({ traceMessages: messages, sessions: [chat.session], tasks: [first, second] });
|
|
261
|
+
const firstLane = timeline.lanes.find((lane) => lane.id === `task:${first.id}`);
|
|
262
|
+
const secondLane = timeline.lanes.find((lane) => lane.id === `task:${second.id}`);
|
|
263
|
+
expect(firstLane).toMatchObject({ start: 1, end: 3 });
|
|
264
|
+
expect(secondLane).toMatchObject({ start: 3, end: 5 });
|
|
265
|
+
const lanesByLabel = new Map(timeline.markers.map((marker) => [marker.label, marker.laneId]));
|
|
266
|
+
expect(lanesByLabel.get('Read file')).toBe(firstLane?.id);
|
|
267
|
+
expect(lanesByLabel.get('Write file')).toBe(secondLane?.id);
|
|
268
|
+
}, Effect.provide(TestTraceService.layer)),
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
it.effect(
|
|
272
|
+
'a task belonging to no lane on this chart cuts nothing',
|
|
273
|
+
Effect.fnUntraced(function* ({ expect }) {
|
|
274
|
+
const mine = Task.make({ title: 'Mine', status: 'done' });
|
|
275
|
+
const other = Task.make({ title: 'Elsewhere', status: 'started' });
|
|
276
|
+
// A second checklist task keeps the session a tree; a lone task folds into its session lane.
|
|
277
|
+
const later = Task.make({ title: 'Later', status: 'todo' });
|
|
278
|
+
const chat = makeChat('Scoped', [mine, later]);
|
|
279
|
+
yield* TestTraceService.withMeta(
|
|
280
|
+
{ pid: 'agent', conversation: chat.feed },
|
|
281
|
+
Effect.gen(function* () {
|
|
282
|
+
yield* Trace.write(Trace.AgentRequestBegin, {}); // 1.
|
|
283
|
+
yield* Trace.write(Trace.TaskStatusChanged, { taskId: mine.id, title: 'Mine', status: 'started' }); // 2.
|
|
284
|
+
yield* toolCall('Read file'); // 3.
|
|
285
|
+
// A task on somebody else's list: it must neither close `Mine` nor move the boundary.
|
|
286
|
+
yield* Trace.write(Trace.TaskStatusChanged, { taskId: other.id, title: 'Elsewhere', status: 'started' }); // 4.
|
|
287
|
+
yield* toolCall('Write file'); // 5.
|
|
288
|
+
yield* Trace.write(Trace.TaskStatusChanged, {
|
|
289
|
+
taskId: mine.id,
|
|
290
|
+
title: 'Mine',
|
|
291
|
+
status: 'done',
|
|
292
|
+
previousStatus: 'started',
|
|
293
|
+
}); // 6.
|
|
294
|
+
}),
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
const messages = yield* TestTraceService.messages;
|
|
298
|
+
const timeline = buildSessionTimeline({ traceMessages: messages, sessions: [chat.session], tasks: [mine, other, later] });
|
|
299
|
+
const laneId = `task:${mine.id}`;
|
|
300
|
+
expect(timeline.lanes.find((lane) => lane.id === laneId)).toMatchObject({ start: 2, end: 6 });
|
|
301
|
+
const lanesByLabel = new Map(timeline.markers.map((marker) => [marker.label, marker.laneId]));
|
|
302
|
+
expect(lanesByLabel.get('Read file')).toBe(laneId);
|
|
303
|
+
expect(lanesByLabel.get('Write file')).toBe(laneId);
|
|
304
|
+
}, Effect.provide(TestTraceService.layer)),
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
it.effect(
|
|
308
|
+
"a task started over an open one closes it at the boundary, which is the newcomer's",
|
|
309
|
+
Effect.fnUntraced(function* ({ expect }) {
|
|
310
|
+
const first = Task.make({ title: 'First', status: 'started' });
|
|
311
|
+
const second = Task.make({ title: 'Second', status: 'started' });
|
|
312
|
+
const chat = makeChat('Handover', [first, second]);
|
|
313
|
+
yield* TestTraceService.withMeta(
|
|
314
|
+
{ pid: 'agent', conversation: chat.feed },
|
|
315
|
+
Effect.gen(function* () {
|
|
316
|
+
yield* Trace.write(Trace.AgentRequestBegin, {}); // 1.
|
|
317
|
+
yield* Trace.write(Trace.TaskStatusChanged, { taskId: first.id, title: 'First', status: 'started' }); // 2.
|
|
318
|
+
yield* toolCall('Read file'); // 3.
|
|
319
|
+
// No close for the first: starting the second is what ends it.
|
|
320
|
+
yield* Trace.write(Trace.TaskStatusChanged, { taskId: second.id, title: 'Second', status: 'started' }); // 4.
|
|
321
|
+
yield* toolCall('Write file'); // 5.
|
|
322
|
+
}),
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
const messages = yield* TestTraceService.messages;
|
|
326
|
+
const timeline = buildSessionTimeline({ traceMessages: messages, sessions: [chat.session], tasks: [first, second] });
|
|
327
|
+
expect(timeline.lanes.find((lane) => lane.id === `task:${first.id}`)).toMatchObject({ start: 2, end: 4 });
|
|
328
|
+
expect(timeline.lanes.find((lane) => lane.id === `task:${second.id}`)).toMatchObject({
|
|
329
|
+
start: 4,
|
|
330
|
+
end: undefined,
|
|
331
|
+
});
|
|
332
|
+
// The two segments share the boundary at 4; the second task's own start node sits on its lane.
|
|
333
|
+
const taskMarkers = timeline.markers.filter((marker) => marker.kind === 'task');
|
|
334
|
+
expect(taskMarkers.map(({ laneId, timestamp }) => ({ laneId, timestamp }))).toEqual([
|
|
335
|
+
{ laneId: `task:${first.id}`, timestamp: 2 },
|
|
336
|
+
{ laneId: `task:${second.id}`, timestamp: 4 },
|
|
337
|
+
]);
|
|
338
|
+
}, Effect.provide(TestTraceService.layer)),
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
it.effect(
|
|
342
|
+
'a session working a single task is one lane, carrying the task and its markers',
|
|
343
|
+
Effect.fnUntraced(function* ({ expect }) {
|
|
344
|
+
const task = Task.make({ title: 'Only', status: 'started' });
|
|
345
|
+
const chat = makeChat('Only', [task]);
|
|
346
|
+
yield* TestTraceService.withMeta(
|
|
347
|
+
{ pid: 'agent', conversation: chat.feed },
|
|
348
|
+
Effect.gen(function* () {
|
|
349
|
+
yield* Trace.write(Trace.AgentRequestBegin, {}); // 1.
|
|
350
|
+
yield* Trace.write(Trace.TaskStatusChanged, { taskId: task.id, title: 'Only', status: 'started' }); // 2.
|
|
351
|
+
yield* toolCall('Read file'); // 3.
|
|
352
|
+
}),
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
const messages = yield* TestTraceService.messages;
|
|
356
|
+
const timeline = buildSessionTimeline({ traceMessages: messages, sessions: [chat.session], tasks: [task] });
|
|
357
|
+
expect(timeline.lanes).toHaveLength(1);
|
|
358
|
+
expect(timeline.lanes[0]).toMatchObject({ id: `session:${chat.id}`, kind: 'session', taskId: task.id });
|
|
359
|
+
const lanesByLabel = new Map(timeline.markers.map((marker) => [marker.label, marker.laneId]));
|
|
360
|
+
expect(lanesByLabel.get('Read file')).toBe(`session:${chat.id}`);
|
|
361
|
+
}, Effect.provide(TestTraceService.layer)),
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
it.effect(
|
|
365
|
+
'a run whose process ended without a request end closes at its last event, and the axis stops following now',
|
|
366
|
+
Effect.fnUntraced(function* ({ expect }) {
|
|
367
|
+
const task = Task.make({ title: 'Only', status: 'started' });
|
|
368
|
+
const chat = makeChat('Only', [task]);
|
|
369
|
+
yield* TestTraceService.withMeta(
|
|
370
|
+
{ pid: 'agent', conversation: chat.feed },
|
|
371
|
+
Effect.gen(function* () {
|
|
372
|
+
yield* Trace.write(Trace.AgentRequestBegin, {}); // 1.
|
|
373
|
+
yield* toolCall('Read file'); // 2 — the run dies here: no Trace.AgentRequestEnd.
|
|
374
|
+
}),
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
const messages = yield* TestTraceService.messages;
|
|
378
|
+
const now = 2 + 60 * 60_000;
|
|
379
|
+
const dead = buildSessionTimeline({
|
|
380
|
+
traceMessages: messages,
|
|
381
|
+
sessions: [chat.session],
|
|
382
|
+
tasks: [task],
|
|
383
|
+
processes: [agentProcess('agent', chat, Process.State.FAILED)],
|
|
384
|
+
now,
|
|
385
|
+
});
|
|
386
|
+
expect(dead.lanes[0]).toMatchObject({ kind: 'session', status: 'failed', start: 1, end: 2 });
|
|
387
|
+
expect(dead.range).toEqual({ start: 1, end: 2 });
|
|
388
|
+
|
|
389
|
+
// Without a process record the begin is taken as still in flight — but an hour of silence
|
|
390
|
+
// keeps the axis on the events rather than on now.
|
|
391
|
+
const unknown = buildSessionTimeline({ traceMessages: messages, sessions: [chat.session], tasks: [task], now });
|
|
392
|
+
expect(unknown.lanes[0]).toMatchObject({ status: 'running', end: undefined });
|
|
393
|
+
expect(unknown.range).toEqual({ start: 1, end: 2 });
|
|
394
|
+
const fresh = buildSessionTimeline({ traceMessages: messages, sessions: [chat.session], tasks: [task], now: 5 });
|
|
395
|
+
expect(fresh.range).toEqual({ start: 1, end: 5 });
|
|
396
|
+
}, Effect.provide(TestTraceService.layer)),
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
it.effect(
|
|
400
|
+
'putting a task back to todo ends its stretch as surely as finishing it',
|
|
401
|
+
Effect.fnUntraced(function* ({ expect }) {
|
|
402
|
+
const task = Task.make({ title: 'Deferred', status: 'todo' });
|
|
403
|
+
const later = Task.make({ title: 'Later', status: 'todo' });
|
|
404
|
+
const chat = makeChat('Deferred', [task, later]);
|
|
405
|
+
yield* TestTraceService.withMeta(
|
|
406
|
+
{ pid: 'agent', conversation: chat.feed },
|
|
407
|
+
Effect.gen(function* () {
|
|
408
|
+
yield* Trace.write(Trace.AgentRequestBegin, {}); // 1.
|
|
409
|
+
yield* Trace.write(Trace.TaskStatusChanged, { taskId: task.id, title: 'Deferred', status: 'started' }); // 2.
|
|
410
|
+
yield* toolCall('Read file'); // 3.
|
|
411
|
+
yield* Trace.write(Trace.TaskStatusChanged, {
|
|
412
|
+
taskId: task.id,
|
|
413
|
+
title: 'Deferred',
|
|
414
|
+
status: 'todo',
|
|
415
|
+
previousStatus: 'started',
|
|
416
|
+
}); // 4.
|
|
417
|
+
yield* toolCall('Think'); // 5 — after the task was put down, so the session's.
|
|
418
|
+
}),
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
const messages = yield* TestTraceService.messages;
|
|
422
|
+
const timeline = buildSessionTimeline({ traceMessages: messages, sessions: [chat.session], tasks: [task, later] });
|
|
423
|
+
expect(timeline.lanes.find((lane) => lane.id === `task:${task.id}`)).toMatchObject({ start: 2, end: 4 });
|
|
424
|
+
const lanesByLabel = new Map(timeline.markers.map((marker) => [marker.label, marker.laneId]));
|
|
425
|
+
expect(lanesByLabel.get('Read file')).toBe(`task:${task.id}`);
|
|
426
|
+
expect(lanesByLabel.get('Think')).toBe(`session:${chat.id}`);
|
|
427
|
+
}, Effect.provide(TestTraceService.layer)),
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
it.effect(
|
|
431
|
+
'a task dismissed or re-closed claims no stretch of the run',
|
|
432
|
+
Effect.fnUntraced(function* ({ expect }) {
|
|
433
|
+
const dismissed = Task.make({ title: 'Dismissed', status: 'blocked' });
|
|
434
|
+
const worked = Task.make({ title: 'Worked', status: 'done' });
|
|
435
|
+
const chat = makeChat('Closes', [dismissed, worked]);
|
|
436
|
+
yield* TestTraceService.withMeta(
|
|
437
|
+
{ pid: 'agent', conversation: chat.feed },
|
|
438
|
+
Effect.gen(function* () {
|
|
439
|
+
yield* Trace.write(Trace.AgentRequestBegin, {}); // 1.
|
|
440
|
+
// Never started, so it owns nothing — not the reading the agent did before it said so.
|
|
441
|
+
yield* Trace.write(Trace.TaskStatusChanged, {
|
|
442
|
+
taskId: dismissed.id,
|
|
443
|
+
title: 'Dismissed',
|
|
444
|
+
status: 'blocked',
|
|
445
|
+
previousStatus: 'todo',
|
|
446
|
+
}); // 2.
|
|
447
|
+
yield* Trace.write(Trace.TaskStatusChanged, { taskId: worked.id, title: 'Worked', status: 'started' }); // 3.
|
|
448
|
+
yield* Trace.write(Trace.TaskStatusChanged, {
|
|
449
|
+
taskId: worked.id,
|
|
450
|
+
title: 'Worked',
|
|
451
|
+
status: 'review',
|
|
452
|
+
previousStatus: 'started',
|
|
453
|
+
}); // 4.
|
|
454
|
+
yield* Trace.write(Trace.TaskStatusChanged, { taskId: dismissed.id, title: 'Dismissed', status: 'started' }); // 5.
|
|
455
|
+
yield* toolCall('Unblock'); // 6.
|
|
456
|
+
// The sign-off on a task closed at 4: it must not stretch that lane over this one's work.
|
|
457
|
+
yield* Trace.write(Trace.TaskStatusChanged, {
|
|
458
|
+
taskId: worked.id,
|
|
459
|
+
title: 'Worked',
|
|
460
|
+
status: 'done',
|
|
461
|
+
previousStatus: 'review',
|
|
462
|
+
}); // 7.
|
|
463
|
+
}),
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
const messages = yield* TestTraceService.messages;
|
|
467
|
+
const timeline = buildSessionTimeline({ traceMessages: messages, sessions: [chat.session], tasks: [dismissed, worked] });
|
|
468
|
+
expect(timeline.lanes.find((lane) => lane.id === `task:${worked.id}`)).toMatchObject({ start: 3, end: 4 });
|
|
469
|
+
expect(timeline.lanes.find((lane) => lane.id === `task:${dismissed.id}`)).toMatchObject({
|
|
470
|
+
start: 5,
|
|
471
|
+
end: undefined,
|
|
472
|
+
});
|
|
473
|
+
const unblock = timeline.markers.find((marker) => marker.label === 'Unblock');
|
|
474
|
+
expect(unblock?.laneId).toBe(`task:${dismissed.id}`);
|
|
475
|
+
}, Effect.provide(TestTraceService.layer)),
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
it.effect(
|
|
479
|
+
'a close arriving after the next task started leaves that task the stretch',
|
|
480
|
+
Effect.fnUntraced(function* ({ expect }) {
|
|
481
|
+
const first = Task.make({ title: 'First', status: 'done' });
|
|
482
|
+
const second = Task.make({ title: 'Second', status: 'started' });
|
|
483
|
+
const chat = makeChat('Interleaved', [first, second]);
|
|
484
|
+
yield* TestTraceService.withMeta(
|
|
485
|
+
{ pid: 'agent', conversation: chat.feed },
|
|
486
|
+
Effect.gen(function* () {
|
|
487
|
+
yield* Trace.write(Trace.AgentRequestBegin, {}); // 1.
|
|
488
|
+
yield* Trace.write(Trace.TaskStatusChanged, { taskId: first.id, title: 'First', status: 'started' }); // 2.
|
|
489
|
+
yield* Trace.write(Trace.TaskStatusChanged, { taskId: second.id, title: 'Second', status: 'started' }); // 3.
|
|
490
|
+
yield* toolCall('Write file'); // 4.
|
|
491
|
+
// The first task's close lands after the second one is under way; the stretch is the
|
|
492
|
+
// second task's, so nothing may be minted over it.
|
|
493
|
+
yield* Trace.write(Trace.TaskStatusChanged, {
|
|
494
|
+
taskId: first.id,
|
|
495
|
+
title: 'First',
|
|
496
|
+
status: 'done',
|
|
497
|
+
previousStatus: 'started',
|
|
498
|
+
}); // 5.
|
|
499
|
+
yield* toolCall('Read file'); // 6.
|
|
500
|
+
}),
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
const messages = yield* TestTraceService.messages;
|
|
504
|
+
const timeline = buildSessionTimeline({ traceMessages: messages, sessions: [chat.session], tasks: [first, second] });
|
|
505
|
+
expect(timeline.lanes.find((lane) => lane.id === `task:${first.id}`)).toMatchObject({ start: 2, end: 3 });
|
|
506
|
+
expect(timeline.lanes.find((lane) => lane.id === `task:${second.id}`)).toMatchObject({
|
|
507
|
+
start: 3,
|
|
508
|
+
end: undefined,
|
|
509
|
+
});
|
|
510
|
+
const lanesByLabel = new Map(timeline.markers.map((marker) => [marker.label, marker.laneId]));
|
|
511
|
+
expect(lanesByLabel.get('Write file')).toBe(`task:${second.id}`);
|
|
512
|
+
expect(lanesByLabel.get('Read file')).toBe(`task:${second.id}`);
|
|
513
|
+
}, Effect.provide(TestTraceService.layer)),
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
it.effect(
|
|
517
|
+
'a delegated task keeps its markers on the session that handed it over',
|
|
518
|
+
Effect.fnUntraced(function* ({ expect }) {
|
|
519
|
+
const task = Task.make({ title: 'Delegated', status: 'done' });
|
|
520
|
+
const chat = makeChat('Handover', [task]);
|
|
521
|
+
yield* TestTraceService.withMeta(
|
|
522
|
+
{ pid: 'agent', conversation: chat.feed },
|
|
523
|
+
Effect.gen(function* () {
|
|
524
|
+
yield* Trace.write(Trace.AgentRequestBegin, {}); // 1.
|
|
525
|
+
yield* Trace.write(Trace.TaskStatusChanged, { taskId: task.id, title: 'Delegated', status: 'started' }); // 2.
|
|
526
|
+
yield* Trace.write(Trace.DelegationSpawned, { taskId: task.id, pid: 'sub' }); // 3.
|
|
527
|
+
yield* toolCall('Wait'); // 4.
|
|
528
|
+
yield* TestTraceService.withMeta(
|
|
529
|
+
{ pid: 'sub', parentPid: 'agent' },
|
|
530
|
+
Effect.gen(function* () {
|
|
531
|
+
yield* Trace.write(Trace.OperationStart, { key: 'run', name: 'Run Instructions' }); // 5.
|
|
532
|
+
yield* Trace.write(Trace.CompleteBlock, {
|
|
533
|
+
messageId: MESSAGE_ID,
|
|
534
|
+
role: 'assistant',
|
|
535
|
+
block: { _tag: 'text', text: 'done' },
|
|
536
|
+
}); // 6.
|
|
537
|
+
yield* Trace.write(Trace.OperationEnd, { key: 'run', outcome: 'success' }); // 7.
|
|
538
|
+
}),
|
|
539
|
+
);
|
|
540
|
+
yield* Trace.write(Trace.TaskStatusChanged, {
|
|
541
|
+
taskId: task.id,
|
|
542
|
+
title: 'Delegated',
|
|
543
|
+
status: 'done',
|
|
544
|
+
previousStatus: 'started',
|
|
545
|
+
}); // 8.
|
|
546
|
+
}),
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
const messages = yield* TestTraceService.messages;
|
|
550
|
+
const timeline = buildSessionTimeline({ traceMessages: messages, sessions: [chat.session], tasks: [task] });
|
|
551
|
+
const sessionId = `session:${chat.id}`;
|
|
552
|
+
// The task lane was replaced by the child session, drawn with the child's own span.
|
|
553
|
+
expect(timeline.lanes.find((lane) => lane.id === `task:${task.id}`)).toBeUndefined();
|
|
554
|
+
const subSession = timeline.lanes.find((lane) => lane.id === 'session:sub');
|
|
555
|
+
expect(subSession).toMatchObject({ taskId: task.id, start: 5, end: 7 });
|
|
556
|
+
// The supervisor's own markers stay on the supervisor, rather than moving onto a bar whose
|
|
557
|
+
// span does not contain them.
|
|
558
|
+
const lanesByLabel = new Map(timeline.markers.map((marker) => [marker.label, marker.laneId]));
|
|
559
|
+
expect(lanesByLabel.get('Wait')).toBe(sessionId);
|
|
560
|
+
expect(lanesByLabel.get('Task started')).toBe(sessionId);
|
|
561
|
+
expect(lanesByLabel.get('Delegated')).toBe(sessionId);
|
|
562
|
+
}, Effect.provide(TestTraceService.layer)),
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
it.effect(
|
|
566
|
+
'joins the sub-agent by the delegationSpawned event and sums tokens per lane',
|
|
567
|
+
Effect.fnUntraced(function* ({ expect }) {
|
|
568
|
+
const task = Task.make({ title: 'Write', status: 'done' });
|
|
569
|
+
const chat = makeChat('Tokens', [task]);
|
|
570
|
+
yield* TestTraceService.withMeta(
|
|
571
|
+
{ pid: 'agent', conversation: chat.feed },
|
|
572
|
+
Effect.gen(function* () {
|
|
573
|
+
yield* Trace.write(Trace.AgentRequestBegin, {});
|
|
574
|
+
yield* stats(10, 20, 1);
|
|
575
|
+
yield* Trace.write(Trace.DelegationSpawned, { taskId: task.id, pid: 'sub' });
|
|
576
|
+
yield* Trace.write(Trace.AgentRequestEnd, { status: 'success' });
|
|
577
|
+
yield* TestTraceService.withMeta(
|
|
578
|
+
{ pid: 'sub', parentPid: 'agent' },
|
|
579
|
+
Effect.gen(function* () {
|
|
580
|
+
yield* Trace.write(Trace.OperationStart, { key: 'run', name: 'Run Instructions' });
|
|
581
|
+
yield* stats(100, 200, 2);
|
|
582
|
+
yield* stats(1, 2, 0);
|
|
583
|
+
yield* Trace.write(Trace.OperationEnd, { key: 'run', outcome: 'success' });
|
|
584
|
+
}),
|
|
585
|
+
);
|
|
586
|
+
}),
|
|
587
|
+
);
|
|
588
|
+
|
|
589
|
+
const messages = yield* TestTraceService.messages;
|
|
590
|
+
const timeline = buildSessionTimeline({ traceMessages: messages, sessions: [chat.session], tasks: [task] });
|
|
591
|
+
const session = timeline.lanes.find((lane) => lane.id === `session:${chat.id}`);
|
|
592
|
+
const subSession = timeline.lanes.find((lane) => lane.id === 'session:sub');
|
|
593
|
+
expect(session).toMatchObject({ status: 'done', tokens: { input: 10, output: 20, total: 30 }, toolCalls: 1 });
|
|
594
|
+
expect(subSession).toMatchObject({
|
|
595
|
+
status: 'done',
|
|
596
|
+
start: 5,
|
|
597
|
+
end: 8,
|
|
598
|
+
tokens: { input: 101, output: 202, total: 303 },
|
|
599
|
+
toolCalls: 2,
|
|
600
|
+
});
|
|
601
|
+
const spawn = timeline.markers.find((marker) => marker.kind === 'delegation');
|
|
602
|
+
expect(spawn?.laneId).toBe(session?.id);
|
|
603
|
+
expect(subSession?.delegatedFrom).toEqual({ laneId: session?.id, markerId: spawn?.id });
|
|
604
|
+
}, Effect.provide(TestTraceService.layer)),
|
|
605
|
+
);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
const agentProcess = (pid: string, chat: TestChat, state: Process.State): Process.Info => ({
|
|
609
|
+
pid: Process.ID.make(pid),
|
|
610
|
+
parentPid: null,
|
|
611
|
+
key: 'agent',
|
|
612
|
+
params: {
|
|
613
|
+
name: null,
|
|
614
|
+
annotations: Annotation.buildDictionary((dictionary) => {
|
|
615
|
+
Annotation.setDictionary(dictionary, Process.HarnessHostAnnotation, true);
|
|
616
|
+
Annotation.setDictionary(dictionary, Process.TargetAnnotation, URI.make(chat.session.uri));
|
|
617
|
+
}),
|
|
618
|
+
},
|
|
619
|
+
environment: {},
|
|
620
|
+
state,
|
|
621
|
+
error: null,
|
|
622
|
+
startedAt: 0,
|
|
623
|
+
completedAt: Option.none(),
|
|
624
|
+
metrics: { wallTime: 0, inputCount: 0, outputCount: 0 },
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
/** A chat reduced to what the builder joins on, with the feed ref its trace meta would carry. */
|
|
628
|
+
type TestChat = { id: string; feed: Ref.Ref<Feed.Feed>; session: Session };
|
|
629
|
+
|
|
630
|
+
const makeChat = (name: string, tasks: readonly Task.Task[]): TestChat => {
|
|
631
|
+
const feed = Feed.make();
|
|
632
|
+
const id = EntityId.random();
|
|
633
|
+
return {
|
|
634
|
+
id,
|
|
635
|
+
feed: Ref.make(feed),
|
|
636
|
+
session: {
|
|
637
|
+
id,
|
|
638
|
+
label: name,
|
|
639
|
+
uri: EID.make({ entityId: id }).toString(),
|
|
640
|
+
feedId: feed.id,
|
|
641
|
+
taskIds: tasks.map((task) => task.id),
|
|
642
|
+
},
|
|
643
|
+
};
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
const toolCall = (name: string) =>
|
|
647
|
+
Trace.write(Trace.CompleteBlock, {
|
|
648
|
+
messageId: MESSAGE_ID,
|
|
649
|
+
role: 'assistant',
|
|
650
|
+
block: { _tag: 'toolCall', toolCallId: name, name, input: '{}', providerExecuted: false },
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
const stats = (input: number, output: number, toolCalls: number) =>
|
|
654
|
+
Trace.write(Trace.CompleteBlock, {
|
|
655
|
+
messageId: MESSAGE_ID,
|
|
656
|
+
role: 'assistant',
|
|
657
|
+
block: {
|
|
658
|
+
_tag: 'stats',
|
|
659
|
+
usage: { inputTokens: input, outputTokens: output, totalTokens: input + output },
|
|
660
|
+
toolCalls,
|
|
661
|
+
},
|
|
662
|
+
});
|