@linxin666/dsh-client-ui-task-board 0.4.4 → 0.4.5

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 (108) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +42 -11
  3. package/README.zh.md +42 -11
  4. package/lib/client.js +4958 -472
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2841 -121
  7. package/lib/types/client/PluginSettingsCard.d.ts +7 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +19 -3
  9. package/lib/types/client/board/ClampedMarkdown.d.ts +3 -0
  10. package/lib/types/client/board/ConfirmDialog.d.ts +4 -1
  11. package/lib/types/client/board/EditTaskModal.d.ts +5 -2
  12. package/lib/types/client/board/LinkSubtaskModal.d.ts +5 -2
  13. package/lib/types/client/board/NewTaskModal.d.ts +4 -1
  14. package/lib/types/client/board/TagManagerModal.d.ts +26 -0
  15. package/lib/types/client/board/TaskBoard.d.ts +10 -1
  16. package/lib/types/client/board/TaskCard.d.ts +14 -1
  17. package/lib/types/client/board/TaskDetail.d.ts +3 -1
  18. package/lib/types/client/board/TaskForm.d.ts +4 -1
  19. package/lib/types/client/board/VerificationReport.d.ts +23 -0
  20. package/lib/types/client/board/card-view.d.ts +113 -0
  21. package/lib/types/client/board/icons.d.ts +32 -0
  22. package/lib/types/client/board/overlay.d.ts +69 -0
  23. package/lib/types/client/board/status-key.d.ts +15 -0
  24. package/lib/types/client/board/task-markdown.d.ts +19 -0
  25. package/lib/types/client/board-card-seat.d.ts +34 -0
  26. package/lib/types/client/index.d.ts +19 -0
  27. package/lib/types/client/locales.d.ts +103 -1
  28. package/lib/types/client/native-panel.d.ts +6 -20
  29. package/lib/types/client/seats.d.ts +18 -0
  30. package/lib/types/client/service.d.ts +32 -0
  31. package/lib/types/client/settings-form.d.ts +16 -0
  32. package/lib/types/core/controller.d.ts +37 -1
  33. package/lib/types/core/extension.d.ts +283 -0
  34. package/lib/types/core/handover.d.ts +13 -1
  35. package/lib/types/core/issue-form.d.ts +16 -0
  36. package/lib/types/core/tasks.d.ts +84 -3
  37. package/lib/types/core/use-cases/task-tag.d.ts +48 -0
  38. package/lib/types/core/use-cases/task-update.d.ts +5 -3
  39. package/lib/types/core/verification.d.ts +386 -0
  40. package/lib/types/core/workspace-target.d.ts +41 -0
  41. package/lib/types/host/agent-tools.d.ts +1 -1
  42. package/lib/types/host/extension-registry.d.ts +111 -0
  43. package/lib/types/host/llm-dispatch.d.ts +63 -0
  44. package/lib/types/host/verification-gate.d.ts +71 -0
  45. package/lib/types/host/verification-runner.d.ts +108 -0
  46. package/lib/types/host/workspace-evidence.d.ts +53 -0
  47. package/lib/types/host-ledger.d.ts +52 -8
  48. package/lib/types/host-runner.d.ts +23 -1
  49. package/lib/types/host-service.d.ts +88 -2
  50. package/lib/types/index.d.ts +80 -26
  51. package/lib/types/protocol.d.ts +49 -5
  52. package/lib/types/tool-surface.d.ts +71 -0
  53. package/package.json +27 -26
  54. package/src/client/PluginSettingsCard.tsx +9 -0
  55. package/src/client/TaskBoardSettingsCard.tsx +304 -65
  56. package/src/client/board/ClampedMarkdown.tsx +69 -0
  57. package/src/client/board/ConfirmDialog.tsx +7 -3
  58. package/src/client/board/EditTaskModal.tsx +5 -2
  59. package/src/client/board/LinkSubtaskModal.tsx +12 -7
  60. package/src/client/board/NewTaskModal.tsx +17 -1
  61. package/src/client/board/TagManagerModal.tsx +235 -0
  62. package/src/client/board/TaskBoard.tsx +259 -103
  63. package/src/client/board/TaskCard.tsx +214 -81
  64. package/src/client/board/TaskDetail.tsx +71 -26
  65. package/src/client/board/TaskForm.tsx +13 -3
  66. package/src/client/board/VerificationReport.tsx +203 -0
  67. package/src/client/board/card-view.ts +220 -0
  68. package/src/client/board/icons.tsx +89 -0
  69. package/src/client/board/overlay.tsx +189 -0
  70. package/src/client/board/status-key.ts +27 -0
  71. package/src/client/board/task-markdown.module.css +137 -0
  72. package/src/client/board/task-markdown.tsx +106 -0
  73. package/src/client/board-card-seat.ts +102 -0
  74. package/src/client/board-settings.module.css +66 -0
  75. package/src/client/board.module.css +488 -63
  76. package/src/client/index.ts +43 -5
  77. package/src/client/locales.ts +208 -4
  78. package/src/client/native-panel.tsx +18 -2
  79. package/src/client/seats.tsx +58 -0
  80. package/src/client/service.ts +94 -0
  81. package/src/client/settings-card.module.css +31 -5
  82. package/src/client/settings-form.ts +27 -1
  83. package/src/core/controller.ts +94 -3
  84. package/src/core/extension.ts +376 -0
  85. package/src/core/freeze-snapshot.ts +2 -2
  86. package/src/core/handover.ts +16 -1
  87. package/src/core/issue-form.ts +19 -0
  88. package/src/core/store.ts +16 -0
  89. package/src/core/tasks.ts +100 -6
  90. package/src/core/use-cases/task-parent.ts +3 -2
  91. package/src/core/use-cases/task-tag.ts +110 -0
  92. package/src/core/use-cases/task-update.ts +10 -4
  93. package/src/core/verification.ts +826 -0
  94. package/src/core/workspace-target.ts +59 -0
  95. package/src/host/agent-tools.ts +20 -18
  96. package/src/host/extension-registry.ts +509 -0
  97. package/src/host/llm-dispatch.ts +87 -0
  98. package/src/host/verification-gate.ts +331 -0
  99. package/src/host/verification-runner.ts +535 -0
  100. package/src/host/workspace-evidence.ts +176 -0
  101. package/src/host-ai.ts +15 -9
  102. package/src/host-ledger.ts +143 -32
  103. package/src/host-routes.ts +15 -2
  104. package/src/host-runner.ts +60 -11
  105. package/src/host-service.ts +253 -10
  106. package/src/index.ts +204 -16
  107. package/src/protocol.ts +102 -8
  108. package/src/tool-surface.ts +95 -0
