dsh-better-tips 0.12.1 → 0.13.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.
@@ -116,22 +116,23 @@ function assertMainAdvice (row, fragment) {
116
116
  }
117
117
  function assertOriginal (row, original) {
118
118
  const expected = [original.code ? `[${original.code}]` : '', original.message].filter(Boolean).join(' ')
119
- const originals = withClass(row.tree, 'bt_original')
120
- assert.ok(originals.some((node) => text(node) === expected), 'complete original remains verbatim in its disclosure')
121
- assert.equal(withClass(row.tree, 'bt_original', true).length, 0, 'full originals never spill into the default summary')
119
+ const originals = [...withClass(row.tree, 'bt_original'), ...withClass(row.tree, 'bt_preview'), ...withClass(row.tree, 'bt_attemptPreview')]
120
+ assert.ok(originals.some((node) => text(node) === expected), 'complete original remains verbatim in either its unchanged preview or full-original disclosure')
121
+ const fallback = withClass(row.tree, 'bt_nativeFallback', true)
122
+ assert.equal(withClass(row.tree, 'bt_original', true).length, fallback.length ? 1 : 0, 'only explicit native fallback may show complete original by default')
122
123
  assert.ok(row.full.includes(expected), 'full error evidence is retained')
123
124
  }
124
125
  function assertCaptureAdvice (row, fragment) {
125
126
  const advice = withClass(row.tree, 'bt_captureAdvice')
126
127
  assert.ok(advice.length > 0, 'capture-specific advice is available')
127
- assert.equal(withClass(row.tree, 'bt_captureAdvice', true).length, 0, 'capture-specific advice stays inside details')
128
+ assert.ok(advice.every((node) => text(node).includes('排查')), 'capture-specific advice stays explicitly labelled')
128
129
  assert.ok(advice.some((node) => text(node).includes(fragment)), `missing capture-specific advice ${fragment}`)
129
130
  }
130
131
 
131
132
  /** Hook instances follow component type/key/path, rather than sharing sibling state. */
