@cat-factory/app 0.232.2 → 0.233.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/components/observability/OutcomeFilterChips.vue +41 -0
- package/app/components/observability/RunFailureSummary.vue +243 -0
- package/app/components/observability/ToolCallList.vue +290 -0
- package/app/components/panels/ObservabilityPanel.vue +396 -129
- package/app/composables/api/execution.ts +22 -0
- package/app/stores/observability/toolCalls.ts +173 -0
- package/app/stores/observability.ts +13 -0
- package/app/types/execution.ts +6 -0
- package/app/utils/observability.spec.ts +313 -2
- package/app/utils/observability.ts +232 -1
- package/i18n/locales/de.json +45 -0
- package/i18n/locales/en.json +45 -0
- package/i18n/locales/es.json +45 -0
- package/i18n/locales/fr.json +45 -0
- package/i18n/locales/he.json +45 -0
- package/i18n/locales/it.json +45 -0
- package/i18n/locales/ja.json +45 -0
- package/i18n/locales/pl.json +45 -0
- package/i18n/locales/tr.json +45 -0
- package/i18n/locales/uk.json +45 -0
- package/package.json +2 -2
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
import type { RunToolCallFailures, RunToolCallTrajectory } from '~/types/execution'
|
|
3
|
+
|
|
4
|
+
// The observability store's TOOL-CALL sink, extracted whole because it is one concern with two
|
|
5
|
+
// reads and its own coherence rule between them.
|
|
6
|
+
//
|
|
7
|
+
// The rule: the two reads answer at DIFFERENT BOUNDS and must never be mistaken for each other.
|
|
8
|
+
// `failures` is about the run — counts aggregated in SQL over every row it ever wrote. The
|
|
9
|
+
// trajectory is a bounded PREFIX of the run, carrying every captured argument and result, which
|
|
10
|
+
// is why it is loaded on demand rather than on open. Anything that counts, judges or headlines
|
|
11
|
+
// reads the first; only the browse view reads the second, and it renders under a flag saying so.
|
|
12
|
+
//
|
|
13
|
+
// Keeping that pairing in one module is the point of the split: a caller reaching for whichever
|
|
14
|
+
// list is nearest is exactly how a panel ends up reporting a long run's opening moves as
|
|
15
|
+
// everything it did.
|
|
16
|
+
|
|
17
|
+
/** What the sink needs from the API layer: the two reads, already bound to a workspace. */
|
|
18
|
+
export interface ToolCallSinkDeps {
|
|
19
|
+
/**
|
|
20
|
+
* Whether a workspace is resolved yet.
|
|
21
|
+
*
|
|
22
|
+
* Checked BEFORE either read rather than left to the binding throwing, because these loads
|
|
23
|
+
* record their failures as "this sink did not answer" — a state the panel reports to an
|
|
24
|
+
* operator — and "no workspace selected yet" is not that. It is nobody having asked.
|
|
25
|
+
*/
|
|
26
|
+
ready: () => boolean
|
|
27
|
+
fetchTrajectory: (executionId: string) => Promise<RunToolCallTrajectory & { executionId: string }>
|
|
28
|
+
fetchFailures: (executionId: string) => Promise<RunToolCallFailures & { executionId: string }>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* What {@link ToolCallSinkState.toolCallsFor} answers for a run that was never loaded.
|
|
33
|
+
*
|
|
34
|
+
* A frozen shared value rather than a fresh object per call: this is read inside computeds, and a
|
|
35
|
+
* new identity on every evaluation re-triggers every one of them downstream.
|
|
36
|
+
*/
|
|
37
|
+
export const EMPTY_TRAJECTORY: RunToolCallTrajectory = Object.freeze({
|
|
38
|
+
toolCalls: Object.freeze([]) as never,
|
|
39
|
+
truncated: false,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
/** Add or remove a key from a reactive `Set` ref, replacing it so the reactivity fires. */
|
|
43
|
+
function withFlag(set: ReturnType<typeof ref<Set<string>>>, key: string, on: boolean) {
|
|
44
|
+
const next = new Set(set.value)
|
|
45
|
+
if (on) next.add(key)
|
|
46
|
+
else next.delete(key)
|
|
47
|
+
set.value = next
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createToolCallSinkState(deps: ToolCallSinkDeps) {
|
|
51
|
+
/**
|
|
52
|
+
* Per-execution-id trajectory PREFIX (oldest first, the order the agent worked in) with the
|
|
53
|
+
* flag saying whether the run made more calls than it holds.
|
|
54
|
+
*/
|
|
55
|
+
const toolCallsByExecution = ref<Record<string, RunToolCallTrajectory>>({})
|
|
56
|
+
/** Execution ids whose trajectory is currently loading. */
|
|
57
|
+
const toolCallsLoading = ref<Set<string>>(new Set())
|
|
58
|
+
/**
|
|
59
|
+
* Last trajectory-load error per execution id, or null. Recorded for the same reason the
|
|
60
|
+
* context load records its own: a swallowed failure would render as the "no tool calls
|
|
61
|
+
* recorded" empty state, which on this sink is a claim rather than a blank tab.
|
|
62
|
+
*/
|
|
63
|
+
const toolCallErrors = ref<Record<string, string | null>>({})
|
|
64
|
+
/**
|
|
65
|
+
* Per-execution-id FAILING tool calls plus the run's exact counts.
|
|
66
|
+
*
|
|
67
|
+
* Separate from the trajectory because the numbers here are SQL aggregates over the whole run
|
|
68
|
+
* while that list is a bounded prefix of it. Counting failures off the prefix is how a panel
|
|
69
|
+
* ends up printing "nothing failed" over a run whose failures came after its opening moves.
|
|
70
|
+
*/
|
|
71
|
+
const toolCallFailuresByExecution = ref<Record<string, RunToolCallFailures>>({})
|
|
72
|
+
/** Execution ids whose failure summary is currently loading. */
|
|
73
|
+
const toolCallFailuresLoading = ref<Set<string>>(new Set())
|
|
74
|
+
/**
|
|
75
|
+
* Last failure-summary load error per execution id, or null.
|
|
76
|
+
*
|
|
77
|
+
* The one error state the panel cannot afford to swallow: this read is what the pinned "what
|
|
78
|
+
* failed" section speaks from, so a failure here has to reach it as "this sink did not answer"
|
|
79
|
+
* rather than as the zero rows it would otherwise be indistinguishable from.
|
|
80
|
+
*/
|
|
81
|
+
const toolCallFailureErrors = ref<Record<string, string | null>>({})
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The loaded trajectory prefix, or the empty one.
|
|
85
|
+
*
|
|
86
|
+
* `truncated: false` on a run that was never loaded is not a claim that the run is short: a
|
|
87
|
+
* caller distinguishes the two through {@link hasToolCalls}, never by finding this empty.
|
|
88
|
+
*/
|
|
89
|
+
function toolCallsFor(executionId: string): RunToolCallTrajectory {
|
|
90
|
+
return toolCallsByExecution.value[executionId] ?? EMPTY_TRAJECTORY
|
|
91
|
+
}
|
|
92
|
+
function isToolCallsLoading(executionId: string): boolean {
|
|
93
|
+
return toolCallsLoading.value.has(executionId)
|
|
94
|
+
}
|
|
95
|
+
/** Whether the trajectory has ever been loaded for this run (an empty answer still counts). */
|
|
96
|
+
function hasToolCalls(executionId: string): boolean {
|
|
97
|
+
return executionId in toolCallsByExecution.value
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Load (or refresh) the tool-call trajectory for a run. */
|
|
101
|
+
async function loadToolCalls(executionId: string) {
|
|
102
|
+
if (!deps.ready()) return
|
|
103
|
+
withFlag(toolCallsLoading, executionId, true)
|
|
104
|
+
toolCallErrors.value = { ...toolCallErrors.value, [executionId]: null }
|
|
105
|
+
try {
|
|
106
|
+
const { toolCalls, truncated } = await deps.fetchTrajectory(executionId)
|
|
107
|
+
toolCallsByExecution.value = {
|
|
108
|
+
...toolCallsByExecution.value,
|
|
109
|
+
[executionId]: { toolCalls, truncated },
|
|
110
|
+
}
|
|
111
|
+
} catch (err) {
|
|
112
|
+
toolCallErrors.value = {
|
|
113
|
+
...toolCallErrors.value,
|
|
114
|
+
[executionId]: err instanceof Error ? err.message : 'Failed to load tool calls',
|
|
115
|
+
}
|
|
116
|
+
} finally {
|
|
117
|
+
withFlag(toolCallsLoading, executionId, false)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The loaded failure summary, or null when this run's has not answered (yet, or at all). */
|
|
122
|
+
function toolCallFailuresFor(executionId: string): RunToolCallFailures | null {
|
|
123
|
+
return toolCallFailuresByExecution.value[executionId] ?? null
|
|
124
|
+
}
|
|
125
|
+
function isToolCallFailuresLoading(executionId: string): boolean {
|
|
126
|
+
return toolCallFailuresLoading.value.has(executionId)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Load (or refresh) the run's failing tool calls and exact counts.
|
|
131
|
+
*
|
|
132
|
+
* Cleared on failure rather than left holding the previous answer: a stale summary beside a
|
|
133
|
+
* fresh error would let the panel keep asserting a failure count the backend just refused to
|
|
134
|
+
* confirm.
|
|
135
|
+
*/
|
|
136
|
+
async function loadToolCallFailures(executionId: string) {
|
|
137
|
+
if (!deps.ready()) return
|
|
138
|
+
withFlag(toolCallFailuresLoading, executionId, true)
|
|
139
|
+
toolCallFailureErrors.value = { ...toolCallFailureErrors.value, [executionId]: null }
|
|
140
|
+
try {
|
|
141
|
+
const { total, failed, failures, failuresTruncated } = await deps.fetchFailures(executionId)
|
|
142
|
+
toolCallFailuresByExecution.value = {
|
|
143
|
+
...toolCallFailuresByExecution.value,
|
|
144
|
+
[executionId]: { total, failed, failures, failuresTruncated },
|
|
145
|
+
}
|
|
146
|
+
} catch (err) {
|
|
147
|
+
const { [executionId]: _dropped, ...rest } = toolCallFailuresByExecution.value
|
|
148
|
+
toolCallFailuresByExecution.value = rest
|
|
149
|
+
toolCallFailureErrors.value = {
|
|
150
|
+
...toolCallFailureErrors.value,
|
|
151
|
+
[executionId]: err instanceof Error ? err.message : 'Failed to load tool-call failures',
|
|
152
|
+
}
|
|
153
|
+
} finally {
|
|
154
|
+
withFlag(toolCallFailuresLoading, executionId, false)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
toolCallsByExecution,
|
|
160
|
+
toolCallErrors,
|
|
161
|
+
toolCallsFor,
|
|
162
|
+
hasToolCalls,
|
|
163
|
+
isToolCallsLoading,
|
|
164
|
+
loadToolCalls,
|
|
165
|
+
toolCallFailuresByExecution,
|
|
166
|
+
toolCallFailureErrors,
|
|
167
|
+
toolCallFailuresFor,
|
|
168
|
+
isToolCallFailuresLoading,
|
|
169
|
+
loadToolCallFailures,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export type ToolCallSinkState = ReturnType<typeof createToolCallSinkState>
|
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
LlmCallMetric,
|
|
8
8
|
} from '~/types/execution'
|
|
9
9
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
10
|
+
import { createToolCallSinkState } from '~/stores/observability/toolCalls'
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* LLM observability state: the full per-call model activity for a run (prompts,
|
|
@@ -22,6 +23,17 @@ export const useObservabilityStore = defineStore('observability', () => {
|
|
|
22
23
|
const api = useApi()
|
|
23
24
|
const workspace = useWorkspaceStore()
|
|
24
25
|
|
|
26
|
+
/**
|
|
27
|
+
* The TOOL-CALL sink, extracted whole: two reads at two different bounds, plus the rule that
|
|
28
|
+
* keeps them apart (see `observability/toolCalls.ts`). The store owns the workspace binding and
|
|
29
|
+
* nothing else about it.
|
|
30
|
+
*/
|
|
31
|
+
const toolCalls = createToolCallSinkState({
|
|
32
|
+
ready: () => !!workspace.workspaceId,
|
|
33
|
+
fetchTrajectory: (executionId) => api.getToolCalls(workspace.requireId(), executionId),
|
|
34
|
+
fetchFailures: (executionId) => api.getToolCallFailures(workspace.requireId(), executionId),
|
|
35
|
+
})
|
|
36
|
+
|
|
25
37
|
/** Per-execution-id call list (newest first). */
|
|
26
38
|
const callsByExecution = ref<Record<string, LlmCallMetric[]>>({})
|
|
27
39
|
/** Per-execution-id provided-context snapshot list (newest first). */
|
|
@@ -223,5 +235,6 @@ export const useObservabilityStore = defineStore('observability', () => {
|
|
|
223
235
|
searchQueriesFor,
|
|
224
236
|
isSearchQueriesLoading,
|
|
225
237
|
loadSearchQueries,
|
|
238
|
+
...toolCalls,
|
|
226
239
|
}
|
|
227
240
|
})
|
package/app/types/execution.ts
CHANGED
|
@@ -24,6 +24,7 @@ export type {
|
|
|
24
24
|
StepPhaseMetrics,
|
|
25
25
|
LlmCallMetric,
|
|
26
26
|
LlmCallActivity,
|
|
27
|
+
LlmCallOutcome,
|
|
27
28
|
LlmExportInsight,
|
|
28
29
|
LlmMetricsExport,
|
|
29
30
|
PlatformObservability,
|
|
@@ -47,6 +48,11 @@ export type {
|
|
|
47
48
|
ReportTotals,
|
|
48
49
|
ReportsView,
|
|
49
50
|
AgentSearchQuery,
|
|
51
|
+
AgentToolCall,
|
|
52
|
+
RunToolCallFailures,
|
|
53
|
+
RunToolCallTrajectory,
|
|
54
|
+
ToolCallBodiesState,
|
|
55
|
+
ToolCallOutcome,
|
|
50
56
|
WebSearchAvailability,
|
|
51
57
|
WebSearchProvider,
|
|
52
58
|
PipelineStep,
|
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import type {
|
|
3
|
-
|
|
2
|
+
import type {
|
|
3
|
+
AgentFailure,
|
|
4
|
+
AgentToolCall,
|
|
5
|
+
LlmCallMetric,
|
|
6
|
+
PipelineStep,
|
|
7
|
+
RunToolCallFailures,
|
|
8
|
+
StepPhaseMetrics,
|
|
9
|
+
} from '~/types/execution'
|
|
10
|
+
import type { SinkAnswer } from './observability'
|
|
11
|
+
import {
|
|
12
|
+
countCallOutcomes,
|
|
13
|
+
deriveRunFailureEvidence,
|
|
14
|
+
filterCallsByOutcome,
|
|
15
|
+
filterToolCallsByOutcome,
|
|
16
|
+
foldRunPhaseMetrics,
|
|
17
|
+
formatCost,
|
|
18
|
+
hasFailureEvidence,
|
|
19
|
+
noFailingCallReason,
|
|
20
|
+
sinkAnswer,
|
|
21
|
+
sumCosts,
|
|
22
|
+
totalInputTokens,
|
|
23
|
+
} from './observability'
|
|
4
24
|
|
|
5
25
|
describe('totalInputTokens', () => {
|
|
6
26
|
it('sums all three input classes, so the headline matches Claude Code’s context gauge', () => {
|
|
@@ -128,3 +148,294 @@ describe('sumCosts', () => {
|
|
|
128
148
|
expect(sumCosts([undefined])).toBeNull()
|
|
129
149
|
})
|
|
130
150
|
})
|
|
151
|
+
|
|
152
|
+
describe('failing-call-first triage', () => {
|
|
153
|
+
const call = (over: Partial<LlmCallMetric> & Pick<LlmCallMetric, 'id'>): LlmCallMetric =>
|
|
154
|
+
({
|
|
155
|
+
workspaceId: 'ws',
|
|
156
|
+
executionId: 'run',
|
|
157
|
+
agentKind: 'coder',
|
|
158
|
+
provider: 'anthropic',
|
|
159
|
+
model: 'm',
|
|
160
|
+
createdAt: 1,
|
|
161
|
+
streaming: false,
|
|
162
|
+
phase: 'agent',
|
|
163
|
+
turnIndex: null,
|
|
164
|
+
messageCount: 1,
|
|
165
|
+
toolCount: 0,
|
|
166
|
+
requestMaxTokens: null,
|
|
167
|
+
promptTokens: 0,
|
|
168
|
+
cacheReadTokens: 0,
|
|
169
|
+
cacheWriteTokens: 0,
|
|
170
|
+
completionTokens: 0,
|
|
171
|
+
totalTokens: 0,
|
|
172
|
+
finishReason: 'stop',
|
|
173
|
+
upstreamMs: 1,
|
|
174
|
+
overheadMs: 1,
|
|
175
|
+
totalMs: 2,
|
|
176
|
+
ok: true,
|
|
177
|
+
httpStatus: 200,
|
|
178
|
+
errorMessage: null,
|
|
179
|
+
promptText: '',
|
|
180
|
+
promptPrefixCount: 0,
|
|
181
|
+
promptHash: '',
|
|
182
|
+
responseText: '',
|
|
183
|
+
reasoningText: '',
|
|
184
|
+
...over,
|
|
185
|
+
}) as LlmCallMetric
|
|
186
|
+
|
|
187
|
+
const tool = (over: Partial<AgentToolCall> & Pick<AgentToolCall, 'id'>): AgentToolCall => ({
|
|
188
|
+
workspaceId: 'ws',
|
|
189
|
+
executionId: 'run',
|
|
190
|
+
agentKind: 'coder',
|
|
191
|
+
jobId: 'job',
|
|
192
|
+
seq: 0,
|
|
193
|
+
tool: 'bash',
|
|
194
|
+
startedAt: 1,
|
|
195
|
+
endedAt: 2,
|
|
196
|
+
ok: true,
|
|
197
|
+
bodies: 'stored',
|
|
198
|
+
args: '',
|
|
199
|
+
result: '',
|
|
200
|
+
argsDropped: 0,
|
|
201
|
+
resultDropped: 0,
|
|
202
|
+
createdAt: 1,
|
|
203
|
+
...over,
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
/** An untruncated failure read holding exactly these rows: the ordinary case. */
|
|
207
|
+
const toolFailures = (
|
|
208
|
+
failures: AgentToolCall[],
|
|
209
|
+
total = failures.length,
|
|
210
|
+
): RunToolCallFailures => ({
|
|
211
|
+
total,
|
|
212
|
+
failed: failures.length,
|
|
213
|
+
failures,
|
|
214
|
+
failuresTruncated: false,
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
const failure = (): AgentFailure => ({
|
|
218
|
+
kind: 'agent',
|
|
219
|
+
message: 'the coder step failed',
|
|
220
|
+
detail: null,
|
|
221
|
+
hint: null,
|
|
222
|
+
occurredAt: 5,
|
|
223
|
+
lastSubtasks: null,
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
describe('countCallOutcomes', () => {
|
|
227
|
+
it('keeps a failed call and a TRUNCATED one in different buckets', () => {
|
|
228
|
+
// They need different fixes (transport/proxy/spend versus an output limit), so a filter
|
|
229
|
+
// that lumped them together would send an operator to the wrong conversation.
|
|
230
|
+
const counts = countCallOutcomes([
|
|
231
|
+
call({ id: 'a' }),
|
|
232
|
+
call({ id: 'b', ok: false, finishReason: null }),
|
|
233
|
+
call({ id: 'c', finishReason: 'length' }),
|
|
234
|
+
call({ id: 'd', finishReason: 'content_filter' }),
|
|
235
|
+
])
|
|
236
|
+
expect(counts).toEqual({ all: 4, ok: 1, warning: 2, error: 1 })
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('counts a failed call as an error even when its finish reason looks like a warning', () => {
|
|
240
|
+
// `ok: false` wins: a call that failed AND reported `length` is a failure, not a
|
|
241
|
+
// truncation, and counting it in both buckets would make the chips sum past the total.
|
|
242
|
+
const counts = countCallOutcomes([call({ id: 'a', ok: false, finishReason: 'length' })])
|
|
243
|
+
expect(counts).toEqual({ all: 1, ok: 0, warning: 0, error: 1 })
|
|
244
|
+
})
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
describe('filterCallsByOutcome', () => {
|
|
248
|
+
const calls = [
|
|
249
|
+
call({ id: 'ok' }),
|
|
250
|
+
call({ id: 'warn', finishReason: 'length' }),
|
|
251
|
+
call({ id: 'err', ok: false }),
|
|
252
|
+
]
|
|
253
|
+
|
|
254
|
+
it('narrows to one class and passes everything through on `all`', () => {
|
|
255
|
+
expect(filterCallsByOutcome(calls, 'error').map((c) => c.id)).toEqual(['err'])
|
|
256
|
+
expect(filterCallsByOutcome(calls, 'warning').map((c) => c.id)).toEqual(['warn'])
|
|
257
|
+
expect(filterCallsByOutcome(calls, 'all').map((c) => c.id)).toEqual(['ok', 'warn', 'err'])
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('returns a fresh array, never the caller’s own list', () => {
|
|
261
|
+
// The panel holds the store's array; a filter that aliased it on `all` would let a sort in
|
|
262
|
+
// the component reorder the store.
|
|
263
|
+
expect(filterCallsByOutcome(calls, 'all')).not.toBe(calls)
|
|
264
|
+
})
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
describe('filterToolCallsByOutcome', () => {
|
|
268
|
+
it('narrows a trajectory to the failing calls, keeping their order', () => {
|
|
269
|
+
const trajectory = [
|
|
270
|
+
tool({ id: '1' }),
|
|
271
|
+
tool({ id: '2', ok: false }),
|
|
272
|
+
tool({ id: '3' }),
|
|
273
|
+
tool({ id: '4', ok: false }),
|
|
274
|
+
]
|
|
275
|
+
expect(filterToolCallsByOutcome(trajectory, 'error').map((c) => c.id)).toEqual(['2', '4'])
|
|
276
|
+
expect(filterToolCallsByOutcome(trajectory, 'ok').map((c) => c.id)).toEqual(['1', '3'])
|
|
277
|
+
expect(filterToolCallsByOutcome(trajectory, 'all')).toHaveLength(4)
|
|
278
|
+
})
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
describe('deriveRunFailureEvidence', () => {
|
|
282
|
+
/** Both sinks answered with the given rows. */
|
|
283
|
+
const answered = (rows: number): SinkAnswer => ({ status: 'answered', rows })
|
|
284
|
+
const evidenceFor = (input: {
|
|
285
|
+
failure?: AgentFailure | null
|
|
286
|
+
calls?: LlmCallMetric[]
|
|
287
|
+
toolFailures?: RunToolCallFailures | null
|
|
288
|
+
}) => {
|
|
289
|
+
const calls = input.calls ?? []
|
|
290
|
+
const toolFailures = input.toolFailures ?? null
|
|
291
|
+
return deriveRunFailureEvidence({
|
|
292
|
+
failure: input.failure ?? null,
|
|
293
|
+
calls,
|
|
294
|
+
callsAnswer: answered(calls.length),
|
|
295
|
+
toolFailures,
|
|
296
|
+
toolsAnswer: toolFailures ? answered(toolFailures.total) : { status: 'pending' },
|
|
297
|
+
})
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
it('picks the LAST failing row from each sink, respecting their opposite orders', () => {
|
|
301
|
+
// Calls arrive newest-first; the failing tool calls arrive oldest-first (trajectory order).
|
|
302
|
+
// Reading either the wrong way round still yields a failing call, just not the one nearest
|
|
303
|
+
// the failure, which is the only reason to pin one.
|
|
304
|
+
const evidence = evidenceFor({
|
|
305
|
+
failure: failure(),
|
|
306
|
+
calls: [
|
|
307
|
+
call({ id: 'newest-error', ok: false, createdAt: 30 }),
|
|
308
|
+
call({ id: 'older-error', ok: false, createdAt: 10 }),
|
|
309
|
+
],
|
|
310
|
+
toolFailures: toolFailures([
|
|
311
|
+
tool({ id: 'oldest-fail', ok: false, startedAt: 10 }),
|
|
312
|
+
tool({ id: 'latest-fail', ok: false, startedAt: 30 }),
|
|
313
|
+
]),
|
|
314
|
+
})
|
|
315
|
+
expect(evidence.lastErroredCall?.id).toBe('newest-error')
|
|
316
|
+
expect(evidence.lastFailedToolCall?.id).toBe('latest-fail')
|
|
317
|
+
expect(evidence.erroredCallCount).toBe(2)
|
|
318
|
+
expect(evidence.failedToolCallCount).toBe(2)
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('counts failing tool calls off the run AGGREGATE, never off the rows it holds', () => {
|
|
322
|
+
// The failure this split exists to prevent. The backend narrows and counts over the whole
|
|
323
|
+
// run; the rows come back bounded. A count taken from the list would under-report exactly
|
|
324
|
+
// the long runs worth opening the panel for, and would disagree with the debug overview's
|
|
325
|
+
// `toolCalls.totals.failures` on the same run.
|
|
326
|
+
const evidence = evidenceFor({
|
|
327
|
+
failure: failure(),
|
|
328
|
+
toolFailures: {
|
|
329
|
+
total: 5_000,
|
|
330
|
+
failed: 240,
|
|
331
|
+
failures: [tool({ id: 'held', ok: false })],
|
|
332
|
+
failuresTruncated: true,
|
|
333
|
+
},
|
|
334
|
+
})
|
|
335
|
+
expect(evidence.failedToolCallCount).toBe(240)
|
|
336
|
+
expect(evidence.failedToolCallsTruncated).toBe(true)
|
|
337
|
+
expect(evidence.lastFailedToolCall?.id).toBe('held')
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it('reports a run whose model calls are all healthy but whose tools are not', () => {
|
|
341
|
+
// The whole failure class this surface exists for: the model call that requested the tool
|
|
342
|
+
// still reports `ok`, so every LLM rollup reads clean.
|
|
343
|
+
const evidence = evidenceFor({
|
|
344
|
+
failure: failure(),
|
|
345
|
+
calls: [call({ id: 'fine' })],
|
|
346
|
+
toolFailures: toolFailures([tool({ id: 'broke', ok: false })]),
|
|
347
|
+
})
|
|
348
|
+
expect(evidence.lastErroredCall).toBeNull()
|
|
349
|
+
expect(evidence.lastFailedToolCall?.id).toBe('broke')
|
|
350
|
+
expect(hasFailureEvidence(evidence)).toBe(true)
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it('has nothing to pin for a run that neither failed nor recorded a failing call', () => {
|
|
354
|
+
expect(hasFailureEvidence(evidenceFor({ calls: [call({ id: 'a' })] }))).toBe(false)
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
it('pins the section for an UNREACHABLE sink even with no failure and no failing call', () => {
|
|
358
|
+
// "One of these reads did not come back" has to reach the operator before they read the
|
|
359
|
+
// rest of the page as whole. Staying silent is how the other numbers get believed.
|
|
360
|
+
const evidence = deriveRunFailureEvidence({
|
|
361
|
+
failure: null,
|
|
362
|
+
calls: [],
|
|
363
|
+
callsAnswer: { status: 'answered', rows: 0 },
|
|
364
|
+
toolFailures: null,
|
|
365
|
+
toolsAnswer: { status: 'unreachable' },
|
|
366
|
+
})
|
|
367
|
+
expect(hasFailureEvidence(evidence)).toBe(true)
|
|
368
|
+
})
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
describe('sinkAnswer', () => {
|
|
372
|
+
it('reports an in-flight read as pending whatever it is still holding', () => {
|
|
373
|
+
// A refresh over a previous answer (or a previous error) is not that answer: what the panel
|
|
374
|
+
// says next depends on what is coming back.
|
|
375
|
+
expect(sinkAnswer({ loading: true, error: 'boom', loaded: true, rows: 3 })).toEqual({
|
|
376
|
+
status: 'pending',
|
|
377
|
+
})
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
it('separates a failed read from an answer of zero rows', () => {
|
|
381
|
+
expect(sinkAnswer({ loading: false, error: 'boom', loaded: false, rows: 0 })).toEqual({
|
|
382
|
+
status: 'unreachable',
|
|
383
|
+
})
|
|
384
|
+
expect(sinkAnswer({ loading: false, error: null, loaded: true, rows: 0 })).toEqual({
|
|
385
|
+
status: 'answered',
|
|
386
|
+
rows: 0,
|
|
387
|
+
})
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
it('treats never-requested as pending, not as an answer', () => {
|
|
391
|
+
expect(sinkAnswer({ loading: false, error: null, loaded: false, rows: 0 })).toEqual({
|
|
392
|
+
status: 'pending',
|
|
393
|
+
})
|
|
394
|
+
})
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
describe('noFailingCallReason', () => {
|
|
398
|
+
const reasonFor = (calls: SinkAnswer, tools: SinkAnswer, failed = 0) =>
|
|
399
|
+
noFailingCallReason(
|
|
400
|
+
deriveRunFailureEvidence({
|
|
401
|
+
failure: failure(),
|
|
402
|
+
calls: [],
|
|
403
|
+
callsAnswer: calls,
|
|
404
|
+
toolFailures: { total: 0, failed, failures: [], failuresTruncated: false },
|
|
405
|
+
toolsAnswer: tools,
|
|
406
|
+
}),
|
|
407
|
+
)
|
|
408
|
+
const answered = (rows: number): SinkAnswer => ({ status: 'answered', rows })
|
|
409
|
+
|
|
410
|
+
it('says nothing when a failing call WAS found', () => {
|
|
411
|
+
expect(reasonFor(answered(1), answered(1), 1)).toBeNull()
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
it('distinguishes "both sinks answered and nothing failed" from "nothing was recorded"', () => {
|
|
415
|
+
// The distinction the whole helper exists for: an unwired sink, a capture opt-out and a
|
|
416
|
+
// container that died before reporting all produce zero failing rows, exactly like a run
|
|
417
|
+
// whose every call succeeded, and only one of those is a clean bill of health.
|
|
418
|
+
expect(reasonFor(answered(1), answered(1))).toBe('recorded-clean')
|
|
419
|
+
expect(reasonFor(answered(0), answered(0))).toBe('no-telemetry')
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
it('names WHICH sink is empty when only one holds rows', () => {
|
|
423
|
+
expect(reasonFor(answered(1), answered(0))).toBe('partial-calls-only')
|
|
424
|
+
expect(reasonFor(answered(0), answered(1))).toBe('partial-tools-only')
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
it('withholds every verdict while a sink has not answered yet', () => {
|
|
428
|
+
// A read still in flight is not a read that came back clean, and rendering it as one is a
|
|
429
|
+
// clean bill of health written before the evidence arrived.
|
|
430
|
+
expect(reasonFor({ status: 'pending' }, answered(1))).toBeNull()
|
|
431
|
+
expect(reasonFor(answered(1), { status: 'pending' })).toBeNull()
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
it('lets an UNREACHABLE sink outrank every statement about the run', () => {
|
|
435
|
+
// Each other reason is a claim about the run. This is the one case where the panel has no
|
|
436
|
+
// standing to make one, so it must win even against a sink that answered richly.
|
|
437
|
+
expect(reasonFor({ status: 'unreachable' }, answered(9))).toBe('sink-unreachable')
|
|
438
|
+
expect(reasonFor(answered(9), { status: 'unreachable' })).toBe('sink-unreachable')
|
|
439
|
+
})
|
|
440
|
+
})
|
|
441
|
+
})
|