@cat-factory/app 0.232.2 → 0.234.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/components/settings/TaskTypeSuppressionsPanel.vue +119 -0
- package/app/components/settings/WorkspaceSettingsPanel.vue +22 -0
- package/app/composables/api/execution.ts +22 -0
- package/app/composables/api/taskTypeSuppressions.ts +25 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/usePipelineHealth.spec.ts +15 -1
- package/app/composables/usePipelineHealth.ts +13 -7
- package/app/docs/consumer-extensions.md +7 -1
- package/app/stores/observability/toolCalls.ts +173 -0
- package/app/stores/observability.ts +13 -0
- package/app/stores/pipelines.ts +22 -5
- package/app/stores/taskTypes.spec.ts +29 -0
- package/app/stores/taskTypes.ts +40 -1
- package/app/stores/workspace/hydrate.ts +5 -0
- package/app/types/domain.ts +3 -0
- package/app/types/execution.ts +6 -0
- package/app/utils/descriptorFields.ts +13 -27
- package/app/utils/observability.spec.ts +313 -2
- package/app/utils/observability.ts +232 -1
- package/i18n/locales/de.json +53 -0
- package/i18n/locales/en.json +53 -0
- package/i18n/locales/es.json +53 -0
- package/i18n/locales/fr.json +53 -0
- package/i18n/locales/he.json +53 -0
- package/i18n/locales/it.json +53 -0
- package/i18n/locales/ja.json +53 -0
- package/i18n/locales/pl.json +53 -0
- package/i18n/locales/tr.json +53 -0
- package/i18n/locales/uk.json +53 -0
- package/package.json +2 -2
package/app/stores/taskTypes.ts
CHANGED
|
@@ -28,6 +28,11 @@ export const useTaskTypesStore = defineStore('taskTypes', () => {
|
|
|
28
28
|
// The active per-workspace capability manifest (shared with the agents store), or null before
|
|
29
29
|
// the first hydrate. This store reads only its own `taskTypes` slot off it.
|
|
30
30
|
const capabilitiesManifest = ref<RemoteModuleManifest<AppSlots> | null>(null)
|
|
31
|
+
// The BACKEND-registered ids this board HIDES (`snapshot.suppressedTaskTypes`). Not part of the
|
|
32
|
+
// catalog above by construction (a suppressed type must not be creatable), but the board did
|
|
33
|
+
// decide about it, and that decision is the difference between a deployment with no operations
|
|
34
|
+
// and one whose operations are all hidden. See {@link hasRegisteredOperations}.
|
|
35
|
+
const suppressedTaskTypes = ref<string[]>([])
|
|
31
36
|
|
|
32
37
|
/**
|
|
33
38
|
* The merged CUSTOM task types (consumer-slot → backend-manifest), de-duplicated and never
|
|
@@ -47,6 +52,23 @@ export const useTaskTypesStore = defineStore('taskTypes', () => {
|
|
|
47
52
|
return out
|
|
48
53
|
})
|
|
49
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Whether this deployment registers any REUSABLE OPERATION on the backend, hidden or not: what
|
|
57
|
+
* decides whether the workspace-settings Operations tab exists.
|
|
58
|
+
*
|
|
59
|
+
* Deliberately NOT `customTaskTypes.length`. That list is what the board OFFERS, so hiding the
|
|
60
|
+
* last operation empties it and the tab that un-hides one would disappear with it, leaving no
|
|
61
|
+
* way back short of an API call. The suppressed ids are the other half of the same catalog.
|
|
62
|
+
*
|
|
63
|
+
* Consumer CODE-shipped types are excluded on purpose: they have no backend row to suppress, so
|
|
64
|
+
* a deployment that ships only those has nothing for that screen to manage.
|
|
65
|
+
*/
|
|
66
|
+
const hasRegisteredOperations = computed<boolean>(
|
|
67
|
+
() =>
|
|
68
|
+
(capabilitiesManifest.value?.slots?.taskTypes ?? []).length > 0 ||
|
|
69
|
+
suppressedTaskTypes.value.length > 0,
|
|
70
|
+
)
|
|
71
|
+
|
|
50
72
|
/** The custom types indexed by id, for a per-type lookup (e.g. the create-form field descriptors). */
|
|
51
73
|
const byTaskType = computed<Record<string, CustomTaskType>>(() =>
|
|
52
74
|
Object.fromEntries(customTaskTypes.value.map((t) => [t.taskType, t])),
|
|
@@ -84,5 +106,22 @@ export const useTaskTypesStore = defineStore('taskTypes', () => {
|
|
|
84
106
|
capabilitiesManifest.value = manifest
|
|
85
107
|
}
|
|
86
108
|
|
|
87
|
-
|
|
109
|
+
/**
|
|
110
|
+
* The suppressed-id half of the same snapshot read. Assigned unconditionally (an absent field is
|
|
111
|
+
* an empty list): this is per-WORKSPACE state, so carrying the previous board's answer forward
|
|
112
|
+
* would leave the Operations tab standing on a board that hid nothing.
|
|
113
|
+
*/
|
|
114
|
+
function hydrateSuppressed(ids: readonly string[]) {
|
|
115
|
+
suppressedTaskTypes.value = [...ids]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
customTaskTypes,
|
|
120
|
+
suppressedTaskTypes,
|
|
121
|
+
hasRegisteredOperations,
|
|
122
|
+
get,
|
|
123
|
+
registerConsumerTaskTypes,
|
|
124
|
+
hydrateCapabilities,
|
|
125
|
+
hydrateSuppressed,
|
|
126
|
+
}
|
|
88
127
|
})
|
|
@@ -71,6 +71,7 @@ export function applySnapshotToStores(snapshot: WorkspaceSnapshot, boardSince?:
|
|
|
71
71
|
snapshot.pipelines,
|
|
72
72
|
snapshot.pipelineCatalogVersions,
|
|
73
73
|
snapshot.retiredPipelines,
|
|
74
|
+
snapshot.pipelineCatalogNames,
|
|
74
75
|
)
|
|
75
76
|
useExecutionStore().hydrate(snapshot.executions, snapshot.workspace.id)
|
|
76
77
|
useAgentRunsStore().hydrate(snapshot.bootstrapJobs ?? [], snapshot.workspace.id)
|
|
@@ -114,6 +115,10 @@ export function applySnapshotToStores(snapshot: WorkspaceSnapshot, boardSince?:
|
|
|
114
115
|
snapshot.binaryGeneratorsUnavailable === true,
|
|
115
116
|
)
|
|
116
117
|
useTaskTypesStore().hydrateCapabilities(capabilities)
|
|
118
|
+
// The complement of the offered catalog above: the registered operations this board HIDES. Both
|
|
119
|
+
// halves come from the one snapshot, so the settings screen can exist for a board that hid
|
|
120
|
+
// every one of them (the state whose only way back is that screen).
|
|
121
|
+
useTaskTypesStore().hydrateSuppressed(snapshot.suppressedTaskTypes ?? [])
|
|
117
122
|
// The per-step parameters each registered gate declares, so a gated step's config form in the
|
|
118
123
|
// builder comes from the gate's own registration rather than a form hard-coded per gate.
|
|
119
124
|
usePipelinesStore().hydrateGateConfigForms(snapshot.gateConfigForms ?? [])
|
package/app/types/domain.ts
CHANGED
|
@@ -67,6 +67,9 @@ export type {
|
|
|
67
67
|
TaskTypePresentation,
|
|
68
68
|
TaskTypeFieldDescriptor,
|
|
69
69
|
TaskTypeFieldOption,
|
|
70
|
+
// One row of the workspace's operation-suppression screen: a registered custom task type plus
|
|
71
|
+
// whether THIS board hides it (`backend/docs/reusable-operations.md`).
|
|
72
|
+
TaskTypeSuppression,
|
|
70
73
|
// The shared descriptor-driven form vocabulary (`contracts/src/form-fields.ts`): one field
|
|
71
74
|
// shape and one filled-value bag behind both the initiative-preset form and a custom task
|
|
72
75
|
// type's per-case form, so `DescriptorFields.vue` renders either.
|
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,40 +1,26 @@
|
|
|
1
|
+
import { descriptorFieldDefaults } from '@cat-factory/contracts'
|
|
1
2
|
import type { DescriptorField, DescriptorFieldValue, DescriptorFieldValues } from '~/types/domain'
|
|
2
3
|
|
|
3
4
|
// Form-side helpers over the shared descriptor-field vocabulary (`contracts/src/form-fields.ts`),
|
|
4
5
|
// used by every surface that renders one through `DescriptorFields.vue`: an initiative preset's
|
|
5
6
|
// create form and a reusable operation's per-case form on a custom task type.
|
|
6
7
|
//
|
|
7
|
-
// The RULES (visibility, validation, sanitization, prose rendering
|
|
8
|
-
// server has to agree about them. What lives here is what only a FORM
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
8
|
+
// The RULES (visibility, validation, sanitization, prose rendering, and now default seeding) live
|
|
9
|
+
// in contracts, because the server has to agree about them. What lives here is what only a FORM
|
|
10
|
+
// decides: how one edit changes the bag. Pure functions over the value bag rather than methods
|
|
11
|
+
// inside the SFC, so the mutation rules a wrong answer would freeze on an entity are unit-testable
|
|
12
|
+
// without mounting a component.
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
|
-
* The initial
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
15
|
+
* The initial values a field list implies, for seeding a freshly opened form. A repo-detection
|
|
16
|
+
* probe's prefill and the user's own edits layer on top.
|
|
17
|
+
*
|
|
18
|
+
* The SHARED helper, not a form-side copy: the server folds the same defaults in at the creation
|
|
19
|
+
* door (`withDescriptorFieldDefaults`), so a duplicate here would be the drift that made a headless
|
|
20
|
+
* caller and this form disagree about what a descriptor's default means.
|
|
20
21
|
*/
|
|
21
22
|
export function defaultDescriptorValues(fields: readonly DescriptorField[]): DescriptorFieldValues {
|
|
22
|
-
|
|
23
|
-
for (const field of fields) {
|
|
24
|
-
if (field.type === 'checkbox-group') {
|
|
25
|
-
if (field.defaultValues?.length) values[field.key] = [...field.defaultValues]
|
|
26
|
-
} else if (field.type === 'checkbox') {
|
|
27
|
-
if (field.default === 'true') values[field.key] = true
|
|
28
|
-
} else if (field.type === 'number') {
|
|
29
|
-
const parsed = Number(field.default)
|
|
30
|
-
if (field.default !== undefined && field.default !== '' && Number.isFinite(parsed)) {
|
|
31
|
-
values[field.key] = parsed
|
|
32
|
-
}
|
|
33
|
-
} else if (field.default) {
|
|
34
|
-
values[field.key] = field.default
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
return values
|
|
23
|
+
return descriptorFieldDefaults(fields)
|
|
38
24
|
}
|
|
39
25
|
|
|
40
26
|
/**
|
|
@@ -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
|
+
})
|