132
133
  async function renderRow (key, props, {
133
134
  response = { items: [], records: [], capture: { installed: true, records: 0 } },
134
- error, status, pending = false, sessions, clickLog = false, interact, respond, timers,
135
+ error, status, pending = false, sessions, clickLog = false, interact, respond, timers, environment, environmentRespond,
135
136
  openDetails = true,
136
137
  } = {}) {
137
138
  const mounted = mountFactory(sessions)
@@ -140,6 +141,7 @@ async function renderRow (key, props, {
140
141
  const originalClearTimeout = globalThis.clearTimeout
141
142
  if (timers) { globalThis.setTimeout = timers.set; globalThis.clearTimeout = timers.clear }
142
143
  const calls = []
144
+ const environmentCalls = []
143
145
  const instances = new Map()
144
146
  const domOpen = new Map()
145
147
  const typeIds = new Map()
@@ -150,6 +152,12 @@ async function renderRow (key, props, {
150
152
  let renderedInstances
151
153
  const typeId = (type) => { if (!typeIds.has(type)) typeIds.set(type, ++nextType); return typeIds.get(type) }
152
154
  globalThis.fetch = (url, options) => {
155
+ if (String(url) === 'api/better-tips/environment') {
156
+ environmentCalls.push({ url: String(url), options })
157
+ if (environmentRespond) return environmentRespond(options, environmentCalls.length)
158
+ return Promise.resolve({ ok: true, json: async () => environment ?? { runtime: {
159
+ ...response?.runtime, enabledPlugins: [{ name: 'dsh-better-tips', version: '0.12.0' }], pluginsStatus: 'complete' } } })
160
+ }
153
161
  calls.push({ url: String(url), options })
154
162
  if (respond) return respond(String(url), options, calls.length)
155
163
  if (pending) return new Promise(() => {})
@@ -194,7 +202,7 @@ async function renderRow (key, props, {
194
202
  result.children = node.children.map((child, index) => render(child, `${path}/${child?.props?.key ?? index}`))
195
203
  return result
196
204
  }
197
- const snapshot = (tree) => ({ tree, calls, primary: text(tree, 'collapsed'), visible: text(tree, true), full: text(tree) })
205
+ const snapshot = (tree) => ({ tree, calls, environmentCalls, primary: text(tree, 'collapsed'), visible: text(tree, true), full: text(tree) })
198
206
  const settle = async () => {
199
207
  for (let pass = 0; pass < 30; pass++) {
200
208
  dirty = false
@@ -272,11 +280,326 @@ function http (status, statusText, rawFailure = { message: `${status} status cod
272
280
  }
273
281
  }
274
282
  const details = (items) => ({ items, capture: { installed: true, records: 78, recent: [] } })
283
+ const deferred = () => {
284
+ let resolve, reject
285
+ const promise = new Promise((yes, no) => { resolve = yes; reject = no })
286
+ return { promise, resolve, reject }
287
+ }
288
+ const jsonResult = (data) => ({ ok: true, json: async () => data })
289
+ async function withClipboard (operation, write) {
290
+ const previous = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
291
+ const copied = []
292
+ Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { clipboard: { writeText: write ?? (async (value) => { copied.push(value) }) } } })
293
+ try { return await operation(copied) } finally {
294
+ if (previous) Object.defineProperty(globalThis, 'navigator', previous)
295
+ else delete globalThis.navigator
296
+ }
297
+ }
298
+ const reportPayload = (items = [socket()]) => ({ ...details(items), runtime: { dshVersion: '0.2.2-rc.2', pluginVersion: '0.12.0' } })
299
+
275
300
  function assertQuiet (primary) {
276
301
  assert.doesNotMatch(primary, /gateway\.example|\/v1\/chat\/completions|Caddy|765432|765\.4|request-debug-id/)
277
302
  assert.doesNotMatch(primary, /推测|可能是同一次|例如|捕获器|进程内失败|本轮命中|未捕获|读取失败|正在读取|暂无捕获|连接失败(|没有保留原因/)
278
303
  }
279
304
 
305
+ test('advice and report follow capture; raw details and native error are sibling disclosures', async () => {
306
+ const row = await renderRow('turn-error', terminal(), { openDetails: false, response: reportPayload() })
307
+ const children = row.tree.children
308
+ const index = (name) => children.findIndex((node) => String(node.props?.className ?? '').split(/\s+/).includes(name))
309
+ assert.ok(index('bt_network') < index('bt_advice'))
310
+ assert.ok(index('bt_advice') < index('bt_report'))
311
+ assert.ok(index('bt_report') < index('bt_detailPair'))
312
+ assert.equal(withClass(row.tree, 'bt_failureTitle').length, 1)
313
+ const pair = withClass(row.tree, 'bt_detailPair')[0]
314
+ assert.equal(pair.children.length, 2)
315
+ assert.ok(pair.children.every((node) => node.type === 'details'))
316
+ assert.match(text(pair.children[0].children[0]), /底层原始详情/)
317
+ assert.match(text(pair.children[1].children[0]), /DSH 原生报错/)
318
+ assert.equal(withClass(withClass(row.tree, 'bt_network')[0], 'bt_networkDetails').length, 0)
319
+ })
320
+
321
+ test('retry screenshot keeps current error then one advice before copy and folded history', async () => {
322
+ const first = { ...retry().node.data.current, seq: 10, retry: 1 }
323
+ const second = { ...retry().node.data.current, seq: 20, retry: 2 }
324
+ const row = await renderRow('model-retry', { ...retry(), node: { data: { attempts: [first, second], current: second } } }, { openDetails: false, response: details([socket()]) })
325
+ assert.equal(row.tree.children[0].props.className, 'bt_failureTitle')
326
+ assert.equal(row.tree.children[1].props.className, 'bt_section bt_history')
327
+ const history = withClass(row.tree, 'bt_history')[0]
328
+ assert.equal(history.children[0].props.key, 20)
329
+ assert.equal(history.children[1].props.className, 'bt_earlierErrors')
330
+ const latest = history.children[0]
331
+ const classes = latest.children.map((entry) => entry.props?.className ?? '')
332
+ assert.equal(classes[0], 'bt_section bt_network')
333
+ assert.equal(classes[1], 'bt_captureAdvice bt_advice')
334
+ assert.equal(classes[2], 'bt_actions')
335
+ assert.equal(classes[3], 'bt_report')
336
+ assert.equal(classes[4], 'bt_detailPair')
337
+ assert.equal(withClass(latest, 'bt_heading', true).filter((entry) => /已重试|重试记录|失败记录/.test(text(entry))).length, 0)
338
+ assert.equal(withClass(row.tree, 'bt_advice', true).length, 1)
339
+ assert.equal(row.visible.match(/排查/g)?.length, 1)
340
+ assert.doesNotMatch(row.visible, /重试记录 ·|按发生顺序|失败记录 2/)
341
+ })
342
+
343
+ test('a new nonconsecutive error folds the previous card and aborts pending evidence', async () => {
344
+ const old = { key: 'old', kind: 'turn-error', visibility: 'visible', data: terminal().node.data }
345
+ const later = { key: 'later', kind: 'model-retry', visibility: 'visible', data: retry().node.data }
346
+ const answer = { key: 'answer', kind: 'assistant-step', visibility: 'visible', data: {} }
347
+ let nodes = [old, answer]
348
+ const useChat = (selector) => selector({ order: nodes.map((node) => node.key), nodes: new Map(nodes.map((node) => [node.key, node])) })
349
+ const waiting = deferred()
350
+ await renderRow('turn-error', { ...terminal(), node: old, useChat }, { openDetails: false, respond: () => waiting.promise, interact: async ({ row, update, settle }) => {
351
+ assert.equal(row.calls.length, 1)
352
+ nodes = [old, answer, later]
353
+ let next = await update({ ...terminal(), node: old, useChat })
354
+ assert.equal(next.calls[0].options.signal.aborted, true)
355
+ assert.equal(withClass(next.tree, 'bt_earlierNode')[0].props.open, false)
356
+ waiting.resolve(jsonResult(details([socket({ detail: { ...socket().detail, error: { message: 'LATE_OLD_ERROR' } } })])))
357
+ next = await settle()
358
+ assert.doesNotMatch(next.full, /LATE_OLD_ERROR/)
359
+ assert.equal(next.calls.length, 1)
360
+ } })
361
+ })
362
+
363
+ test('session identity change resets both native and raw disclosures', async () => {
364
+ const props = { ...terminal(), node: { ...terminal().node, key: 'stable', kind: 'turn-error' } }
365
+ await renderRow('turn-error', props, { openDetails: false, response: details([socket()]), interact: async ({ row, toggle, update }) => {
366
+ row = await toggle(withClass(row.tree, 'bt_disclosure')[0], true)
367
+ row = await toggle(withClass(row.tree, 'bt_networkDetails')[0], true)
368
+ assert.equal(withClass(row.tree, 'bt_disclosure')[0].props.open, true)
369
+ const next = await update({ ...props, sessionId: 'session-other' })
370
+ assert.equal(withClass(next.tree, 'bt_disclosure')[0].props.open, false)
371
+ assert.equal(withClass(next.tree, 'bt_networkDetails')[0].props.open, false)
372
+ assert.ok(next.calls[0].options.signal.aborted)
373
+ } })
374
+ })
375
+
376
+ test('screenshot HTTP JSON case defaults to capture and opens complete native original in one layer', async () => {
377
+ const body = JSON.stringify({ error: { message: 'unknown provider for model example-model', type: 'invalid_request_error' } })
378
+ const message = `400: ${JSON.stringify({ message: 'unknown provider for model example-model', type: 'invalid_request_error' })}`
379
+ const source = http(400, 'Bad Request', { code: 'INVALID_REQUEST', message }, { detail: { ...http(400, 'Bad Request').detail, body, bodyState: 'complete' } })
380
+ await withClipboard(async (copied) => {
381
+ await renderRow('turn-error', terminal(message, 'INVALID_REQUEST'), { openDetails: false, response: details([source]), interact: async ({ row, toggle, click, beforeDisclosure }) => {
382
+ assert.equal(beforeDisclosure.calls.length, 1)
383
+ assert.equal(withClass(row.tree, 'bt_network')[0].type, 'section')
384
+ assert.match(row.visible, /插件捕获的底层报错.*HTTP 400 Bad Request · unknown provider for model example-model/)
385
+ assert.ok(!row.visible.includes(message))
386
+ assert.ok(!row.visible.includes('gateway.example'))
387
+ assert.ok(!row.visible.includes(body))
388
+ assert.doesNotMatch(row.visible, /摘要 ·|查看完整原文|网络诊断详情/)
389
+ assert.match(row.visible, /底层原始详情|一键复制排障报告/)
390
+ assert.equal(withClass(row.tree, 'bt_fullOriginal').length, 0)
391
+ const raw = await toggle(withClass(row.tree, 'bt_disclosure')[0], true)
392
+ assert.ok(raw.visible.includes(`[INVALID_REQUEST] ${message}`))
393
+ await click(elements(raw.tree, 'button').find((node) => text(node) === '复制完整错误'))
394
+ const network = await toggle(withClass(raw.tree, 'bt_networkDetails')[0], true)
395
+ assert.ok(network.visible.includes(body))
396
+ assert.ok(network.visible.includes('gateway.example'))
397
+ } })
398
+ assert.deepEqual(copied, [`[INVALID_REQUEST] ${message}`])
399
+ })
400
+ })
401
+
402
+ test('short native errors stay folded and copy completely without a summary duplicate', async () => {
403
+ await withClipboard(async (copied) => {
404
+ await renderRow('turn-error', terminal(), { openDetails: false, response: details([socket()]), interact: async ({ row, click, toggle }) => {
405
+ assert.equal(withClass(row.tree, 'bt_original').length, 1)
406
+ assert.equal(withClass(row.tree, 'bt_fullOriginal').length, 0)
407
+ assert.doesNotMatch(row.visible, /\[TRANSPORT\] Connection error\./)
408
+ assert.match(row.visible, /插件捕获的底层报错.*ECONNRESET/)
409
+ assert.doesNotMatch(row.visible, /socket wrapper|gateway\.example/)
410
+ const next = await toggle(withClass(row.tree, 'bt_disclosure')[0], true)
411
+ assert.equal(next.visible.split('[TRANSPORT] Connection error.').length - 1, 1)
412
+ await click(elements(next.tree, 'button').find((node) => text(node) === '复制完整错误'))
413
+ } })
414
+ assert.deepEqual(copied, ['[TRANSPORT] Connection error.'])
415
+ })
416
+ })
417
+
418
+ test('capture preview stays bounded and marks partial or protected response state without decoding incomplete JSON', async () => {
419
+ for (const state of ['pending', 'truncated', 'unreadable']) {
420
+ const source = http(400, 'Bad Request', failure)
421
+ source.detail = { ...source.detail, body: '{"error":{"message":"' + '😀'.repeat(180), bodyState: state }
422
+ const row = await renderRow('turn-error', terminal(), { response: details([source]) })
423
+ const preview = text(withClass(row.tree, 'bt_primary')[0])
424
+ assert.ok(Array.from(preview).length <= 121)
425
+ assert.ok(preview.includes('{"error"'), 'incomplete JSON remains literal rather than fabricated provider text')
426
+ assert.ok(!preview.includes('\uFFFD'))
427
+ assert.match(row.visible, /响应正文(?:读取中|已截断|读取失败)/)
428
+ }
429
+ const source = http(401, 'Unauthorized', failure)
430
+ source.detail = { ...source.detail, body: 'WITHHELD_BODY', bodyReason: 'credential-protection', protected: true }
431
+ const row = await renderRow('turn-error', terminal(), { response: details([source]) })
432
+ assert.doesNotMatch(text(withClass(row.tree, 'bt_primary')[0]), /WITHHELD_BODY/)
433
+ assert.match(row.visible, /认证响应正文已受保护/)
434
+ })
435
+
436
+ test('default capture copy is complete raw capture while native copy stays exact and report reuses one mounted read', async () => {
437
+ await withClipboard(async (copied) => {
438
+ const capture = http(503, 'Service Unavailable', failure)
439
+ capture.detail.body = JSON.stringify({ error: { message: 'captured provider message\nsecond line', opaque: 'RAW_BODY_TAIL' } })
440
+ await renderRow('turn-error', terminal(), { openDetails: false, response: reportPayload([capture]), interact: async ({ row, click, toggle }) => {
441
+ assert.equal(row.calls.length, 1)
442
+ const captureButton = elements(row.tree, 'button').find((node) => text(node) === '复制截获错误')
443
+ assert.ok(withClass(row.tree, 'bt_copy', true).includes(captureButton), 'capture copy is available without native expansion')
444
+ const expectedCapture = text(withClass(withClass(row.tree, 'bt_networkDetails')[0], 'bt_raw')[0])
445
+ await click(captureButton)
446
+ assert.equal(copied[0], expectedCapture, 'capture copy uses complete raw capture, not bounded preview')
447
+ for (const value of ['HTTP 503 Service Unavailable', 'gateway.example/v1/chat/completions', 'x-request-id: request-debug-id', 'RAW_BODY_TAIL', 'elapsedMs=765432']) assert.ok(copied[0].includes(value), value)
448
+ const reportButton = withClass(row.tree, 'bt_reportCopy', true)[0]
449
+ assert.equal(text(reportButton), '一键复制排障报告')
450
+ const report = await click(reportButton)
451
+ assert.equal(report.calls.length, 1, 'one-click report reuses already fetched evidence')
452
+ assert.match(copied[1], /DSH 原生报错: \[TRANSPORT\] Connection error\./)
453
+ const native = await toggle(withClass(report.tree, 'bt_disclosure')[0], true)
454
+ await click(elements(native.tree, 'button').find((node) => text(node) === '复制完整错误'))
455
+ assert.equal(copied[2], '[TRANSPORT] Connection error.')
456
+ assert.equal(native.calls.length, 1)
457
+ } })
458
+ })
459
+ })
460
+
461
+ test('retry default report and capture copy stay inside their own attempt with no duplicate latest controls', async () => {
462
+ const first = { ...retry('first native', 'TRANSPORT').node.data.current, seq: 20, retry: 1, attemptId: 'first' }
463
+ const second = { ...retry('second native', 'SERVER').node.data.current, seq: 30, retry: 2, attemptId: 'second' }
464
+ const a = socket({ failure: first.failure, attemptId: 'first', context: { sessionId: 'session-client-test', provider: 'test-provider', attemptId: 'first' } })
465
+ const b = http(503, 'Second capture', second.failure, { attemptId: 'second', context: { sessionId: 'session-client-test', provider: 'test-provider', attemptId: 'second' } })
466
+ await withClipboard(async (copied) => {
467
+ await renderRow('model-retry', { ...retry(), node: { data: { attempts: [first, second], current: second } } }, { openDetails: false, response: reportPayload([a, b]), interact: async ({ row, click, toggle }) => {
468
+ const attempts = withClass(row.tree, 'bt_attempt').toSorted((a, b) => a.props.key - b.props.key)
469
+ assert.equal(attempts.length, 2)
470
+ assert.equal(withClass(row.tree, 'bt_reportCopy', true).length, 1)
471
+ assert.equal(withClass(row.tree, 'bt_network', true).length, 1)
472
+ assert.equal(withClass(row.tree, 'bt_disclosure').length, 0)
473
+ row = await toggle(withClass(row.tree, 'bt_earlierErrors')[0], true)
474
+ row = await toggle(withClass(row.tree, 'bt_earlierError')[0], true)
475
+ assert.match(row.visible, /ECONNRESET/)
476
+ for (let index = 0; index < attempts.length; index++) {
477
+ assert.equal(withClass(attempts[index], 'bt_reportCopy', true).length, 1)
478
+ assert.equal(withClass(attempts[index], 'bt_attemptNative')[0].props.open, false)
479
+ await click(withClass(attempts[index], 'bt_reportCopy', true)[0])
480
+ }
481
+ assert.match(copied[0], /first native|ECONNRESET/)
482
+ assert.doesNotMatch(copied[0], /second native|Second capture/)
483
+ assert.match(copied[1], /second native|HTTP 503/)
484
+ assert.doesNotMatch(copied[1], /first native|ECONNRESET/)
485
+ assert.equal(row.calls.length, 1)
486
+ } })
487
+ })
488
+ })
489
+
490
+ test('consecutive error nodes fold older blocks lazily and reset when a newer error arrives', async () => {
491
+ const node = { key: 'first', kind: 'turn-error', visibility: 'visible', data: terminal().node.data }
492
+ const later = { key: 'second', kind: 'turn-error', visibility: 'visible', data: terminal('later failure').node.data }
493
+ const tail = { key: 'tail', kind: 'turn-tail', visibility: 'visible', data: {} }
494
+ let ordered = [node, tail, later]
495
+ const useChat = (selector) => selector({ order: ordered.map((entry) => entry.key), nodes: { get: (key) => ordered.find((entry) => entry.key === key) } })
496
+ await renderRow('turn-error', { ...terminal(), node, useChat }, { openDetails: false, response: details([socket()]), interact: async ({ row, toggle, update }) => {
497
+ assert.equal(row.calls.length, 0, 'folded older node does not fetch capture until revealed')
498
+ assert.match(row.visible, /更早的报错/)
499
+ assert.doesNotMatch(row.visible, /ECONNRESET|一键复制排障报告/)
500
+ let next = await toggle(withClass(row.tree, 'bt_earlierNode')[0], true)
501
+ assert.equal(next.calls.length, 1)
502
+ assert.match(next.visible, /ECONNRESET/)
503
+ ordered = [node, { ...later, key: 'third' }]
504
+ next = await update({ ...terminal(), node, useChat })
505
+ assert.equal(withClass(next.tree, 'bt_earlierNode')[0].props.open, false, 'new successor resets old manual expansion')
506
+ assert.doesNotMatch(next.visible, /ECONNRESET/)
507
+ ordered = [node, { key: 'answer', kind: 'assistant-step', visibility: 'visible', data: {} }, later]
508
+ next = await update({ ...terminal(), node, useChat })
509
+ assert.equal(withClass(next.tree, 'bt_earlierNode').length, 1, 'an intervening reply does not remove historical folding')
510
+ assert.match(next.visible, /ECONNRESET/, 'manual expansion survives while the newest error identity is unchanged')
511
+ } })
512
+ for (const boundary of [
513
+ { key: 'user', kind: 'user', visibility: 'visible' },
514
+ { key: 'tool', kind: 'tool-call', visibility: 'visible' },
515
+ { key: 'success', kind: 'manual-compaction', visibility: 'visible', data: { compaction: {} } },
516
+ { key: 'missing' },
517
+ ]) {
518
+ const useBoundary = (selector) => selector({ order: [node.key, boundary.key, later.key], nodes: { get: (key) => key === boundary.key ? boundary.kind ? boundary : undefined : key === node.key ? node : later } })
519
+ const separated = await renderRow('turn-error', { ...terminal(), node, useChat: useBoundary }, { openDetails: false })
520
+ assert.equal(withClass(separated.tree, 'bt_earlierNode').length, boundary.kind ? 1 : 0, boundary.key)
521
+ }
522
+ ordered = [node, later]
523
+ const latest = await renderRow('turn-error', { ...terminal('later failure'), node: later, useChat }, { openDetails: false })
524
+ assert.equal(withClass(latest.tree, 'bt_earlierNode').length, 0)
525
+ assert.equal(latest.calls.length, 1, 'latest error keeps normal capture behavior')
526
+ })
527
+
528
+ test('earlier retries fold by stable record identity while newest record stays visible', async () => {
529
+ const attempts = [1, 2, 3].map((retryNumber) => ({ ...retry('native-' + retryNumber).node.data.current, seq: retryNumber * 10, retry: retryNumber, attemptId: 'a' + retryNumber }))
530
+ const captures = attempts.map((attempt, index) => http(503, 'capture-' + (index + 1), attempt.failure, { attemptId: attempt.attemptId, context: { sessionId: 'session-client-test', provider: 'test-provider', attemptId: attempt.attemptId } }))
531
+ const props = { ...retry(), node: { data: { attempts, current: attempts.at(-1) } } }
532
+ await renderRow('model-retry', props, { openDetails: false, response: details(captures), interact: async ({ row, toggle, update }) => {
533
+ assert.match(row.visible, /capture-3/)
534
+ assert.doesNotMatch(row.visible, /capture-1|capture-2/)
535
+ assert.match(row.visible, /更早的报错 · 2/)
536
+ let next = await toggle(withClass(row.tree, 'bt_earlierErrors')[0], true)
537
+ assert.doesNotMatch(next.visible, /capture-1|capture-2/)
538
+ next = await toggle(withClass(next.tree, 'bt_earlierError')[0], true)
539
+ assert.match(next.visible, /capture-1/)
540
+ assert.doesNotMatch(next.visible, /capture-2/)
541
+ const current = { ...attempts.at(-1), seq: 40, retry: 4, attemptId: 'a4' }
542
+ next = await update({ ...props, node: { data: { attempts: [...attempts, current], current } } })
543
+ assert.equal(withClass(next.tree, 'bt_earlierErrors')[0].props.open, false)
544
+ assert.doesNotMatch(next.visible, /capture-1|capture-2|capture-3/)
545
+ assert.equal(withClass(next.tree, 'bt_attempt').filter((block) => block.type === 'div').length, 4, 'no errors are discarded or deduplicated')
546
+ } })
547
+ })
548
+
549
+ test('report reads complete enabled-plugin environment on every click without cached inventory or private fields', async () => {
550
+ const names = Array.from({ length: 125 }, (_, index) => ({ name: 'enabled-plugin-' + index, version: '1.2.' + index }))
551
+ await withClipboard(async (copied) => {
552
+ await renderRow('turn-error', terminal(), { openDetails: false, response: reportPayload(), environmentRespond: async (_options, count) => jsonResult({ runtime: {
553
+ dshVersion: '0.2.2-rc.2', pluginVersion: '0.12.0', pluginsStatus: 'complete',
554
+ enabledPlugins: [...names, names[0], { name: 'new-plugin', version: count === 1 ? '1.0.0' : '2.0.0', config: 'PRIVATE_CONFIG', path: 'PRIVATE_PATH', source: 'PRIVATE_SOURCE' }],
555
+ } }), interact: async ({ row, click }) => {
556
+ assert.equal(row.environmentCalls.length, 0, 'no inventory request before manual report generation')
557
+ let next = await click(withClass(row.tree, 'bt_reportCopy')[0])
558
+ assert.equal(next.environmentCalls.length, 1)
559
+ const line = copied[0].split('\n').find((line) => line.startsWith('已启用第三方插件: '))
560
+ for (const plugin of names) assert.ok(line.includes(plugin.name + '@' + plugin.version))
561
+ assert.equal(line.split('enabled-plugin-0@').length - 1, 1)
562
+ assert.match(line, /new-plugin@1\.0\.0/)
563
+ assert.doesNotMatch(copied[0], /PRIVATE_CONFIG|PRIVATE_PATH|PRIVATE_SOURCE/)
564
+ next = await click(withClass(next.tree, 'bt_reportCopy')[0])
565
+ assert.equal(next.environmentCalls.length, 2, 'inventory is not reused from cached evidence')
566
+ assert.match(copied[1], /new-plugin@2\.0\.0/)
567
+ assert.doesNotMatch(copied[1], /new-plugin@1\.0\.0/)
568
+ assert.equal(next.calls.length, 1, 'network evidence still reuses its completed cache')
569
+ } })
570
+ })
571
+ })
572
+
573
+ test('unavailable or partial plugin inventory remains explicit rather than claiming an empty list', async () => {
574
+ for (const [environmentRespond, marker] of [
575
+ [async () => { throw Error('SECRET_ENV_ERROR') }, '清单读取失败或不可用'],
576
+ [async () => jsonResult({ runtime: { enabledPlugins: [{ name: 'known-plugin', version: null }], pluginsStatus: 'partial' } }), '部分插件信息未能确认'],
577
+ [async () => jsonResult({ runtime: { enabledPlugins: [], pluginsStatus: 'complete' } }), '已启用第三方插件: 无'],
578
+ ]) await withClipboard(async (copied) => {
579
+ await renderRow('turn-error', terminal(), { openDetails: false, response: reportPayload(), environmentRespond, interact: async ({ row, click }) => {
580
+ await click(withClass(row.tree, 'bt_reportCopy')[0])
581
+ assert.ok(copied[0].includes(marker))
582
+ assert.doesNotMatch(copied[0], /SECRET_ENV_ERROR/)
583
+ } })
584
+ })
585
+ })
586
+
587
+ test('changing identity aborts inventory read and cannot copy old report', async () => {
588
+ const pending = deferred()
589
+ await withClipboard(async (copied) => {
590
+ await renderRow('turn-error', terminal(), { openDetails: false, response: reportPayload(), environmentRespond: () => pending.promise, interact: async ({ row, update, settle }) => {
591
+ const task = withClass(row.tree, 'bt_reportCopy')[0].props.onClick()
592
+ let next = await settle()
593
+ assert.equal(next.environmentCalls.length, 1)
594
+ next = await update({ ...terminal(), sessionId: 'new-report-session' })
595
+ assert.equal(next.environmentCalls[0].options.signal.aborted, true)
596
+ pending.resolve(jsonResult({ runtime: { enabledPlugins: [{ name: 'OLD_ENV', version: '1' }], pluginsStatus: 'complete' } }))
597
+ await task
598
+ assert.equal(copied.length, 0)
599
+ } })
600
+ })
601
+ })
602
+
280
603
  test('only five inline renderers and the automatic-compaction definition are registered', () => {
281
604
  assert.deepEqual([...client.module.inject].sort(), ['locale', 'slots', 'uiConversation'])
282
605
  assert.deepEqual(client.slots.map((entry) => entry.options.key).sort(), ['better-tips-compaction', 'compaction', 'manual-compaction', 'model-retry', 'turn-error'])
@@ -300,31 +623,29 @@ test('unloading restores shipped cells without same-priority collisions', () =>
300
623
  assert.ok([...mounted.occupied.values()].every((entry) => entry.shipped))
301
624
  })
302
625
 
303
- test('default summary omits native chain while complete evidence preserves deepest code and endpoint', async () => {
304
- const row = await renderRow('turn-error', terminal(), { response: details([socket()]) })
305
- assert.doesNotMatch(row.primary, /ECONNRESET|read ECONNRESET/)
306
- assert.match(text(withClass(row.tree, 'bt_primary')[0]), /ECONNRESET.*read ECONNRESET/)
307
- assert.match(row.primary, /Connection error\./)
308
- assert.match(row.primary, /TRANSPORT/)
309
- assert.doesNotMatch(row.primary, /fetch failed|socket wrapper/)
626
+ test('default capture preserves deepest code while complete evidence and native original stay independently folded', async () => {
627
+ const row = await renderRow('turn-error', terminal(), { openDetails: false, response: details([socket()]) })
628
+ assert.match(row.primary, /ECONNRESET.*read ECONNRESET/)
629
+ assert.doesNotMatch(row.primary, /Connection error\.|TRANSPORT|fetch failed|socket wrapper/)
310
630
  assertQuiet(row.primary)
311
631
  for (const value of ['Connection error.', 'TRANSPORT', 'fetch failed', 'socket wrapper', 'ECONNRESET', '10.0.0.1', '443', 'gateway.example', '/v1/chat/completions']) assert.ok(row.full.includes(value), `raw details lost ${value}`)
312
- assert.equal(row.beforeDisclosure.calls.length, 0)
632
+ assert.equal(row.beforeDisclosure.calls.length, 1)
313
633
  assert.ok(elements(row.beforeDisclosure.tree, 'details').every((node) => !node.props.open))
314
634
  assert.match(row.calls[0].url, /^api\/better-tips\/details\?/)
315
635
  })
316
636
 
317
- test('literal first-line excerpt stays outside disclosure while full original remains verbatim inside', async () => {
637
+ test('native multiline original is not excerpted and opens in one native disclosure', async () => {
318
638
  const original = 'Connection error.\nprovider diagnostic: keep this line'
319
639
  const capture = socket({ failure: { code: 'TRANSPORT', message: original } })
320
640
  for (const response of [details([]), details([capture])]) {
321
- const row = await renderRow('turn-error', terminal(original), { response })
322
- assertOriginal(row, { code: 'TRANSPORT', message: original })
323
- assert.equal(text(withClass(row.tree, 'bt_preview')[0]), '[TRANSPORT] Connection error.…')
324
- assert.match(row.primary, /摘要 · 完整原文见详情/)
325
- assert.ok(!row.primary.includes('provider diagnostic'))
326
- assert.ok(!row.primary.includes('ECONNRESET'))
327
- if (response.items.length) assert.ok(row.full.indexOf(original) < row.full.indexOf('ECONNRESET'))
641
+ await renderRow('turn-error', terminal(original), { openDetails: false, response, interact: async ({ row, toggle }) => {
642
+ assertOriginal(row, { code: 'TRANSPORT', message: original })
643
+ assert.equal(withClass(row.tree, 'bt_preview').length, 0)
644
+ assert.doesNotMatch(row.visible, /provider diagnostic|摘要 ·/)
645
+ const next = await toggle(withClass(row.tree, 'bt_disclosure')[0], true)
646
+ assert.ok(next.visible.includes(original))
647
+ assert.equal(next.calls.length, 1)
648
+ } })
328
649
  }
329
650
  })
330
651
 
@@ -332,10 +653,10 @@ test('HTTP status/statusText is primary; headers, body, endpoint and timing are
332
653
  const raw = { message: '504 status code (no body)', code: 'SERVER' }
333
654
  const row = await renderRow('turn-error', terminal(raw.message, raw.code), { response: details([http(504, 'Gateway Timeout', raw)]) })
334
655
  assert.match(text(withClass(row.tree, 'bt_primary')[0]), /HTTP\s*504.*Gateway Timeout/)
335
- assert.doesNotMatch(row.primary, /Gateway Timeout/)
656
+ assert.match(row.primary, /Gateway Timeout/)
336
657
  assertQuiet(row.primary)
337
658
  for (const value of ['Caddy', 'request-debug-id', 'raw-response-504', 'provider detail', 'gateway.example', '/v1/chat/completions', raw.message]) assert.ok(row.full.includes(value), `HTTP evidence lost ${value}`)
338
- assert.doesNotMatch(row.primary, /raw-response-504|provider detail/)
659
+ assert.match(row.primary, /raw-response-504/); assert.doesNotMatch(row.primary, /provider detail/)
339
660
  })
340
661
 
341
662
  test('auth fallback preserves raw provider code/message and adds separate category advice without guessing quota', async () => {
@@ -358,7 +679,7 @@ test('exact matching includes step and original failure, never a neighboring att
358
679
  assert.match(text(withClass(second.tree, 'bt_primary')), /HTTP\s*431/)
359
680
  assert.doesNotMatch(text(withClass(second.tree, 'bt_primary')), /504/)
360
681
  const other = await renderRow('turn-error', terminal(), { response: details([socket({ step: 8 })]) })
361
- assert.match(other.primary, /Connection error\./)
682
+ assert.match(other.primary, /未取得额外网络诊断/)
362
683
  assert.doesNotMatch(other.primary, /ECONNRESET/)
363
684
  })
364
685
 
@@ -376,8 +697,8 @@ test('broken SSE preserves its deepest native cause primary and stream metadata
376
697
  capture.detail = { ...capture.detail, kind: 'stream', status: 200, bytes: 54321 }
377
698
  const row = await renderRow('turn-error', terminal(), { response: details([capture]) })
378
699
  assert.match(text(withClass(row.tree, 'bt_primary')), /ECONNRESET/)
379
- assert.doesNotMatch(row.primary, /ECONNRESET|54321|fetch failed|socket wrapper/)
380
- assert.match(row.primary, /Connection error\./)
700
+ assert.match(row.primary, /ECONNRESET/)
701
+ assert.doesNotMatch(row.primary, /54321|fetch failed|socket wrapper|Connection error\./)
381
702
  assertQuiet(row.primary)
382
703
  assert.match(row.full, /54321/)
383
704
  assert.match(row.full, /fetch failed/)
@@ -392,7 +713,7 @@ test('one non-exact capture has only the short unconfirmed marker, never specula
392
713
  const row = await renderRow('model-retry', retry(), { response: details([http(499, 'Client Closed Request')]) })
393
714
  assert.match(row.full, /HTTP\s*499/)
394
715
  assert.match(row.full, /关联未确认/)
395
- assert.doesNotMatch(row.primary, /HTTP\s*499|关联未确认/)
716
+ assert.match(row.primary, /HTTP\s*499.*关联未确认|关联未确认.*HTTP\s*499/)
396
717
  assertQuiet(row.primary)
397
718
  assert.doesNotMatch(row.full, /可能是同一次|例如我们|只有这一条捕获|与本行的.*不同|无法证实/)
398
719
  assert.match(row.full, /Connection error\./)
@@ -401,9 +722,8 @@ test('one non-exact capture has only the short unconfirmed marker, never specula
401
722
 
402
723
  test('multiple unmatched captures stay candidates in details; raw row failure stays primary', async () => {
403
724
  const row = await renderRow('model-retry', retry(), { response: details([http(499, 'Client Closed Request'), http(431, 'Request Header Fields Too Large')]) })
404
- assert.match(row.primary, /Connection error\./)
405
- assert.match(row.primary, /TRANSPORT/)
406
- assert.doesNotMatch(row.primary, /HTTP\s*(499|431)|关联未确认|候选|不一致|捕获/)
725
+ assert.doesNotMatch(row.primary, /Connection error\.|TRANSPORT|HTTP\s*(499|431)/)
726
+ assert.match(row.primary, /候选捕获/)
407
727
  assert.match(row.full, /499/)
408
728
  assert.match(row.full, /431/)
409
729
  assert.match(row.full, /候选|另一次|candidate/i)
@@ -418,12 +738,11 @@ test('loading, empty capture and API failure never displace the primary error',
418
738
  { key: 'ui.loadFailed', options: { error: new Error('offline-test-marker') } },
419
739
  ]) {
420
740
  const row = await renderRow('turn-error', terminal(), options)
421
- assert.match(row.primary, /Connection error\./)
422
- assert.match(row.primary, /TRANSPORT/)
423
- assertQuiet(row.primary)
741
+ assert.doesNotMatch(row.primary, /Connection error\.|TRANSPORT/)
742
+ assertOriginal(row, failure)
424
743
  const marker = client.t(key, { message: '', error: '' }).replace(/\{[^{}]+\}/g, '').trim()
425
744
  assert.ok(row.full.includes(marker), `${key} should be available in details`)
426
- assert.equal(row.primary.includes(marker), false, `${key} leaked into primary`)
745
+ assert.equal(row.primary.includes(marker), true, `${key} is honestly visible without fabricated capture`)
427
746
  if (options.status) assert.match(row.full, /HTTP\s*401/)
428
747
  if (options.error) assert.match(row.full, /offline-test-marker/)
429
748
  }
@@ -435,10 +754,8 @@ test('retry summary includes state/count/delay and the same native primary', asy
435
754
  assert.match(row.primary, /重试/)
436
755
  assert.match(row.primary, /2\s*\/\s*5/)
437
756
  assert.match(row.primary, /987\s*(?:ms|毫秒)|0\.987\s*s/)
438
- assert.doesNotMatch(row.primary, /ECONNRESET/)
439
- assert.match(text(withClass(row.tree, 'bt_primary')), /ECONNRESET/)
440
- assert.match(row.primary, /Connection error\./)
441
- assert.match(row.primary, /TRANSPORT/)
757
+ assert.match(row.primary, /ECONNRESET/)
758
+ assert.doesNotMatch(row.primary, /Connection error\.|TRANSPORT/)
442
759
  assertOriginal(row, failure)
443
760
  assertMainAdvice(row, '检查代理与上游连接')
444
761
  assertQuiet(row.primary)
@@ -457,20 +774,20 @@ const manualFailure = {
457
774
  }
458
775
  const autoFailure = {
459
776
  sessionId: 'session-client-compact-auto',
460
- node: { data: { compactionId: 'compact-1', startedAt: 1000, endedAt: 4000, endSeq: 8, error: 'Connection error.' } },
777
+ node: { data: { compactionId: 'compact-1', startedAt: 1000, endedAt: 4000, endSeq: 8, error: 'summarization produced no text summary content' } },
461
778
  }
462
779
 
463
780
  test('manual/auto compaction failures use the same native primary; metadata stays expanded', async () => {
464
781
  for (const [key, props] of [['manual-compaction', manualFailure], ['better-tips-compaction', autoFailure]]) {
465
782
  const row = await renderRow(key, props, { response: compactions() })
466
783
  assert.match(row.primary, /压缩|compact/i)
467
- assert.doesNotMatch(row.primary, /ECONNRESET|read ECONNRESET/)
784
+ assert.match(row.primary, /ECONNRESET|read ECONNRESET/)
468
785
  assert.match(row.full, /ECONNRESET|read ECONNRESET/)
469
786
  assert.equal(withClass(row.tree, 'bt_compactionHistory').length, 1)
470
787
  assert.equal(withClass(row.tree, 'bt_attempt').length, 2)
471
788
  assert.doesNotMatch(row.primary, /provider-debug|model-debug|次尝试|等待|重试/)
472
789
  const original = key === 'manual-compaction' ? manualFailure.node.data.command.outcome.text : autoFailure.node.data.error
473
- assert.ok(row.primary.includes(original), 'the original compaction error stays visible')
790
+ assert.ok(!row.primary.includes(original), 'native compaction error stays in its disclosure')
474
791
  assertOriginal(row, { message: original })
475
792
  assertMainAdvice(row, '展开错误详情,核对请求地址与服务端日志。')
476
793
  assertCaptureAdvice(row, '检查代理与上游连接')
@@ -481,17 +798,183 @@ test('manual/auto compaction failures use the same native primary; metadata stay
481
798
  }
482
799
  })
483
800
 
801
+ test('manual compaction replaces generic notice with exact durable transaction failure in display copy and report', async () => {
802
+ const concrete = 'summary is not smaller than the shadowed content (900 estimated framed tokens >= 800)'
803
+ const source = sessionFixture([
804
+ logEvent(1, 'command/run', { commandId: 'cmd-1', name: 'compact' }),
805
+ logEvent(2, 'compaction/start', { sourceCommandId: 'cmd-1', compactionId: 'compact-exact' }),
806
+ logEvent(3, 'compaction/end', { sourceCommandId: 'cmd-OTHER', compactionId: 'other', error: 'FOREIGN_ERROR' }),
807
+ logEvent(4, 'compaction/end', { sourceCommandId: 'cmd-1', compactionId: 'compact-exact', error: concrete }),
808
+ logEvent(5, 'command/done', { commandId: 'cmd-1', kind: 'error', text: manualFailure.node.data.command.outcome.text }),
809
+ ])
810
+ await withClipboard(async (copied) => {
811
+ await renderRow('manual-compaction', manualFailure, { openDetails: false, sessions: source.service, response: { ...compactions([]), runtime: reportPayload().runtime }, interact: async ({ row, click }) => {
812
+ assert.ok(row.visible.includes(concrete))
813
+ assert.ok(!row.visible.includes(manualFailure.node.data.command.outcome.text))
814
+ assert.doesNotMatch(row.full, /FOREIGN_ERROR/)
815
+ await click(elements(row.tree, 'button').find((node) => text(node) === '复制完整错误'))
816
+ await click(withClass(row.tree, 'bt_reportCopy')[0])
817
+ assert.equal(copied[0], concrete)
818
+ assert.ok(copied[1].includes(concrete))
819
+ assert.ok(!copied[1].includes(manualFailure.node.data.command.outcome.text))
820
+ } })
821
+ })
822
+ assert.equal(source.observers.size, 0)
823
+ assert.equal(source.stats.subscriptions, source.stats.cleanups)
824
+ })
825
+
826
+ test('manual compaction rejects ambiguous transient and foreign transactions and updates on exact arrival', async () => {
827
+ const event = (seq, id, error) => logEvent(seq, 'compaction/end', { sourceCommandId: 'cmd-1', compactionId: id, error })
828
+ for (const events of [
829
+ [event(3, 'a', 'AMBIGUOUS_A'), event(4, 'b', 'AMBIGUOUS_B')],
830
+ [{ type: 'transient', event: event(3, 'a', 'TRANSIENT_ERROR') }],
831
+ [logEvent(3, 'compaction/end', { sourceCommandId: 'cmd-other', compactionId: 'a', error: 'FOREIGN_ERROR' })],
832
+ ]) {
833
+ const source = sessionFixture(events)
834
+ const row = await renderRow('manual-compaction', manualFailure, { openDetails: false, sessions: source.service, response: compactions([]) })
835
+ assert.ok(row.visible.includes(manualFailure.node.data.command.outcome.text))
836
+ assert.match(row.visible, /具体失败记录尚未加载/)
837
+ assert.doesNotMatch(row.full, /AMBIGUOUS_|TRANSIENT_ERROR|FOREIGN_ERROR/)
838
+ }
839
+ const source = sessionFixture([])
840
+ await renderRow('manual-compaction', manualFailure, { openDetails: false, sessions: source.service, response: compactions([]), interact: async ({ row, settle, update }) => {
841
+ source.publish([event(4, 'exact', 'summarization produced no text summary content')])
842
+ let next = await settle()
843
+ assert.match(next.visible, /summarization produced no text summary content/)
844
+ const changed = structuredClone(manualFailure)
845
+ changed.sessionId = 'session-new'
846
+ changed.node.data.command.commandId = 'cmd-new'
847
+ next = await update(changed)
848
+ assert.doesNotMatch(next.full, /summarization produced no text summary content/)
849
+ source.publish([event(4, 'exact', 'OLD_IDENTITY_LEAK')])
850
+ next = await settle()
851
+ assert.doesNotMatch(next.full, /OLD_IDENTITY_LEAK/)
852
+ } })
853
+ assert.equal(source.observers.size, 0)
854
+ })
855
+
856
+ test('loaded compaction failure survives pagination error and summary failure remains separate from native notice', async () => {
857
+ const concrete = 'summarization produced no text summary content'
858
+ const event = logEvent(4, 'compaction/end', { sourceCommandId: 'cmd-1', compactionId: 'exact', error: concrete })
859
+ const row = await renderRow('manual-compaction', manualFailure, { openDetails: false,
860
+ sessions: sessionFixture([event], { loadError: Error('fixture pagination failed') }).service, response: compactions([]) })
861
+ assert.ok(row.visible.includes(concrete))
862
+ const record = { ...compactFailureRecord, attempts: [{ failure: { code: 'TRANSPORT', message: 'Stream ended without finish_reason' } }] }
863
+ await withClipboard(async (copied) => {
864
+ await renderRow('manual-compaction', manualFailure, { openDetails: false, response: { ...compactions([record]), runtime: reportPayload().runtime }, interact: async ({ row, click }) => {
865
+ assert.match(row.visible, /摘要请求报错 · 关联未确认.*Stream ended without finish_reason/)
866
+ assert.ok(row.visible.includes(manualFailure.node.data.command.outcome.text), 'separate native command notice remains honest')
867
+ await click(withClass(row.tree, 'bt_reportCopy')[0])
868
+ assert.match(copied[0], /摘要请求报错: \[TRANSPORT\] Stream ended without finish_reason/)
869
+ } })
870
+ })
871
+ })
872
+
873
+ test('manual durable failure hides AUTH and filters common credentials without changing source event', async () => {
874
+ for (const message of ['AUTH: ARBITRARY_PRIVATE_ERROR', 'unrecognized-auth-value', 'provider failure sk-credentialSecret123456789']) {
875
+ const event = logEvent(4, 'compaction/end', { sourceCommandId: 'cmd-1', compactionId: 'exact', error: message })
876
+ const row = await renderRow('manual-compaction', manualFailure, { openDetails: false, sessions: sessionFixture([event]).service, response: compactions([]) })
877
+ assert.doesNotMatch(row.full, /ARBITRARY_PRIVATE_ERROR|unrecognized-auth-value|credentialSecret123456789/)
878
+ assert.equal(event.data.error, message)
879
+ }
880
+ })
881
+
882
+ test('automatic durable compaction text is protected in fallback, report, target log and neighbor log', async () => {
883
+ for (const message of ['AUTH: ARBITRARY_AUTO_AUTH_VALUE', 'unrecognized-auto-credential-value', 'provider failure sk-credentialSecret123456789']) {
884
+ const props = { ...autoFailure, node: { data: { ...autoFailure.node.data, error: message } } }
885
+ const event = logEvent(8, 'compaction/end', { compactionId: 'compact-1', error: message })
886
+ const source = sessionFixture([event, logEvent(9, 'turn/end', { turn: 9, step: 3, error: failure })])
887
+ await withClipboard(async (copied) => {
888
+ const row = await renderRow('better-tips-compaction', props, { response: compactions([]), sessions: source.service, clickLog: true, interact: async ({ row, click }) => {
889
+ assert.doesNotMatch(row.full, /ARBITRARY_AUTO_AUTH_VALUE|unrecognized-auto-credential-value|credentialSecret123456789/)
890
+ assert.match(row.full, /正文已隐藏|认证错误正文已隐藏/)
891
+ assert.match(text(withClass(row.tree, 'bt_logJson')[0]), /compaction diagnostic hidden/)
892
+ await click(withClass(row.tree, 'bt_reportCopy')[0])
893
+ assert.doesNotMatch(copied[0], /ARBITRARY_AUTO_AUTH_VALUE|unrecognized-auto-credential-value|credentialSecret123456789/)
894
+ } })
895
+ assert.equal(event.data.error, message, 'durable event is never changed')
896
+ assert.ok(row.full.includes('compaction diagnostic hidden'))
897
+ })
898
+ const neighboring = await renderRow('turn-error', terminalLog(9), { sessions: source.service, clickLog: true })
899
+ assert.doesNotMatch(neighboring.full, /ARBITRARY_AUTO_AUTH_VALUE|unrecognized-auto-credential-value|credentialSecret123456789/)
900
+ }
901
+ })
902
+
903
+ test('compaction selects final max-tokens or incomplete attempt without reviving an earlier network failure', async () => {
904
+ for (const [status, finishReason] of [['max-tokens', 'max-tokens'], ['incomplete', 'unexpected-kind']]) {
905
+ const earlier = { index: 1, attemptId: 'older-summary', finishReason: 'error', failure: { code: 'SERVER', message: 'EARLIER_SUMMARY_FAILURE' }, detail: http(502, 'EARLIER_CAPTURE').detail }
906
+ const latest = { index: 2, attemptId: 'latest-summary', finishReason }
907
+ const record = { ...compactFailureRecord, status, attempts: [earlier, latest] }
908
+ await withClipboard(async (copied) => {
909
+ await renderRow('manual-compaction', manualFailure, { openDetails: false, response: compactions([record]), interact: async ({ row, click, toggle }) => {
910
+ assert.doesNotMatch(row.visible, /EARLIER_SUMMARY_FAILURE|EARLIER_CAPTURE|HTTP 502/)
911
+ assert.ok(row.visible.includes('finish=' + finishReason))
912
+ assert.equal(withClass(row.tree, 'bt_nativeFallback').length, 1)
913
+ await click(withClass(row.tree, 'bt_reportCopy')[0])
914
+ assert.doesNotMatch(copied[0], /EARLIER_SUMMARY_FAILURE|EARLIER_CAPTURE|HTTP 502/)
915
+ assert.ok(copied[0].includes('finish=' + finishReason))
916
+ assert.match(copied[0], /未取得唯一匹配的底层证据/)
917
+ const expanded = await toggle(withClass(row.tree, 'bt_disclosure')[0], true)
918
+ assert.ok(expanded.full.includes('EARLIER_SUMMARY_FAILURE'), 'earlier evidence remains in history')
919
+ assert.ok(expanded.full.includes('EARLIER_CAPTURE'))
920
+ assert.doesNotMatch(expanded.visible, /EARLIER_SUMMARY_FAILURE|EARLIER_CAPTURE|HTTP 502/)
921
+ } })
922
+ })
923
+ }
924
+ })
925
+
484
926
  test('compaction without capture falls back to raw error, with evidence status only in details', async () => {
485
- const auto = await renderRow('better-tips-compaction', { ...autoFailure, node: { data: { ...autoFailure.node.data, error: 'MAX_TOKENS: summarization truncated at token cap' } } }, { response: compactions([]) })
486
- assert.match(auto.primary, /MAX_TOKENS: summarization truncated at token cap/)
927
+ const auto = await renderRow('better-tips-compaction', { ...autoFailure, node: { data: { ...autoFailure.node.data, error: 'summarization truncated at the token cap (incomplete checkpoint)' } } }, { response: compactions([]) })
928
+ assert.match(auto.primary, /未取得额外网络诊断/)
929
+ assert.match(auto.primary, /MAX_TOKENS/)
930
+ assert.equal(withClass(auto.tree, 'bt_nativeFallback').length, 1)
931
+ assert.equal(withClass(auto.tree, 'bt_network', true).length, 0)
932
+ assertOriginal(auto, { code: 'MAX_TOKENS', message: 'summarization truncated at the token cap (incomplete checkpoint)' })
487
933
  assertMainAdvice(auto, '摘要输出 token 上限')
488
934
  assertQuiet(auto.primary)
489
935
  const manual = await renderRow('manual-compaction', manualFailure, { response: compactions([]) })
490
- assert.match(manual.primary, /Compaction could not produce a useful summary\./)
936
+ assert.match(manual.primary, /未取得额外网络诊断/)
937
+ assert.ok(manual.primary.includes(manualFailure.node.data.command.outcome.text))
938
+ assertOriginal(manual, { message: manualFailure.node.data.command.outcome.text })
491
939
  assertMainAdvice(manual, '展开错误详情,核对请求地址与服务端日志。')
492
940
  assertQuiet(manual.primary)
493
941
  })
494
942
 
943
+ test('compaction native-only failures stay visible and source-labelled through missing capture states', async () => {
944
+ for (const options of [
945
+ { pending: true },
946
+ { response: compactions([]) },
947
+ { response: compactions([{ ...compactFailureRecord, attempts: [{ failure }] }]) },
948
+ { response: { records: [], capture: { installed: false, reason: 'config-disabled' } } },
949
+ { response: { records: [], capture: { installed: false, reason: 'unavailable' } } },
950
+ { status: 503 },
951
+ ]) {
952
+ for (const [key, props, message] of [
953
+ ['manual-compaction', manualFailure, manualFailure.node.data.command.outcome.text],
954
+ ['better-tips-compaction', autoFailure, autoFailure.node.data.error],
955
+ ]) {
956
+ const row = await renderRow(key, props, { ...options, openDetails: false })
957
+ assert.ok(row.visible.includes(message))
958
+ const native = withClass(row.tree, 'bt_nativeFallback')[0]
959
+ assert.ok(native)
960
+ assert.match(text(native), /DSH 原生报错/)
961
+ assert.equal(withClass(row.tree, 'bt_original').filter((node) => text(node) === message).length, 1, 'native failure is not duplicated in the log disclosure')
962
+ assert.equal(withClass(row.tree, 'bt_network', true).length, 0, 'native text never masquerades as plugin capture')
963
+ assert.equal(withClass(row.tree, 'bt_reportCopy', true).length, 1)
964
+ }
965
+ }
966
+ const waiting = deferred()
967
+ await renderRow('better-tips-compaction', autoFailure, { openDetails: false, respond: () => waiting.promise, interact: async ({ row, settle }) => {
968
+ assert.ok(row.visible.includes(autoFailure.node.data.error))
969
+ waiting.resolve(jsonResult(compactions()))
970
+ const ready = await settle()
971
+ assert.equal(withClass(ready.tree, 'bt_nativeFallback').length, 0)
972
+ assert.match(ready.visible, /插件捕获的底层报错.*ECONNRESET/)
973
+ assert.ok(!ready.visible.includes(autoFailure.node.data.error), 'captured evidence replaces fallback, native source remains folded')
974
+ assert.equal(ready.calls.length, 1)
975
+ } })
976
+ })
977
+
495
978
  test('successful manual/auto compaction preserves counts/summary; request metadata is expanded', async () => {
496
979
  const marker = { kind: 'compaction', seq: 44, time: 12100, summary: '## Preserved summary\nexact body\nnext line', shadowedItemCount: 12, shadowedTokenCount: 84000 }
497
980
  const record = { id: 'success', provider: 'provider-debug', model: 'model-debug', startedAt: 5000, endedAt: 12100, durationMs: 7100, status: 'ok', delays: [], attempts: [{ index: 1 }], usage: { inputTokens: 84000, outputTokens: 2200 } }
@@ -548,7 +1031,8 @@ test('confirmed deepest native codes select concise advice without changing orig
548
1031
  assertMainAdvice(row, fragment)
549
1032
  const native = withClass(row.tree, 'bt_primary')
550
1033
  assert.equal(native.length, 1)
551
- assert.equal(text(native[0]), `[${code}] ${nativeMessage}`, `${code} native headline remains verbatim`)
1034
+ assert.equal(text(native[0]), `[${code}] native diagnostic for ${code}…`, `${code} capture preview is a bounded first line`)
1035
+ assert.ok(row.full.includes(nativeMessage), 'complete native message remains verbatim in network details')
552
1036
  assert.ok(row.full.includes('fetch failed'), 'outer cause remains in raw details')
553
1037
  }
554
1038
  })
@@ -563,7 +1047,7 @@ test('confirmed HTTP status selects advice while preserving the response headlin
563
1047
  const row = await renderRow('turn-error', terminal(), { response: details([capture]) })
564
1048
  assertOriginal(row, failure)
565
1049
  assertMainAdvice(row, fragment)
566
- assert.equal(text(withClass(row.tree, 'bt_primary')[0]), `HTTP ${status} ${statusText}`)
1050
+ assert.equal(text(withClass(row.tree, 'bt_primary')[0]), `HTTP ${status} ${statusText} · raw-response-${status}…`)
567
1051
  assert.ok(row.full.includes(capture.detail.body), 'response body remains unchanged in details')
568
1052
  }
569
1053
  })
@@ -581,7 +1065,7 @@ test('structured exact provider error.code or error.type takes precedence over H
581
1065
  const advice = assertMainAdvice(row, fragment)
582
1066
  assert.ok(!advice.includes(excluded), `${field}=${code} overrides generic HTTP 429 advice`)
583
1067
  assert.match(text(withClass(row.tree, 'bt_primary')), /HTTP 429 Too Many Requests/)
584
- assert.doesNotMatch(row.primary, /HTTP 429 Too Many Requests/)
1068
+ assert.match(row.primary, /HTTP 429 Too Many Requests/)
585
1069
  assert.ok(row.full.includes(capture.detail.body), 'structured JSON is preserved verbatim')
586
1070
  assert.ok(!row.primary.includes(capture.detail.body), 'provider body stays collapsed')
587
1071
  }
@@ -620,9 +1104,8 @@ test('unconfirmed HTTP401 capture cannot change original TRANSPORT main advice',
620
1104
  ]) {
621
1105
  const row = await renderRow('turn-error', terminal(), { response: details([capture]) })
622
1106
  assert.equal(assertMainAdvice(row), expected)
623
- assert.doesNotMatch(row.primary, /API 密钥/)
624
- assert.match(row.full, /HTTP 401 Unauthorized|关联未确认/)
625
- assert.doesNotMatch(row.primary, /HTTP 401 Unauthorized|关联未确认/)
1107
+ assert.doesNotMatch(assertMainAdvice(row), /API 密钥/)
1108
+ assert.match(row.primary, /HTTP 401 Unauthorized|关联未确认/)
626
1109
  assertCaptureAdvice(row, 'API 密钥')
627
1110
  assertOriginal(row, failure)
628
1111
  }
@@ -799,7 +1282,7 @@ test('missing raw-log coordinates disable the opener instead of falling back to
799
1282
 
800
1283
  test('automatic compaction log uses endSeq and validates the exact compaction identity', async () => {
801
1284
  const props = { ...autoFailure, node: { ...autoFailure.node, anchorSeq: 11, data: { ...autoFailure.node.data, endSeq: 22 } } }
802
- const event = logEvent(22, 'compaction/end', { compactionId: 'compact-1', error: 'Connection error.' })
1285
+ const event = logEvent(22, 'compaction/end', { compactionId: 'compact-1', error: 'summarization produced no text summary content' })
803
1286
  const source = sessionFixture([logEvent(11, 'compaction/start', { compactionId: 'compact-1' }), event])
804
1287
  const row = await renderRow('better-tips-compaction', props, { sessions: source.service, clickLog: true })
805
1288
  assertLogTarget(row, event)
@@ -822,7 +1305,7 @@ test('manual compaction selects its unique command/done, not command/run or anot
822
1305
  const source = sessionFixture([run, done, other])
823
1306
  const row = await renderRow('manual-compaction', manualFailure, { sessions: source.service, clickLog: true })
824
1307
  assertLogTarget(row, done)
825
- assert.deepEqual(source.stats.loads, [1])
1308
+ assert.deepEqual(source.stats.loads, [1, 1])
826
1309
  for (const events of [[run], [run, other], [run, done, { ...done, seq: 10 }]]) {
827
1310
  const rejected = await renderRow('manual-compaction', manualFailure, { sessions: sessionFixture(events).service, clickLog: true })
828
1311
  assert.ok(rejected.full.includes(logMissing), 'absent or ambiguous done events remain unresolved')
@@ -939,7 +1422,7 @@ test('legacy evidence never confirms itself and foreign request context stays un
939
1422
  for (const capture of [socket({ provenance: undefined }), socket({ context: { sessionId: 'other-session' } })]) {
940
1423
  const row = await renderRow('turn-error', terminal(), { response: details([capture]) })
941
1424
  assert.match(row.full, /关联未确认/)
942
- assert.doesNotMatch(row.primary, /关联未确认/)
1425
+ assert.match(row.primary, /关联未确认/)
943
1426
  assert.doesNotMatch(assertMainAdvice(row), /检查代理与上游连接/)
944
1427
  }
945
1428
  })
@@ -1123,48 +1606,41 @@ test('AUTH body protection is not presented as an empty response', async () => {
1123
1606
  assert.ok(!row.full.includes('响应正文为空'))
1124
1607
  })
1125
1608
 
1126
- test('all default row types issue zero diagnostic APIs until their outer disclosure opens', async () => {
1609
+ test('failed rows request capture once on mount while successful compaction remains lazy', async () => {
1127
1610
  const marker = { time: 1500, summary: 'preserved summary', shadowedItemCount: 4, shadowedTokenCount: 800 }
1128
- for (const [key, props] of [
1129
- ['turn-error', terminal()], ['model-retry', retry()], ['manual-compaction', manualFailure],
1130
- ['better-tips-compaction', autoFailure], ['compaction', { sessionId: 'session-summary', node: { data: marker } }],
1131
- ['manual-compaction', { sessionId: 'session-summary', node: { data: { command: {}, compaction: marker } } }],
1611
+ for (const [key, props, expectedReads] of [
1612
+ ['turn-error', terminal(), 1], ['model-retry', retry(), 1], ['manual-compaction', manualFailure, 1],
1613
+ ['better-tips-compaction', autoFailure, 1], ['compaction', { sessionId: 'session-summary', node: { data: marker } }, 0],
1614
+ ['manual-compaction', { sessionId: 'session-summary', node: { data: { command: {}, compaction: marker } } }, 0],
1132
1615
  ]) {
1133
1616
  const source = sessionFixture([])
1134
1617
  const row = await renderRow(key, props, { sessions: source.service, openDetails: false })
1135
- assert.equal(row.calls.length, 0, `${key} is API-lazy by default`)
1136
- assert.equal(row.beforeDisclosure.calls.length, 0)
1137
- assert.equal(source.stats.reads, 0)
1138
- assert.equal(source.stats.bindings.length, 0)
1139
- assert.ok(elements(row.tree, 'details').every((node) => !node.props.open), `${key} is folded by default`)
1140
- assert.equal(text(row.tree, true), row.primary, 'default visible text obeys actual open state')
1618
+ assert.equal(row.calls.length, expectedReads, `${key} has exactly the intended initial read`)
1619
+ assert.equal(row.beforeDisclosure.calls.length, expectedReads)
1620
+ const manualError = key === 'manual-compaction' && props.node.data.command?.outcome?.kind === 'error'
1621
+ assert.equal(source.stats.reads, manualError ? 2 : 0)
1622
+ assert.equal(source.stats.bindings.length, manualError ? 1 : 0)
1623
+ assert.ok(elements(row.tree, 'details').every((node) => !node.props.open))
1624
+ assert.equal(text(row.tree, true), row.primary)
1141
1625
  }
1142
1626
  })
1143
1627
 
1144
- test('long Unicode excerpt is bounded and labelled while full original copies unchanged', async () => {
1145
- const previous = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
1146
- const copied = []
1147
- const message = '😀'.repeat(170) + '\nsecond line must survive copying'
1148
- Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { clipboard: { async writeText (value) { copied.push(value) } } } })
1149
- try {
1628
+ test('long Unicode native original opens completely in one layer and copies unchanged', async () => {
1629
+ await withClipboard(async (copied) => {
1630
+ const message = '😀'.repeat(170) + '\nsecond line must survive copying'
1150
1631
  await renderRow('turn-error', terminal(message), { openDetails: false, interact: async ({ row, toggle, click }) => {
1151
- const excerpt = text(withClass(row.tree, 'bt_preview')[0])
1152
- assert.equal(Array.from(excerpt).length, 121, '120 Unicode points plus an explicit ellipsis')
1153
- assert.ok(excerpt.endsWith('…'))
1154
- assert.ok(!excerpt.includes('\uFFFD'), 'never splits a surrogate pair')
1155
- assert.match(row.primary, /摘要 · 完整原文见详情/)
1156
- assert.ok(!row.primary.includes('second line'))
1157
- assert.equal(row.calls.length, 0)
1632
+ assert.equal(withClass(row.tree, 'bt_preview').length, 0)
1633
+ assert.doesNotMatch(row.primary, /摘要 ·|second line|😀/)
1634
+ assert.equal(row.calls.length, 1)
1158
1635
  const expanded = await toggle(withClass(row.tree, 'bt_disclosure')[0], true)
1159
1636
  assertOriginal(expanded, { code: 'TRANSPORT', message })
1160
- assert.ok(expanded.visible.includes(message), 'full text becomes visible on opening')
1161
- await click(withClass(expanded.tree, 'bt_copy')[0])
1637
+ assert.ok(expanded.visible.includes(message))
1638
+ assert.ok(!expanded.visible.includes('\uFFFD'))
1639
+ assert.equal(withClass(expanded.tree, 'bt_fullOriginal').length, 0)
1640
+ await click(elements(expanded.tree, 'button').find((node) => text(node) === '复制完整错误'))
1162
1641
  } })
1163
1642
  assert.deepEqual(copied, [`[TRANSPORT] ${message}`])
1164
- } finally {
1165
- if (previous) Object.defineProperty(globalThis, 'navigator', previous)
1166
- else delete globalThis.navigator
1167
- }
1643
+ })
1168
1644
  })
1169
1645
 
1170
1646
  test('durable mixed retry records remain chronological even without volatile captures', async () => {
@@ -1176,18 +1652,21 @@ test('durable mixed retry records remain chronological even without volatile cap
1176
1652
  const props = retry()
1177
1653
  props.node.data = { attempts, current: attempts.at(-1) }
1178
1654
  const row = await renderRow('model-retry', props, { response: details([]) })
1179
- assert.match(row.primary, /重试记录 · 3/)
1180
- assert.match(row.primary, /2 类错误/)
1655
+ assert.match(row.primary, /更早的报错 · 2/)
1656
+ assert.doesNotMatch(row.primary, /重试记录 · 3|类别概览|本组包含不同错误/)
1181
1657
  assert.doesNotMatch(row.primary, /→|↔|⇒|->/)
1182
- assert.match(row.primary, /类别概览|错误类别/)
1183
- assert.match(assertMainAdvice(row), /本组包含不同错误/)
1658
+ assert.doesNotMatch(row.primary, /first endpoint failed|429 insufficient_quota/)
1659
+ assert.match(assertMainAdvice(row), /查看会话日志/)
1184
1660
  assert.equal(withClass(row.tree, 'bt_history').length, 1)
1185
1661
  const records = withClass(row.tree, 'bt_attempt')
1186
1662
  assert.equal(records.length, 3, 'identical failures at distinct durable seqs must never deduplicate')
1187
- assert.deepEqual(records.map((record) => record.props.key), [41, 52, 63])
1188
- assert.deepEqual(records.map((record) => text(withClass(record, 'bt_original')[0])), attempts.map((attempt) => `[${attempt.failure.code}] ${attempt.failure.message}`))
1189
- assert.ok(records.every((record) => !record.props.open), 'each record stays independently folded')
1190
- assert.doesNotMatch(row.visible, /原始报错|未取得额外网络诊断|复制完整错误/)
1663
+ assert.deepEqual(records.map((record) => record.props.key), [63, 41, 52])
1664
+ assert.deepEqual(records.toSorted((a, b) => a.props.key - b.props.key).map((record) => text(withClass(record, 'bt_original')[0])), attempts.map((attempt) => `[${attempt.failure.code}] ${attempt.failure.message}`))
1665
+ assert.ok(records.every((record) => record.type === 'div'))
1666
+ assert.ok(withClass(row.tree, 'bt_attemptNative').every((record) => !record.props.open), 'each native original stays independently folded')
1667
+ assert.match(row.visible, /未取得额外网络诊断/)
1668
+ assert.doesNotMatch(row.visible, /复制完整错误|first endpoint failed|429 insufficient_quota/)
1669
+ assert.equal(withClass(row.tree, 'bt_reportCopy', true).length, 1, 'only latest report is outside earlier-error disclosures')
1191
1670
  assert.equal(row.calls.length, 1, 'one outer history read, not one API per durable attempt')
1192
1671
  })
1193
1672
 
@@ -1201,12 +1680,13 @@ test('every retry attempt opens its own exact durable log coordinate and same er
1201
1680
  await renderRow('model-retry', props, { sessions: source.service, response: details([]), interact: async ({ row, toggle, click }) => {
1202
1681
  assert.equal(source.stats.bindings.length, 0)
1203
1682
  for (let index = 0; index < attempts.length; index++) {
1204
- let record = withClass(row.tree, 'bt_attempt')[index]
1205
- let next = await toggle(record, true)
1206
- record = withClass(next.tree, 'bt_attempt')[index]
1683
+ const ownRecord = (tree) => withClass(tree, 'bt_attempt').find((record) => record.props.key === attempts[index].seq)
1684
+ let record = ownRecord(row.tree)
1685
+ let next = await toggle(withClass(record, 'bt_attemptNative')[0], true)
1686
+ record = ownRecord(next.tree)
1207
1687
  assert.ok(!withClass(record, 'bt_logPanel').length)
1208
1688
  next = await click(withClass(record, 'bt_logButton')[0])
1209
- const panel = withClass(withClass(next.tree, 'bt_attempt')[index], 'bt_logTarget')[0]
1689
+ const panel = withClass(ownRecord(next.tree), 'bt_logTarget')[0]
1210
1690
  assert.equal(panel.props['data-seq'], attempts[index].seq)
1211
1691
  assert.equal(text(withClass(panel, 'bt_logJson')[0]), JSON.stringify(events[index], null, 2))
1212
1692
  assert.equal(withClass(next.tree, 'bt_logTarget').length, index + 1)
@@ -1226,80 +1706,86 @@ test('mixed retry advice remains per-attempt with only exact matched native evid
1226
1706
  const props = retry()
1227
1707
  props.node.data = { attempts: [a, b], current: b }
1228
1708
  const row = await renderRow('model-retry', props, { response: details([captureA, captureB]) })
1229
- assert.match(assertMainAdvice(row), /本组包含不同错误/)
1230
- const records = withClass(row.tree, 'bt_attempt')
1709
+ assert.match(assertMainAdvice(row), /Retry-After/)
1710
+ const records = withClass(row.tree, 'bt_attempt').toSorted((a, b) => a.props.key - b.props.key)
1231
1711
  assert.match(text(withClass(records[0], 'bt_primary')), /ECONNRESET/)
1232
1712
  assert.doesNotMatch(text(withClass(records[0], 'bt_primary')), /HTTP 429/)
1233
1713
  assert.match(text(withClass(records[1], 'bt_primary')), /HTTP 429/)
1234
1714
  assert.doesNotMatch(text(withClass(records[1], 'bt_primary')), /ECONNRESET/)
1235
1715
  assert.match(text(withClass(records[0], 'bt_captureAdvice')), /检查代理与上游连接/)
1236
1716
  assert.match(text(withClass(records[1], 'bt_captureAdvice')), /Retry-After/)
1717
+ assert.match(text(records[0], true), /检查代理与上游连接/)
1718
+ assert.match(text(records[1], true), /Retry-After/)
1237
1719
  })
1238
1720
 
1239
- test('compaction attempts have independent folded complete evidence and explicit time-correlation warning', async () => {
1721
+ test('compaction latest capture defaults visible while historical originals remain independent', async () => {
1240
1722
  const record = { ...compactFailureRecord, attempts: [
1241
1723
  { index: 1, attemptId: 'ca', failure: { code: 'SERVER', message: '503 first\ncomplete first body' }, detail: http(503, 'Unavailable').detail },
1242
1724
  { index: 2, attemptId: 'cb', failure: { code: 'TRANSPORT', message: 'reset second\ncomplete second body' }, detail: socket().detail },
1243
1725
  ] }
1244
- await renderRow('better-tips-compaction', autoFailure, { response: compactions([record]), interact: async ({ row, toggle }) => {
1245
- assert.match(row.visible, /按时间关联 · 未确认请求身份/)
1246
- const attempts = withClass(row.tree, 'bt_attempt')
1726
+ await renderRow('better-tips-compaction', autoFailure, { openDetails: false, response: compactions([record]), interact: async ({ row, toggle }) => {
1727
+ assert.match(row.visible, /ECONNRESET|关联未确认/)
1728
+ assert.doesNotMatch(row.visible, /complete first body|complete second body|HTTP 503/)
1729
+ let next = await toggle(withClass(row.tree, 'bt_disclosure')[0], true)
1730
+ assert.match(next.visible, /按时间关联 · 未确认请求身份/)
1731
+ const attempts = withClass(next.tree, 'bt_attempt')
1247
1732
  assert.equal(attempts.length, 2)
1248
- assert.ok(attempts.every((node) => !node.props.open))
1249
- assert.doesNotMatch(row.visible, /complete first body|complete second body|ECONNRESET|HTTP 503/)
1250
- const next = await toggle(attempts[0], true)
1733
+ assert.ok(attempts.every((node) => node.type === 'div'))
1734
+ assert.ok(withClass(next.tree, 'bt_attemptNative').every((node) => !node.props.open))
1735
+ assert.doesNotMatch(next.visible, /HTTP 503/)
1736
+ next = await toggle(withClass(next.tree, 'bt_earlierErrors')[0], true)
1737
+ next = await toggle(withClass(next.tree, 'bt_earlierError')[0], true)
1738
+ assert.match(next.visible, /HTTP 503/)
1739
+ next = await toggle(withClass(withClass(next.tree, 'bt_attempt')[0], 'bt_attemptNative')[0], true)
1251
1740
  assert.match(next.visible, /complete first body/)
1252
1741
  assert.doesNotMatch(next.visible, /complete second body/)
1253
- assert.equal(withClass(next.tree, 'bt_network').length, 2, 'network evidence belongs to each attempt, not an empty duplicated global panel')
1742
+ assert.equal(withClass(next.tree, 'bt_network').length, 3, 'default latest capture plus two isolated history captures')
1254
1743
  assert.ok(next.full.includes('complete second body'))
1255
1744
  } })
1256
1745
  })
1257
1746
 
1258
- const deferred = () => {
1259
- let resolve, reject
1260
- const promise = new Promise((yes, no) => { resolve = yes; reject = no })
1261
- return { promise, resolve, reject }
1262
- }
1263
- const jsonResult = (data) => ({ ok: true, json: async () => data })
1264
-
1265
- test('close and reopen reuse only completed evidence cache, without stale loading or extra fetch', async () => {
1266
- await renderRow('turn-error', terminal(), { openDetails: false, response: details([socket()]), interact: async ({ row, toggle }) => {
1747
+ test('closing native original leaves completed capture visible without extra fetch', async () => {
1748
+ await renderRow('turn-error', terminal(), { openDetails: false, response: details([socket()]), interact: async ({ row, toggle, settle }) => {
1749
+ assert.equal(row.calls.length, 1)
1267
1750
  const opened = await toggle(withClass(row.tree, 'bt_disclosure')[0], true)
1268
- assert.equal(opened.calls.length, 1)
1269
- assert.match(opened.full, /ECONNRESET/)
1751
+ assert.match(opened.visible, /Connection error\./)
1270
1752
  const closed = await toggle(withClass(opened.tree, 'bt_disclosure')[0], false)
1271
1753
  assert.equal(closed.calls.length, 1)
1272
- assert.ok(closed.calls[0].options.signal.aborted)
1273
- assert.doesNotMatch(closed.visible, /ECONNRESET|fetch failed/)
1754
+ assert.equal(closed.calls[0].options.signal.aborted, false)
1755
+ assert.match(closed.visible, /ECONNRESET/)
1756
+ assert.doesNotMatch(closed.visible, /Connection error\.|fetch failed/)
1274
1757
  const reopened = await toggle(withClass(closed.tree, 'bt_disclosure')[0], true)
1275
- assert.equal(reopened.calls.length, 1, 'completed same-identity evidence is cached')
1276
- assert.match(reopened.full, /ECONNRESET/)
1758
+ assert.equal(reopened.calls.length, 1)
1759
+ assert.match(reopened.visible, /Connection error\./)
1277
1760
  assert.doesNotMatch(reopened.visible, /加载中/)
1761
+ assert.equal((await settle()).calls.length, 1, 'no polling after settling')
1278
1762
  } })
1279
1763
  })
1280
1764
 
1281
- test('closing a pending fetch aborts it; reopen starts fresh and late cancelled response cannot poison cache', async () => {
1765
+ test('closing native original does not abort pending capture; refresh cancels stale capture and unmount drains', async () => {
1282
1766
  const first = deferred()
1283
- await renderRow('turn-error', terminal(), { openDetails: false,
1767
+ const result = await renderRow('turn-error', terminal(), { openDetails: false,
1284
1768
  respond: (_url, _options, index) => index === 1 ? first.promise : Promise.resolve(jsonResult(details([http(503, 'Fresh evidence', failure)]))),
1285
- interact: async ({ row, toggle, settle }) => {
1769
+ interact: async ({ row, toggle, settle, click }) => {
1770
+ assert.equal(row.calls.length, 1)
1286
1771
  let next = await toggle(withClass(row.tree, 'bt_disclosure')[0], true)
1287
- assert.equal(next.calls.length, 1)
1288
- assert.match(next.full, /加载中/)
1289
1772
  next = await toggle(withClass(next.tree, 'bt_disclosure')[0], false)
1290
- assert.ok(next.calls[0].options.signal.aborted)
1291
- next = await toggle(withClass(next.tree, 'bt_disclosure')[0], true)
1773
+ assert.equal(next.calls[0].options.signal.aborted, false)
1774
+ assert.match(next.visible, /加载中/)
1775
+ next = await click(withClass(next.tree, 'bt_refresh')[0])
1292
1776
  assert.equal(next.calls.length, 2)
1293
- assert.match(next.full, /Fresh evidence/)
1777
+ assert.equal(next.calls[0].options.signal.aborted, true)
1778
+ assert.match(next.visible, /Fresh evidence/)
1294
1779
  first.resolve(jsonResult(details([http(401, 'STALE_CANCELLED', failure)])))
1295
1780
  next = await settle()
1296
1781
  assert.doesNotMatch(next.full, /STALE_CANCELLED/)
1297
- next = await toggle(withClass(next.tree, 'bt_disclosure')[0], false)
1298
1782
  next = await toggle(withClass(next.tree, 'bt_disclosure')[0], true)
1783
+ next = await toggle(withClass(next.tree, 'bt_disclosure')[0], false)
1299
1784
  assert.equal(next.calls.length, 2)
1300
- assert.match(next.full, /Fresh evidence/)
1785
+ assert.match(next.visible, /Fresh evidence/)
1301
1786
  },
1302
1787
  })
1788
+ assert.ok(result.calls.every((call) => call.options.signal.aborted), 'unmount cancels owned request scopes')
1303
1789
  })
1304
1790
 
1305
1791
  test('changing row identity rejects late old-session responses and never sends old log metadata coordinates', async () => {
@@ -1368,7 +1854,7 @@ test('nested disclosure toggle events do not mutate the outer lazy disclosure',
1368
1854
  outer.props.onToggle({ currentTarget: { open: true }, target: { nested: true } })
1369
1855
  let next = await settle()
1370
1856
  assert.equal(withClass(next.tree, 'bt_disclosure')[0].props.open, false)
1371
- assert.equal(next.calls.length, 0)
1857
+ assert.equal(next.calls.length, 1)
1372
1858
  next = await toggle(withClass(next.tree, 'bt_disclosure')[0], true)
1373
1859
  assert.equal(next.calls.length, 1)
1374
1860
  const openOuter = withClass(next.tree, 'bt_disclosure')[0]
@@ -1385,31 +1871,23 @@ test('opening then closing before fetch microtask never starts an unnecessary re
1385
1871
  outer.props.onToggle({ currentTarget: { open: true } })
1386
1872
  outer.props.onToggle({ currentTarget: { open: false } })
1387
1873
  const next = await settle()
1388
- assert.equal(next.calls.length, 0)
1874
+ assert.equal(next.calls.length, 1, 'native toggles do not restart the mount-time capture read')
1389
1875
  assert.equal(withClass(next.tree, 'bt_disclosure')[0].props.open, false)
1390
1876
  } })
1391
1877
  })
1392
1878
 
1393
- test('structured long provider JSON has a useful bounded excerpt but copy preserves complete JSON wrapper', async () => {
1394
- const previous = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
1395
- const copied = []
1396
- const message = '503: ' + JSON.stringify({ error: { message: 'literal provider '.repeat(40) + '\nfull second diagnostic line', type: 'overloaded_error', extra: 'opaque evidence preserved' } })
1397
- Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { clipboard: { async writeText (value) { copied.push(value) } } } })
1398
- try {
1879
+ test('structured long native JSON remains complete with no native excerpt and copies verbatim', async () => {
1880
+ await withClipboard(async (copied) => {
1881
+ const message = '503: ' + JSON.stringify({ error: { message: 'literal provider '.repeat(40) + '\nfull second diagnostic line', type: 'overloaded_error', extra: 'opaque evidence preserved' } })
1399
1882
  await renderRow('turn-error', terminal(message, 'SERVER'), { interact: async ({ row, click }) => {
1400
- const preview = text(withClass(row.tree, 'bt_preview')[0])
1401
- assert.match(preview, /^\[SERVER\] HTTP 503 literal provider /)
1402
- assert.equal(Array.from(preview).length, 121)
1403
- assert.ok(preview.endsWith('…'))
1404
- assert.doesNotMatch(row.primary, /opaque evidence|full second diagnostic|overloaded_error/)
1883
+ assert.equal(withClass(row.tree, 'bt_preview').length, 0)
1884
+ assert.doesNotMatch(row.primary, /opaque evidence|full second diagnostic|overloaded_error|摘要/)
1405
1885
  assertOriginal(row, { code: 'SERVER', message })
1406
- await click(withClass(row.tree, 'bt_copy')[0])
1886
+ assert.ok(row.visible.includes(message))
1887
+ await click(elements(row.tree, 'button').find((node) => text(node) === '复制完整错误'))
1407
1888
  } })
1408
1889
  assert.deepEqual(copied, ['[SERVER] ' + message])
1409
- } finally {
1410
- if (previous) Object.defineProperty(globalThis, 'navigator', previous)
1411
- else delete globalThis.navigator
1412
- }
1890
+ })
1413
1891
  })
1414
1892
 
1415
1893
  test('same HTTP status with different native codes remains distinct and structured status takes precedence', async () => {
@@ -1420,45 +1898,36 @@ test('same HTTP status with different native codes remains distinct and structur
1420
1898
  const props = retry()
1421
1899
  props.node.data = { attempts, current: attempts[1] }
1422
1900
  const row = await renderRow('model-retry', props, { openDetails: false })
1423
- assert.match(row.primary, /2 类错误/)
1424
- assert.match(row.primary, /RATE_LIMIT \/ HTTP 429/)
1425
- assert.match(row.primary, /QUOTA \/ HTTP 429/)
1426
- assert.equal(row.calls.length, 0)
1901
+ assert.match(row.primary, /更早的报错 · 1/)
1902
+ assert.match(assertMainAdvice(row), /账号余额、用量配额/)
1903
+ assertOriginal(row, attempts[0].failure)
1904
+ assertOriginal(row, attempts[1].failure)
1905
+ assert.equal(row.calls.length, 1)
1427
1906
  assert.equal(withClass(row.tree, 'bt_history').length, 1)
1428
1907
  })
1429
1908
 
1430
- test('structured HTTP JSON excerpt extracts only its literal message without changing full evidence', async () => {
1431
- for (const [field, body] of [
1432
- ['error.message', { error: { message: '真实供应商首行\n第二行必须保留', code: 'overloaded_error' } }],
1433
- ['message', { message: 'top-level literal message', code: 'arbitrary' }],
1909
+ test('complete native JSON is never decoded or shortened in its single native disclosure', async () => {
1910
+ for (const body of [
1911
+ { error: { message: '真实供应商首行\n第二行必须保留', code: 'overloaded_error' } },
1912
+ { message: 'top-level literal message', code: 'arbitrary' },
1434
1913
  ]) {
1435
1914
  const message = `503: ${JSON.stringify(body)}`
1436
- const row = await renderRow('turn-error', terminal(message, 'SERVER'), { openDetails: false })
1437
- const excerpt = text(withClass(row.tree, 'bt_preview')[0])
1438
- assert.match(excerpt, /^\[SERVER\] HTTP 503 /, field)
1439
- assert.ok(excerpt.includes(field === 'error.message' ? '真实供应商首行' : 'top-level literal message'))
1440
- assert.doesNotMatch(excerpt, /overloaded_error|arbitrary|"message"|第二行/)
1441
- assert.match(row.primary, /摘要 · 完整原文见详情/)
1442
- assertOriginal(row, { code: 'SERVER', message })
1915
+ await renderRow('turn-error', terminal(message, 'SERVER'), { openDetails: false, interact: async ({ row, toggle }) => {
1916
+ assert.equal(withClass(row.tree, 'bt_preview').length, 0)
1917
+ assert.doesNotMatch(row.visible, /真实供应商首行|top-level literal message|摘要/)
1918
+ assertOriginal(row, { code: 'SERVER', message })
1919
+ const next = await toggle(withClass(row.tree, 'bt_disclosure')[0], true)
1920
+ assert.equal(text(withClass(next.tree, 'bt_original')[0]), '[SERVER] ' + message)
1921
+ assert.equal(withClass(next.tree, 'bt_fullOriginal').length, 0)
1922
+ } })
1443
1923
  }
1444
1924
  for (const message of ['503: {"error":{"message":"broken"}', '503: {"error":{"message":42}}', 'prefix 503: {"message":"not an exact wrapper"}']) {
1445
1925
  const row = await renderRow('turn-error', terminal(message, 'SERVER'), { openDetails: false })
1446
- assert.equal(text(withClass(row.tree, 'bt_preview')[0]), `[SERVER] ${message}`)
1926
+ assert.equal(text(withClass(row.tree, 'bt_original')[0]), `[SERVER] ${message}`)
1447
1927
  assertOriginal(row, { code: 'SERVER', message })
1448
1928
  }
1449
1929
  })
1450
1930
 
1451
- async function withClipboard (operation, write) {
1452
- const previous = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
1453
- const copied = []
1454
- Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { clipboard: { writeText: write ?? (async (value) => { copied.push(value) }) } } })
1455
- try { return await operation(copied) } finally {
1456
- if (previous) Object.defineProperty(globalThis, 'navigator', previous)
1457
- else delete globalThis.navigator
1458
- }
1459
- }
1460
- const reportPayload = (items = [socket()]) => ({ ...details(items), runtime: { dshVersion: '0.2.2-rc.2', pluginVersion: '0.12.0' } })
1461
-
1462
1931
  test('one-click current report loads only on click and exports native error, exact evidence, runtime and timing', async () => {
1463
1932
  await withClipboard(async (copied) => {
1464
1933
  const props = retry()
@@ -1466,7 +1935,7 @@ test('one-click current report loads only on click and exports native error, exa
1466
1935
  const capture = socket()
1467
1936
  Object.assign(capture.detail, { at: 1700000001000, startedAt: 1700000000000 })
1468
1937
  await renderRow('model-retry', props, { openDetails: false, response: reportPayload([capture, http(401, 'UNRELATED_CAPTURE')]), interact: async ({ row, click }) => {
1469
- assert.equal(row.calls.length, 0)
1938
+ assert.equal(row.calls.length, 1)
1470
1939
  const next = await click(withClass(row.tree, 'bt_reportCopy')[0])
1471
1940
  assert.equal(next.calls.length, 1)
1472
1941
  assert.match(next.primary, /已复制/)
@@ -1584,13 +2053,32 @@ test('report failure still copies native diagnosis with explicit missing version
1584
2053
  })
1585
2054
  })
1586
2055
 
2056
+ test('successful report copy never adds report text; failed fallback disappears after a successful retry', async () => {
2057
+ let denied = true
2058
+ await withClipboard(async () => {
2059
+ await renderRow('turn-error', terminal(), { openDetails: false, response: reportPayload(), interact: async ({ row, click }) => {
2060
+ let next = await click(withClass(row.tree, 'bt_reportCopy')[0])
2061
+ assert.equal(withClass(next.tree, 'bt_reportText').length, 1)
2062
+ assert.doesNotMatch(next.full, /查看报告文本/)
2063
+ denied = false
2064
+ next = await click(withClass(next.tree, 'bt_reportCopy')[0])
2065
+ assert.equal(withClass(next.tree, 'bt_reportText').length, 0)
2066
+ assert.doesNotMatch(next.full, /查看报告文本|DSH 原生报错:/)
2067
+ assert.match(next.visible, /已复制/)
2068
+ assert.equal(next.calls.length, 1)
2069
+ } })
2070
+ }, async () => { if (denied) throw Error('clipboard denied') })
2071
+ })
2072
+
1587
2073
  test('clipboard denial exposes selectable report text inline without a second upload or request', async () => {
1588
2074
  await withClipboard(async () => {
1589
2075
  await renderRow('turn-error', terminal(), { openDetails: false, response: reportPayload(), interact: async ({ row, click }) => {
1590
2076
  const next = await click(withClass(row.tree, 'bt_reportCopy')[0])
1591
2077
  assert.match(next.primary, /复制失败,可选择/)
1592
2078
  const report = withClass(next.tree, 'bt_reportText')[0]
1593
- assert.equal(report.props.open, true)
2079
+ assert.equal(report.type, 'div')
2080
+ assert.equal(elements(report, 'summary').length, 0)
2081
+ assert.ok(next.visible.includes('DSH 原生报错:'))
1594
2082
  assert.match(text(report), /DSH 原生报错:.*Connection error/)
1595
2083
  assert.match(text(report), /插件捕获的底层报错:.*ECONNRESET/)
1596
2084
  assert.equal(next.calls.length, 1)
@@ -1630,23 +2118,24 @@ test('compaction thrown AUTH report never recovers arbitrary authentication text
1630
2118
  })
1631
2119
  })
1632
2120
 
1633
- test('source labels distinguish native excerpts, plugin overview and captured evidence', async () => {
2121
+ test('capture label is default and native original has one explicit disclosure without summary copy', async () => {
1634
2122
  const original = 'native message line one\nline two remains intact'
1635
2123
  const raw = { code: 'TRANSPORT', message: original }
1636
- const row = await renderRow('turn-error', terminal(original), { response: reportPayload([socket({ failure: raw })]) })
1637
- assert.equal(text(withClass(row.tree, 'bt_source')[0]), 'DSH 原生报错 · 摘要')
1638
- assert.ok(row.primary.includes('DSH 原生报错 · 摘要'))
2124
+ const row = await renderRow('turn-error', terminal(original), { openDetails: false, response: reportPayload([socket({ failure: raw })]) })
2125
+ assert.equal(withClass(row.tree, 'bt_source').length, 0)
2126
+ assert.doesNotMatch(row.primary, /DSH 原生报错 · 摘要|native message line one/)
1639
2127
  assertOriginal(row, raw)
1640
- const headings = elements(row.tree, 'h4').map((node) => text(node))
1641
- assert.ok(headings.includes('DSH 原生报错'))
1642
2128
  const network = withClass(row.tree, 'bt_network')[0]
1643
- assert.match(text(elements(network, 'summary')[0]), /插件捕获的底层报错/)
2129
+ assert.equal(network.type, 'section')
2130
+ assert.match(text(elements(network, 'h4')[0]), /插件捕获的底层报错/)
2131
+ assert.match(text(elements(withClass(row.tree, 'bt_disclosure')[0], 'summary')[0]), /DSH 原生报错/)
1644
2132
  const attempts = [
1645
2133
  { ...retry('503 first failure', 'SERVER').node.data.current, retry: 1, seq: 20 },
1646
2134
  { ...retry('second failure', 'TRANSPORT').node.data.current, retry: 2, seq: 30 },
1647
2135
  ]
1648
2136
  const overview = await renderRow('model-retry', { ...retry(), node: { data: { attempts, current: attempts[1] } } }, { openDetails: false })
1649
- assert.equal(text(withClass(overview.tree, 'bt_source')[0]), '插件整理 · 重试概览')
2137
+ assert.equal(withClass(overview.tree, 'bt_disclosure').length, 0, 'no duplicate latest capture/native wrapper around retry history')
2138
+ assert.equal(withClass(overview.tree, 'bt_reportCopy', true).length, 1)
1650
2139
  assertOriginal(overview, attempts[0].failure)
1651
2140
  assertOriginal(overview, attempts[1].failure)
1652
2141
  })
@@ -1694,7 +2183,7 @@ test('support report caps native, deepest evidence and response excerpts while U
1694
2183
  assert.ok(!excerpt.includes('\uFFFD'), 'Unicode points are not cut into invalid surrogate halves')
1695
2184
  }
1696
2185
  assert.doesNotMatch(report, /NATIVE_TAIL_PRESERVED|UNDERLYING_TAIL_PRESERVED|RESPONSE_TAIL_PRESERVED|UNNECESSARY_PROVIDER_FIELD/)
1697
- await click(withClass(row.tree, 'bt_copy')[0])
2186
+ await click(elements(row.tree, 'button').find((node) => text(node) === '复制完整错误'))
1698
2187
  } })
1699
2188
  assert.equal(copied[1], '[TRANSPORT] ' + message, 'existing raw-error copy remains complete and unmodified')
1700
2189
  })
@@ -1736,7 +2225,7 @@ test('complete provider HTTP JSON wrapper becomes readable once in the short rep
1736
2225
  assert.equal(report.split('SECOND_LINE_UNIQUE_PROVIDER_DIAGNOSTIC').length - 1, 1, 'decoded multiline provider diagnostic appears only once, not again as response excerpt')
1737
2226
  assert.doesNotMatch(report, /响应摘要:|"(?:message|error|type|code|unrelated)"|overloaded_error|NATIVE_WRAPPER_METADATA|RESPONSE_METADATA_NOT_NEEDED|\\\\n/)
1738
2227
  assert.ok(report.length < 1000, 'support report remains concise without JSON scaffolding')
1739
- await click(withClass(row.tree, 'bt_copy')[0])
2228
+ await click(elements(row.tree, 'button').find((node) => text(node) === '复制完整错误'))
1740
2229
  } })
1741
2230
  assert.equal(copied[1], '[SERVER] ' + message, 'raw-error copy keeps complete original JSON rather than decoded report formatting')
1742
2231
  })