@@ -0,0 +1,535 @@
1
+ /**
2
+ * Goal acceptance runner: evidence assembly and the judge calls of one
3
+ * acceptance attempt.
4
+ *
5
+ * Lives in the host half because it needs the live session event log and the
6
+ * official `llm` service; every rule it applies (criteria, scale, tags,
7
+ * thresholds, budgets, caps) comes from `../core/verification.ts`, which mirrors
8
+ * the installed dsh-llm-verifier 0.8.4 default final acceptance.
9
+ */
10
+ import { createUserMessage, type FinishReason, type LlmRuntime } from '@deepseek-ai/dsh-llm'
11
+ import { openOneShotStream } from './llm-dispatch.ts'
12
+ import { createHash } from 'node:crypto'
13
+ import {
14
+ CODING_CRITERIA,
15
+ EMPTY_WORK_BASELINE,
16
+ VERIFICATION_ROUNDS,
17
+ VERIFICATION_THRESHOLD,
18
+ acceptancePassed,
19
+ buildAcceptancePrompt,
20
+ extractScore,
21
+ type VerificationAttempt,
22
+ type CatalogGroup,
23
+ type CatalogModel,
24
+ type CatalogReasoningEffort,
25
+ type ModelCatalogView,
26
+ type VerificationContract,
27
+ type VerificationCriterion,
28
+ type VerificationCriterionScore,
29
+ type VerificationEvidenceSummary,
30
+ type VerificationRoute,
31
+ type VerificationSettings,
32
+ type VerificationUsage,
33
+ } from '../core/verification.ts'
34
+
35
+ /** Largest trace handed to the judge, mirroring the verifier's default cap. */
36
+ export const VERIFICATION_MAX_TRACE_CHARS = 80_000
37
+ /** Largest single trace entry; one huge tool result must not eat the budget. */
38
+ export const VERIFICATION_MAX_ENTRY_CHARS = 4_000
39
+ /** Largest per-finding text kept from one judge answer. */
40
+ export const VERIFICATION_MAX_FINDING_CHARS = 400
41
+ /** Findings kept per judge answer and per acceptance. */
42
+ export const VERIFICATION_MAX_FINDINGS_PER_CALL = 3
43
+ export const VERIFICATION_MAX_FINDINGS = 6
44
+ /** One judge request's ceiling and its retry budget. */
45
+ export const VERIFICATION_CALL_TIMEOUT_MS = 120_000
46
+ export const VERIFICATION_MAX_TOKENS = 16_384
47
+ export const VERIFICATION_TEMPERATURE = 0.2
48
+ export const VERIFICATION_REQUEST_ATTEMPTS = 3
49
+
50
+ /** Redaction patterns, mirrored from the verifier's defaults. */
51
+ export const VERIFICATION_REDACT_PATTERNS: readonly string[] = [
52
+ "Bearer\\s+[A-Za-z0-9._~+\\/=-]+",
53
+ '(?:api[_-]?key|token|password|secret)\\s*[=:]\\s*["\']?[^\\s,"\';}]+',
54
+ ]
55
+
56
+ /** Why one acceptance could not produce a verdict. */
57
+ export type VerificationErrorKind = 'route-unavailable' | 'timeout' | 'auth' | 'parse' | 'request' | 'aborted'
58
+
59
+ /** An acceptance anomaly: never a quality verdict, always recorded as one. */
60
+ export class VerificationError extends Error {
61
+ constructor(
62
+ readonly kind: VerificationErrorKind,
63
+ message: string,
64
+ readonly usage: VerificationUsage = emptyUsage(),
65
+ options?: { cause?: unknown },
66
+ ) {
67
+ super(message, options)
68
+ this.name = 'VerificationError'
69
+ }
70
+ }
71
+
72
+ /** Fresh zeroed usage counters. */
73
+ export function emptyUsage(): VerificationUsage {
74
+ return { calls: 0, inputTokens: 0, outputTokens: 0, reasoningTokens: 0 }
75
+ }
76
+
77
+ /** Whether a thrown error is worth one more request attempt. */
78
+ function retryable(error: unknown): boolean {
79
+ const message = error instanceof Error ? error.message : String(error)
80
+ return /rate|quota|timeout|timed out|temporar|network|fetch|socket|5\d\d|overload/i.test(message)
81
+ }
82
+
83
+ /** Redact one text in place, failing closed on an unusable pattern. */
84
+ function redact(text: string, patterns: readonly string[]): string {
85
+ let result = text
86
+ for (const pattern of patterns) {
87
+ if (pattern.length === 0 || pattern.length > 500) continue
88
+ // Reject nested/unbounded quantifiers that can backtrack catastrophically.
89
+ if (/\([^)]*[+*][^)]*\)[+*{]|\.\*[+*{]|\.\+[+*{]/u.test(pattern)) continue
90
+ try {
91
+ result = result.replace(new RegExp(pattern, 'giu'), '[REDACTED]')
92
+ } catch {
93
+ // An unusable pattern is skipped rather than failing the acceptance.
94
+ }
95
+ }
96
+ return result
97
+ }
98
+
99
+ /** Bound one text, keeping its tail: the newest output is what the judge grades. */
100
+ function boundTail(text: string, maxChars: number, notice: string): { text: string; omitted: number } {
101
+ if (text.length <= maxChars) return { text, omitted: 0 }
102
+ const omitted = text.length - maxChars
103
+ const prefix = notice.replace('%d', String(omitted))
104
+ if (prefix.length >= maxChars) return { text: text.slice(-maxChars), omitted }
105
+ return { text: prefix + text.slice(-(maxChars - prefix.length)), omitted }
106
+ }
107
+
108
+ /** One rendered content block of a tool result or assistant message. */
109
+ function blockText(block: unknown): string {
110
+ if (typeof block !== 'object' || block === null) return ''
111
+ const row = block as Record<string, unknown>
112
+ const type = row.type
113
+ if (type === 'text' && typeof row.text === 'string') return row.text
114
+ if (type === 'reasoning' && typeof row.text === 'string') return '[Reasoning] ' + row.text
115
+ if (type === 'tool-call') return '[Tool Call] ' + String(row.name ?? '') + ' ' + String(row.arguments ?? '')
116
+ if (type === 'tool-result') {
117
+ const nested = Array.isArray(row.content) ? row.content : []
118
+ return '[Tool Result] ' + nested.map(blockText).filter(part => part !== '').join('\n')
119
+ }
120
+ if (type === 'file') return '[File] ' + String(row.path ?? row.filename ?? row.title ?? 'attachment')
121
+ return ''
122
+ }
123
+
124
+ /** Render one message's blocks as plain text. */
125
+ function messageText(content: unknown): string {
126
+ if (!Array.isArray(content)) return ''
127
+ return content.map(blockText).filter(part => part !== '').join('\n')
128
+ }
129
+
130
+ /** Structural view of one session event, as the host log carries it. */
131
+ interface RawEvent {
132
+ type?: unknown
133
+ seq?: unknown
134
+ time?: unknown
135
+ data?: unknown
136
+ }
137
+
138
+ /** Read one session's events across the shapes the SDK cohort exposes. */
139
+ export function sessionEvents(session: unknown): readonly RawEvent[] {
140
+ const candidate = session as { snapshotEvents?: () => readonly RawEvent[]; events?: readonly RawEvent[] } | undefined
141
+ if (typeof candidate?.snapshotEvents === 'function') return candidate.snapshotEvents()
142
+ return candidate?.events ?? []
143
+ }
144
+
145
+ /** Evidence assembled for one acceptance attempt. */
146
+ export interface AcceptanceEvidence {
147
+ problem: string
148
+ trace: string
149
+ entries: number
150
+ fromSeq?: number
151
+ toSeq?: number
152
+ omittedCharacters: number
153
+ hash: string
154
+ }
155
+
156
+ /**
157
+ * Assemble the evidence one acceptance judges.
158
+ *
159
+ * The window starts at the execution's own `startedAt`, never at the session's
160
+ * first event: a reused session carries the history of earlier executions, and
161
+ * those runs must not be judged as this one's work. Only observed output is
162
+ * rendered — tool calls with their arguments and their results, assistant
163
+ * prose, goal round markers and team messages — and the result is redacted and
164
+ * capped before it ever reaches a judge.
165
+ * @param session - the live session of the execution.
166
+ * @param problem - the execution's composed prompt, shown as the task statement.
167
+ * @param startedAt - the instant the execution opened (ms epoch).
168
+ * @returns the bounded, redacted evidence.
169
+ */
170
+ /**
171
+ * The session events inside one execution's own window.
172
+ *
173
+ * A reused session carries the history of earlier executions; only the events
174
+ * at or after this execution's start belong to the work being judged.
175
+ * @param session - the live session.
176
+ * @param startedAt - the execution's start instant (ms epoch).
177
+ * @returns the events of this execution, oldest first.
178
+ */
179
+ export function evidenceEvents(session: unknown, startedAt: number): readonly RawEvent[] {
180
+ return sessionEvents(session).filter(event => typeof event.time !== 'number' || startedAt <= 0 || event.time >= startedAt)
181
+ }
182
+
183
+ export function collectEvidence(session: unknown, problem: string, startedAt: number): AcceptanceEvidence {
184
+ const events = evidenceEvents(session, startedAt)
185
+ .slice()
186
+ .sort((a, b) => (typeof a.seq === 'number' && typeof b.seq === 'number' ? a.seq - b.seq : 0))
187
+ const entries: string[] = []
188
+ for (const event of events) {
189
+ const data = (typeof event.data === 'object' && event.data !== null ? event.data : {}) as Record<string, unknown>
190
+ const seq = typeof event.seq === 'number' ? event.seq : 0
191
+ if (event.type === 'user/message') {
192
+ const source = (typeof data.source === 'object' && data.source !== null ? data.source : {}) as Record<string, unknown>
193
+ const kind = String(source.kind ?? '')
194
+ if (kind !== 'user' && kind !== 'team-message' && kind !== 'goal') continue
195
+ const label = kind === 'goal' ? 'Goal Round' : kind === 'team-message' ? 'Team Message' : 'User'
196
+ entries.push('--- ' + label + ' seq ' + seq + ' ---\n' + messageText(data.content))
197
+ } else if (event.type === 'assistant/message') {
198
+ const message = (typeof data.message === 'object' && data.message !== null ? data.message : {}) as Record<string, unknown>
199
+ entries.push('--- Assistant turn ' + String(data.turn ?? '?') + ' step ' + String(data.step ?? '?') + ' ---\n' + messageText(message.content))
200
+ } else if (event.type === 'tool/call') {
201
+ entries.push('--- Tool Call turn ' + String(data.turn ?? '?') + ' step ' + String(data.step ?? '?') + ' ---\n[Command] ' + String(data.name ?? '') + ' ' + String(data.arguments ?? ''))
202
+ } else if (event.type === 'tool/result') {
203
+ const message = (typeof data.message === 'object' && data.message !== null ? data.message : {}) as Record<string, unknown>
204
+ const failed = message.isError === true ? ' [Error]' : ''
205
+ entries.push('--- Tool Result' + failed + ' turn ' + String(data.turn ?? '?') + ' step ' + String(data.step ?? '?') + ' ---\n[Output] ' + messageText(message.content))
206
+ } else if (event.type === 'tool/ptc-dispatch' || event.type === 'tool/code-dispatch') {
207
+ const label = event.type === 'tool/ptc-dispatch' ? 'PTC Dispatch' : 'Code Dispatch'
208
+ const status = data.isError === true ? ' [Error]' : ''
209
+ const args = data.arguments === undefined ? '' : ' ' + (typeof data.arguments === 'string' ? data.arguments : JSON.stringify(data.arguments))
210
+ entries.push('--- ' + label + ' ' + String(data.name ?? '') + status + ' seq ' + seq + ' ---\n[Command] ' + String(data.name ?? '') + args + '\n[Output] ' + messageText(data.content))
211
+ } else if (event.type === 'team/message/queued') {
212
+ const message = (typeof data.message === 'object' && data.message !== null ? data.message : {}) as Record<string, unknown>
213
+ entries.push('--- Team Message Queued from ' + String(message.senderName ?? 'Teammate') + ' seq ' + seq + ' ---\n' + messageText(message.content))
214
+ }
215
+ }
216
+ const bounded = entries
217
+ .map(entry => entry.length <= VERIFICATION_MAX_ENTRY_CHARS ? entry : entry.slice(0, VERIFICATION_MAX_ENTRY_CHARS) + '\n[Entry truncated]')
218
+ .filter(entry => entry.trim() !== '')
219
+ const joined = redact(bounded.join('\n\n'), VERIFICATION_REDACT_PATTERNS)
220
+ const window = boundTail(joined, VERIFICATION_MAX_TRACE_CHARS, '[Earlier trace truncated: %d characters omitted]\n')
221
+ const problemText = redact(problem, VERIFICATION_REDACT_PATTERNS).trim()
222
+ const hash = createHash('sha256').update(problemText).update('\u0000').update(window.text).digest('hex')
223
+ const seqs = events.map(event => (typeof event.seq === 'number' ? event.seq : undefined)).filter((seq): seq is number => seq !== undefined)
224
+ return {
225
+ problem: problemText,
226
+ trace: window.text,
227
+ entries: bounded.length,
228
+ ...(seqs.length === 0 ? {} : { fromSeq: Math.min(...seqs), toSeq: Math.max(...seqs) }),
229
+ omittedCharacters: window.omitted,
230
+ hash,
231
+ }
232
+ }
233
+
234
+ /** One judge answer's parsed verdict. */
235
+ interface JudgeAnswer {
236
+ scoreWork: number
237
+ scoreBaseline: number
238
+ findings: string[]
239
+ usage: VerificationUsage
240
+ }
241
+
242
+ /** One finding the judge located, before it is rendered. */
243
+ interface RawFinding {
244
+ criterion?: string
245
+ evidence: string
246
+ body: string
247
+ action?: string
248
+ }
249
+
250
+ /** Parse the optional `<finding ...>` lines out of one judge answer. */
251
+ function parseFindings(text: string, criterion: VerificationCriterion): RawFinding[] {
252
+ const pattern = /<finding\s+([^>]*)>([\s\S]*?)<\/finding>/giu
253
+ const attributes = /([A-Za-z_][A-Za-z0-9_]*)\s*=\s*"([^"]*)"/gu
254
+ const found: RawFinding[] = []
255
+ const seen = new Set<string>()
256
+ for (let match = pattern.exec(text); match !== null && found.length < VERIFICATION_MAX_FINDINGS_PER_CALL; match = pattern.exec(text)) {
257
+ const attrs = new Map<string, string>()
258
+ for (let attribute = attributes.exec(match[1] ?? ''); attribute !== null; attribute = attributes.exec(match[1] ?? '')) {
259
+ if (['criterion', 'evidence', 'action'].includes(attribute[1] ?? '')) attrs.set(attribute[1] ?? '', attribute[2] ?? '')
260
+ }
261
+ const named = attrs.get('criterion') ?? ''
262
+ // A finding must name the criterion this prompt offered and cite an evidence
263
+ // token the prompt actually rendered; anything else is dropped, never echoed.
264
+ if (named !== '' && named.toLowerCase() !== criterion.name.toLowerCase() && named.toLowerCase() !== criterion.id.toLowerCase()) continue
265
+ const evidence = (attrs.get('evidence') ?? '').trim().toUpperCase()
266
+ if (!['TASK', 'A', 'B'].includes(evidence)) continue
267
+ const body = (match[2] ?? '').trim().slice(0, VERIFICATION_MAX_FINDING_CHARS)
268
+ if (body === '') continue
269
+ const action = (attrs.get('action') ?? '').trim().slice(0, 300)
270
+ const key = named + '|' + evidence + '|' + body
271
+ if (seen.has(key)) continue
272
+ seen.add(key)
273
+ found.push({ criterion: criterion.name, evidence, body, ...(action === '' ? {} : { action }) })
274
+ }
275
+ return found
276
+ }
277
+
278
+ /** Render one finding as the single feedback line the agent reads. */
279
+ function renderFinding(finding: RawFinding): string {
280
+ const locator = '[' + (finding.criterion ?? 'finding') + ' evidence=' + finding.evidence + ']'
281
+ const action = finding.action === undefined ? '' : ' → 建议核实:' + finding.action
282
+ return locator + ' ' + finding.body + action
283
+ }
284
+
285
+ /** One judge request's outcome. */
286
+ async function judgeOnce(
287
+ llm: LlmRuntime,
288
+ route: VerificationRoute,
289
+ prompt: string,
290
+ signal: AbortSignal,
291
+ ): Promise<{ text: string; usage: VerificationUsage }> {
292
+ const usage = emptyUsage()
293
+ let lastError: unknown
294
+ for (let attempt = 0; attempt < VERIFICATION_REQUEST_ATTEMPTS; attempt++) {
295
+ if (signal.aborted) throw new VerificationError('aborted', 'the acceptance was cancelled before the judge answer', usage)
296
+ const timeout = new AbortController()
297
+ const timer = setTimeout(() => { timeout.abort(new Error('task-board verification: the judge request timed out')) }, VERIFICATION_CALL_TIMEOUT_MS)
298
+ const forward = (): void => { timeout.abort(signal.reason) }
299
+ signal.addEventListener('abort', forward, { once: true })
300
+ try {
301
+ // The judge dispatches through the registration-bound prepared call, the
302
+ // same path the official agent loop uses: the public `stream()` is a
303
+ // mutable instance property a third-party plugin may replace, and a
304
+ // replacement with the `llm/stream` listener signature broke every
305
+ // acceptance with "next(...) is not a function or its return value is not
306
+ // async iterable".
307
+ const stream = await openOneShotStream(
308
+ llm,
309
+ {
310
+ provider: route.provider,
311
+ model: route.model,
312
+ ...(route.reasoningEffort === undefined ? {} : { reasoningEffort: route.reasoningEffort }),
313
+ temperature: VERIFICATION_TEMPERATURE,
314
+ maxTokens: VERIFICATION_MAX_TOKENS,
315
+ },
316
+ {
317
+ messages: [createUserMessage({ content: [{ type: 'text' as const, text: prompt }], source: { kind: 'user' as const } })],
318
+ signal: timeout.signal,
319
+ },
320
+ )
321
+ let text = ''
322
+ usage.calls += 1
323
+ let finishReason: FinishReason | undefined
324
+ for await (const chunk of stream) {
325
+ const row = chunk as unknown as Record<string, unknown>
326
+ if (row.type === 'text-delta' && typeof row.text === 'string') text += row.text
327
+ else if (row.type === 'usage') {
328
+ const reported = (typeof row.usage === 'object' && row.usage !== null ? row.usage : {}) as Record<string, unknown>
329
+ usage.inputTokens += typeof reported.inputTokens === 'number' ? reported.inputTokens : 0
330
+ usage.outputTokens += typeof reported.outputTokens === 'number' ? reported.outputTokens : 0
331
+ usage.reasoningTokens += typeof reported.reasoningTokens === 'number' ? reported.reasoningTokens : 0
332
+ } else if (row.type === 'finish') {
333
+ finishReason = chunk.type === 'finish' ? chunk.reason : undefined
334
+ }
335
+ }
336
+ if (signal.aborted) throw new VerificationError('aborted', 'the acceptance was cancelled', usage)
337
+ if (timeout.signal.aborted) throw new VerificationError('timeout', 'the judge request timed out', usage)
338
+ if (finishReason?.kind === 'error' || finishReason?.kind === 'aborted') {
339
+ const failure = finishReason.failure
340
+ const message = redact([failure.code, failure.status, failure.message].filter(value => value !== undefined).join(': '), VERIFICATION_REDACT_PATTERNS).slice(0, 2_000)
341
+ const kind = finishReason.kind === 'aborted' ? 'aborted'
342
+ : failure.status === 401 || failure.status === 403 ? 'auth' : 'request'
343
+ throw new VerificationError(kind, message, usage)
344
+ }
345
+ if (finishReason?.kind === 'max-tokens') {
346
+ throw new VerificationError('parse', 'the judge answer hit the output ceiling (' + VERIFICATION_MAX_TOKENS + ' tokens) and carries no usable verdict', usage)
347
+ }
348
+ if (text.trim() === '') {
349
+ throw new VerificationError('parse', 'the judge returned an empty answer', usage)
350
+ }
351
+ try {
352
+ extractScore(text, 'score_A')
353
+ extractScore(text, 'score_B')
354
+ } catch {
355
+ throw new VerificationError('parse', 'the judge answer did not contain valid score_A and score_B A-T scores', usage)
356
+ }
357
+ return { text, usage }
358
+ } catch (error) {
359
+ lastError = error
360
+ if (error instanceof VerificationError && (error.kind === 'aborted' || error.kind === 'auth')) throw error
361
+ if (signal.aborted) throw new VerificationError('aborted', 'the acceptance was cancelled', usage)
362
+ if (timeout.signal.aborted) {
363
+ lastError = new VerificationError('timeout', 'the judge request timed out after ' + Math.round(VERIFICATION_CALL_TIMEOUT_MS / 1000) + 's', usage)
364
+ }
365
+ const parseFailure = lastError instanceof VerificationError && lastError.kind === 'parse'
366
+ if (attempt + 1 >= VERIFICATION_REQUEST_ATTEMPTS || (!parseFailure && !retryable(lastError))) break
367
+ if (!parseFailure) usage.usageIncomplete = true
368
+ await new Promise(resolve => { setTimeout(resolve, 500 * (attempt + 1)) })
369
+ } finally {
370
+ clearTimeout(timer)
371
+ signal.removeEventListener('abort', forward)
372
+ }
373
+ }
374
+ const failure = lastError
375
+ if (failure instanceof VerificationError) throw new VerificationError(failure.kind, failure.message, usage)
376
+ const message = failure instanceof Error ? failure.message : String(failure)
377
+ const kind: VerificationErrorKind = /401|403|unauthor|api key|forbidden|credential/i.test(message) ? 'auth' : 'request'
378
+ throw new VerificationError(kind, message, usage)
379
+ }
380
+
381
+ /** The verdict of one acceptance attempt. */
382
+ export interface AcceptanceResult {
383
+ attempt: VerificationAttempt
384
+ }
385
+
386
+ /**
387
+ * Run one acceptance: every criterion is judged twice, the odd round with the
388
+ * A/B slots swapped, and the per-round scores are mapped back and averaged per
389
+ * criterion.
390
+ * @param input - the judge route, evidence, threshold and cancellation.
391
+ * @returns the recorded attempt (quality verdict or anomaly).
392
+ */
393
+ export async function runAcceptance(input: {
394
+ llm: LlmRuntime
395
+ route: VerificationRoute
396
+ evidence: AcceptanceEvidence
397
+ threshold: number
398
+ index: number
399
+ signal: AbortSignal
400
+ /** Host-observed workspace changes, rendered as the prompt's reference context. */
401
+ context?: string
402
+ /** How many changed files that context shows. */
403
+ workspaceFiles?: number
404
+ now?: () => number
405
+ }): Promise<AcceptanceResult> {
406
+ const now = input.now ?? Date.now
407
+ const startedAt = now()
408
+ const usage = emptyUsage()
409
+ const evidenceSummary: VerificationEvidenceSummary = {
410
+ chars: input.evidence.trace.length,
411
+ omittedCharacters: input.evidence.omittedCharacters,
412
+ entries: input.evidence.entries,
413
+ hash: input.evidence.hash,
414
+ ...(input.evidence.fromSeq === undefined ? {} : { fromSeq: input.evidence.fromSeq }),
415
+ ...(input.evidence.toSeq === undefined ? {} : { toSeq: input.evidence.toSeq }),
416
+ ...(input.workspaceFiles === undefined || input.workspaceFiles <= 0 ? {} : { workspaceFiles: input.workspaceFiles }),
417
+ }
418
+ const findings: string[] = []
419
+ const seenFindings = new Set<string>()
420
+ try {
421
+ const perCriterion = new Map<string, { criterion: VerificationCriterion; work: number[]; baseline: number[] }>()
422
+ for (const criterion of CODING_CRITERIA) perCriterion.set(criterion.id, { criterion, work: [], baseline: [] })
423
+ for (const criterion of CODING_CRITERIA) {
424
+ for (let repeat = 0; repeat < VERIFICATION_ROUNDS; repeat++) {
425
+ const swapped = repeat % 2 === 1
426
+ const candidateA = swapped ? EMPTY_WORK_BASELINE : input.evidence.trace
427
+ const candidateB = swapped ? input.evidence.trace : EMPTY_WORK_BASELINE
428
+ const prompt = buildAcceptancePrompt(input.evidence.problem, candidateA, candidateB, criterion, undefined, input.context)
429
+ const answer = await judgeOnce(input.llm, input.route, prompt, input.signal)
430
+ const scoreA = extractScore(answer.text, 'score_A')
431
+ const scoreB = extractScore(answer.text, 'score_B')
432
+ if (answer.usage.usageIncomplete === true) usage.usageIncomplete = true
433
+ usage.calls += answer.usage.calls
434
+ usage.inputTokens += answer.usage.inputTokens
435
+ usage.outputTokens += answer.usage.outputTokens
436
+ usage.reasoningTokens += answer.usage.reasoningTokens
437
+ const bucket = perCriterion.get(criterion.id)!
438
+ bucket.work.push(swapped ? scoreB : scoreA)
439
+ bucket.baseline.push(swapped ? scoreA : scoreB)
440
+ for (const finding of parseFindings(answer.text, criterion)) {
441
+ // The prompt rendered the swapped slots; the finding must cite the
442
+ // caller's slots, or it would point the agent at the wrong object.
443
+ const located: RawFinding = swapped && (finding.evidence === 'A' || finding.evidence === 'B')
444
+ ? { ...finding, evidence: finding.evidence === 'A' ? 'B' : 'A' }
445
+ : finding
446
+ const line = renderFinding(located)
447
+ if (seenFindings.has(line)) continue
448
+ seenFindings.add(line)
449
+ if (findings.length < VERIFICATION_MAX_FINDINGS) findings.push(line)
450
+ }
451
+ }
452
+ }
453
+ const criteria: VerificationCriterionScore[] = CODING_CRITERIA.map(criterion => {
454
+ const bucket = perCriterion.get(criterion.id)!
455
+ const score = average(bucket.work)
456
+ const baseline = average(bucket.baseline)
457
+ return { id: criterion.id, name: criterion.name, score, baseline, threshold: input.threshold, passed: score >= input.threshold }
458
+ })
459
+ const score = average(criteria.map(criterion => criterion.score))
460
+ const baseline = average(criteria.map(criterion => criterion.baseline))
461
+ const passed = acceptancePassed(score, baseline, criteria, input.threshold)
462
+ return {
463
+ attempt: {
464
+ index: input.index,
465
+ at: startedAt,
466
+ stage: 'quality',
467
+ passed,
468
+ score,
469
+ baseline,
470
+ criteria,
471
+ findings,
472
+ usage,
473
+ evidence: evidenceSummary,
474
+ route: input.route,
475
+ channel: 'explicit-tag',
476
+ rounds: VERIFICATION_ROUNDS,
477
+ },
478
+ }
479
+ } catch (error) {
480
+ const failure = error instanceof Error ? error : new Error(String(error))
481
+ const kind = failure instanceof VerificationError ? failure.kind : 'request'
482
+ const merged = mergeUsage(usage, failure instanceof VerificationError ? failure.usage : emptyUsage())
483
+ return {
484
+ attempt: {
485
+ index: input.index,
486
+ at: startedAt,
487
+ stage: 'exception',
488
+ passed: false,
489
+ score: 0,
490
+ baseline: 0,
491
+ criteria: [],
492
+ findings,
493
+ usage: merged,
494
+ evidence: evidenceSummary,
495
+ route: input.route,
496
+ channel: 'explicit-tag',
497
+ rounds: VERIFICATION_ROUNDS,
498
+ error: classifyMessage(kind, failure.message),
499
+ },
500
+ }
501
+ }
502
+ }
503
+
504
+ /** Average of a list, 0 when empty. */
505
+ function average(values: readonly number[]): number {
506
+ if (values.length === 0) return 0
507
+ return values.reduce((total, value) => total + value, 0) / values.length
508
+ }
509
+
510
+ /** Fold one usage record into another. */
511
+ function mergeUsage(target: VerificationUsage, source: VerificationUsage): VerificationUsage {
512
+ return {
513
+ calls: target.calls + source.calls,
514
+ inputTokens: target.inputTokens + source.inputTokens,
515
+ outputTokens: target.outputTokens + source.outputTokens,
516
+ reasoningTokens: target.reasoningTokens + source.reasoningTokens,
517
+ ...(target.usageIncomplete === true || source.usageIncomplete === true ? { usageIncomplete: true } : {}),
518
+ }
519
+ }
520
+
521
+ /** Prefix one anomaly message with its class so the report stays actionable. */
522
+ function classifyMessage(kind: VerificationErrorKind, message: string): string {
523
+ const labels: Record<VerificationErrorKind, string> = {
524
+ 'route-unavailable': '验收异常(裁判路由不可用)',
525
+ timeout: '验收异常(裁判超时)',
526
+ auth: '验收异常(鉴权失败)',
527
+ parse: '验收异常(裁判回答无法解析)',
528
+ request: '验收异常(裁判请求失败)',
529
+ aborted: '验收异常(验收被取消)',
530
+ }
531
+ return labels[kind] + ': ' + message
532
+ }
533
+
534
+
535
+