dsh-llm-verifier 0.2.0 → 0.3.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.
Files changed (46) hide show
  1. package/README.md +15 -11
  2. package/lib/client.js +137 -13
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +377 -115
  5. package/lib/index.js.map +1 -1
  6. package/lib/types/auto.d.ts +32 -12
  7. package/lib/types/auto.d.ts.map +1 -1
  8. package/lib/types/auto.js +50 -38
  9. package/lib/types/auto.js.map +1 -1
  10. package/lib/types/client-i18n.d.ts +50 -1
  11. package/lib/types/client-i18n.d.ts.map +1 -1
  12. package/lib/types/client-i18n.js +83 -4
  13. package/lib/types/client-i18n.js.map +1 -1
  14. package/lib/types/client.d.ts +2 -2
  15. package/lib/types/client.d.ts.map +1 -1
  16. package/lib/types/client.js +81 -32
  17. package/lib/types/client.js.map +1 -1
  18. package/lib/types/engine.d.ts +19 -0
  19. package/lib/types/engine.d.ts.map +1 -1
  20. package/lib/types/engine.js +54 -4
  21. package/lib/types/engine.js.map +1 -1
  22. package/lib/types/index.d.ts.map +1 -1
  23. package/lib/types/index.js +32 -43
  24. package/lib/types/index.js.map +1 -1
  25. package/lib/types/router.d.ts +31 -0
  26. package/lib/types/router.d.ts.map +1 -1
  27. package/lib/types/router.js +181 -17
  28. package/lib/types/router.js.map +1 -1
  29. package/lib/types/statistics.d.ts +28 -0
  30. package/lib/types/statistics.d.ts.map +1 -1
  31. package/lib/types/statistics.js +101 -0
  32. package/lib/types/statistics.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/auto.test.ts +42 -23
  35. package/src/auto.ts +55 -46
  36. package/src/client-i18n.ts +93 -4
  37. package/src/client.test.ts +134 -3
  38. package/src/client.tsx +61 -4
  39. package/src/config.test.ts +6 -3
  40. package/src/engine.test.ts +40 -1
  41. package/src/engine.ts +56 -4
  42. package/src/index.ts +34 -44
  43. package/src/router.test.ts +191 -3
  44. package/src/router.ts +171 -14
  45. package/src/statistics.test.ts +116 -0
  46. package/src/statistics.ts +112 -0
