@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.
@@ -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
  })
@@ -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 { PipelineStep, StepPhaseMetrics } from '~/types/execution'
3
- import { foldRunPhaseMetrics, formatCost, sumCosts, totalInputTokens } from './observability'
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
+ })