@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,1205 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Effect from 'effect/Effect';
|
|
6
|
+
import * as Option from 'effect/Option';
|
|
7
|
+
import { describe, test } from 'vitest';
|
|
8
|
+
|
|
9
|
+
import { RUN_AGAIN_ERROR_CODE, RUN_AGAIN_MESSAGE } from '@dxos/compute';
|
|
10
|
+
import * as Process from '@dxos/compute/Process';
|
|
11
|
+
import * as Trace from '@dxos/compute/Trace';
|
|
12
|
+
import { Annotation } from '@dxos/echo';
|
|
13
|
+
import { EntityId, URI } from '@dxos/keys';
|
|
14
|
+
import { LogLevel } from '@dxos/log';
|
|
15
|
+
import { type Commit, renderTimelineAscii } from '../components/Timeline/index.ts';
|
|
16
|
+
|
|
17
|
+
import { CommitSelector, buildExecutionGraph } from './execution-graph.ts';
|
|
18
|
+
import { collectTraceEvents, withMeta } from './testing/index.ts';
|
|
19
|
+
|
|
20
|
+
EntityId.dangerouslyDisableRandomness();
|
|
21
|
+
|
|
22
|
+
const makeCommit = (id: string, opts: Partial<Commit> = {}): Commit => ({
|
|
23
|
+
id,
|
|
24
|
+
branch: opts.branch ?? 'main',
|
|
25
|
+
message: opts.message ?? id,
|
|
26
|
+
...opts,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const renderGraph = (messages: Trace.Message[], collapseCompletedSpans = false) => {
|
|
30
|
+
const { commits, branches } = buildExecutionGraph({ traceMessages: messages, collapseCompletedSpans });
|
|
31
|
+
return renderTimelineAscii(commits, branches);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
describe('CommitSelector', () => {
|
|
35
|
+
describe('make', () => {
|
|
36
|
+
test('wraps a select function', ({ expect }) => {
|
|
37
|
+
const selector = CommitSelector.make((commits) => commits);
|
|
38
|
+
const a = makeCommit('a');
|
|
39
|
+
expect(selector.select([a])).toEqual([a]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('returns a pipeable selector', ({ expect }) => {
|
|
43
|
+
const selector = CommitSelector.make((commits) => commits);
|
|
44
|
+
expect(typeof selector.pipe).toBe('function');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('does not mutate the input array', ({ expect }) => {
|
|
48
|
+
const input = [makeCommit('a'), makeCommit('b')];
|
|
49
|
+
const before = [...input];
|
|
50
|
+
CommitSelector.identity().select(input);
|
|
51
|
+
expect(input).toEqual(before);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('identity', () => {
|
|
56
|
+
test('returns all commits', ({ expect }) => {
|
|
57
|
+
const a = makeCommit('a');
|
|
58
|
+
const b = makeCommit('b');
|
|
59
|
+
expect(CommitSelector.identity().select([a, b])).toEqual([a, b]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('returns empty for empty input', ({ expect }) => {
|
|
63
|
+
expect(CommitSelector.identity().select([])).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('filter', () => {
|
|
68
|
+
test('keeps commits matching predicate', ({ expect }) => {
|
|
69
|
+
const a = makeCommit('a', { tags: ['x'] });
|
|
70
|
+
const b = makeCommit('b');
|
|
71
|
+
const selector = CommitSelector.filter((commit) => commit.tags?.includes('x'));
|
|
72
|
+
expect(selector.select([a, b])).toEqual([a]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('returns empty array on no match', ({ expect }) => {
|
|
76
|
+
const a = makeCommit('a');
|
|
77
|
+
expect(CommitSelector.filter(() => false).select([a])).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('id', () => {
|
|
82
|
+
test('matches a single commit by id', ({ expect }) => {
|
|
83
|
+
const a = makeCommit('a');
|
|
84
|
+
const b = makeCommit('b');
|
|
85
|
+
expect(CommitSelector.id('b').select([a, b])).toEqual([b]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('returns empty when id is missing', ({ expect }) => {
|
|
89
|
+
expect(CommitSelector.id('missing').select([makeCommit('a')])).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('tag', () => {
|
|
94
|
+
test('matches commits whose tags include the tag', ({ expect }) => {
|
|
95
|
+
const a = makeCommit('a', { tags: ['x'] });
|
|
96
|
+
const b = makeCommit('b', { tags: ['y'] });
|
|
97
|
+
expect(CommitSelector.tag('x').select([a, b])).toEqual([a]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('returns empty for falsy tag (undefined / null / false)', ({ expect }) => {
|
|
101
|
+
const a = makeCommit('a', { tags: ['x'] });
|
|
102
|
+
expect(CommitSelector.tag(undefined).select([a])).toEqual([]);
|
|
103
|
+
expect(CommitSelector.tag(null).select([a])).toEqual([]);
|
|
104
|
+
expect(CommitSelector.tag(false).select([a])).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('returns empty when commit has no tags', ({ expect }) => {
|
|
108
|
+
expect(CommitSelector.tag('x').select([makeCommit('a')])).toEqual([]);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('branch', () => {
|
|
113
|
+
test('matches commits by branch', ({ expect }) => {
|
|
114
|
+
const a = makeCommit('a', { branch: 'main' });
|
|
115
|
+
const b = makeCommit('b', { branch: 'feature' });
|
|
116
|
+
expect(CommitSelector.branch('feature').select([a, b])).toEqual([b]);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('compose', () => {
|
|
121
|
+
test('applies next selector after prev', ({ expect }) => {
|
|
122
|
+
const a = makeCommit('a', { branch: 'main', tags: ['keep'] });
|
|
123
|
+
const b = makeCommit('b', { branch: 'main' });
|
|
124
|
+
const c = makeCommit('c', { branch: 'feature', tags: ['keep'] });
|
|
125
|
+
const selector = CommitSelector.branch('main').pipe(CommitSelector.compose(CommitSelector.tag('keep')));
|
|
126
|
+
expect(selector.select([a, b, c])).toEqual([a]);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('orElse', () => {
|
|
131
|
+
test('returns prev result when prev is non-empty', ({ expect }) => {
|
|
132
|
+
const a = makeCommit('a', { branch: 'main' });
|
|
133
|
+
const b = makeCommit('b', { branch: 'feature' });
|
|
134
|
+
const selector = CommitSelector.branch('main').pipe(CommitSelector.orElse(CommitSelector.branch('feature')));
|
|
135
|
+
expect(selector.select([a, b])).toEqual([a]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('returns next result when prev is empty', ({ expect }) => {
|
|
139
|
+
const b = makeCommit('b', { branch: 'feature' });
|
|
140
|
+
const selector = CommitSelector.branch('main').pipe(CommitSelector.orElse(CommitSelector.branch('feature')));
|
|
141
|
+
expect(selector.select([b])).toEqual([b]);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('last', () => {
|
|
146
|
+
test('returns the last commit by default', ({ expect }) => {
|
|
147
|
+
const a = makeCommit('a');
|
|
148
|
+
const b = makeCommit('b');
|
|
149
|
+
const c = makeCommit('c');
|
|
150
|
+
expect(
|
|
151
|
+
CommitSelector.last()
|
|
152
|
+
.select([a, b, c])
|
|
153
|
+
.map((commit) => commit.id),
|
|
154
|
+
).toEqual(['c']);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('produces the most recent commit on a branch when composed', ({ expect }) => {
|
|
158
|
+
const a = makeCommit('a', { branch: 'main' });
|
|
159
|
+
const b = makeCommit('b', { branch: 'main' });
|
|
160
|
+
const c = makeCommit('c', { branch: 'main' });
|
|
161
|
+
const selector = CommitSelector.branch('main').pipe(CommitSelector.compose(CommitSelector.last()));
|
|
162
|
+
expect(selector.select([a, b, c]).map((commit) => commit.id)).toEqual(['c']);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe('unionAll', () => {
|
|
167
|
+
test('combines results from multiple selectors', ({ expect }) => {
|
|
168
|
+
const a = makeCommit('a', { branch: 'main' });
|
|
169
|
+
const b = makeCommit('b', { branch: 'feature' });
|
|
170
|
+
const selector = CommitSelector.unionAll(CommitSelector.branch('main'), CommitSelector.branch('feature'));
|
|
171
|
+
expect(
|
|
172
|
+
selector
|
|
173
|
+
.select([a, b])
|
|
174
|
+
.map((commit) => commit.id)
|
|
175
|
+
.toSorted(),
|
|
176
|
+
).toEqual(['a', 'b']);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('dedupes by id', ({ expect }) => {
|
|
180
|
+
const a = makeCommit('a', { branch: 'main', tags: ['x'] });
|
|
181
|
+
const selector = CommitSelector.unionAll(CommitSelector.branch('main'), CommitSelector.tag('x'));
|
|
182
|
+
expect(selector.select([a])).toHaveLength(1);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const MESSAGE_ID = '01HQ0000000000000000000000';
|
|
188
|
+
|
|
189
|
+
const SHIMMER_EFFECT_TAG = 'effect:shimmer';
|
|
190
|
+
|
|
191
|
+
const makeActiveProcess = (
|
|
192
|
+
overrides: Partial<Process.Info> & Pick<Process.Info, 'pid' | 'key' | 'state'>,
|
|
193
|
+
): Process.Info => ({
|
|
194
|
+
parentPid: null,
|
|
195
|
+
params: { name: null, annotations: {} },
|
|
196
|
+
environment: {},
|
|
197
|
+
error: null,
|
|
198
|
+
startedAt: 0,
|
|
199
|
+
completedAt: Option.none(),
|
|
200
|
+
metrics: { wallTime: 0, inputCount: 0, outputCount: 0 },
|
|
201
|
+
...overrides,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('buildExecutionGraph (span-tree based)', () => {
|
|
205
|
+
test('empty input → no commits', ({ expect }) => {
|
|
206
|
+
const { commits, branches } = buildExecutionGraph({ traceMessages: [] });
|
|
207
|
+
expect(commits).toEqual([]);
|
|
208
|
+
expect(branches).toEqual(['main']);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('top-level operation with no inner events → collapsed to single end commit on main', ({ expect }) => {
|
|
212
|
+
const messages = collectTraceEvents(
|
|
213
|
+
withMeta(
|
|
214
|
+
{ pid: 'op-1' },
|
|
215
|
+
Effect.gen(function* () {
|
|
216
|
+
yield* Trace.write(Trace.OperationStart, { key: 'reply', name: 'Reply' });
|
|
217
|
+
yield* Trace.write(Trace.OperationEnd, { key: 'reply', name: 'Reply', outcome: 'success' });
|
|
218
|
+
}),
|
|
219
|
+
),
|
|
220
|
+
);
|
|
221
|
+
const { commits, branches } = buildExecutionGraph({ traceMessages: messages });
|
|
222
|
+
expect(branches).toEqual(['main']);
|
|
223
|
+
expect(commits).toHaveLength(1);
|
|
224
|
+
expect(commits[0].branch).toBe('main');
|
|
225
|
+
expect(`\n${renderTimelineAscii(commits, branches)}\n`).toMatchInlineSnapshot(`
|
|
226
|
+
"
|
|
227
|
+
● [function] Reply
|
|
228
|
+
"
|
|
229
|
+
`);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('nested operation under an agent → collapsed to a single end commit', ({ expect }) => {
|
|
233
|
+
const messages = collectTraceEvents(
|
|
234
|
+
Effect.gen(function* () {
|
|
235
|
+
yield* withMeta(
|
|
236
|
+
{ pid: 'agent-1' },
|
|
237
|
+
Effect.gen(function* () {
|
|
238
|
+
yield* Trace.write(Trace.AgentRequestBegin, {});
|
|
239
|
+
yield* Trace.write(Trace.CompleteBlock, {
|
|
240
|
+
messageId: MESSAGE_ID,
|
|
241
|
+
role: 'user',
|
|
242
|
+
block: { _tag: 'text', text: 'hello', pending: false },
|
|
243
|
+
});
|
|
244
|
+
}),
|
|
245
|
+
);
|
|
246
|
+
yield* withMeta(
|
|
247
|
+
{ pid: 'op-1', parentPid: 'agent-1' },
|
|
248
|
+
Effect.gen(function* () {
|
|
249
|
+
yield* Trace.write(Trace.OperationStart, { key: 'lookup', name: 'Lookup' });
|
|
250
|
+
yield* Trace.write(Trace.OperationEnd, { key: 'lookup', name: 'Lookup', outcome: 'success' });
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
yield* withMeta({ pid: 'agent-1' }, Trace.write(Trace.AgentRequestEnd, { status: 'success' }));
|
|
254
|
+
}),
|
|
255
|
+
);
|
|
256
|
+
const { commits, branches } = buildExecutionGraph({ traceMessages: messages });
|
|
257
|
+
expect(branches).toEqual(['main', 'agent-1']);
|
|
258
|
+
expect(`\n${renderTimelineAscii(commits, branches)}\n`).toMatchInlineSnapshot(`
|
|
259
|
+
"
|
|
260
|
+
● [atom] Agent processing request...
|
|
261
|
+
├──● [function] Lookup
|
|
262
|
+
│ ● [user] hello
|
|
263
|
+
◆──╯ [atom] Agent completed request
|
|
264
|
+
"
|
|
265
|
+
`);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('nested operation with its own inner status message → expanded fork/merge', ({ expect }) => {
|
|
269
|
+
const messages = collectTraceEvents(
|
|
270
|
+
withMeta(
|
|
271
|
+
{ pid: 'op-1' },
|
|
272
|
+
Effect.gen(function* () {
|
|
273
|
+
yield* Trace.write(Trace.OperationStart, { key: 'work', name: 'Work' });
|
|
274
|
+
yield* Trace.write(Trace.CompleteBlock, {
|
|
275
|
+
messageId: MESSAGE_ID,
|
|
276
|
+
role: 'assistant',
|
|
277
|
+
block: { _tag: 'status', statusText: 'thinking', pending: false },
|
|
278
|
+
});
|
|
279
|
+
yield* Trace.write(Trace.OperationEnd, { key: 'work', name: 'Work', outcome: 'success' });
|
|
280
|
+
}),
|
|
281
|
+
),
|
|
282
|
+
);
|
|
283
|
+
const { commits } = buildExecutionGraph({ traceMessages: messages });
|
|
284
|
+
// Three commits: begin on main, status on op-1 branch, end on main as merge.
|
|
285
|
+
expect(commits).toHaveLength(3);
|
|
286
|
+
expect(commits[0].branch).toBe('main');
|
|
287
|
+
expect(commits[1].branch).toBe('op-1');
|
|
288
|
+
expect(commits[2].branch).toBe('main');
|
|
289
|
+
expect(commits[2].parents?.length).toBe(2);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('child process (sub-agent) forks onto its own branch from its first event', ({ expect }) => {
|
|
293
|
+
const messages = collectTraceEvents(
|
|
294
|
+
Effect.gen(function* () {
|
|
295
|
+
yield* withMeta({ pid: 'supervisor' }, Trace.write(Trace.AgentRequestBegin, {}));
|
|
296
|
+
// A delegated sub-agent runs as a separate process: its own pid, parented to the supervisor.
|
|
297
|
+
// With an inner status event the span is non-collapsible, so its begin event is emitted as a
|
|
298
|
+
// real fork — and because it crosses a process boundary it forks onto its OWN branch.
|
|
299
|
+
yield* withMeta(
|
|
300
|
+
{ pid: 'sub', parentPid: 'supervisor' },
|
|
301
|
+
Effect.gen(function* () {
|
|
302
|
+
yield* Trace.write(Trace.OperationStart, { key: 'routine', name: 'Run Routine' });
|
|
303
|
+
yield* Trace.write(Trace.CompleteBlock, {
|
|
304
|
+
messageId: MESSAGE_ID,
|
|
305
|
+
role: 'assistant',
|
|
306
|
+
block: { _tag: 'status', statusText: 'working', pending: false },
|
|
307
|
+
});
|
|
308
|
+
yield* Trace.write(Trace.OperationEnd, { key: 'routine', name: 'Run Routine', outcome: 'success' });
|
|
309
|
+
}),
|
|
310
|
+
);
|
|
311
|
+
yield* withMeta({ pid: 'supervisor' }, Trace.write(Trace.AgentRequestEnd, { status: 'success' }));
|
|
312
|
+
}),
|
|
313
|
+
);
|
|
314
|
+
const { commits } = buildExecutionGraph({ traceMessages: messages });
|
|
315
|
+
// The sub-agent's begin ("Run Routine") lands on its OWN branch ('sub'), not the supervisor's —
|
|
316
|
+
// so a concurrent sub-agent gets its own lane from its first event rather than sharing the
|
|
317
|
+
// parent lane until its first middle commit.
|
|
318
|
+
const begin = commits.find((commit) => commit.message === 'Run Routine');
|
|
319
|
+
expect(begin?.branch).toBe('sub');
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test('pending span with user message → user lands on own branch, not parent', ({ expect }) => {
|
|
323
|
+
const messages = collectTraceEvents(
|
|
324
|
+
// Agent has begun and emitted a user message, but has NOT yet completed.
|
|
325
|
+
withMeta(
|
|
326
|
+
{ pid: 'agent-1' },
|
|
327
|
+
Effect.gen(function* () {
|
|
328
|
+
yield* Trace.write(Trace.AgentRequestBegin, {});
|
|
329
|
+
yield* Trace.write(Trace.CompleteBlock, {
|
|
330
|
+
messageId: MESSAGE_ID,
|
|
331
|
+
role: 'user',
|
|
332
|
+
block: { _tag: 'text', text: 'hello', pending: false },
|
|
333
|
+
});
|
|
334
|
+
}),
|
|
335
|
+
),
|
|
336
|
+
);
|
|
337
|
+
const { commits, branches } = buildExecutionGraph({ traceMessages: messages });
|
|
338
|
+
// Two commits: AgentBegin on main (fork), user "hello" on agent-1 branch (middle).
|
|
339
|
+
// No end commit yet — the span is pending.
|
|
340
|
+
expect(commits).toHaveLength(2);
|
|
341
|
+
expect(commits[0].branch).toBe('main');
|
|
342
|
+
expect(commits[0].message).toBe('Agent processing request...');
|
|
343
|
+
expect(commits[1].branch).toBe('agent-1');
|
|
344
|
+
expect(commits[1].message).toBe('hello');
|
|
345
|
+
expect(`\n${renderTimelineAscii(commits, branches)}\n`).toMatchInlineSnapshot(`
|
|
346
|
+
"
|
|
347
|
+
● [atom] Agent processing request...
|
|
348
|
+
├──● [user] hello
|
|
349
|
+
"
|
|
350
|
+
`);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test('pending span with collapsed sub-span then user message → user on own branch', ({ expect }) => {
|
|
354
|
+
const messages = collectTraceEvents(
|
|
355
|
+
Effect.gen(function* () {
|
|
356
|
+
yield* withMeta({ pid: 'agent-1' }, Trace.write(Trace.AgentRequestBegin, {}));
|
|
357
|
+
// Sub-span completes (will be collapsed).
|
|
358
|
+
yield* withMeta(
|
|
359
|
+
{ pid: 'op-1', parentPid: 'agent-1' },
|
|
360
|
+
Effect.gen(function* () {
|
|
361
|
+
yield* Trace.write(Trace.OperationStart, { key: 'lookup', name: 'Lookup' });
|
|
362
|
+
yield* Trace.write(Trace.OperationEnd, { key: 'lookup', name: 'Lookup', outcome: 'success' });
|
|
363
|
+
}),
|
|
364
|
+
);
|
|
365
|
+
// User message arrives after the sub-span; agent is still pending.
|
|
366
|
+
yield* withMeta(
|
|
367
|
+
{ pid: 'agent-1' },
|
|
368
|
+
Trace.write(Trace.CompleteBlock, {
|
|
369
|
+
messageId: '01HQ0000000000000000000001',
|
|
370
|
+
role: 'user',
|
|
371
|
+
block: { _tag: 'text', text: 'hello', pending: false },
|
|
372
|
+
}),
|
|
373
|
+
);
|
|
374
|
+
}),
|
|
375
|
+
);
|
|
376
|
+
const { commits } = buildExecutionGraph({ traceMessages: messages });
|
|
377
|
+
// AgentBegin on main, Lookup-Success on agent branch, user "hello" on agent branch.
|
|
378
|
+
expect(commits).toHaveLength(3);
|
|
379
|
+
expect(commits[0].branch).toBe('main');
|
|
380
|
+
expect(commits[1].message).toBe('Lookup');
|
|
381
|
+
expect(commits[1].branch).toBe('agent-1');
|
|
382
|
+
expect(commits[2].branch).toBe('agent-1');
|
|
383
|
+
expect(commits[2].message).toBe('hello');
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test('orders sub-spans categorically under their parent', ({ expect }) => {
|
|
387
|
+
const messages = collectTraceEvents(
|
|
388
|
+
Effect.gen(function* () {
|
|
389
|
+
yield* withMeta({ pid: 'agent-1' }, Trace.write(Trace.AgentRequestBegin, {}));
|
|
390
|
+
yield* withMeta(
|
|
391
|
+
{ pid: 'op-1', parentPid: 'agent-1' },
|
|
392
|
+
Effect.gen(function* () {
|
|
393
|
+
yield* Trace.write(Trace.OperationStart, { key: 'a', name: 'A' });
|
|
394
|
+
yield* Trace.write(Trace.OperationEnd, { key: 'a', name: 'A', outcome: 'success' });
|
|
395
|
+
}),
|
|
396
|
+
);
|
|
397
|
+
yield* withMeta(
|
|
398
|
+
{ pid: 'op-2', parentPid: 'agent-1' },
|
|
399
|
+
Effect.gen(function* () {
|
|
400
|
+
yield* Trace.write(Trace.OperationStart, { key: 'b', name: 'B' });
|
|
401
|
+
yield* Trace.write(Trace.OperationEnd, { key: 'b', name: 'B', outcome: 'success' });
|
|
402
|
+
}),
|
|
403
|
+
);
|
|
404
|
+
yield* withMeta({ pid: 'agent-1' }, Trace.write(Trace.AgentRequestEnd, { status: 'success' }));
|
|
405
|
+
}),
|
|
406
|
+
);
|
|
407
|
+
const { commits } = buildExecutionGraph({ traceMessages: messages });
|
|
408
|
+
// After collapsing, expect: AgentBegin, Op1.End, Op2.End, AgentEnd → 4 commits.
|
|
409
|
+
expect(commits.map((commit) => commit.message)).toEqual([
|
|
410
|
+
'Agent processing request...',
|
|
411
|
+
'A',
|
|
412
|
+
'B',
|
|
413
|
+
'Agent completed request',
|
|
414
|
+
]);
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
describe('buildExecutionGraph scenarios', () => {
|
|
419
|
+
/**
|
|
420
|
+
* Single top-level operation invocation. With no inner activity the span is collapsible
|
|
421
|
+
* and renders as a single end commit on `main`.
|
|
422
|
+
*/
|
|
423
|
+
test('single top-level operation collapses to one commit on main', ({ expect }) => {
|
|
424
|
+
const messages = collectTraceEvents(
|
|
425
|
+
withMeta(
|
|
426
|
+
{ pid: 'op-1' },
|
|
427
|
+
Effect.gen(function* () {
|
|
428
|
+
yield* Trace.write(Trace.OperationStart, { key: 'reply', name: 'Reply' });
|
|
429
|
+
yield* Trace.write(Trace.OperationEnd, { key: 'reply', name: 'Reply', outcome: 'success' });
|
|
430
|
+
}),
|
|
431
|
+
),
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
expect(`\n${renderGraph(messages)}\n`).toMatchInlineSnapshot(`
|
|
435
|
+
"
|
|
436
|
+
● [function] Reply
|
|
437
|
+
"
|
|
438
|
+
`);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Top-level operation containing one nested operation. The parent has a child so it cannot
|
|
443
|
+
* collapse — it forks to its own branch on begin and merges back on end. The nested op is
|
|
444
|
+
* still collapsible so it emits a single end commit on the parent's branch.
|
|
445
|
+
*/
|
|
446
|
+
test('top-level operation with one child forks then merges', ({ expect }) => {
|
|
447
|
+
const messages = collectTraceEvents(
|
|
448
|
+
Effect.gen(function* () {
|
|
449
|
+
yield* withMeta({ pid: 'parent-1' }, Trace.write(Trace.OperationStart, { key: 'parent', name: 'Parent' }));
|
|
450
|
+
yield* withMeta(
|
|
451
|
+
{ pid: 'child-1', parentPid: 'parent-1' },
|
|
452
|
+
Effect.gen(function* () {
|
|
453
|
+
yield* Trace.write(Trace.OperationStart, { key: 'child', name: 'Child' });
|
|
454
|
+
yield* Trace.write(Trace.OperationEnd, { key: 'child', name: 'Child', outcome: 'success' });
|
|
455
|
+
}),
|
|
456
|
+
);
|
|
457
|
+
yield* withMeta(
|
|
458
|
+
{ pid: 'parent-1' },
|
|
459
|
+
Trace.write(Trace.OperationEnd, { key: 'parent', name: 'Parent', outcome: 'success' }),
|
|
460
|
+
);
|
|
461
|
+
}),
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
expect(`\n${renderGraph(messages)}\n`).toMatchInlineSnapshot(`
|
|
465
|
+
"
|
|
466
|
+
● [function] Parent
|
|
467
|
+
├──● [function] Child
|
|
468
|
+
◆──╯ [function] Parent
|
|
469
|
+
"
|
|
470
|
+
`);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Top-level operation with three sequential collapsible children. Each child renders one
|
|
475
|
+
* commit on the parent's own branch in chronological order, then the parent merges back.
|
|
476
|
+
*/
|
|
477
|
+
test('top-level operation with N sequential children renders one commit per child', ({ expect }) => {
|
|
478
|
+
const messages = collectTraceEvents(
|
|
479
|
+
Effect.gen(function* () {
|
|
480
|
+
yield* withMeta({ pid: 'parent-1' }, Trace.write(Trace.OperationStart, { key: 'parent', name: 'Parent' }));
|
|
481
|
+
for (const name of ['Alpha', 'Beta', 'Gamma']) {
|
|
482
|
+
yield* withMeta(
|
|
483
|
+
{ pid: `child-${name}`, parentPid: 'parent-1' },
|
|
484
|
+
Effect.gen(function* () {
|
|
485
|
+
yield* Trace.write(Trace.OperationStart, { key: name.toLowerCase(), name });
|
|
486
|
+
yield* Trace.write(Trace.OperationEnd, { key: name.toLowerCase(), name, outcome: 'success' });
|
|
487
|
+
}),
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
yield* withMeta(
|
|
491
|
+
{ pid: 'parent-1' },
|
|
492
|
+
Trace.write(Trace.OperationEnd, { key: 'parent', name: 'Parent', outcome: 'success' }),
|
|
493
|
+
);
|
|
494
|
+
}),
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
expect(`\n${renderGraph(messages)}\n`).toMatchInlineSnapshot(`
|
|
498
|
+
"
|
|
499
|
+
● [function] Parent
|
|
500
|
+
├──● [function] Alpha
|
|
501
|
+
│ ● [function] Beta
|
|
502
|
+
│ ● [function] Gamma
|
|
503
|
+
◆──╯ [function] Parent
|
|
504
|
+
"
|
|
505
|
+
`);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Two overlapping top-level operations (different pids, both parented at root). R2 starts
|
|
510
|
+
* before R1 ends. Each routine has a nested sub-operation so they cannot collapse — they
|
|
511
|
+
* occupy their own branches off main. Regression: end commits must anchor to the immediate
|
|
512
|
+
* predecessor on main rather than skipping back to their matching begin commit.
|
|
513
|
+
*/
|
|
514
|
+
test('two overlapping top-level operations stay on independent branches without skip-back edges', ({ expect }) => {
|
|
515
|
+
const messages = collectTraceEvents(
|
|
516
|
+
Effect.gen(function* () {
|
|
517
|
+
yield* withMeta({ pid: 'r1' }, Trace.write(Trace.OperationStart, { key: 'routine', name: 'Run Routine 1' }));
|
|
518
|
+
yield* withMeta({ pid: 'r2' }, Trace.write(Trace.OperationStart, { key: 'routine', name: 'Run Routine 2' }));
|
|
519
|
+
yield* withMeta(
|
|
520
|
+
{ pid: 'lookup-1', parentPid: 'r1' },
|
|
521
|
+
Effect.gen(function* () {
|
|
522
|
+
yield* Trace.write(Trace.OperationStart, { key: 'lookup', name: 'Lookup A' });
|
|
523
|
+
yield* Trace.write(Trace.OperationEnd, { key: 'lookup', name: 'Lookup A', outcome: 'success' });
|
|
524
|
+
}),
|
|
525
|
+
);
|
|
526
|
+
yield* withMeta(
|
|
527
|
+
{ pid: 'lookup-2', parentPid: 'r2' },
|
|
528
|
+
Effect.gen(function* () {
|
|
529
|
+
yield* Trace.write(Trace.OperationStart, { key: 'lookup', name: 'Lookup B' });
|
|
530
|
+
yield* Trace.write(Trace.OperationEnd, { key: 'lookup', name: 'Lookup B', outcome: 'success' });
|
|
531
|
+
}),
|
|
532
|
+
);
|
|
533
|
+
yield* withMeta(
|
|
534
|
+
{ pid: 'r1' },
|
|
535
|
+
Trace.write(Trace.OperationEnd, { key: 'routine', name: 'Run Routine 1', outcome: 'success' }),
|
|
536
|
+
);
|
|
537
|
+
yield* withMeta(
|
|
538
|
+
{ pid: 'r2' },
|
|
539
|
+
Trace.write(Trace.OperationEnd, { key: 'routine', name: 'Run Routine 2', outcome: 'success' }),
|
|
540
|
+
);
|
|
541
|
+
}),
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
const { commits, branches } = buildExecutionGraph({ traceMessages: messages });
|
|
545
|
+
// Sanity check the regression invariant: every parent edge on main connects to the
|
|
546
|
+
// immediate predecessor on main, never to an older commit (which would render as a
|
|
547
|
+
// skip-back edge crossing intervening commits).
|
|
548
|
+
const mainCommits = commits.filter((commit) => commit.branch === 'main');
|
|
549
|
+
const mainIndexById = new Map(mainCommits.map((commit, index) => [commit.id, index]));
|
|
550
|
+
for (let index = 1; index < mainCommits.length; index += 1) {
|
|
551
|
+
const onMainParents = (mainCommits[index].parents ?? []).filter((id) => mainIndexById.has(id));
|
|
552
|
+
expect(onMainParents.length).toBeGreaterThan(0);
|
|
553
|
+
for (const parentId of onMainParents) {
|
|
554
|
+
expect(mainIndexById.get(parentId)).toBe(index - 1);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
expect(`\n${renderTimelineAscii(commits, branches)}\n`).toMatchInlineSnapshot(`
|
|
559
|
+
"
|
|
560
|
+
● [function] Run Routine 1
|
|
561
|
+
├──● [function] Lookup A
|
|
562
|
+
◆──╯ [function] Run Routine 1
|
|
563
|
+
● │ [function] Run Routine 2
|
|
564
|
+
├──● [function] Lookup B
|
|
565
|
+
◆──╯ [function] Run Routine 2
|
|
566
|
+
"
|
|
567
|
+
`);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Two sequential agent sessions sharing the same pid. Each request is an independent span
|
|
572
|
+
* but they share a branch (both pid="session-1"), so middle events chain across the gap
|
|
573
|
+
* between sessions. Matches the existing `'sequential prompts in a session'` ASCII snapshot
|
|
574
|
+
* style produced by real conversations.
|
|
575
|
+
*/
|
|
576
|
+
test('two sequential agent sessions sharing a pid chain on the same branch', ({ expect }) => {
|
|
577
|
+
const messages = collectTraceEvents(
|
|
578
|
+
withMeta(
|
|
579
|
+
{ pid: 'session-1' },
|
|
580
|
+
Effect.gen(function* () {
|
|
581
|
+
yield* Trace.write(Trace.AgentRequestBegin, {});
|
|
582
|
+
yield* Trace.write(Trace.CompleteBlock, {
|
|
583
|
+
messageId: MESSAGE_ID,
|
|
584
|
+
role: 'user',
|
|
585
|
+
block: { _tag: 'text', text: 'First question', pending: false },
|
|
586
|
+
});
|
|
587
|
+
yield* Trace.write(Trace.AgentRequestEnd, { status: 'success' });
|
|
588
|
+
yield* Trace.write(Trace.AgentRequestBegin, {});
|
|
589
|
+
yield* Trace.write(Trace.CompleteBlock, {
|
|
590
|
+
messageId: MESSAGE_ID,
|
|
591
|
+
role: 'user',
|
|
592
|
+
block: { _tag: 'text', text: 'Follow-up question', pending: false },
|
|
593
|
+
});
|
|
594
|
+
yield* Trace.write(Trace.AgentRequestEnd, { status: 'success' });
|
|
595
|
+
}),
|
|
596
|
+
),
|
|
597
|
+
);
|
|
598
|
+
|
|
599
|
+
expect(`\n${renderGraph(messages)}\n`).toMatchInlineSnapshot(`
|
|
600
|
+
"
|
|
601
|
+
● [atom] Agent processing request...
|
|
602
|
+
├──● [user] First question
|
|
603
|
+
◆──╯ [atom] Agent completed request
|
|
604
|
+
● │ [atom] Agent processing request...
|
|
605
|
+
│ ● [user] Follow-up question
|
|
606
|
+
◆──╯ [atom] Agent completed request
|
|
607
|
+
"
|
|
608
|
+
`);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Top-level operation interleaved with several status updates between begin and end.
|
|
613
|
+
* Status events are middle events: they render on the operation's own branch and the end
|
|
614
|
+
* commit merges them back into main.
|
|
615
|
+
*/
|
|
616
|
+
test('mixed boundary and status events keep statuses on the op branch and merge at end', ({ expect }) => {
|
|
617
|
+
const messages = collectTraceEvents(
|
|
618
|
+
withMeta(
|
|
619
|
+
{ pid: 'op-1' },
|
|
620
|
+
Effect.gen(function* () {
|
|
621
|
+
yield* Trace.write(Trace.OperationStart, { key: 'work', name: 'Work' });
|
|
622
|
+
yield* Trace.write(Trace.CompleteBlock, {
|
|
623
|
+
messageId: MESSAGE_ID,
|
|
624
|
+
role: 'assistant',
|
|
625
|
+
block: { _tag: 'status', statusText: 'thinking', pending: false },
|
|
626
|
+
});
|
|
627
|
+
yield* Trace.write(Trace.CompleteBlock, {
|
|
628
|
+
messageId: MESSAGE_ID,
|
|
629
|
+
role: 'assistant',
|
|
630
|
+
block: { _tag: 'status', statusText: 'searching', pending: false },
|
|
631
|
+
});
|
|
632
|
+
yield* Trace.write(Trace.CompleteBlock, {
|
|
633
|
+
messageId: MESSAGE_ID,
|
|
634
|
+
role: 'assistant',
|
|
635
|
+
block: { _tag: 'status', statusText: 'writing', pending: false },
|
|
636
|
+
});
|
|
637
|
+
yield* Trace.write(Trace.OperationEnd, { key: 'work', name: 'Work', outcome: 'success' });
|
|
638
|
+
}),
|
|
639
|
+
),
|
|
640
|
+
);
|
|
641
|
+
|
|
642
|
+
expect(`\n${renderGraph(messages)}\n`).toMatchInlineSnapshot(`
|
|
643
|
+
"
|
|
644
|
+
● [function] Work
|
|
645
|
+
├──● [info] thinking
|
|
646
|
+
│ ● [info] searching
|
|
647
|
+
│ ● [info] writing
|
|
648
|
+
◆──╯ [function] Work
|
|
649
|
+
"
|
|
650
|
+
`);
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Plain (non-operation) tool calls — e.g. a toolkit handler or an MCP tool — render as a
|
|
655
|
+
* single auto-collapsed commit on the agent's branch so the timeline shows that work
|
|
656
|
+
* happened without forking a dedicated routine span.
|
|
657
|
+
*/
|
|
658
|
+
test('non-operation tool call renders as a single commit on the agent branch', ({ expect }) => {
|
|
659
|
+
const messages = collectTraceEvents(
|
|
660
|
+
withMeta(
|
|
661
|
+
{ pid: 'agent-1' },
|
|
662
|
+
Effect.gen(function* () {
|
|
663
|
+
yield* Trace.write(Trace.AgentRequestBegin, {});
|
|
664
|
+
yield* Trace.write(Trace.CompleteBlock, {
|
|
665
|
+
messageId: MESSAGE_ID,
|
|
666
|
+
role: 'assistant',
|
|
667
|
+
block: {
|
|
668
|
+
_tag: 'toolCall',
|
|
669
|
+
toolCallId: 'call-1',
|
|
670
|
+
name: 'web-search',
|
|
671
|
+
input: '{"query":"hello"}',
|
|
672
|
+
providerExecuted: false,
|
|
673
|
+
pending: false,
|
|
674
|
+
},
|
|
675
|
+
});
|
|
676
|
+
yield* Trace.write(Trace.AgentRequestEnd, { status: 'success' });
|
|
677
|
+
}),
|
|
678
|
+
),
|
|
679
|
+
);
|
|
680
|
+
|
|
681
|
+
expect(`\n${renderGraph(messages)}\n`).toMatchInlineSnapshot(`
|
|
682
|
+
"
|
|
683
|
+
● [atom] Agent processing request...
|
|
684
|
+
├──● [wrench] web-search
|
|
685
|
+
◆──╯ [atom] Agent completed request
|
|
686
|
+
"
|
|
687
|
+
`);
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Completed (toolCall + toolResult) pair collapses to a single result commit, mirroring how
|
|
692
|
+
* an operation begin/end span with no inner activity collapses to its end commit. The
|
|
693
|
+
* pending toolCall is suppressed once the result arrives because the result commit carries
|
|
694
|
+
* the final success/error state.
|
|
695
|
+
*/
|
|
696
|
+
test('tool call + tool result pair collapses to a single result commit', ({ expect }) => {
|
|
697
|
+
const messages = collectTraceEvents(
|
|
698
|
+
withMeta(
|
|
699
|
+
{ pid: 'agent-1' },
|
|
700
|
+
Effect.gen(function* () {
|
|
701
|
+
yield* Trace.write(Trace.AgentRequestBegin, {});
|
|
702
|
+
yield* Trace.write(Trace.CompleteBlock, {
|
|
703
|
+
messageId: MESSAGE_ID,
|
|
704
|
+
role: 'assistant',
|
|
705
|
+
block: {
|
|
706
|
+
_tag: 'toolCall',
|
|
707
|
+
toolCallId: 'call-1',
|
|
708
|
+
name: 'web-search',
|
|
709
|
+
input: '{"query":"hello"}',
|
|
710
|
+
providerExecuted: false,
|
|
711
|
+
pending: false,
|
|
712
|
+
},
|
|
713
|
+
});
|
|
714
|
+
yield* Trace.write(Trace.CompleteBlock, {
|
|
715
|
+
messageId: MESSAGE_ID,
|
|
716
|
+
role: 'tool',
|
|
717
|
+
block: {
|
|
718
|
+
_tag: 'toolResult',
|
|
719
|
+
toolCallId: 'call-1',
|
|
720
|
+
name: 'web-search',
|
|
721
|
+
result: '"ok"',
|
|
722
|
+
providerExecuted: false,
|
|
723
|
+
pending: false,
|
|
724
|
+
},
|
|
725
|
+
});
|
|
726
|
+
yield* Trace.write(Trace.AgentRequestEnd, { status: 'success' });
|
|
727
|
+
}),
|
|
728
|
+
),
|
|
729
|
+
);
|
|
730
|
+
|
|
731
|
+
expect(`\n${renderGraph(messages)}\n`).toMatchInlineSnapshot(`
|
|
732
|
+
"
|
|
733
|
+
● [atom] Agent processing request...
|
|
734
|
+
├──● [wrench] web-search
|
|
735
|
+
◆──╯ [atom] Agent completed request
|
|
736
|
+
"
|
|
737
|
+
`);
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Error results render as a single commit with an error level and an " - Error" suffix —
|
|
742
|
+
* matches the operation-end error convention.
|
|
743
|
+
*/
|
|
744
|
+
test('tool call with failing tool result renders as a single error commit', ({ expect }) => {
|
|
745
|
+
const messages = collectTraceEvents(
|
|
746
|
+
withMeta(
|
|
747
|
+
{ pid: 'agent-1' },
|
|
748
|
+
Effect.gen(function* () {
|
|
749
|
+
yield* Trace.write(Trace.AgentRequestBegin, {});
|
|
750
|
+
yield* Trace.write(Trace.CompleteBlock, {
|
|
751
|
+
messageId: MESSAGE_ID,
|
|
752
|
+
role: 'assistant',
|
|
753
|
+
block: {
|
|
754
|
+
_tag: 'toolCall',
|
|
755
|
+
toolCallId: 'call-1',
|
|
756
|
+
name: 'flaky',
|
|
757
|
+
input: '{}',
|
|
758
|
+
providerExecuted: false,
|
|
759
|
+
pending: false,
|
|
760
|
+
},
|
|
761
|
+
});
|
|
762
|
+
yield* Trace.write(Trace.CompleteBlock, {
|
|
763
|
+
messageId: MESSAGE_ID,
|
|
764
|
+
role: 'tool',
|
|
765
|
+
block: {
|
|
766
|
+
_tag: 'toolResult',
|
|
767
|
+
toolCallId: 'call-1',
|
|
768
|
+
name: 'flaky',
|
|
769
|
+
error: 'boom',
|
|
770
|
+
providerExecuted: false,
|
|
771
|
+
pending: false,
|
|
772
|
+
},
|
|
773
|
+
});
|
|
774
|
+
yield* Trace.write(Trace.AgentRequestEnd, { status: 'success' });
|
|
775
|
+
}),
|
|
776
|
+
),
|
|
777
|
+
);
|
|
778
|
+
|
|
779
|
+
expect(`\n${renderGraph(messages)}\n`).toMatchInlineSnapshot(`
|
|
780
|
+
"
|
|
781
|
+
● [atom] Agent processing request...
|
|
782
|
+
├──● [wrench] flaky - Error
|
|
783
|
+
◆──╯ [atom] Agent completed request
|
|
784
|
+
"
|
|
785
|
+
`);
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Operation-backed tool calls (those carrying an `operationKey`) are suppressed in the
|
|
790
|
+
* timeline because the surrounding `Trace.OperationStart` / `Trace.OperationEnd` span
|
|
791
|
+
* already produces a routine commit. Otherwise the user would see two entries for the
|
|
792
|
+
* same logical invocation.
|
|
793
|
+
*/
|
|
794
|
+
test('operation-backed tool call is suppressed in favor of the routine span', ({ expect }) => {
|
|
795
|
+
const messages = collectTraceEvents(
|
|
796
|
+
Effect.gen(function* () {
|
|
797
|
+
yield* withMeta(
|
|
798
|
+
{ pid: 'agent-1' },
|
|
799
|
+
Effect.gen(function* () {
|
|
800
|
+
yield* Trace.write(Trace.AgentRequestBegin, {});
|
|
801
|
+
yield* Trace.write(Trace.CompleteBlock, {
|
|
802
|
+
messageId: MESSAGE_ID,
|
|
803
|
+
role: 'assistant',
|
|
804
|
+
block: {
|
|
805
|
+
_tag: 'toolCall',
|
|
806
|
+
toolCallId: 'call-1',
|
|
807
|
+
name: 'lookup-something',
|
|
808
|
+
input: '{}',
|
|
809
|
+
providerExecuted: false,
|
|
810
|
+
pending: false,
|
|
811
|
+
operationKey: 'lookup',
|
|
812
|
+
operationName: 'Lookup',
|
|
813
|
+
},
|
|
814
|
+
});
|
|
815
|
+
}),
|
|
816
|
+
);
|
|
817
|
+
yield* withMeta(
|
|
818
|
+
{ pid: 'op-1', parentPid: 'agent-1' },
|
|
819
|
+
Effect.gen(function* () {
|
|
820
|
+
yield* Trace.write(Trace.OperationStart, { key: 'lookup', name: 'Lookup' });
|
|
821
|
+
yield* Trace.write(Trace.OperationEnd, { key: 'lookup', name: 'Lookup', outcome: 'success' });
|
|
822
|
+
}),
|
|
823
|
+
);
|
|
824
|
+
yield* withMeta({ pid: 'agent-1' }, Trace.write(Trace.AgentRequestEnd, { status: 'success' }));
|
|
825
|
+
}),
|
|
826
|
+
);
|
|
827
|
+
|
|
828
|
+
expect(`\n${renderGraph(messages)}\n`).toMatchInlineSnapshot(`
|
|
829
|
+
"
|
|
830
|
+
● [atom] Agent processing request...
|
|
831
|
+
├──● [function] Lookup
|
|
832
|
+
◆──╯ [atom] Agent completed request
|
|
833
|
+
"
|
|
834
|
+
`);
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Failed agent requests surface as an error-level end commit with the failure message.
|
|
839
|
+
*/
|
|
840
|
+
test('agent request end with error status renders as error commit', ({ expect }) => {
|
|
841
|
+
const messages = collectTraceEvents(
|
|
842
|
+
withMeta(
|
|
843
|
+
{ pid: 'agent-1' },
|
|
844
|
+
Effect.gen(function* () {
|
|
845
|
+
yield* Trace.write(Trace.AgentRequestBegin, {});
|
|
846
|
+
yield* Trace.write(Trace.CompleteBlock, {
|
|
847
|
+
messageId: MESSAGE_ID,
|
|
848
|
+
role: 'user',
|
|
849
|
+
block: { _tag: 'text', text: 'hello', pending: false },
|
|
850
|
+
});
|
|
851
|
+
yield* Trace.write(Trace.AgentRequestEnd, {
|
|
852
|
+
status: 'error',
|
|
853
|
+
error: 'Unsupported schema AST: UnknownKeyword',
|
|
854
|
+
});
|
|
855
|
+
}),
|
|
856
|
+
),
|
|
857
|
+
);
|
|
858
|
+
|
|
859
|
+
const { commits } = buildExecutionGraph({ traceMessages: messages });
|
|
860
|
+
const endCommit = commits.at(-1);
|
|
861
|
+
expect(endCommit?.message).toBe('Agent request failed: Unsupported schema AST: UnknownKeyword');
|
|
862
|
+
expect(endCommit?.level).toBe(LogLevel.ERROR);
|
|
863
|
+
expect(`\n${renderGraph(messages)}\n`).toMatchInlineSnapshot(`
|
|
864
|
+
"
|
|
865
|
+
● [atom] Agent processing request...
|
|
866
|
+
├──● [user] hello
|
|
867
|
+
◆──╯ [atom] Agent request failed: Unsupported schema AST: UnknownKeyword
|
|
868
|
+
"
|
|
869
|
+
`);
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Interrupted agent requests surface as a warning-level end commit.
|
|
874
|
+
*/
|
|
875
|
+
test('agent request end with interrupted status renders as interrupted commit', ({ expect }) => {
|
|
876
|
+
const messages = collectTraceEvents(
|
|
877
|
+
withMeta(
|
|
878
|
+
{ pid: 'agent-1' },
|
|
879
|
+
Effect.gen(function* () {
|
|
880
|
+
yield* Trace.write(Trace.AgentRequestBegin, {});
|
|
881
|
+
yield* Trace.write(Trace.AgentRequestEnd, { status: 'interrupted' });
|
|
882
|
+
}),
|
|
883
|
+
),
|
|
884
|
+
);
|
|
885
|
+
|
|
886
|
+
const { commits } = buildExecutionGraph({ traceMessages: messages });
|
|
887
|
+
const endCommit = commits.at(-1);
|
|
888
|
+
expect(endCommit?.message).toBe('Agent request interrupted');
|
|
889
|
+
expect(endCommit?.level).toBe(LogLevel.WARN);
|
|
890
|
+
// Begin/end only — collapsible span renders a single end commit on main.
|
|
891
|
+
expect(`\n${renderGraph(messages)}\n`).toMatchInlineSnapshot(`
|
|
892
|
+
"
|
|
893
|
+
● [atom] Agent request interrupted
|
|
894
|
+
"
|
|
895
|
+
`);
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Orphan `OperationEnd` event — no matching `OperationStart` was captured. The span tree
|
|
900
|
+
* attaches it to the synthetic root rather than crashing, so the renderer produces a
|
|
901
|
+
* single commit on main.
|
|
902
|
+
*/
|
|
903
|
+
test('orphan end event without a matching begin attaches to root without crashing', ({ expect }) => {
|
|
904
|
+
const messages = collectTraceEvents(
|
|
905
|
+
withMeta({ pid: 'ghost' }, Trace.write(Trace.OperationEnd, { key: 'lost', name: 'Lost', outcome: 'failure' })),
|
|
906
|
+
);
|
|
907
|
+
|
|
908
|
+
const { commits, branches } = buildExecutionGraph({ traceMessages: messages });
|
|
909
|
+
expect(commits).toHaveLength(1);
|
|
910
|
+
expect(commits[0].branch).toBe('main');
|
|
911
|
+
expect(`\n${renderTimelineAscii(commits, branches)}\n`).toMatchInlineSnapshot(`
|
|
912
|
+
"
|
|
913
|
+
● [function] Lost - Error
|
|
914
|
+
"
|
|
915
|
+
`);
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* A `RunAgainError` (Operation.runAgain) is recorded as a `failure` tagged with the run-again error
|
|
920
|
+
* code. The UI detects this and presents it as a warn-level ` - Incomplete` state, never as an error.
|
|
921
|
+
*/
|
|
922
|
+
test('run-again failure renders as a warn-level incomplete commit, not an error', ({ expect }) => {
|
|
923
|
+
const messages = collectTraceEvents(
|
|
924
|
+
withMeta(
|
|
925
|
+
{ pid: 'op-1' },
|
|
926
|
+
Effect.gen(function* () {
|
|
927
|
+
yield* Trace.write(Trace.OperationStart, { key: 'sync', name: 'Sync Google Mail' });
|
|
928
|
+
yield* Trace.write(Trace.OperationEnd, {
|
|
929
|
+
key: 'sync',
|
|
930
|
+
name: 'Sync Google Mail',
|
|
931
|
+
outcome: 'failure',
|
|
932
|
+
error: 'Run again',
|
|
933
|
+
errorCode: RUN_AGAIN_ERROR_CODE,
|
|
934
|
+
});
|
|
935
|
+
}),
|
|
936
|
+
),
|
|
937
|
+
);
|
|
938
|
+
|
|
939
|
+
const { commits } = buildExecutionGraph({ traceMessages: messages });
|
|
940
|
+
const endCommit = commits.find((commit) => commit.id.endsWith('sync:end'));
|
|
941
|
+
expect(endCommit?.message).toBe('Sync Google Mail - Incomplete');
|
|
942
|
+
expect(endCommit?.level).toBe(LogLevel.WARN);
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Legacy events persisted before `errorCode` existed carry only the run-again message. They must
|
|
947
|
+
* still render as incomplete via the message fallback.
|
|
948
|
+
*/
|
|
949
|
+
test('legacy run-again failure (message only, no errorCode) renders as incomplete', ({ expect }) => {
|
|
950
|
+
const messages = collectTraceEvents(
|
|
951
|
+
withMeta(
|
|
952
|
+
{ pid: 'op-1' },
|
|
953
|
+
Effect.gen(function* () {
|
|
954
|
+
yield* Trace.write(Trace.OperationStart, { key: 'sync', name: 'Sync Google Mail' });
|
|
955
|
+
yield* Trace.write(Trace.OperationEnd, {
|
|
956
|
+
key: 'sync',
|
|
957
|
+
name: 'Sync Google Mail',
|
|
958
|
+
outcome: 'failure',
|
|
959
|
+
error: RUN_AGAIN_MESSAGE,
|
|
960
|
+
});
|
|
961
|
+
}),
|
|
962
|
+
),
|
|
963
|
+
);
|
|
964
|
+
|
|
965
|
+
const { commits } = buildExecutionGraph({ traceMessages: messages });
|
|
966
|
+
const endCommit = commits.find((commit) => commit.id.endsWith('sync:end'));
|
|
967
|
+
expect(endCommit?.message).toBe('Sync Google Mail - Incomplete');
|
|
968
|
+
expect(endCommit?.level).toBe(LogLevel.WARN);
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* A genuine operation failure (any error code other than the run-again one) still renders as an error.
|
|
973
|
+
*/
|
|
974
|
+
test('non-run-again failure still renders as an error commit', ({ expect }) => {
|
|
975
|
+
const messages = collectTraceEvents(
|
|
976
|
+
withMeta(
|
|
977
|
+
{ pid: 'op-1' },
|
|
978
|
+
Effect.gen(function* () {
|
|
979
|
+
yield* Trace.write(Trace.OperationStart, { key: 'sync', name: 'Sync Google Mail' });
|
|
980
|
+
yield* Trace.write(Trace.OperationEnd, {
|
|
981
|
+
key: 'sync',
|
|
982
|
+
name: 'Sync Google Mail',
|
|
983
|
+
outcome: 'failure',
|
|
984
|
+
error: 'Network unavailable',
|
|
985
|
+
errorCode: 'NetworkError',
|
|
986
|
+
});
|
|
987
|
+
}),
|
|
988
|
+
),
|
|
989
|
+
);
|
|
990
|
+
|
|
991
|
+
const { commits } = buildExecutionGraph({ traceMessages: messages });
|
|
992
|
+
const endCommit = commits.find((commit) => commit.id.endsWith('sync:end'));
|
|
993
|
+
expect(endCommit?.message).toBe('Sync Google Mail - Error');
|
|
994
|
+
expect(endCommit?.level).toBe(LogLevel.ERROR);
|
|
995
|
+
});
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
describe('buildExecutionGraph collapseCompletedSpans', () => {
|
|
999
|
+
test('completed span with middle status events collapses to a single end commit', ({ expect }) => {
|
|
1000
|
+
const messages = collectTraceEvents(
|
|
1001
|
+
withMeta(
|
|
1002
|
+
{ pid: 'op-1' },
|
|
1003
|
+
Effect.gen(function* () {
|
|
1004
|
+
yield* Trace.write(Trace.OperationStart, { key: 'work', name: 'Work' });
|
|
1005
|
+
yield* Trace.write(Trace.CompleteBlock, {
|
|
1006
|
+
messageId: MESSAGE_ID,
|
|
1007
|
+
role: 'assistant',
|
|
1008
|
+
block: { _tag: 'status', statusText: 'thinking', pending: false },
|
|
1009
|
+
});
|
|
1010
|
+
yield* Trace.write(Trace.CompleteBlock, {
|
|
1011
|
+
messageId: MESSAGE_ID,
|
|
1012
|
+
role: 'assistant',
|
|
1013
|
+
block: { _tag: 'status', statusText: 'writing', pending: false },
|
|
1014
|
+
});
|
|
1015
|
+
yield* Trace.write(Trace.OperationEnd, { key: 'work', name: 'Work', outcome: 'success' });
|
|
1016
|
+
}),
|
|
1017
|
+
),
|
|
1018
|
+
);
|
|
1019
|
+
|
|
1020
|
+
const { commits } = buildExecutionGraph({ traceMessages: messages, collapseCompletedSpans: true });
|
|
1021
|
+
expect(commits).toHaveLength(1);
|
|
1022
|
+
expect(commits[0].message).toBe('Work');
|
|
1023
|
+
expect(commits[0].branch).toBe('main');
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
test('completed agent span with user message and nested op collapses inner noise', ({ expect }) => {
|
|
1027
|
+
const messages = collectTraceEvents(
|
|
1028
|
+
Effect.gen(function* () {
|
|
1029
|
+
yield* withMeta(
|
|
1030
|
+
{ pid: 'agent-1' },
|
|
1031
|
+
Effect.gen(function* () {
|
|
1032
|
+
yield* Trace.write(Trace.AgentRequestBegin, {});
|
|
1033
|
+
yield* Trace.write(Trace.CompleteBlock, {
|
|
1034
|
+
messageId: MESSAGE_ID,
|
|
1035
|
+
role: 'user',
|
|
1036
|
+
block: { _tag: 'text', text: 'hello', pending: false },
|
|
1037
|
+
});
|
|
1038
|
+
}),
|
|
1039
|
+
);
|
|
1040
|
+
yield* withMeta(
|
|
1041
|
+
{ pid: 'op-1', parentPid: 'agent-1' },
|
|
1042
|
+
Effect.gen(function* () {
|
|
1043
|
+
yield* Trace.write(Trace.OperationStart, { key: 'lookup', name: 'Lookup' });
|
|
1044
|
+
yield* Trace.write(Trace.OperationEnd, { key: 'lookup', name: 'Lookup', outcome: 'success' });
|
|
1045
|
+
}),
|
|
1046
|
+
);
|
|
1047
|
+
yield* withMeta({ pid: 'agent-1' }, Trace.write(Trace.AgentRequestEnd, { status: 'success' }));
|
|
1048
|
+
}),
|
|
1049
|
+
);
|
|
1050
|
+
|
|
1051
|
+
expect(`\n${renderGraph(messages, true)}\n`).toMatchInlineSnapshot(`
|
|
1052
|
+
"
|
|
1053
|
+
● [atom] Agent completed request
|
|
1054
|
+
"
|
|
1055
|
+
`);
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
test('pending span is not collapsed even when collapseCompletedSpans is enabled', ({ expect }) => {
|
|
1059
|
+
const messages = collectTraceEvents(
|
|
1060
|
+
withMeta(
|
|
1061
|
+
{ pid: 'agent-1' },
|
|
1062
|
+
Effect.gen(function* () {
|
|
1063
|
+
yield* Trace.write(Trace.AgentRequestBegin, {});
|
|
1064
|
+
yield* Trace.write(Trace.CompleteBlock, {
|
|
1065
|
+
messageId: MESSAGE_ID,
|
|
1066
|
+
role: 'user',
|
|
1067
|
+
block: { _tag: 'text', text: 'hello', pending: false },
|
|
1068
|
+
});
|
|
1069
|
+
}),
|
|
1070
|
+
),
|
|
1071
|
+
);
|
|
1072
|
+
|
|
1073
|
+
const { commits } = buildExecutionGraph({ traceMessages: messages, collapseCompletedSpans: true });
|
|
1074
|
+
expect(commits).toHaveLength(2);
|
|
1075
|
+
expect(commits[0].message).toBe('Agent processing request...');
|
|
1076
|
+
expect(commits[1].message).toBe('hello');
|
|
1077
|
+
});
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
describe('buildExecutionGraph in-progress shimmer tags', () => {
|
|
1081
|
+
test('pending agent begin → effect:shimmer', ({ expect }) => {
|
|
1082
|
+
const messages = collectTraceEvents(
|
|
1083
|
+
withMeta(
|
|
1084
|
+
{ pid: 'agent-1' },
|
|
1085
|
+
Effect.gen(function* () {
|
|
1086
|
+
yield* Trace.write(Trace.AgentRequestBegin, {});
|
|
1087
|
+
}),
|
|
1088
|
+
),
|
|
1089
|
+
);
|
|
1090
|
+
|
|
1091
|
+
const { commits } = buildExecutionGraph({ traceMessages: messages });
|
|
1092
|
+
expect(commits).toHaveLength(1);
|
|
1093
|
+
expect(commits[0]?.tags).toContain(SHIMMER_EFFECT_TAG);
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
test('pending tool call → effect:shimmer', ({ expect }) => {
|
|
1097
|
+
const messages = collectTraceEvents(
|
|
1098
|
+
withMeta(
|
|
1099
|
+
{ pid: 'agent-1' },
|
|
1100
|
+
Effect.gen(function* () {
|
|
1101
|
+
yield* Trace.write(Trace.AgentRequestBegin, {});
|
|
1102
|
+
yield* Trace.write(Trace.CompleteBlock, {
|
|
1103
|
+
messageId: MESSAGE_ID,
|
|
1104
|
+
role: 'assistant',
|
|
1105
|
+
block: {
|
|
1106
|
+
_tag: 'toolCall',
|
|
1107
|
+
toolCallId: 'call-1',
|
|
1108
|
+
name: 'web-search',
|
|
1109
|
+
input: '{"query":"hello"}',
|
|
1110
|
+
providerExecuted: false,
|
|
1111
|
+
pending: false,
|
|
1112
|
+
},
|
|
1113
|
+
});
|
|
1114
|
+
}),
|
|
1115
|
+
),
|
|
1116
|
+
);
|
|
1117
|
+
|
|
1118
|
+
const { commits } = buildExecutionGraph({ traceMessages: messages });
|
|
1119
|
+
const toolCall = commits.find((commit) => commit.message === 'web-search');
|
|
1120
|
+
expect(toolCall?.tags).toContain(SHIMMER_EFFECT_TAG);
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
test('completed operation end → no effect:shimmer', ({ expect }) => {
|
|
1124
|
+
const messages = collectTraceEvents(
|
|
1125
|
+
withMeta(
|
|
1126
|
+
{ pid: 'worker' },
|
|
1127
|
+
Effect.gen(function* () {
|
|
1128
|
+
yield* Trace.write(Trace.OperationStart, { key: 'routine', name: 'Lookup' });
|
|
1129
|
+
yield* Trace.write(Trace.OperationEnd, { key: 'routine', name: 'Lookup', outcome: 'success' });
|
|
1130
|
+
}),
|
|
1131
|
+
),
|
|
1132
|
+
);
|
|
1133
|
+
|
|
1134
|
+
const { commits } = buildExecutionGraph({ traceMessages: messages });
|
|
1135
|
+
expect(commits.every((commit) => !commit.tags?.includes(SHIMMER_EFFECT_TAG))).toBe(true);
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
test('active running process → Running... has effect:shimmer after span subtree', ({ expect }) => {
|
|
1139
|
+
const messages = collectTraceEvents(
|
|
1140
|
+
withMeta(
|
|
1141
|
+
{ pid: 'worker' },
|
|
1142
|
+
Effect.gen(function* () {
|
|
1143
|
+
yield* Trace.write(Trace.OperationStart, { key: 'routine', name: 'Lookup' });
|
|
1144
|
+
yield* Trace.write(Trace.CompleteBlock, {
|
|
1145
|
+
messageId: MESSAGE_ID,
|
|
1146
|
+
role: 'assistant',
|
|
1147
|
+
block: { _tag: 'status', statusText: 'searching', pending: false },
|
|
1148
|
+
});
|
|
1149
|
+
}),
|
|
1150
|
+
),
|
|
1151
|
+
);
|
|
1152
|
+
|
|
1153
|
+
const { commits } = buildExecutionGraph({
|
|
1154
|
+
traceMessages: messages,
|
|
1155
|
+
activeProcesses: [
|
|
1156
|
+
makeActiveProcess({
|
|
1157
|
+
pid: Process.ID.make('worker'),
|
|
1158
|
+
key: 'worker',
|
|
1159
|
+
state: Process.State.RUNNING,
|
|
1160
|
+
}),
|
|
1161
|
+
],
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
const running = commits.find((commit) => commit.message === 'Running...');
|
|
1165
|
+
expect(running?.tags).toContain(SHIMMER_EFFECT_TAG);
|
|
1166
|
+
expect(commits.at(-1)).toBe(running);
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
test('agent Generating... after a collapsed request anchors to the tail of main', ({ expect }) => {
|
|
1170
|
+
// A completed request collapses onto `main`, so the agent's own branch holds nothing; a fresh
|
|
1171
|
+
// turn on the same process must still hang off the previous completion rather than float.
|
|
1172
|
+
const messages = collectTraceEvents(
|
|
1173
|
+
withMeta(
|
|
1174
|
+
{ pid: 'agent' },
|
|
1175
|
+
Effect.gen(function* () {
|
|
1176
|
+
yield* Trace.write(Trace.AgentRequestBegin, {});
|
|
1177
|
+
yield* Trace.write(Trace.AgentRequestEnd, { status: 'success' });
|
|
1178
|
+
}),
|
|
1179
|
+
),
|
|
1180
|
+
);
|
|
1181
|
+
|
|
1182
|
+
const { commits } = buildExecutionGraph({
|
|
1183
|
+
traceMessages: messages,
|
|
1184
|
+
activeProcesses: [
|
|
1185
|
+
makeActiveProcess({
|
|
1186
|
+
pid: Process.ID.make('agent'),
|
|
1187
|
+
key: 'agent',
|
|
1188
|
+
state: Process.State.RUNNING,
|
|
1189
|
+
params: {
|
|
1190
|
+
name: null,
|
|
1191
|
+
annotations: Annotation.buildDictionary((dictionary) => {
|
|
1192
|
+
Annotation.setDictionary(dictionary, Process.HarnessHostAnnotation, true);
|
|
1193
|
+
Annotation.setDictionary(dictionary, Process.TargetAnnotation, URI.make('dxn:echo:@:chat'));
|
|
1194
|
+
}),
|
|
1195
|
+
},
|
|
1196
|
+
}),
|
|
1197
|
+
],
|
|
1198
|
+
});
|
|
1199
|
+
|
|
1200
|
+
const completed = commits.find((commit) => commit.message === 'Agent completed request');
|
|
1201
|
+
const generating = commits.find((commit) => commit.message === 'Generating...');
|
|
1202
|
+
expect(completed?.branch).toBe('main');
|
|
1203
|
+
expect(generating?.parents).toEqual([completed?.id]);
|
|
1204
|
+
});
|
|
1205
|
+
});
|