package/src/index.ts CHANGED
@@ -12,12 +12,12 @@ import { ScoreCache, SingleFlight, resolveCacheFile, stableHash, type CachedPair
12
12
  import { VerifierEngine, normalizeCriteria, type JudgeScore, type RunStats } from './engine.ts'
13
13
  import { loadVerifierImages } from './images.ts'
14
14
  import { extractSession, sanitizeVerifierText, sessionEvents } from './session.ts'
15
- import { analyzeAutoTask, automaticFeedback, isSubagentSession } from './auto.ts'
16
- import { AutoVerifierRouter, analyzeStructuredRoute, boundDecision, buildSemanticRoutePrompt, estimateRoutedCalls, parseSemanticRoute, semanticDecision, semanticRouteHint, type RouteDecision, type RoutedVerifierKind } from './router.ts'
15
+ import { analyzeAutoTask, automaticFeedback, failedAcceptanceCriteria, isSubagentSession, sessionAccepted, type AcceptanceCriterion } from './auto.ts'
16
+ import { AutoVerifierRouter, analyzeStructuredRoute, boundDecision, buildSemanticRoutePrompt, estimateRoutedCalls, parseSemanticRoute, routedRepeats, semanticDecision, semanticRouteHint, type RouteDecision, type RoutedVerifierKind } from './router.ts'
17
17
  import { DEFAULT_CRITERIA } from './core.ts'
18
18
  import { buildPlanPreReviewPrompt, parseVerdictLetter, planFromArguments } from './plan-gate.ts'
19
19
  import { inspectTeamTasks, buildTeamTaskVerificationPrompt } from './team-gate.ts'
20
- import { StatisticsStore, emptyRunStats, errorDetails, mergeStatisticsOverviews, parseStatisticsQuery, resolveStatisticsFile, type StatisticsOverview, type VerifierToolName, type VerdictSummary } from './statistics.ts'
20
+ import { StatisticsStore, emptyRunStats, errorDetails, mergeStatisticsOverviews, parseStatisticsQuery, resolveStatisticsFile, summarizeVerdict, type StatisticsOverview, type VerifierToolName } from './statistics.ts'
21
21
  import { resolveTopicDataDir, type SessionArtifactLocator } from './topic-storage.ts'
22
22
 
23
23
  export const name = 'llm-verifier'
@@ -98,7 +98,7 @@ function rpcSuccess<T>(value: T) { return { ok: true as const, value } }
98
98
  function rpcFailure(message: string) { return { ok: false as const, error: { code: 'bad-request' as const, message, details: { issues: [] } } } }
99
99
 
100
100
  interface SessionVerificationOptions { fromSeq?: number; toSeq?: number; includeAssistantText?: boolean; redactPatterns?: readonly string[]; maxChars?: number; repeats?: number }
101
- interface SessionVerificationResult { sessionId: string; problem: string; score: number; baselineScore: number; winner: 'A' | 'B' | 'tie'; fromSeq: number; toSeq: number; omittedCharacters: number; calls: number; stats: RunStats; judges: JudgeScore[]; agreement: number }
101
+ interface SessionVerificationResult { sessionId: string; problem: string; score: number; baselineScore: number; winner: 'A' | 'B' | 'tie'; criteria: AcceptanceCriterion[]; fromSeq: number; toSeq: number; omittedCharacters: number; calls: number; stats: RunStats; judges: JudgeScore[]; agreement: number }
102
102
 
103
103
  export function apply(ctx: Context, config: Config = {}): void {
104
104
  const services = ctx as Context & { attachments: AttachmentStore; connection: HostConnectionHandle; sessionPersistence: SessionArtifactLocator & { list(signal?: AbortSignal): Promise<readonly unknown[]> } }
@@ -139,36 +139,12 @@ export function apply(ctx: Context, config: Config = {}): void {
139
139
  const images = (values: readonly string[] | undefined, signal: AbortSignal) => loadVerifierImages(values, signal)
140
140
  const route = (selected: { provider: string; model: string }) => ({ provider: selected.provider, model: selected.model })
141
141
  const requireEnabled = () => { if (!current().enabled) throw new Error('llm-verifier: verifier tools are disabled — enable them in Settings → LLM Verifier') }
142
- /**
143
- * Compact summary of what the judges decided, stored beside the call counters so
144
- * the dashboard can answer "why did this fail?" instead of only "how much did it cost".
145
- * @param toolName - the verifier tool that produced the value.
146
- * @param value - its rendered result.
147
- * @param phase - which stage produced it (explicit | compare | select | track | final | ...).
148
- * @returns A verdict summary; score fields are omitted when the tool has none.
149
- */
150
- const verdictFrom = (toolName: VerifierToolName, value: unknown, phase: string): VerdictSummary => {
142
+ const verdictFrom = (toolName: VerifierToolName, value: unknown, phase: string) => {
151
143
  const selected = current()
152
- const row = typeof value === 'object' && value !== null ? value as Record<string, unknown> : {}
153
- const numberAt = (key: string): number | undefined => (typeof row[key] === 'number' && Number.isFinite(row[key]) ? row[key] as number : undefined)
154
- const scores = Array.isArray(row.scores) ? row.scores.filter((entry): entry is number => typeof entry === 'number' && Number.isFinite(entry)) : []
155
- const winner = row.winner === 'A' || row.winner === 'B' || row.winner === 'tie' ? row.winner : undefined
156
- if (toolName === 'verifier_route_classify') return { phase, outcome: 'classified' }
157
- if (toolName === 'verifier_select') {
158
- const index = numberAt('index')
159
- const best = index === undefined ? undefined : scores[index]
160
- return { phase, outcome: 'ranked', ...(best !== undefined ? { score: best } : {}) }
161
- }
162
- if (toolName === 'verifier_track') {
163
- const worst = scores.length > 0 ? Math.min(...scores) : undefined
164
- const threshold = selected.autoTrackCompletionThreshold
165
- return { phase, outcome: worst !== undefined && worst >= threshold ? 'passed' : 'below-threshold', ...(worst !== undefined ? { score: worst } : {}), threshold }
166
- }
167
- const score = numberAt('score') ?? numberAt('scoreA')
168
- const baselineScore = numberAt('baselineScore')
169
- const threshold = selected.autoVerifyThreshold
170
- const outcome = winner === 'tie' ? 'tie' : winner === 'A' && score !== undefined && score >= threshold ? 'passed' : 'below-threshold'
171
- return { phase, outcome, ...(score !== undefined ? { score } : {}), ...(baselineScore !== undefined ? { baselineScore } : {}), ...(winner !== undefined ? { winner } : {}), threshold }
144
+ return summarizeVerdict(toolName, value, phase, {
145
+ autoVerifyThreshold: selected.autoVerifyThreshold,
146
+ autoTrackCompletionThreshold: selected.autoTrackCompletionThreshold,
147
+ })
172
148
  }
173
149
  const record = async <T>(toolName: VerifierToolName, agent: Agent, operation: () => Promise<{ result: T; selected: { provider: string; model: string } }>, phase = 'explicit'): Promise<T & { provider: string; model: string }> => {
174
150
  const startedAt = Date.now()
@@ -218,7 +194,7 @@ export function apply(ctx: Context, config: Config = {}): void {
218
194
  if (!extracted.problem.trim()) throw new Error('llm-verifier: no direct user task found in the selected session range — widen from_seq so the task statement is included')
219
195
  const { verifier, selected } = await engine(agent)
220
196
  const compared = await verifier.compare({ problem: extracted.problem, candidateA: extracted.trace, candidateB: '(No useful work or verification was performed.)', repeats: positive(options.repeats, 2, 'repeats'), images: extracted.images }, signal)
221
- const result: SessionVerificationResult = { sessionId: extracted.sessionId, problem: extracted.problem, score: compared.scoreA, baselineScore: compared.scoreB, winner: compared.winner, fromSeq: extracted.fromSeq, toSeq: extracted.toSeq, omittedCharacters: extracted.omittedCharacters, calls: compared.calls, stats: compared.stats, judges: compared.judges, agreement: compared.agreement }
197
+ const result: SessionVerificationResult = { sessionId: extracted.sessionId, problem: extracted.problem, score: compared.scoreA, baselineScore: compared.scoreB, winner: compared.winner, criteria: compared.criteria.map(row => ({ id: row.id, name: row.name, score: row.scoreA })), fromSeq: extracted.fromSeq, toSeq: extracted.toSeq, omittedCharacters: extracted.omittedCharacters, calls: compared.calls, stats: compared.stats, judges: compared.judges, agreement: compared.agreement }
222
198
  return { result, selected }
223
199
  }, phase)
224
200
  const compareCandidates = async (agent: Agent, problem: string, candidateA: string, candidateB: string, repeats: number, signal: AbortSignal, routedImages: readonly import('./caller.ts').VerifierImage[] = [], phase = 'explicit') => record('verifier_compare', agent, async () => {
@@ -393,8 +369,11 @@ export function apply(ctx: Context, config: Config = {}): void {
393
369
  if (!stillCurrent()) { autoRouter.fail(agent, taskReservation, false); return }
394
370
  const verdict = parseVerdictLetter(classified.text)
395
371
  if (verdict === undefined) {
396
- autoRouter.fail(agent, taskReservation, false)
397
- ctx.logger.warn('llm-verifier team task verification produced no verdict line; task ' + task.id + ' was not gated')
372
+ // Fail closed in strict mode like every other routed phase: an unparseable
373
+ // verdict must not silently leave the task ungated.
374
+ const strict = selected.autoVerifyMode === 'strict'
375
+ autoRouter.fail(agent, taskReservation, strict)
376
+ ctx.logger.warn('llm-verifier team task verification produced no verdict line; task ' + task.id + (strict ? ' stays blocked' : ' was not gated'))
398
377
  continue
399
378
  }
400
379
  if (verdict.score >= selected.autoVerifyThreshold) {
@@ -462,8 +441,10 @@ export function apply(ctx: Context, config: Config = {}): void {
462
441
 
463
442
  if (!stillCurrent()) return
464
443
  if (decision) {
465
- // Every judge scores every match, so the reservation has to cover the ensemble.
466
- const expectedCalls = estimateRoutedCalls(decision, selected.autoVerifyRepeats, DEFAULT_CRITERIA.length) * selected.judges.length
444
+ // Every judge scores every match, and compare/select round the repeat count up to an
445
+ // even number so the A/B slots get swapped, so the reservation has to cover both.
446
+ const repeats = routedRepeats(decision, selected.autoVerifyRepeats)
447
+ const expectedCalls = estimateRoutedCalls(decision, repeats, DEFAULT_CRITERIA.length) * selected.judges.length
467
448
  const reservation = autoRouter.reserve(agent, decision.kind, decision.fingerprint, expectedCalls, policy)
468
449
  if (reservation === undefined) {
469
450
  // A refused reservation used to drop the whole routed decision silently.
@@ -483,7 +464,7 @@ export function apply(ctx: Context, config: Config = {}): void {
483
464
  try {
484
465
  const extracted = await extractTask(agent, evidence.taskStartSeq, admittedLastSeq, selected.autoVerifyMaxChars, signal)
485
466
  if (decision.kind === 'compare') {
486
- const result = await compareCandidates(agent, extracted.problem, decision.candidates[0].content, decision.candidates[1].content, selected.autoVerifyRepeats, signal, extracted.images, 'compare')
467
+ const result = await compareCandidates(agent, extracted.problem, decision.candidates[0].content, decision.candidates[1].content, repeats, signal, extracted.images, 'compare')
487
468
  if (!stillCurrent()) { autoRouter.fail(agent, reservation, false); return }
488
469
  if (!autoRouter.commit(agent, reservation, admittedLastSeq)) return
489
470
  const winner = result.winner === 'A' ? decision.candidates[0].label : result.winner === 'B' ? decision.candidates[1].label : 'tie'
@@ -491,18 +472,23 @@ export function apply(ctx: Context, config: Config = {}): void {
491
472
  return
492
473
  }
493
474
  if (decision.kind === 'select') {
494
- const result = await selectCandidates(agent, extracted.problem, decision.candidates.map(candidate => candidate.content), selected.autoVerifyRepeats, signal, extracted.images, 'select')
475
+ const result = await selectCandidates(agent, extracted.problem, decision.candidates.map(candidate => candidate.content), repeats, signal, extracted.images, 'select')
495
476
  if (!stillCurrent()) { autoRouter.fail(agent, reservation, false); return }
496
477
  if (!autoRouter.commit(agent, reservation, admittedLastSeq)) return
497
478
  const ranking = result.ranking.map((index, rank) => (rank + 1) + '. ' + decision.candidates[index]!.label).join('\n')
498
479
  agent.steer(routeFeedback(decision, 'Ranking:\n' + ranking + '\nProceed with ' + decision.candidates[result.index]!.label + '.'))
499
480
  return
500
481
  }
501
- const result = await trackProgress(agent, extracted.problem, decision.steps, decision.checkpoints, selected.autoVerifyRepeats, signal, extracted.images, 'track')
482
+ const result = await trackProgress(agent, extracted.problem, decision.steps, decision.checkpoints, repeats, signal, extracted.images, 'track')
502
483
  if (!stillCurrent()) { autoRouter.fail(agent, reservation, false); return }
503
484
  if (!autoRouter.commit(agent, reservation, admittedLastSeq)) return
504
485
  const detail = result.scores.map((score, index) => 'Checkpoint step ' + decision.checkpoints[index] + ': ' + (score * 100).toFixed(1) + '%').join('\n')
505
- const continuation = result.scores.some(score => score < selected.autoTrackCompletionThreshold) ? '\nContinue the unfinished work.' : '\nPrepare final delivery evidence; final session verification is mandatory.'
486
+ // Judge the CURRENT state, not the whole history: the first checkpoint is the
487
+ // state right after the first todo snapshot, which is always "nothing done
488
+ // yet", so "any checkpoint below threshold" made the continue branch
489
+ // unconditional (5/5 live routes) and the threshold meaningless.
490
+ const latest = result.scores.length > 0 ? result.scores[result.scores.length - 1]! : 0
491
+ const continuation = latest < selected.autoTrackCompletionThreshold ? '\nContinue the unfinished work.' : '\nPrepare final delivery evidence; final session verification is mandatory.'
506
492
  agent.steer(routeFeedback(decision, detail + continuation))
507
493
  return
508
494
  } catch (error) {
@@ -542,11 +528,15 @@ export function apply(ctx: Context, config: Config = {}): void {
542
528
  try {
543
529
  const result = await verifySession(agent, { fromSeq: finalFromSeq, toSeq: admittedLastSeq, includeAssistantText: true, maxChars: selected.autoVerifyMaxChars, repeats: selected.autoVerifyFinalRepeats }, signal, 'final')
544
530
  if (!stillCurrent()) { autoRouter.fail(agent, finalReservation, false); return }
545
- const passed = result.winner === 'A' && result.score >= selected.autoVerifyThreshold
531
+ // The mean over criteria used to hide a single failed requirement, and the fixed
532
+ // empty-work baseline can never win on its own, so the gate is (a) the winner,
533
+ // (b) the mean score and (c) every criterion on its own.
534
+ const failed = failedAcceptanceCriteria(result.criteria, selected.autoVerifyThreshold)
535
+ const passed = sessionAccepted({ score: result.score, winner: result.winner, criteria: result.criteria }, selected.autoVerifyThreshold)
546
536
  if (passed) autoRouter.commit(agent, finalReservation)
547
537
  else {
548
538
  autoRouter.fail(agent, finalReservation, selected.autoVerifyMode === 'strict')
549
- agent.steer(createUserMessage({ content: [{ type: 'text', text: automaticFeedback(result.score, result.baselineScore, result.winner, selected.autoVerifyThreshold) }], source: { kind: 'plugin', plugin: 'dsh-llm-verifier' } }))
539
+ agent.steer(createUserMessage({ content: [{ type: 'text', text: automaticFeedback(result.score, result.baselineScore, result.winner, selected.autoVerifyThreshold, failed) }], source: { kind: 'plugin', plugin: 'dsh-llm-verifier' } }))
550
540
  }
551
541
  } catch (error) {
552
542
  autoRouter.fail(agent, finalReservation, selected.autoVerifyMode === 'strict')
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { Session } from '@deepseek-ai/dsh-session'
3
- import { createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
4
- import { analyzeStructuredRoute, AutoVerifierRouter, boundDecision, buildSemanticRoutePrompt, estimateRoutedCalls, latestDirectUserSeq, MAX_ROUTED_CHECKPOINTS, parseSemanticRoute, semanticDecision, semanticRouteHint, type RouterPolicy } from './router.ts'
3
+ import { createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
4
+ import { analyzeStructuredRoute, AutoVerifierRouter, boundDecision, buildSemanticRoutePrompt, estimateRoutedCalls, latestDirectUserSeq, MAX_ROUTED_CHECKPOINTS, parseSemanticRoute, routedRepeats, semanticDecision, semanticRouteHint, type RouterPolicy } from './router.ts'
5
5
  import { sanitizeVerifierText } from './session.ts'
6
6
 
7
7
  function session() {
@@ -13,6 +13,9 @@ function tool(value: ReturnType<typeof session>, name: string, id: string, text:
13
13
  value.append('tool/call', { turn, step, callId: id as never, name, arguments: '{}' })
14
14
  value.append('tool/result', { turn, step, message: createToolResultMessage({ callId: id as never, content: [{ type: 'text', text }], isError: false }) }, { surfaceOp: 'append' })
15
15
  }
16
+ function assistant(value: ReturnType<typeof session>, text: string, turn = 1, step = 1) {
17
+ value.append('assistant/message', { turn, step, message: createAssistantMessage({ content: [{ type: 'text', text }], source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }) }, { surfaceOp: 'append' })
18
+ }
16
19
  const policy: RouterPolicy = { mode: 'smart', minConfidence: .9, maxCandidates: 8, maxPerTask: 5, maxPerSession: 20, maxModelCallsPerTask: 48, maxModelCallsPerSession: 160, maxInputChars: 60000, maxItemChars: 20000 }
17
20
  const envelope = (count: number) => JSON.stringify({ protocol: 'dsh-verifier-candidates', version: 1, groupId: 'auth', candidates: Array.from({ length: count }, (_, i) => ({ id: String(i + 1), label: 'C' + (i + 1), status: 'completed', content: 'candidate ' + (i + 1) })) })
18
21
 
@@ -45,6 +48,133 @@ describe('production structured routing', () => {
45
48
  expect(decision.steps.every(step => step.length <= 400)).toBe(true)
46
49
  }
47
50
  })
51
+ it('never attaches bookkeeping output as checkpoint evidence', () => {
52
+ // Regression: the newest successful result before a todo snapshot is usually the
53
+ // bookkeeping call that wrote it (create_goal/update_goal/todo_write/interrupt_agent),
54
+ // so every checkpoint showed the router its own metadata instead of the work that
55
+ // had just run the task's tests.
56
+ const value = session()
57
+ value.append('todo/write', { todos: [{ content: 'Implement', status: 'in_progress' }, { content: 'Test', status: 'pending' }] })
58
+ tool(value, 'pwsh', 'run', 'all tests passed: 91 passed')
59
+ tool(value, 'create_goal', 'goal', '{"goal":{"id":"g1","phase":"active"}}')
60
+ tool(value, 'interrupt_agent', 'stop', 'interrupt requested for agent abc')
61
+ value.append('todo/write', { todos: [{ content: 'Implement', status: 'completed' }, { content: 'Test', status: 'completed' }] })
62
+ const decision = analyzeStructuredRoute(value.events, 8, 400, 800)
63
+ expect(decision?.kind).toBe('track')
64
+ if (decision?.kind !== 'track') return
65
+ // The real run is older than both bookkeeping results, and must still win.
66
+ expect(decision.steps[1]).toContain('all tests passed')
67
+ expect(decision.steps[1]).not.toContain('"phase":"active"')
68
+ expect(decision.steps[1]).not.toContain('interrupt requested')
69
+ })
70
+
71
+ it('attaches the newest narration to the current checkpoint only', () => {
72
+ // A prose deliverable (review, analysis) never reaches a tool result, so every
73
+ // checkpoint scored "certainly NO" while the same session's final acceptance
74
+ // passed. The narration is labelled as a claim, and only the checkpoint the route
75
+ // actually judges carries it.
76
+ const value = session()
77
+ assistant(value, 'Early plan: read the parser first.')
78
+ value.append('todo/write', { todos: [{ content: 'Review', status: 'in_progress' }, { content: 'Report', status: 'pending' }] })
79
+ assistant(value, 'Deliverable: the review found two blocking issues.', 1, 2)
80
+ value.append('todo/write', { todos: [{ content: 'Review', status: 'completed' }, { content: 'Report', status: 'completed' }] })
81
+ const decision = analyzeStructuredRoute(value.events, 8, 20000, 60000)
82
+ expect(decision?.kind).toBe('track')
83
+ if (decision?.kind !== 'track') return
84
+ expect(decision.steps[0]).not.toContain('Newest agent narration')
85
+ expect(decision.steps[1]).toContain('Newest agent narration')
86
+ expect(decision.steps[1]).toContain('two blocking issues')
87
+ expect(decision.steps[1]).not.toContain('Early plan')
88
+ })
89
+
90
+ it('keeps narration and evidence inside the checkpoint caps', () => {
91
+ const value = session()
92
+ value.append('todo/write', { todos: [{ content: 'Implement', status: 'in_progress' }, { content: 'Test', status: 'pending' }] })
93
+ tool(value, 'pwsh', 'run', 'Z'.repeat(4000))
94
+ assistant(value, 'Q'.repeat(4000))
95
+ value.append('todo/write', { todos: [{ content: 'Implement', status: 'completed' }, { content: 'Test', status: 'completed' }] })
96
+ for (const maxItemChars of [128, 200, 2000, 20000]) {
97
+ const maxInputChars = Math.max(60000, maxItemChars * 2)
98
+ const decision = analyzeStructuredRoute(value.events, 8, maxItemChars, maxInputChars)
99
+ expect(decision, 'maxItemChars=' + maxItemChars).toMatchObject({ kind: 'track' })
100
+ if (decision?.kind !== 'track') continue
101
+ for (const step of decision.steps) expect(step.length, 'maxItemChars=' + maxItemChars).toBeLessThanOrEqual(maxItemChars)
102
+ expect(decision.steps.reduce((sum, step) => sum + step.length, 0)).toBeLessThanOrEqual(maxInputChars)
103
+ // Regression guard for the caps that boundDecision() enforces on the whole route.
104
+ expect(boundDecision(decision, { ...policy, maxItemChars, maxInputChars }), 'maxItemChars=' + maxItemChars).toBeDefined()
105
+ }
106
+ })
107
+
108
+ it('re-routes the same todo snapshots once the rendered evidence changed', () => {
109
+ // Regression: the fingerprint covered only the todo snapshots, so a route whose
110
+ // prompt had changed (new evidence, new narration) was refused as "already routed".
111
+ const value = session()
112
+ tool(value, 'pwsh', 'run-a', 'first output')
113
+ value.append('todo/write', { todos: [{ content: 'Implement', status: 'in_progress' }, { content: 'Test', status: 'pending' }] })
114
+ tool(value, 'pwsh', 'run-b', 'second output')
115
+ value.append('todo/write', { todos: [{ content: 'Implement', status: 'completed' }, { content: 'Test', status: 'completed' }] })
116
+ const first = analyzeStructuredRoute(value.events, 8, 20000, 60000)
117
+ const again = analyzeStructuredRoute(value.events, 8, 20000, 60000)
118
+ expect(first?.kind).toBe('track')
119
+ // An unchanged log keeps its identity, so the budget guard still works.
120
+ expect(again?.fingerprint).toBe(first?.fingerprint)
121
+ assistant(value, 'Report: the deliverable is ready.', 2, 1)
122
+ const afterNarration = analyzeStructuredRoute(value.events, 8, 20000, 60000)
123
+ expect(afterNarration?.fingerprint).not.toBe(first?.fingerprint)
124
+ if (first?.kind !== 'track' || afterNarration?.kind !== 'track') return
125
+ expect(afterNarration.steps.at(-1)).toContain('Report: the deliverable is ready.')
126
+ })
127
+
128
+ it('renders the newest checkpoint as the state at routing time', () => {
129
+ // Regression: evidence was frozen at the last todo snapshot's seq, so work done
130
+ // after it — often the actual verification run — never reached the judge.
131
+ const value = session()
132
+ tool(value, 'pwsh', 'first', 'first run output')
133
+ value.append('todo/write', { todos: [{ content: 'Implement', status: 'in_progress' }, { content: 'Test', status: 'pending' }] })
134
+ tool(value, 'pwsh', 'second', 'verification run output')
135
+ value.append('todo/write', { todos: [{ content: 'Implement', status: 'completed' }, { content: 'Test', status: 'completed' }] })
136
+ tool(value, 'pwsh', 'third', 'output after the last snapshot')
137
+ const decision = analyzeStructuredRoute(value.events, 8, 20000, 60000)
138
+ expect(decision?.kind).toBe('track')
139
+ if (decision?.kind !== 'track') return
140
+ // Historical checkpoints keep the output that was current for them...
141
+ expect(decision.steps[0]).toContain('first run output')
142
+ expect(decision.steps[0]).not.toContain('verification run output')
143
+ // ...while the newest one carries the newest output and says so.
144
+ expect(decision.steps[1]).toContain('at routing time')
145
+ expect(decision.steps[1]).toContain('output after the last snapshot')
146
+ })
147
+
148
+ it('treats a wrapper that only dispatched bookkeeping as bookkeeping', () => {
149
+ // Regression: a PTC program that only called todo_write produced a run_code result
150
+ // whose text was the todo list echoed back, and the wrapper's name hid what it did.
151
+ const value = session()
152
+ tool(value, 'pwsh', 'run', 'all tests passed: 91 passed')
153
+ value.append('todo/write', { todos: [{ content: 'Implement', status: 'in_progress' }, { content: 'Test', status: 'pending' }] })
154
+ value.append('tool/call', { turn: 1, step: 1, callId: 'wrap' as never, name: 'run_code', arguments: '{}' })
155
+ value.append('tool/ptc-dispatch' as never, { rootCallId: 'wrap', subCallId: 'wrap:ptc:1', name: 'todo_write', arguments: '{}', isError: false, content: [{ type: 'text', text: '{todos:[{content:"Implement",status:"completed"}]}' }] } as never)
156
+ value.append('tool/result', { turn: 1, step: 1, message: createToolResultMessage({ callId: 'wrap' as never, content: [{ type: 'text', text: '{ todos: [ { content: "Implement", status: "completed" } ], counts: {} }' }], isError: false }) }, { surfaceOp: 'append' })
157
+ value.append('todo/write', { todos: [{ content: 'Implement', status: 'completed' }, { content: 'Test', status: 'completed' }] })
158
+ const decision = analyzeStructuredRoute(value.events, 8, 20000, 60000)
159
+ expect(decision?.kind).toBe('track')
160
+ if (decision?.kind !== 'track') return
161
+ expect(decision.steps[1]).toContain('all tests passed')
162
+ expect(decision.steps[1]).not.toContain('counts:')
163
+ })
164
+
165
+ it('keeps a wrapper that dispatched real work', () => {
166
+ const value = session()
167
+ value.append('tool/call', { turn: 1, step: 1, callId: 'wrap' as never, name: 'run_code', arguments: '{}' })
168
+ value.append('tool/ptc-dispatch' as never, { rootCallId: 'wrap', subCallId: 'wrap:ptc:1', name: 'pwsh', arguments: '{}', isError: false, content: [{ type: 'text', text: 'tests passed' }] } as never)
169
+ value.append('tool/result', { turn: 1, step: 1, message: createToolResultMessage({ callId: 'wrap' as never, content: [{ type: 'text', text: 'tests passed in the wrapper output' }], isError: false }) }, { surfaceOp: 'append' })
170
+ value.append('todo/write', { todos: [{ content: 'Implement', status: 'in_progress' }, { content: 'Test', status: 'pending' }] })
171
+ value.append('todo/write', { todos: [{ content: 'Implement', status: 'completed' }, { content: 'Test', status: 'completed' }] })
172
+ const decision = analyzeStructuredRoute(value.events, 8, 20000, 60000)
173
+ expect(decision?.kind).toBe('track')
174
+ if (decision?.kind !== 'track') return
175
+ expect(decision.steps[1]).toContain('tests passed in the wrapper output')
176
+ })
177
+
48
178
  it('deduplicates identical todo snapshots', () => {
49
179
  const value = session()
50
180
  const todos = [{ content: 'Implement', status: 'in_progress' as const }, { content: 'Test', status: 'pending' as const }]
@@ -99,6 +229,30 @@ describe('semantic evidence references', () => {
99
229
  expect(semanticDecision(parsed, value.events)).toMatchObject({ kind: 'compare', source: 'semantic' })
100
230
  })
101
231
 
232
+ it('never offers bookkeeping calls as semantic candidates', () => {
233
+ // Regression: the artifact list carried every paired tool result, so the classifier
234
+ // could cite two goal/agent-control calls as "competing alternatives".
235
+ const value = session()
236
+ tool(value, 'subagent', 'a', 'candidate A from a real subagent')
237
+ tool(value, 'create_goal', 'goal', '{"goal":{"id":"g1","phase":"active"}}')
238
+ tool(value, 'skill', 'skill', 'loaded review skill body')
239
+ const prompt = buildSemanticRoutePrompt('pick the better one', value.events, 8, 20000, 60000)
240
+ expect(prompt).toContain('candidate A from a real subagent')
241
+ expect(prompt).not.toContain('"phase":"active"')
242
+ expect(prompt).not.toContain('loaded review skill body')
243
+ })
244
+
245
+ it('rejects a semantic decision that cites bookkeeping evidence', () => {
246
+ const value = session()
247
+ tool(value, 'create_goal', 'goal-1', '{"goal":{"id":"g1"}}')
248
+ tool(value, 'create_goal', 'goal-2', '{"goal":{"id":"g2"}}')
249
+ const parsed = parseSemanticRoute(JSON.stringify({ kind: 'compare', confidence: .95, reason: 'alternatives', candidateCallIds: ['goal-1', 'goal-2'], checkpointSeqs: [] }))
250
+ expect(parsed?.kind).toBe('compare')
251
+ // Fail closed: the whole decision is dropped, and the caller records the reference
252
+ // as invalid instead of comparing metadata.
253
+ expect(semanticDecision(parsed!, value.events)).toBeUndefined()
254
+ })
255
+
102
256
  it('resolves candidates emitted via Session V3 tool/ptc-dispatch', () => {
103
257
  const value = session()
104
258
  value.append('tool/ptc-dispatch' as never, { subCallId: 'ptc-1', name: 'subagent', arguments: '{}', isError: false, content: [{ type: 'text', text: 'candidate A from ptc' }] } as never)
@@ -210,10 +364,28 @@ describe('semantic evidence references', () => {
210
364
  const value = session()
211
365
  value.append('team/task' as never, { task: { id: 'task-1', revision: 1, subject: 'Backend API', status: 'in_progress' } } as never)
212
366
  value.append('team/task' as never, { task: { id: 'task-1', revision: 2, subject: 'Backend API', status: 'completed' } } as never)
213
- expect(semanticRouteHint(value.events)).toBe(true)
367
+ // Snapshots are structured material, not a semantic hint: the structured pass claims
368
+ // them first, so asking the classifier about them would only ever pay for a route
369
+ // that could not have been produced anyway.
370
+ expect(semanticRouteHint(value.events)).toBe(false)
214
371
  const decision = analyzeStructuredRoute(value.events)
215
372
  expect(decision).toMatchObject({ kind: 'track', source: 'structured', reason: 'changed durable team tasks' })
216
373
  })
374
+
375
+ it('hints a classification only for material the structured pass never consumes', () => {
376
+ const subagent = session(); tool(subagent, 'subagent', 'a', 'candidate A')
377
+ expect(semanticRouteHint(subagent.events)).toBe(true)
378
+ const plan = session()
379
+ plan.append('tool/call', { turn: 1, step: 1, callId: 'plan' as never, name: 'exit_plan_mode', arguments: '{}' })
380
+ plan.append('tool/result', { turn: 1, step: 1, message: createToolResultMessage({ callId: 'plan' as never, content: [{ type: 'text', text: 'approved' }], isError: false }) }, { surfaceOp: 'append' })
381
+ expect(semanticRouteHint(plan.events)).toBe(true)
382
+ // Todo snapshots alone (all lists shorter than two items) are still structured-only.
383
+ const todos = session()
384
+ todos.append('todo/write', { todos: [{ content: 'Only step', status: 'in_progress' }] })
385
+ todos.append('todo/write', { todos: [{ content: 'Only step', status: 'completed' }] })
386
+ expect(analyzeStructuredRoute(todos.events)).toBeUndefined()
387
+ expect(semanticRouteHint(todos.events)).toBe(false)
388
+ })
217
389
  })
218
390
 
219
391
  describe('transactional router state', () => {
@@ -256,6 +428,22 @@ describe('transactional router state', () => {
256
428
  value.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'next task' }], source: { kind: 'user' } }), { surfaceOp: 'append' })
257
429
  expect(router.claimExhaustedNotice(agent)).toBe(true)
258
430
  })
431
+ it('rounds compare repeats up to an even count and leaves the other routes alone', () => {
432
+ // compare judges ONE pair, so an odd count leaves its A/B preference uncorrected
433
+ // (the engine only swaps positions on odd repeat indices). select is already
434
+ // symmetric per pair and track has no slots, so both keep the configured count.
435
+ const compare = { kind: 'compare', candidates: [{}, {}] } as never
436
+ const select = { kind: 'select', candidates: [{}, {}, {}] } as never
437
+ const track = { kind: 'track', steps: [] } as never
438
+ expect(routedRepeats(compare, 1)).toBe(2)
439
+ expect(routedRepeats(compare, 2)).toBe(2)
440
+ expect(routedRepeats(compare, 3)).toBe(4)
441
+ expect(routedRepeats(select, 1)).toBe(1)
442
+ expect(routedRepeats(select, 3)).toBe(3)
443
+ expect(routedRepeats(track, 1)).toBe(1)
444
+ expect(routedRepeats(track, 3)).toBe(3)
445
+ })
446
+
259
447
  it('estimates routed calls from the real tournament shape', () => {
260
448
  const candidate = { id: 'c', groupId: 'g', label: 'c', content: 'x', callId: 'a', fromSeq: 1, toSeq: 2 }
261
449
  const base = { source: 'structured' as const, confidence: 1, reason: 'r', fingerprint: 'f' }