diff --git a/.oxlintrc.json b/.oxlintrc.json index 869ce76..bb16b9a 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -226,6 +226,127 @@ "jsPlugins": [ "@stylistic/eslint-plugin" ] + }, + { + "files": [ + "tests/**/*.ts" + ], + "rules": { + "no-var": "error", + "prefer-const": "error", + "prefer-rest-params": "error", + "prefer-spread": "error", + "no-array-constructor": "error", + "no-unused-expressions": "error", + "no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_" + } + ], + "no-useless-constructor": "error", + "typescript/await-thenable": "error", + "typescript/ban-ts-comment": [ + "error", + { + "minimumDescriptionLength": 10 + } + ], + "typescript/no-array-delete": "error", + "typescript/no-base-to-string": "error", + "typescript/no-confusing-void-expression": "error", + "typescript/no-deprecated": "error", + "typescript/no-duplicate-enum-values": "error", + "typescript/no-duplicate-type-constituents": "error", + "typescript/no-dynamic-delete": "error", + "typescript/no-empty-object-type": "off", + "typescript/no-explicit-any": "error", + "typescript/no-extra-non-null-assertion": "error", + "typescript/no-extraneous-class": "error", + "typescript/no-floating-promises": "error", + "typescript/no-for-in-array": "error", + "typescript/no-implied-eval": "error", + "typescript/no-invalid-void-type": "off", + "typescript/no-meaningless-void-operator": "error", + "typescript/no-misused-new": "error", + "typescript/no-misused-promises": "error", + "typescript/no-misused-spread": "error", + "typescript/no-mixed-enums": "error", + "typescript/no-namespace": "off", + "typescript/no-non-null-asserted-nullish-coalescing": "error", + "typescript/no-non-null-asserted-optional-chain": "error", + "typescript/no-non-null-assertion": "error", + "typescript/no-redundant-type-constituents": "error", + "typescript/no-require-imports": "error", + "typescript/no-this-alias": "error", + "typescript/no-unnecessary-boolean-literal-compare": "error", + "typescript/no-unnecessary-condition": [ + "error", + { + "allowConstantLoopConditions": true + } + ], + "typescript/no-unnecessary-template-expression": "error", + "typescript/no-unnecessary-type-arguments": "error", + "typescript/no-unnecessary-type-assertion": "error", + "typescript/no-unnecessary-type-constraint": "error", + "typescript/no-unnecessary-type-conversion": "error", + "typescript/no-unnecessary-type-parameters": "error", + "typescript/no-unsafe-argument": "error", + "typescript/no-unsafe-assignment": "error", + "typescript/no-unsafe-call": "error", + "typescript/no-unsafe-declaration-merging": "error", + "typescript/no-unsafe-enum-comparison": "error", + "typescript/no-unsafe-function-type": "error", + "typescript/no-unsafe-member-access": "error", + "typescript/no-unsafe-return": "error", + "typescript/no-unsafe-unary-minus": "error", + "typescript/no-useless-default-assignment": "error", + "typescript/no-wrapper-object-types": "error", + "typescript/only-throw-error": "error", + "typescript/prefer-as-const": "error", + "typescript/prefer-literal-enum-member": "error", + "typescript/prefer-namespace-keyword": "error", + "typescript/prefer-promise-reject-errors": "error", + "typescript/prefer-reduce-type-parameter": "error", + "typescript/prefer-return-this-type": "error", + "typescript/related-getter-setter-pairs": "error", + "typescript/require-await": "error", + "typescript/restrict-plus-operands": [ + "error", + { + "allowAny": false, + "allowBoolean": false, + "allowNullish": false, + "allowNumberAndString": false, + "allowRegExp": false + } + ], + "typescript/restrict-template-expressions": [ + "error", + { + "allowNumber": true, + "allowBoolean": true + } + ], + "typescript/return-await": [ + "error", + "error-handling-correctness-only" + ], + "typescript/switch-exhaustiveness-check": [ + "error", + { + "considerDefaultExhaustiveForUnions": true + } + ], + "typescript/triple-slash-reference": "error", + "typescript/unbound-method": "error", + "typescript/unified-signatures": "error", + "typescript/use-unknown-in-catch-callback-variable": "error", + "no-void": "off" + } } ] -} +} \ No newline at end of file diff --git a/package.json b/package.json index 34f0e0e..4149b6b 100644 --- a/package.json +++ b/package.json @@ -36,8 +36,9 @@ "lint:fix": "oxlint --fix", "watch": "tsdown --watch", "register": "bash scripts/register.sh", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.tests.json", "test": "pnpm run typecheck && vitest run", + "test:bundle": "node scripts/bundle-smoke.mjs", "coverage": "vitest run --coverage", "release": "bash scripts/publish.sh", "hooks": "bash scripts/install-hooks.sh", @@ -93,7 +94,9 @@ "@deepseek-ai/dsh-settings": "^0.1.0-rc.7", "@deepseek-ai/schemastery": "^3.18.1", "@stylistic/eslint-plugin": "^5.10.0", + "@types/node": "^26.2.0", "@types/react": "^18.3.31", + "@types/react-dom": "^19.2.4", "@vitest/coverage-v8": "^4.1.11", "eslint-plugin-sonarjs": "^4.2.0", "jsdom": "^30.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22f40e2..35b388d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,9 +33,15 @@ importers: '@stylistic/eslint-plugin': specifier: ^5.10.0 version: 5.10.0(eslint@10.8.1) + '@types/node': + specifier: ^26.2.0 + version: 26.2.0 '@types/react': specifier: ^18.3.31 version: 18.3.31 + '@types/react-dom': + specifier: ^19.2.4 + version: 19.2.4(@types/react@18.3.31) '@vitest/coverage-v8': specifier: ^4.1.11 version: 4.1.11(vitest@4.1.11) @@ -71,7 +77,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.11 - version: 4.1.11(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(yaml@2.9.0)) + version: 4.1.11(@types/node@26.2.0)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.2.0)(yaml@2.9.0)) packages: @@ -634,9 +640,17 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@26.2.0': + resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==} + '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/react-dom@19.2.4': + resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} + peerDependencies: + '@types/react': ^19.2.0 + '@types/react@18.3.31': resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} @@ -1850,6 +1864,9 @@ packages: unconfig-core@7.5.0: resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici@8.10.0: resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} engines: {node: '>=22.19.0'} @@ -2462,8 +2479,16 @@ snapshots: '@types/ms@2.1.0': {} + '@types/node@26.2.0': + dependencies: + undici-types: 8.3.0 + '@types/prop-types@15.7.15': {} + '@types/react-dom@19.2.4(@types/react@18.3.31)': + dependencies: + '@types/react': 18.3.31 + '@types/react@18.3.31': dependencies: '@types/prop-types': 15.7.15 @@ -2547,7 +2572,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.11(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(yaml@2.9.0)) + vitest: 4.1.11(@types/node@26.2.0)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.2.0)(yaml@2.9.0)) '@vitest/expect@4.1.11': dependencies: @@ -2558,13 +2583,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.11(vite@8.2.2(yaml@2.9.0))': + '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@26.2.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.2.2(yaml@2.9.0) + vite: 8.2.2(@types/node@26.2.0)(yaml@2.9.0) '@vitest/pretty-format@4.1.11': dependencies: @@ -3746,6 +3771,8 @@ snapshots: '@quansync/fs': 1.0.0 quansync: 1.0.0 + undici-types@8.3.0: {} + undici@8.10.0: {} unist-util-is@6.0.1: @@ -3792,7 +3819,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.2.2(yaml@2.9.0): + vite@8.2.2(@types/node@26.2.0)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 @@ -3800,13 +3827,14 @@ snapshots: rolldown: 1.2.5 tinyglobby: 0.2.17 optionalDependencies: + '@types/node': 26.2.0 fsevents: 2.3.3 yaml: 2.9.0 - vitest@4.1.11(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(yaml@2.9.0)): + vitest@4.1.11(@types/node@26.2.0)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.2.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.2.2(yaml@2.9.0)) + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@26.2.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -3823,9 +3851,10 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.2.2(yaml@2.9.0) + vite: 8.2.2(@types/node@26.2.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: + '@types/node': 26.2.0 '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) jsdom: 30.0.1 transitivePeerDependencies: diff --git a/tests/client/assemble.spec.ts b/tests/client/assemble.spec.ts new file mode 100644 index 0000000..fa5f7f9 --- /dev/null +++ b/tests/client/assemble.spec.ts @@ -0,0 +1,138 @@ +// Context assembly (src/client/assemble.ts): the header epoch lookup and +// the per-step surface reconstruction with its coverage flags. + +import assert from 'node:assert/strict' +import { describe, test } from 'vitest' +import { assemble, headerAt } from '../../src/client/assemble' +import type { ContextHeaders, ContextTimeline, HeaderRecord, SurfaceNode } from '../../src/shared/types' + +function node(seq: number, over: Partial = {}): SurfaceNode { + return { seq, cat: 'user', tokens: 1, ...over } +} + +function epoch(seq: number): HeaderRecord { + return { seq, time: 0, tools: [] } +} + +function headers(...seqs: number[]): ContextHeaders { + return { headers: seqs.map(epoch) } +} + +function timeline(over: Partial = {}): ContextTimeline { + return { + ok: true, + current: { system: 0, tools: 0, user: 0, inject: 0, assistant: 0, tool: 0, total: 0 }, + toolList: [], + requests: [], + events: [], + nodes: [], + droppedNodes: 0, + archive: [], + ...over, + } +} + +describe('headerAt', () => { + test('absent or empty headers yield null', () => { + assert.equal(headerAt(null, 10), null) + assert.equal(headerAt({ headers: [] }, 10), null) + }) + + test('a null seq picks the newest epoch', () => { + const h = headers(10, 20) + assert.equal(headerAt(h, null), h.headers[1]) + }) + + test('a seq at or before every epoch yields null', () => { + assert.equal(headerAt(headers(10, 20), 5), null) + assert.equal(headerAt(headers(10, 20), 10), null) + }) + + test('the latest epoch before the seq is in force', () => { + const h = headers(10, 20) + assert.equal(headerAt(h, 15), h.headers[0]) + assert.equal(headerAt(h, 25), h.headers[1]) + }) +}) + +describe('assemble live view', () => { + test('a null seq returns all live nodes for the next request', () => { + const data = timeline({ nodes: [node(1), node(5)] }) + const h = headers(10) + const out = assemble(data, h, null) + assert.equal(out.live, true) + assert.deepEqual(out.nodes.map(n => n.seq), [1, 5]) + assert.notEqual(out.nodes, data.nodes) + assert.equal(out.header, h.headers[0]) + assert.equal(out.missingLive, 0) + assert.equal(out.approximate, false) + }) + + test('dropped live nodes are all part of the live context', () => { + const data = timeline({ nodes: [node(5)], droppedNodes: 3, surfaceFloor: 4 }) + assert.equal(assemble(data, null, null).missingLive, 3) + }) +}) + +describe('assemble past step', () => { + test('the surface is live nodes before the step plus still-alive archive nodes', () => { + const data = timeline({ + nodes: [node(1), node(3), node(8)], + archive: [ + node(2, { gone: 10 }), + node(4, { gone: 5 }), + node(6), + node(9, { gone: 12 }), + ], + }) + const out = assemble(data, null, 7) + assert.equal(out.live, false) + assert.deepEqual(out.nodes.map(n => n.seq), [1, 2, 3]) + }) + + test('the assembled nodes come back in seq order', () => { + const data = timeline({ nodes: [node(3), node(1)], archive: [node(2, { gone: 10 })] }) + assert.deepEqual(assemble(data, null, 7).nodes.map(n => n.seq), [1, 2, 3]) + }) + + test('the header in force at the step rides along', () => { + const h = headers(2, 10) + assert.equal(assemble(timeline(), h, 7).header, h.headers[0]) + assert.equal(assemble(timeline(), h, 1).header, null) + }) +}) + +describe('assemble missingLive for past steps', () => { + test('a step after the surface floor contains every dropped live node', () => { + const data = timeline({ droppedNodes: 2, surfaceFloor: 5 }) + assert.equal(assemble(data, null, 6).missingLive, 2) + }) + + test('a step at or below the surface floor flags nothing', () => { + const data = timeline({ droppedNodes: 2, surfaceFloor: 5 }) + assert.equal(assemble(data, null, 5).missingLive, 0) + assert.equal(assemble(data, null, 3).missingLive, 0) + }) + + test('without a surface floor a past step flags nothing', () => { + const data = timeline({ droppedNodes: 2 }) + assert.equal(assemble(data, null, 6).missingLive, 0) + }) +}) + +describe('assemble approximate flag', () => { + test('a step older than the archive floor is approximate', () => { + const data = timeline({ archiveFloor: 10 }) + assert.equal(assemble(data, null, 6).approximate, true) + }) + + test('a step at or after the archive floor is exact', () => { + const data = timeline({ archiveFloor: 10 }) + assert.equal(assemble(data, null, 10).approximate, false) + assert.equal(assemble(data, null, 12).approximate, false) + }) + + test('without an archive floor no step is approximate', () => { + assert.equal(assemble(timeline(), null, 6).approximate, false) + }) +}) diff --git a/tests/client/brief.spec.ts b/tests/client/brief.spec.ts new file mode 100644 index 0000000..6b6eeec --- /dev/null +++ b/tests/client/brief.spec.ts @@ -0,0 +1,161 @@ +// Step brief (src/client/brief.ts): node merge/sort, the per-step brief +// (response hit, turn-start detection, opener scan, mid-turn inputs), and +// the tooltip reply previews. + +import assert from 'node:assert/strict' +import { describe, test } from 'vitest' +import { briefNodes, briefOf, replyTipsOf } from '../../src/client/brief' +import type { ContextTimeline, RequestRecord, SurfaceNode } from '../../src/shared/types' + +function node(seq: number, over: Partial = {}): SurfaceNode { + return { seq, cat: 'user', tokens: 1, ...over } +} + +function req(seq: number, over: Partial = {}): RequestRecord { + return { time: 0, seq, system: 0, tools: 0, user: 0, inject: 0, assistant: 0, tool: 0, total: 0, ...over } +} + +function timeline(nodes: SurfaceNode[], archive: SurfaceNode[]): ContextTimeline { + return { + ok: true, + current: { system: 0, tools: 0, user: 0, inject: 0, assistant: 0, tool: 0, total: 0 }, + toolList: [], + requests: [], + events: [], + nodes, + droppedNodes: 0, + archive, + } +} + +describe('briefNodes', () => { + test('merges live and archived nodes sorted by seq', () => { + const data = timeline([node(5), node(1)], [node(3, { gone: 9 }), node(2, { gone: 9 })]) + assert.deepEqual(briefNodes(data).map(n => n.seq), [1, 2, 3, 5]) + }) +}) + +describe('briefOf index bounds', () => { + test('an out-of-range index yields no brief', () => { + assert.equal(briefOf([], [req(1)], -1), null) + assert.equal(briefOf([], [req(1)], 1), null) + }) +}) + +describe('briefOf response', () => { + test('the assistant node at the request seq is the response', () => { + const nodes = [node(5, { cat: 'assistant', text: 'hi' })] + const brief = briefOf(nodes, [req(5, { turn: 1 })], 0) + assert.equal(brief?.response, nodes[0]) + }) + + test('a non-assistant node at the request seq is not a response', () => { + const brief = briefOf([node(5)], [req(5, { turn: 1 })], 0) + assert.equal(brief?.response, undefined) + }) + + test('no node at the request seq means no response', () => { + const brief = briefOf([node(4)], [req(5, { turn: 1 })], 0) + assert.equal(brief?.response, undefined) + }) +}) + +describe('briefOf opener scan', () => { + test('the opener is the newest user message before the turn starts', () => { + const nodes = [node(1), node(2), node(3, { cat: 'tool' }), node(5, { cat: 'assistant' })] + const brief = briefOf(nodes, [req(5, { turn: 1 })], 0) + assert.equal(brief?.opener, nodes[1]) + }) + + test('no user message in the window means no opener', () => { + const nodes = [node(3, { cat: 'tool' }), node(5, { cat: 'assistant' })] + const brief = briefOf(nodes, [req(5, { turn: 1 })], 0) + assert.equal(brief?.opener, undefined) + }) + + test('the scan stops at the previous turn’s last bar', () => { + const nodes = [node(1), node(3, { cat: 'assistant' }), node(5, { cat: 'tool' }), node(7, { cat: 'assistant' })] + const requests = [req(3, { turn: 1 }), req(7, { turn: 2 })] + const brief = briefOf(nodes, requests, 1) + // The user at seq 1 lies at/below the lower bound (3) and is not found. + assert.equal(brief?.opener, undefined) + }) + + test('a user message inside the window of a later turn is found', () => { + const nodes = [node(3, { cat: 'assistant' }), node(5), node(7, { cat: 'assistant' })] + const requests = [req(3, { turn: 1 }), req(7, { turn: 2 })] + const brief = briefOf(nodes, requests, 1) + assert.equal(brief?.opener, nodes[1]) + }) +}) + +describe('briefOf inputs', () => { + test('a mid-turn step collects the nodes since the previous bar', () => { + const nodes = [node(2), node(3, { cat: 'assistant' }), node(4, { cat: 'tool' }), node(5), node(7, { cat: 'assistant' })] + const requests = [req(3, { turn: 1 }), req(7, { turn: 1 })] + const brief = briefOf(nodes, requests, 1) + assert.deepEqual(brief?.inputs.map(n => n.seq), [4, 5]) + }) + + test('the input scan stops at the request seq even past the node tail', () => { + const nodes = [node(4, { cat: 'tool' }), node(9, { cat: 'tool' })] + const requests = [req(3, { turn: 1 }), req(7, { turn: 1 })] + const brief = briefOf(nodes, requests, 1) + assert.deepEqual(brief?.inputs.map(n => n.seq), [4]) + }) + + test('a turn start has no inputs row', () => { + const nodes = [node(4, { cat: 'tool' }), node(5)] + const requests = [req(3, { turn: 1 }), req(7, { turn: 2 })] + const brief = briefOf(nodes, requests, 1) + assert.deepEqual(brief?.inputs, []) + }) + + test('requests without turn fields share the default turn', () => { + const nodes = [node(4, { cat: 'tool' })] + const requests = [req(3), req(7)] + const brief = briefOf(nodes, requests, 1) + assert.deepEqual(brief?.inputs.map(n => n.seq), [4]) + }) + + test('a missing previous turn field differs from a numbered turn', () => { + const requests = [req(3), req(7, { turn: 2 })] + const brief = briefOf([], requests, 1) + assert.deepEqual(brief?.inputs, []) + }) +}) + +describe('replyTipsOf', () => { + test('an assistant reply previews its text', () => { + const tips = replyTipsOf([node(5, { cat: 'assistant', text: 'hello' })]) + assert.equal(tips.get(5), 'hello') + }) + + test('text wins over the call breadcrumb', () => { + const tips = replyTipsOf([node(5, { cat: 'assistant', text: 'hello', calls: ['bash'] })]) + assert.equal(tips.get(5), 'hello') + }) + + test('a text-less reply previews its joined call breadcrumb', () => { + const tips = replyTipsOf([node(5, { cat: 'assistant', calls: ['bash', 'read'] })]) + assert.equal(tips.get(5), 'bash › read') + }) + + test('a reply with neither text nor calls is skipped', () => { + const tips = replyTipsOf([node(5, { cat: 'assistant' })]) + assert.equal(tips.size, 0) + }) + + test('an empty text and an empty call list are both skipped', () => { + const tips = replyTipsOf([ + node(5, { cat: 'assistant', text: '' }), + node(6, { cat: 'assistant', calls: [] }), + ]) + assert.equal(tips.size, 0) + }) + + test('non-assistant nodes are skipped', () => { + const tips = replyTipsOf([node(5, { text: 'hi' })]) + assert.equal(tips.size, 0) + }) +}) diff --git a/tests/client/callSummary.spec.ts b/tests/client/callSummary.spec.ts new file mode 100644 index 0000000..6c66212 --- /dev/null +++ b/tests/client/callSummary.spec.ts @@ -0,0 +1,125 @@ +// Tool-call summaries (src/client/callSummary.ts): argument JSON parsing, +// the description/file_path/path/filePath key priority, and the +// conversation-block scanners. + +import assert from 'node:assert/strict' +import { describe, test } from 'vitest' +import { blockSummaryOf, callNamesOf, callSummaryOf, parseCallArgs, summaryInArgs } from '../../src/client/callSummary' +import type { ConversationNodeLike } from '../../src/client/services' + +function conv(over: Partial = {}): ConversationNodeLike { + return { kind: 'assistant', seq: 1, ...over } +} + +describe('parseCallArgs', () => { + test('non-string and empty input parses to null', () => { + assert.equal(parseCallArgs(42), null) + assert.equal(parseCallArgs(undefined), null) + assert.equal(parseCallArgs(''), null) + }) + + test('invalid JSON parses to null', () => { + assert.equal(parseCallArgs('{oops'), null) + }) + + test('non-object JSON parses to null', () => { + assert.equal(parseCallArgs('[1,2]'), null) + assert.equal(parseCallArgs('null'), null) + assert.equal(parseCallArgs('"text"'), null) + }) + + test('a JSON object parses to a record', () => { + assert.deepEqual(parseCallArgs('{"description":"d"}'), { description: 'd' }) + }) +}) + +describe('summaryInArgs', () => { + test('null args yield null', () => { + assert.equal(summaryInArgs(null), null) + }) + + test('the keys win in description, file_path, path, filePath order', () => { + assert.equal(summaryInArgs({ description: 'd', file_path: 'f', path: 'p', filePath: 'fp' }), 'd') + assert.equal(summaryInArgs({ file_path: 'f', path: 'p', filePath: 'fp' }), 'f') + assert.equal(summaryInArgs({ path: 'p', filePath: 'fp' }), 'p') + assert.equal(summaryInArgs({ filePath: 'fp' }), 'fp') + }) + + test('empty strings are skipped', () => { + assert.equal(summaryInArgs({ description: '', file_path: 'f' }), 'f') + }) + + test('non-string values are skipped', () => { + assert.equal(summaryInArgs({ description: 42, path: 'p' }), 'p') + }) + + test('no usable key yields null', () => { + assert.equal(summaryInArgs({ foo: 'bar' }), null) + }) +}) + +describe('callSummaryOf', () => { + test('an undefined conversation yields null', () => { + assert.equal(callSummaryOf(undefined), null) + }) + + test('a missing or null call yields null', () => { + assert.equal(callSummaryOf(conv()), null) + assert.equal(callSummaryOf(conv({ call: null })), null) + }) + + test('an unusable call payload yields null', () => { + assert.equal(callSummaryOf(conv({ call: { name: 'bash', argsRaw: '{oops' } })), null) + }) + + test('the call’s args supply the summary', () => { + assert.equal(callSummaryOf(conv({ call: { name: 'edit', argsRaw: '{"file_path":"a.ts"}' } })), 'a.ts') + }) +}) + +describe('blockSummaryOf', () => { + test('an undefined conversation or non-array blocks yield null', () => { + assert.equal(blockSummaryOf(undefined), null) + assert.equal(blockSummaryOf(conv({ blocks: 'junk' as unknown as unknown[] })), null) + }) + + test('null, non-object, and non-tool-call entries are skipped', () => { + const blocks = [null, 42, { kind: 'text' }, { kind: 'tool-call', argsRaw: '{"description":"d"}' }] + assert.equal(blockSummaryOf(conv({ blocks })), 'd') + }) + + test('the first tool-call with a usable summary wins', () => { + const blocks = [ + { kind: 'tool-call', argsRaw: '{oops' }, + { kind: 'tool-call', argsRaw: '{"foo":1}' }, + { kind: 'tool-call', argsRaw: '{"description":"first"}' }, + { kind: 'tool-call', argsRaw: '{"description":"second"}' }, + ] + assert.equal(blockSummaryOf(conv({ blocks })), 'first') + }) + + test('no usable tool-call summary yields null', () => { + const blocks = [{ kind: 'tool-call', argsRaw: '{oops' }, null] + assert.equal(blockSummaryOf(conv({ blocks })), null) + }) +}) + +describe('callNamesOf', () => { + test('an undefined conversation or non-array blocks yield an empty list', () => { + assert.deepEqual(callNamesOf(undefined), []) + assert.deepEqual(callNamesOf(conv({ blocks: 'junk' as unknown as unknown[] })), []) + }) + + test('mixed entries are filtered down to tool-call names in order', () => { + const blocks = [ + null, + 42, + { kind: 'text', name: 'not-a-call' }, + { kind: 'tool-call' }, + { kind: 'tool-call', name: 42 }, + { kind: 'tool-call', name: 'bash' }, + { kind: 'tool-call', name: 'read' }, + ] + assert.deepEqual(callNamesOf(conv({ blocks })), ['bash', 'read']) + }) +}) diff --git a/tests/client/categories.spec.ts b/tests/client/categories.spec.ts new file mode 100644 index 0000000..d86bda8 --- /dev/null +++ b/tests/client/categories.spec.ts @@ -0,0 +1,122 @@ +// Category parts (src/client/categories.ts): the six-bucket parts builder, +// the official contextBreakdown split (rounding residue on the largest +// category, clamped), and the provider-anchored reproportioning. + +import assert from 'node:assert/strict' +import { describe, test } from 'vitest' +import { anchoredParts, officialParts, partsOf, type PartsPart } from '../../src/client/categories' +import type { ContextBreakdown, Snapshot } from '../../src/shared/types' + +function current(over: Partial = {}): Snapshot['current'] { + return { system: 0, tools: 0, user: 0, inject: 0, assistant: 0, tool: 0, total: 0, ...over } +} + +function values(parts: PartsPart[]): Record { + return Object.fromEntries(parts.map(p => [p.key, p.value])) +} + +function part(value: number, raw?: number): PartsPart { + return raw === undefined ? { key: 'k', color: '#000', value } : { key: 'k', color: '#000', value, raw } +} + +describe('partsOf', () => { + test('reads all six categories in CATS order with their colors', () => { + const parts = partsOf(current({ system: 1, tools: 2, user: 3, inject: 4, assistant: 5, tool: 6 })) + assert.deepEqual(parts.map(p => [p.key, p.color, p.value]), [ + ['system', '#6366f1', 1], + ['tools', '#f59e0b', 2], + ['user', '#22c55e', 3], + ['inject', '#a855f7', 4], + ['assistant', '#3b82f6', 5], + ['tool', '#14b8a6', 6], + ]) + }) + + test('missing keys fall back to zero', () => { + const parts = partsOf({ system: 5, user: 10 } as unknown as Snapshot['current']) + assert.deepEqual(values(parts), { system: 5, tools: 0, user: 10, inject: 0, assistant: 0, tool: 0 }) + }) +}) + +describe('officialParts', () => { + test('without a breakdown the fold sums serve as-is', () => { + const cur = current({ system: 5, tools: 7, user: 10, assistant: 30, tool: 60, total: 112 }) + const parts = officialParts(cur, null) + assert.deepEqual(values(parts), { system: 5, tools: 7, user: 10, inject: 0, assistant: 30, tool: 60 }) + }) + + test('a delivered breakdown supplies system/tools/messages figures', () => { + const cur = current({ system: 1, tools: 2, user: 50, assistant: 50, total: 102 }) + const bd: ContextBreakdown = { systemTokens: 100, toolsTokens: 200, messageTokens: 20 } + const parts = officialParts(cur, bd) + assert.deepEqual(values(parts), { system: 100, tools: 200, user: 10, inject: 0, assistant: 10, tool: 0 }) + }) + + test('rounding residue lands on the largest category', () => { + const cur = current({ system: 1, tools: 2, user: 33, assistant: 33, tool: 34, total: 103 }) + const bd: ContextBreakdown = { systemTokens: 0, toolsTokens: 0, messageTokens: 10 } + // Shares round to 3/3/3 (assigned 9); the residue of 1 lands on `tool`. + const parts = officialParts(cur, bd) + assert.deepEqual(values(parts), { system: 0, tools: 0, user: 3, inject: 0, assistant: 3, tool: 4 }) + }) + + test('a negative rounding residue is clamped to zero', () => { + const cur = current({ user: 25, inject: 25, assistant: 25, tool: 25, total: 100 }) + const bd: ContextBreakdown = { systemTokens: 0, toolsTokens: 0, messageTokens: 2 } + // Each share rounds 0.5 up to 1 (assigned 4); the residue of -2 on the + // largest (first-seen, `user`) would go negative without the clamp. + const parts = officialParts(cur, bd) + assert.deepEqual(values(parts), { system: 0, tools: 0, user: 0, inject: 1, assistant: 1, tool: 1 }) + }) + + test('an empty surface yields zero message shares even with official messages', () => { + const bd: ContextBreakdown = { systemTokens: 1, toolsTokens: 2, messageTokens: 100 } + const parts = officialParts(current(), bd) + assert.deepEqual(values(parts), { system: 1, tools: 2, user: 0, inject: 0, assistant: 0, tool: 0 }) + }) + + test('partial breakdown fields fall back to the fold figures', () => { + const cur = current({ system: 1, tools: 2, user: 10, total: 13 }) + const bd = { systemTokens: 9 } as unknown as ContextBreakdown + const parts = officialParts(cur, bd) + assert.deepEqual(values(parts), { system: 9, tools: 2, user: 10, inject: 0, assistant: 0, tool: 0 }) + }) +}) + +describe('anchoredParts', () => { + test('a null target returns the parts unchanged with raw defaulted to value', () => { + const out = anchoredParts([part(100), part(50, 20)], null) + assert.deepEqual(out, [ + { key: 'k', color: '#000', value: 100, raw: 100 }, + { key: 'k', color: '#000', value: 50, raw: 20 }, + ]) + }) + + test('a non-positive target returns the parts unchanged', () => { + const out = anchoredParts([part(100)], 0) + assert.deepEqual(out, [{ key: 'k', color: '#000', value: 100, raw: 100 }]) + }) + + test('a zero raw total returns the parts unchanged', () => { + const out = anchoredParts([part(0), part(0)], 100) + assert.deepEqual(out, [ + { key: 'k', color: '#000', value: 0, raw: 0 }, + { key: 'k', color: '#000', value: 0, raw: 0 }, + ]) + }) + + test('a target equal to the raw total rewrites value to raw', () => { + const out = anchoredParts([part(10, 60), part(90, 40)], 100) + assert.deepEqual(out.map(p => [p.value, p.raw]), [[60, 60], [40, 40]]) + }) + + test('otherwise values scale to the target by the raw ratios', () => { + const out = anchoredParts([part(0, 30), part(0, 10)], 100) + assert.deepEqual(out.map(p => [p.value, p.raw]), [[75, 30], [25, 10]]) + }) + + test('scaling rounds each part to the nearest integer', () => { + const out = anchoredParts([part(0, 20), part(0, 50)], 40) + assert.deepEqual(out.map(p => p.value), [11, 29]) + }) +}) diff --git a/tests/client/command.spec.ts b/tests/client/command.spec.ts new file mode 100644 index 0000000..e89269d --- /dev/null +++ b/tests/client/command.spec.ts @@ -0,0 +1,126 @@ +// The /context slash command (src/client/command.ts): soft service +// dependency, trigger-source registration, candidates, pick, and enter +// adjudication over a faithful harness ctx. Session ids are unique per test +// so the module-level modal/consume state never leaks between cases. + +import assert from 'node:assert/strict' +import { describe, test } from 'vitest' +import { registerContextCommand } from '../../src/client/command' +import { DICT_EN } from '../../src/client/i18n' +import { modalStoreOf, takePendingConsume } from '../../src/client/modalStore' +import type { InputTriggersFace } from '../../src/client/services' +import { asClientCtx, TestClientCtx } from './helpers/harness' +import { makeKit } from './helpers/kit' + +type Source = Parameters[0] + +/** A real in-memory input-trigger face (registerSource records + disposes). */ +class TestInputTriggers { + readonly sources: Source[] = [] + + registerSource(src: Source): () => void { + this.sources.push(src) + return () => { + const i = this.sources.indexOf(src) + if (i >= 0) this.sources.splice(i, 1) + } + } +} + +const SIGNAL = new AbortController().signal + +function setup(): { ctx: TestClientCtx; inputTriggers: TestInputTriggers; source: Source } { + const inputTriggers = new TestInputTriggers() + const ctx = new TestClientCtx({ services: { inputTriggers } }) + registerContextCommand(asClientCtx(ctx), makeKit()) + assert.equal(inputTriggers.sources.length, 1) + return { ctx, inputTriggers, source: inputTriggers.sources[0] } +} + +describe('registerContextCommand', () => { + test('without the input-triggers service nothing registers and disposal is safe', () => { + const ctx = new TestClientCtx() + registerContextCommand(asClientCtx(ctx), makeKit()) + ctx.dispose() + }) + + test('with the service it registers one "/" source named context at order 1', () => { + const { ctx, inputTriggers, source } = setup() + assert.equal(source.trigger, '/') + assert.equal(source.name, 'context') + assert.equal(source.order, 1) + ctx.dispose() + assert.equal(inputTriggers.sources.length, 0) + }) +}) + +describe('candidates', () => { + test('a non-leading position yields no candidates', async () => { + const { source } = setup() + assert.deepEqual(await source.candidates({ sessionId: 'cmd-pos' }, { query: '', position: 'trailing', signal: SIGNAL }), []) + }) + + test('an empty query yields the context candidate', async () => { + const { source } = setup() + const candidates = await source.candidates({ sessionId: 'cmd-empty' }, { query: '', position: 'leading', signal: SIGNAL }) + assert.deepEqual(candidates, [{ name: 'context', description: DICT_EN['cmd.desc'] }]) + }) + + test('a matching prefix yields the candidate; a mismatch yields none', async () => { + const { source } = setup() + const session = { sessionId: 'cmd-prefix' } + const hit = await source.candidates(session, { query: 'con', position: 'leading', signal: SIGNAL }) + assert.equal(hit.length, 1) + assert.equal(hit[0].name, 'context') + assert.deepEqual(await source.candidates(session, { query: 'xyz', position: 'leading', signal: SIGNAL }), []) + }) + + test('the query match is case- and whitespace-insensitive', async () => { + const { source } = setup() + const candidates = await source.candidates({ sessionId: 'cmd-case' }, { query: ' CONTEXT ', position: 'leading', signal: SIGNAL }) + assert.equal(candidates.length, 1) + }) +}) + +describe('onPick', () => { + test('records the span guard, opens the modal, and answers handled', () => { + const { source } = setup() + const span = { start: 0, end: 8, draftRev: 3 } + const outcome = source.onPick({ + candidate: { name: 'context' }, + session: { sessionId: 'cmd-pick' }, + position: 'leading', + via: 'menu', + span, + }) + assert.equal(outcome, 'handled') + assert.equal(modalStoreOf('cmd-pick').getSnapshot(), true) + assert.deepEqual(takePendingConsume('cmd-pick'), { kind: 'span', span }) + }) +}) + +describe('matchEnter', () => { + // The contract marks matchEnter optional; registerContextCommand always + // provides it, and the spec asserts that registration shape once. + test('the bare /context line arms the bare-token guard, opens the modal, and answers handled', async () => { + const { source } = setup() + assert.ok(source.matchEnter, 'the /context source registers matchEnter') + const outcome = await source.matchEnter({ sessionId: 'cmd-enter' }, '/context', SIGNAL) + assert.equal(outcome, 'handled') + assert.equal(modalStoreOf('cmd-enter').getSnapshot(), true) + assert.deepEqual(takePendingConsume('cmd-enter'), { kind: 'bare-token', token: '/context' }) + }) + + test('a /context line with trailing text is not matched', async () => { + const { source } = setup() + assert.ok(source.matchEnter) + assert.equal(await source.matchEnter({ sessionId: 'cmd-extra' }, '/context extra', SIGNAL), undefined) + assert.equal(takePendingConsume('cmd-extra'), undefined) + }) + + test('any other line is not matched', async () => { + const { source } = setup() + assert.ok(source.matchEnter) + assert.equal(await source.matchEnter({ sessionId: 'cmd-other' }, 'hello', SIGNAL), undefined) + }) +}) diff --git a/tests/client/components/browser.spec.ts b/tests/client/components/browser.spec.ts new file mode 100644 index 0000000..73ec327 --- /dev/null +++ b/tests/client/components/browser.spec.ts @@ -0,0 +1,1090 @@ +// ContextBrowser (src/client/components/browser.tsx) rendered with real +// React in jsdom: picker, category accordion, header content (schema +// narrowing matrix), conversation join (block cascade, tail-status matrix), +// history auto-pagination, hover linkage, and the focus bridges. + +import assert from 'node:assert/strict' +import { act } from 'react' +import { describe, test } from 'vitest' +import { h } from '../../../src/client/react' +import { makeContextBrowser, type ContextBrowserProps } from '../../../src/client/components/browser' +import { makeStackedBar } from '../../../src/client/components/stackedBar' +import type { ContextHeaders, ContextTimeline, RequestRecord, SurfaceNode } from '../../../src/shared/types' +import type { ConversationNodeLike, ImageLoader, UseSessionLike } from '../../../src/client/services' +import { click, flush, hover, makeKit, mount, query, queryAll, text, unhover, type Mounted } from '../helpers/kit' + +const kit = makeKit() +const Browser = makeContextBrowser(kit, makeStackedBar(kit)) + +// Category row order is CATS: system, tools, user, inject, assistant, tool. +const ROW = { system: 0, tools: 1, user: 2, inject: 3, assistant: 4, tool: 5 } as const + +function tl(over: Partial): ContextTimeline { + return { + ok: true, + current: { system: 0, tools: 0, user: 0, inject: 0, assistant: 0, tool: 0, total: 0 }, + toolList: [], requests: [], events: [], nodes: [], droppedNodes: 0, archive: [], + ...over, + } +} + +function req(over: Partial): RequestRecord { + return { + time: 1_000, seq: 1, turn: 1, step: 0, + system: 0, tools: 0, user: 0, inject: 0, assistant: 0, tool: 0, total: 0, + ...over, + } +} + +function node(over: Partial & { seq: number }): SurfaceNode { + return { cat: 'user', tokens: 5, ...over } +} + +/** A REAL tiny selector over an in-memory snapshot — the documented UseSessionLike contract. */ +function sess(snap: { nodes?: readonly ConversationNodeLike[]; hasMore?: boolean; loadingOlder?: boolean }): UseSessionLike { + return (sel) => sel(snap) +} + +interface Snap { nodes?: readonly ConversationNodeLike[]; hasMore?: boolean; loadingOlder?: boolean } + +/** Same contract, but re-reads the snapshot on every render (pagination tests mutate it). */ +function liveSess(get: () => Snap): UseSessionLike { + return (sel) => sel(get()) +} + +function catRow(m: Mounted, cat: keyof typeof ROW): HTMLElement { + return queryAll(m.container, '.lc-br-cat-row')[ROW[cat]] +} + +function elemRows(m: Mounted): HTMLElement[] { + return queryAll(m.container, '.lc-br-elem-row') +} + +async function pickStep(m: Mounted, value: string): Promise { + const sel = query(m.container, 'select.lc-br-pick') + await act(async () => { + sel.value = value + sel.dispatchEvent(new Event('change', { bubbles: true })) + }) +} + +function props(over: Partial): ContextBrowserProps { + return { data: tl({}), headers: null, ...over } +} + +describe('ContextBrowser live surface', () => { + test('title, picker, live meta, category rows; empty categories stay shut', async () => { + const data = tl({ + current: { system: 100, tools: 200, user: 50, inject: 0, assistant: 0, tool: 0, total: 350 }, + requests: [ + req({ seq: 10, turn: 1, step: 0 }), + req({ seq: 20, turn: 1, step: 1, prompt: 800 }), + req({ seq: 7, time: 500, turn: undefined, step: undefined }), + ], + nodes: [node({ seq: 1, text: 'hello' })], + }) + const m = await mount(h(Browser, props({ data }))) + assert.ok(text(query(m.container, '.lc-card-title-text')).includes('Context Browser')) + assert.ok(text(query(m.container, '.lc-br-hint')).includes('vs previous turn')) + const sel = query(m.container, 'select.lc-br-pick') + assert.equal(sel.value, 'live') + const options = queryAll(sel, 'option').map(o => text(o)) + assert.equal(options.length, 4, 'live + one option per request') + assert.equal(options[0], 'Live (Next Request)') + assert.ok(options.some(o => o.includes('Turn 0 · Step 0')), 'requests without turn/step degrade to zeroes') + const meta = text(query(m.container, '.lc-br-meta')) + assert.ok(meta.includes('Live · Next Request')) + assert.ok(meta.includes('Estimated ≈ 350')) + assert.ok(!meta.includes('Actual Prompt'), 'no actual figure on the live surface') + assert.equal(queryAll(m.container, '.lc-br-cat-row').length, 6) + assert.ok(text(catRow(m, 'user')).includes('1 Items')) + assert.ok(queryAll(m.container, '.lc-br-cat')[ROW.inject].className.includes('lc-br-cat-empty'), 'empty category is marked') + // Picking the stamp-less request: meta degrades to zeroes, no baseline exists. + await pickStep(m, '7') + const meta2 = text(query(m.container, '.lc-br-meta')) + assert.ok(meta2.includes('Turn 0 · Step 0')) + assert.equal(queryAll(m.container, '.lc-br-delta').length, 0, 'no previous turn to compare against') + await pickStep(m, 'live') + await click(catRow(m, 'inject')) + assert.equal(queryAll(m.container, '.lc-br-body').length, 0) + assert.ok(queryAll(m.container, '.lc-br-pct').some(el => text(el).endsWith('%'))) + await m.unmount() + }) + + test('zero-total surface renders no percentages; no requests means no delta pills', async () => { + const m = await mount(h(Browser, props({ data: tl({}) }))) + assert.ok(queryAll(m.container, '.lc-br-pct').every(el => text(el) === '')) + assert.equal(queryAll(m.container, '.lc-br-delta').length, 0) + assert.equal(queryAll(m.container, '.lc-br-tdelta').length, 0) + assert.ok(text(query(m.container, '.lc-br-meta')).includes('Estimated ≈ 0')) + await m.unmount() + }) + + test('dropped live nodes raise the missing-window note, live and on later steps only', async () => { + const data = tl({ + current: { system: 0, tools: 0, user: 10, inject: 0, assistant: 0, tool: 0, total: 10 }, + requests: [req({ seq: 3, turn: 1, step: 0 }), req({ seq: 10, turn: 1, step: 1 })], + nodes: [node({ seq: 6, text: 'served' })], + droppedNodes: 2, + surfaceFloor: 5, + archiveFloor: 8, + }) + const m = await mount(h(Browser, props({ data }))) + assert.ok(text(m.container).includes('2 earlier messages are also part of the context'), 'live flags the dropped tail') + // Step before the floor: the dropped slice is not attributable → no note. + await pickStep(m, '3') + assert.ok(!text(m.container).includes('earlier messages are also part')) + assert.ok(text(m.container).includes('approximate'), 'seq below the archive floor is approximate') + // Step after the floor: the dropped slice is inside the context. + await pickStep(m, '10') + assert.ok(text(m.container).includes('2 earlier messages are also part of the context')) + assert.ok(!text(m.container).includes('approximate'), 'seq at/after the archive floor is exact') + await m.unmount() + }) + + test('step picking switches the assembled view; back-to-live restores the surface', async () => { + const data = tl({ + current: { system: 0, tools: 0, user: 30, inject: 0, assistant: 10, tool: 0, total: 40 }, + requests: [req({ seq: 10, turn: 1, step: 0, user: 25 }), req({ seq: 20, turn: 1, step: 1, user: 30, assistant: 10, prompt: 800 })], + nodes: [node({ seq: 1, text: 'first question' }), node({ seq: 11, cat: 'assistant', tokens: 10, text: 'first answer' })], + archive: [node({ seq: 0, tokens: 5, text: 'archived hello', gone: 15 })], + }) + const m = await mount(h(Browser, props({ data }))) + await pickStep(m, '10') + const meta = text(query(m.container, '.lc-br-meta')) + assert.ok(meta.includes('Turn 1 · Step 0')) + assert.ok(!meta.includes('Actual Prompt'), 'this step reported no usage') + // The step's surface: archived node (gone 15 > 10) + seq 1; seq 11 is the response. + await click(catRow(m, 'user')) + assert.equal(elemRows(m).length, 2, 'archived + live nodes reconstructed') + assert.ok(text(m.container).includes('archived hello')) + await pickStep(m, '20') + const meta2 = text(query(m.container, '.lc-br-meta')) + assert.ok(meta2.includes('Turn 1 · Step 1')) + assert.ok(meta2.includes('Actual Prompt 800')) + assert.equal(queryAll(m.container, '.lc-br-body').length, 0, 'picking a step collapses the accordion') + await pickStep(m, 'live') + await click(catRow(m, 'user')) + assert.equal(elemRows(m).length, 1, 'live excludes removed nodes') + assert.ok(!text(m.container).includes('archived hello')) + await m.unmount() + }) + + test('delta pills read against the previous turn’s last step; live reads the last request', async () => { + const data = tl({ + current: { system: 1, tools: 2, user: 99, inject: 0, assistant: 0, tool: 0, total: 102 }, + requests: [ + req({ seq: 1, turn: 1, step: 0, system: 1, tools: 2, user: 10, total: 13 }), + req({ seq: 2, turn: 1, step: 1, system: 1, tools: 2, user: 20, total: 23 }), + req({ seq: 3, turn: 2, step: 0, system: 1, tools: 2, user: 30, total: 33 }), + req({ seq: 4, turn: 2, step: 1, system: 1, tools: 2, user: 40, total: 43 }), + req({ seq: 5, turn: 3, step: 0, system: 1, tools: 2, user: 50, total: 53 }), + ], + nodes: [1, 2, 3, 4, 5].map(seq => node({ seq, tokens: seq })), + }) + const m = await mount(h(Browser, props({ data }))) + await pickStep(m, '4') + const countPills = queryAll(m.container, '.lc-br-delta') + assert.equal(countPills.length, 1, 'only the user count changed') + assert.equal(text(countPills[0]), '+2') + assert.ok(countPills[0].className.includes('lc-br-delta-up')) + const tokenPills = queryAll(m.container, '.lc-br-tdelta') + assert.equal(tokenPills.length, 1) + assert.equal(text(tokenPills[0]), '+20') + // First turn: no previous-turn baseline → no pills at all. + await pickStep(m, '1') + assert.equal(queryAll(m.container, '.lc-br-delta').length, 0) + assert.equal(queryAll(m.container, '.lc-br-tdelta').length, 0) + // Live: baseline is the most recent request. + await pickStep(m, 'live') + assert.equal(text(query(m.container, '.lc-br-delta')), '+1') + assert.equal(text(query(m.container, '.lc-br-tdelta')), '+49') + await m.unmount() + }) + + test('shrinking categories render downward pills', async () => { + const data = tl({ + current: { system: 0, tools: 0, user: 15, inject: 0, assistant: 0, tool: 0, total: 15 }, + requests: [ + req({ seq: 2, turn: 1, step: 1, user: 25, total: 25 }), + req({ seq: 3, turn: 2, step: 0, user: 15, total: 15 }), + ], + nodes: [node({ seq: 1, tokens: 10, text: 'kept' })], + archive: [node({ seq: 0, tokens: 15, text: 'pruned away', gone: 3 })], + }) + const m = await mount(h(Browser, props({ data }))) + await pickStep(m, '3') + const countPill = query(m.container, '.lc-br-delta') + assert.equal(text(countPill), '-1', 'the archived node left the step’s surface') + assert.ok(countPill.className.includes('lc-br-delta-down')) + const tokenPill = query(m.container, '.lc-br-tdelta') + assert.equal(text(tokenPill), '-10') + assert.ok(tokenPill.className.includes('lc-br-tdelta-down')) + await m.unmount() + }) + + test('hover linkage: category rows report hovers and mirror the shared key while live', async () => { + const data = tl({ + current: { system: 0, tools: 0, user: 10, inject: 0, assistant: 0, tool: 0, total: 10 }, + requests: [req({ seq: 10, turn: 1, step: 0, user: 10, total: 10 })], + nodes: [node({ seq: 1, text: 'hi' })], + }) + const hovers: (string | null)[] = [] + const el = h(Browser, props({ data, hoverKey: null, onHoverKey: (k) => { hovers.push(k) } })) + const m = await mount(el) + await hover(catRow(m, 'user')) + await unhover(catRow(m, 'user')) + assert.deepEqual(hovers, ['user', null]) + // Mirrored hover lights the row; the 'free' key has no category row. + await m.update(h(Browser, props({ data, hoverKey: 'user', onHoverKey: (k) => { hovers.push(k) } }))) + assert.ok(catRow(m, 'user').className.includes('lc-br-cat-on')) + await m.update(h(Browser, props({ data, hoverKey: 'free', onHoverKey: (k) => { hovers.push(k) } }))) + assert.ok(!catRow(m, 'user').className.includes('lc-br-cat-on')) + // A picked step has a different composition: the linkage drops. + await pickStep(m, '10') + const before = hovers.length + await hover(catRow(m, 'user')) + assert.equal(hovers.length, before, 'no hover reporting while a step is shown') + assert.ok(!catRow(m, 'user').className.includes('lc-br-cat-on')) + await m.unmount() + + // No hoverKey wiring at all: hovering is a no-op. + const m2 = await mount(h(Browser, props({ data }))) + await hover(catRow(m2, 'user')) + await m2.unmount() + }) + + test('previewSeq transiently shows a step; unknown preview/pin seqs fall back to live', async () => { + const data = tl({ + current: { system: 0, tools: 0, user: 10, inject: 0, assistant: 0, tool: 0, total: 10 }, + requests: [req({ seq: 10, turn: 1, step: 0, user: 10, total: 10, prompt: 700 })], + nodes: [node({ seq: 1, text: 'hi' })], + }) + const m = await mount(h(Browser, props({ data, previewSeq: 10 }))) + const meta = text(query(m.container, '.lc-br-meta')) + assert.ok(meta.includes('Turn 1 · Step 0')) + assert.ok(meta.includes('Preview')) + assert.ok(meta.includes('Actual Prompt 700')) + // Unknown preview seq: no request matches → live surface. + await m.update(h(Browser, props({ data, previewSeq: 999 }))) + assert.ok(text(query(m.container, '.lc-br-meta')).includes('Live · Next Request')) + // A pinned step trimmed out of retention falls back to live too. + await m.update(h(Browser, props({ data, previewSeq: null, pinSeq: 999 }))) + assert.ok(text(query(m.container, '.lc-br-meta')).includes('Live · Next Request')) + assert.equal(query(m.container, 'select.lc-br-pick').value, 'live') + await m.unmount() + }) +}) + +describe('ContextBrowser header epochs', () => { + const HEADERS: ContextHeaders = { + headers: [ + { seq: 15, time: 1500, system: 'SYS A', tools: [{ name: 'old', tokens: 1 }] }, + { seq: 35, time: 3500, system: 'SYS B\nsecond line', tools: [{ name: 'fresh', tokens: 9, description: 'fresh tool' }] }, + ], + } + + test('system category opens its prompt row directly; rich switch toggles raw/markdown', async () => { + const data = tl({ current: { system: 30, tools: 9, user: 0, inject: 0, assistant: 0, tool: 0, total: 39 } }) + const m = await mount(h(Browser, props({ data, headers: HEADERS }))) + await click(catRow(m, 'system')) + const body = query(m.container, '.lc-br-body') + assert.ok(text(body).includes('SYS B'), 'the newest epoch’s prompt shows on the live surface') + assert.ok(text(body).includes('2 lines'), 'line count rides the section head') + // The single system row is already expanded. + assert.equal(queryAll(m.container, '.lc-br-content').length, 1) + assert.ok(queryAll(m.container, '.lc-ts-desc-md').length >= 1, 'markdown view by default') + const rawBtn = queryAll(m.container, '.lc-rich-seg-btn')[0] + assert.equal(text(rawBtn), 'Raw') + await click(rawBtn) + assert.ok(text(query(m.container, '.lc-ts-desc-body')).includes('SYS B\nsecond line')) + await click(queryAll(m.container, '.lc-rich-seg-btn')[1]) + assert.ok(queryAll(m.container, '.lc-ts-desc-md').length >= 1, 'markdown restored') + await click(catRow(m, 'system')) + assert.equal(queryAll(m.container, '.lc-br-body').length, 0) + await m.unmount() + }) + + test('a past step reads the epoch in force at its seq', async () => { + const data = tl({ + current: { system: 30, tools: 9, user: 0, inject: 0, assistant: 0, tool: 0, total: 39 }, + requests: [req({ seq: 20, turn: 1, step: 0 }), req({ seq: 40, turn: 1, step: 1 })], + }) + const m = await mount(h(Browser, props({ data, headers: HEADERS }))) + await pickStep(m, '20') + await click(catRow(m, 'system')) + assert.ok(text(query(m.container, '.lc-br-body')).includes('SYS A'), 'epoch before the request applies') + await m.unmount() + }) + + test('absent headers projection degrades to the tokens-only note', async () => { + const data = tl({ current: { system: 30, tools: 9, user: 0, inject: 0, assistant: 0, tool: 0, total: 39 } }) + const m = await mount(h(Browser, props({ data, headers: null }))) + await click(catRow(m, 'system')) + assert.ok(text(query(m.container, '.lc-br-body')).includes('older plugin build')) + await click(catRow(m, 'tools')) + assert.ok(text(queryAll(m.container, '.lc-br-body')[0]).includes('older plugin build')) + await m.unmount() + }) + + test('an epoch outside retention degrades to the no-epoch note', async () => { + const data = tl({ + current: { system: 30, tools: 9, user: 0, inject: 0, assistant: 0, tool: 0, total: 39 }, + requests: [req({ seq: 10, turn: 1, step: 0 })], + }) + const m = await mount(h(Browser, props({ data, headers: HEADERS }))) + await pickStep(m, '10') + await click(catRow(m, 'system')) + assert.ok(text(query(m.container, '.lc-br-body')).includes('outside retention')) + await click(catRow(m, 'tools')) + assert.ok(text(queryAll(m.container, '.lc-br-body')[0]).includes('outside retention')) + await m.unmount() + }) + + test('an epoch without a system prompt keeps the system category shut', async () => { + const headers: ContextHeaders = { headers: [{ seq: 1, time: 1, tools: [{ name: 'x', tokens: 1 }] }] } + const data = tl({ current: { system: 0, tools: 1, user: 0, inject: 0, assistant: 0, tool: 0, total: 1 } }) + const m = await mount(h(Browser, props({ data, headers }))) + assert.ok(text(catRow(m, 'system')).includes('0 Items')) + await click(catRow(m, 'system')) + assert.equal(queryAll(m.container, '.lc-br-body').length, 0, 'no prompt, no body') + await m.unmount() + }) +}) + +describe('ContextBrowser tool schemas', () => { + const headers: ContextHeaders = { + headers: [{ + seq: 1, time: 1, system: 'SYS', + tools: [ + // Producer order is NOT meaningful: rows re-rank by token price. + { name: 'omega', tokens: 5 }, + { name: 'rho', tokens: 6, schema: 'nope' }, + { name: 'theta', tokens: 8, schema: { type: 'object', properties: null } }, + { name: 'zeta', tokens: 10, schema: { type: 'object' } }, + { name: 'epsilon', tokens: 20, schema: { type: 'object', properties: { z: { type: 'integer' } } } }, + { name: 'delta', tokens: 30, schema: { parameters: 'junk', input_schema: { properties: { y: { type: 'integer' } } } } }, + { name: 'gamma', tokens: 40, schema: { inputSchema: { properties: 'nope' } } }, + { name: 'beta', tokens: 50, schema: { input_schema: { properties: { x: { type: 'string' } } } } }, + { + name: 'mega', tokens: 100, description: 'does everything', + schema: { + name: 'mega', + parameters: { + type: 'object', + properties: { + a: { type: 'string', description: 'the a param' }, + b: { type: 'object', properties: { x: {}, y: {} } }, + c: { type: 'array', items: { type: 'number' } }, + d: { type: 'array' }, + d2: { type: 'array', items: null }, + e: { type: 'string', enum: ['x', 'y'] }, + e2: { type: 'string', enum: [] }, + f: { enum: [1, 2] }, + g: { anyOf: [{ type: 'string' }, { type: 'number' }] }, + h: { oneOf: [{ type: 'boolean' }] }, + i: { anyOf: [null, { type: 'string' }] }, + j: { anyOf: [] }, + k: { anyOf: [42, null] }, + l: { type: 'object', properties: {} }, + m: { type: 'object', properties: null }, + n: { type: 'object', properties: 42 }, + o: 'not-an-object', + p: { description: 42 }, + q: { description: '' }, + }, + required: ['a', 42], + }, + }, + }, + ], + }], + } + const data = tl({ current: { system: 10, tools: 248, user: 0, inject: 0, assistant: 0, tool: 0, total: 258 } }) + + test('rows rank by token price; the schema narrowing matrix renders', async () => { + const m = await mount(h(Browser, props({ data, headers }))) + await click(catRow(m, 'tools')) + const rows = elemRows(m) + assert.equal(rows.length, 9) + assert.deepEqual(rows.map(r => text(query(r, '.lc-br-preview'))), + ['mega', 'beta', 'gamma', 'delta', 'epsilon', 'zeta', 'theta', 'rho', 'omega']) + + // mega: the full parameter matrix. + await click(rows[0]) + const content = query(m.container, '.lc-br-content') + assert.ok(text(content).includes('does everything'), 'description section renders') + const paramRows = queryAll(content, '.lc-ts-param-row') + assert.equal(paramRows.length, 18, 'one row per object-valued property') + const byName = (n: string) => paramRows.find(r => text(query(r, '.lc-ts-param-name')) === n) + const typeOf = (n: string) => text(query(byName(n) as HTMLElement, '.lc-ts-param-type')) + assert.equal(typeOf('a'), 'string') + assert.equal(typeOf('b'), 'object{2}') + assert.equal(typeOf('c'), 'array') + assert.equal(typeOf('d'), 'array') + assert.equal(typeOf('d2'), 'array') + assert.equal(typeOf('e'), 'string (enum)') + assert.equal(typeOf('e2'), 'string') + assert.equal(typeOf('f'), '(enum)') + assert.equal(typeOf('g'), 'string | number') + assert.equal(typeOf('h'), 'boolean') + assert.equal(typeOf('i'), 'string') + assert.equal(typeOf('j'), 'unknown') + assert.equal(typeOf('k'), 'unknown') + assert.equal(typeOf('l'), 'object') + assert.equal(typeOf('m'), 'object') + assert.equal(typeOf('n'), 'object') + assert.equal(typeOf('p'), 'unknown') + assert.equal(text(query(byName('a') as HTMLElement, '.lc-ts-param-req')), '✓') + assert.equal(text(query(byName('b') as HTMLElement, '.lc-ts-param-req-off')), '·') + assert.ok(text(byName('a') as HTMLElement).includes('the a param')) + assert.equal(queryAll(byName('p') as HTMLElement, '.lc-ts-param-desc').length, 0, 'non-string description hidden') + assert.equal(queryAll(byName('q') as HTMLElement, '.lc-ts-param-desc').length, 0, 'empty description hidden') + assert.ok(text(content).includes('Parameters')) + + const toggle = query(content, '.lc-ts-json-toggle') + assert.ok(text(toggle).includes('View Raw JSON')) + await click(toggle) + assert.ok(text(query(content, '.lc-ts-desc-body')).includes('"parameters"')) + assert.ok(text(query(content, '.lc-ts-json-toggle')).includes('Collapse')) + await click(query(content, '.lc-ts-json-toggle')) + assert.equal(queryAll(content, 'pre').length, 0) + await click(elemRows(m)[0]) + assert.equal(queryAll(m.container, '.lc-br-content').length, 0) + await m.unmount() + }) + + test('schema nesting variants and the empty/degenerate arms', async () => { + const m = await mount(h(Browser, props({ data, headers }))) + await click(catRow(m, 'tools')) + const rows = elemRows(m) + const open = async (name: string) => { + const row = rows.find(r => text(query(r, '.lc-br-preview')) === name) as HTMLElement + await click(row) + return query(m.container, '.lc-br-content') + } + // beta: input_schema nesting, no description. + let body = await open('beta') + assert.equal(queryAll(body, '.lc-ts-param-row').length, 1) + assert.ok(!text(body).includes('Description'), 'no description section without one') + await click(elemRows(m).find(r => text(query(r, '.lc-br-preview')) === 'beta') as HTMLElement) + + // gamma: inputSchema nesting with non-object properties → params empty. + body = await open('gamma') + assert.ok(text(body).includes('(no parameters)')) + await click(elemRows(m).find(r => text(query(r, '.lc-br-preview')) === 'gamma') as HTMLElement) + + // delta: a non-object `parameters` falls through to input_schema. + body = await open('delta') + assert.equal(queryAll(body, '.lc-ts-param-row').length, 1) + await click(elemRows(m).find(r => text(query(r, '.lc-br-preview')) === 'delta') as HTMLElement) + + // epsilon: bare-root object schema is itself the parameter object. + body = await open('epsilon') + assert.equal(queryAll(body, '.lc-ts-param-row').length, 1) + assert.ok(text(body).includes('integer')) + await click(elemRows(m).find(r => text(query(r, '.lc-br-preview')) === 'epsilon') as HTMLElement) + + // zeta: object type without properties → no params section at all. + body = await open('zeta') + assert.equal(queryAll(body, '.lc-ts-param-row').length, 0) + assert.ok(!text(body).includes('(no parameters)')) + assert.ok(queryAll(body, '.lc-ts-json-toggle').length === 1, 'raw JSON still available') + await click(elemRows(m).find(r => text(query(r, '.lc-br-preview')) === 'zeta') as HTMLElement) + + // theta: null properties counts as a (bare) params object with no rows. + body = await open('theta') + assert.ok(text(body).includes('(no parameters)')) + await click(elemRows(m).find(r => text(query(r, '.lc-br-preview')) === 'theta') as HTMLElement) + + // rho: non-object schema → no params, JSON toggle shows the literal. + body = await open('rho') + assert.equal(queryAll(body, '.lc-ts-param-row').length, 0) + await click(query(body, '.lc-ts-json-toggle')) + assert.ok(text(query(body, 'pre')).includes('"nope"')) + await click(elemRows(m).find(r => text(query(r, '.lc-br-preview')) === 'rho') as HTMLElement) + + // omega: no schema and no description → nothing behind the row. + body = await open('omega') + assert.equal(queryAll(body, '.lc-ts-json-toggle').length, 0) + assert.equal(text(body), '') + await m.unmount() + }) +}) + +describe('ContextBrowser message categories', () => { + // One rich live surface exercising every NodeContent/BlocksBody branch. + const convNodes: ConversationNodeLike[] = [ + // user with two images then text (image group flush mid-loop + trailing) + { kind: 'user', seq: 1, content: [ + { type: 'image', attachment: { attachmentId: 'a1', name: 'one.png', bytes: 2048, width: 100, height: 50 } }, + { type: 'image', attachment: { attachmentId: 'a2' } }, + { type: 'text', text: 'with images' }, + ] }, + // user with text then one image + { kind: 'user', seq: 2, content: [ + { type: 'text', text: 'one pic' }, + { kind: 'image', attachment: { attachmentId: 'a3' } }, + ] }, + // user without a content array → hint fallback + { kind: 'user', seq: 5 }, + // assistant full block cascade + { kind: 'assistant', seq: 68, blocks: [ + { kind: 'text', text: 'final answer' }, + { type: 'reasoning', text: 'thinking hard' }, + { kind: 'tool-call', name: 'bash', argsRaw: '{"command":"ls","timeout":30,"opts":{"a":1}}' }, + { kind: 'tool-call', name: 'broken', argsRaw: 'not json' }, + { type: 'tool-call', arguments: '{"file_path":"x.ts"}' }, + { kind: 'tool-call', name: 'noargs' }, + { type: 'tool-result', content: [ + { type: 'text', text: 'inner result' }, + { type: 'image', attachment: { attachmentId: 'b1' } }, + ] }, + { type: 'tool-result', content: 'not-an-array' }, + { kind: 'image', attachment: { attachmentId: 'b2', name: 'pic.png', bytes: 4096, width: 640, height: 480 } }, + { type: 'mystery', foo: 1 }, + { type: 'text', text: 42 }, + 'plain string block', + { foo: 'bar' }, + ] }, + // assistant without blocks but with content (legacy shape) + { kind: 'assistant', seq: 67, content: [{ type: 'text', text: 'legacy body' }] }, + // assistant summary sources + { kind: 'assistant', seq: 62, blocks: [{ kind: 'tool-call', name: 'write', argsRaw: '{"file_path":"a.ts"}' }] }, + { kind: 'assistant', seq: 63, blocks: [{ kind: 'tool-call', name: 'read', argsRaw: '{}' }] }, + { kind: 'assistant', seq: 64, blocks: [{ kind: 'tool-call', name: 'edit', argsRaw: '{"path":"b.ts"}' }] }, + // compaction nodes + { kind: 'compaction', seq: 70, summary: 'SUMMARY BODY' }, + { kind: 'compaction', seq: 71, summary: null }, + { kind: 'compaction', seq: 72, summary: '' }, + // inject join + { kind: 'user', seq: 54, content: [{ type: 'text', text: 'relay body full' }] }, + ] + + const data = tl({ + current: { system: 0, tools: 0, user: 40, inject: 20, assistant: 60, tool: 30, total: 150 }, + nodes: [ + node({ seq: 1, tokens: 10, text: 'with images', time: 100 }), + node({ seq: 2, tokens: 9, text: 'one pic', time: 200 }), + node({ seq: 3, tokens: 5, text: 'fallback text', time: 300 }), + node({ seq: 4, tokens: 4, text: '', time: 400 }), + node({ seq: 5, tokens: 4, text: 'no content array', time: 500 }), + node({ seq: 50, cat: 'inject', tokens: 5, form: 'snapshot', text: 'state' }), + node({ seq: 51, cat: 'inject', tokens: 5, form: 'notice', text: 'heads up' }), + node({ seq: 52, cat: 'inject', tokens: 5, skill: 'code-review', text: 'skill body' }), + node({ seq: 53, cat: 'inject', tokens: 5, form: 'catalog' }), + node({ seq: 55, cat: 'inject', tokens: 5, form: 'notice', text: '' }), + node({ seq: 56, cat: 'inject', tokens: 5, text: 'no form' }), + node({ seq: 54, cat: 'inject', tokens: 5, form: 'relay', text: 'relay body' }), + node({ seq: 61, cat: 'assistant', tokens: 8, calls: ['bash', 'write'], text: 'done all' }), + node({ seq: 62, cat: 'assistant', tokens: 8, calls: ['write'] }), + node({ seq: 63, cat: 'assistant', tokens: 8, calls: ['read'] }), + node({ seq: 64, cat: 'assistant', tokens: 8 }), + node({ seq: 65, cat: 'assistant', tokens: 8 }), + node({ seq: 66, cat: 'assistant', tokens: 8, calls: [], text: 'plain text' }), + node({ seq: 67, cat: 'assistant', tokens: 8, text: 'legacy' }), + node({ seq: 68, cat: 'assistant', tokens: 20, text: 'full cascade' }), + node({ seq: 70, tokens: 6, text: 'summary node' }), + node({ seq: 71, tokens: 6, text: '' }), + node({ seq: 72, tokens: 6, text: 'empty summary' }), + ], + }) + + const loadImage: ImageLoader = async (att) => { + if (att.attachmentId === 'a3' || att.attachmentId === 'b1') throw new Error('denied') + return 'blob:' + att.attachmentId + } + + const mountBrowser = async (over: Partial = {}) => + mount(h(Browser, props({ data, useSession: sess({ nodes: convNodes }), loadImage, ...over }))) + + test('user rows: image chips on collapsed rows, join content, fallbacks', async () => { + const m = await mountBrowser() + await click(catRow(m, 'user')) + const rows = elemRows(m) + assert.equal(rows.length, 8, 'user + compaction nodes, newest first') + assert.equal(text(query(rows[0], '.lc-br-preview')), 'empty summary') + // Image chips ride the collapsed row (seq 1 ×2, seq 2 ×1). + const rowOf = (preview: string) => rows.find(r => text(r).includes(preview)) as HTMLElement + assert.ok(text(rowOf('with images')).includes('Image ×2')) + assert.ok(text(rowOf('one pic')).includes('Image')) + assert.ok(!text(rowOf('one pic')).includes('×')) + // Open seq 1: the chip makes way for the grid; loader resolves two cards. + await click(rowOf('with images')) + await flush() + let content = query(m.container, '.lc-br-content') + assert.ok(!text(elemRows(m).find(r => text(r).includes('with images')) as HTMLElement).includes('Image ×2'), + 'expanded row drops the chip') + assert.ok(text(content).includes('Images')) + const imgs = queryAll(content, '.lc-att-thumb img') + assert.equal(imgs.length, 2, 'both loads resolved') + assert.ok(imgs[0].src.includes('blob:a1')) + assert.ok(text(content).includes('one.png')) + assert.ok(text(content).includes('100×50')) + await click(elemRows(m).find(r => text(r).includes('with images')) as HTMLElement) + + // Open seq 2: single image, load REJECTS → error placeholder. + await click(rowOf('one pic')) + await flush() + content = query(m.container, '.lc-br-content') + assert.equal(queryAll(content, '.lc-att-err').length, 1) + assert.ok(text(query(content, '.lc-att-err')).includes('⚠')) + await click(elemRows(m).find(r => text(r).includes('one pic')) as HTMLElement) + + // Join missed with preview text: content section + the window note. + await click(rowOf('fallback text')) + content = query(m.container, '.lc-br-content') + assert.ok(text(content).includes('fallback text')) + assert.ok(text(content).includes('outside the loaded message window')) + await click(elemRows(m).find(r => text(r).includes('fallback text')) as HTMLElement) + + // Join missed without text (seq 4, rows newest-first: 72,71,70,5,4,…): the note alone. + assert.ok(text(rows[4]).includes('(non-text message)')) + await click(rows[4]) + content = query(m.container, '.lc-br-content') + assert.ok(text(content).includes('outside the loaded message window')) + assert.ok(!text(content).includes('Content')) + await m.unmount() + }) + + test('user node whose conversation entry carries no content array shows the note', async () => { + const m = await mountBrowser() + await click(catRow(m, 'user')) + await click(elemRows(m).find(r => text(r).includes('no content array')) as HTMLElement) + const content = query(m.container, '.lc-br-content') + assert.ok(text(content).includes('outside the loaded message window')) + await m.unmount() + }) + + test('user images render a placeholder when no loader is wired', async () => { + const m = await mountBrowser({ loadImage: undefined }) + await click(catRow(m, 'user')) + await click(elemRows(m).find(r => text(r).includes('one pic')) as HTMLElement) + assert.equal(queryAll(m.container, '.lc-att-ph').length, 1) + await m.unmount() + }) + + test('assistant rows: preview cascade and the full block vocabulary', async () => { + const m = await mountBrowser() + await click(catRow(m, 'assistant')) + const rows = elemRows(m) + const rowOf = (preview: string) => rows.find(r => text(r).includes(preview)) as HTMLElement + assert.ok(text(rowOf('done all')).includes('bash › write'), 'call breadcrumb tag') + assert.ok(text(rowOf('a.ts')).includes('write'), 'block summary previews a textless turn') + const tags = rows.map(r => { + const tag = r.querySelector('.lc-br-tag') + const preview = text(query(r, '.lc-br-preview')) + return `${tag === null ? '∅' : text(tag)}|${preview}` + }) + assert.ok(tags.includes('read|(empty reply)'), 'no self-summarizing call → empty marker') + assert.ok(tags.includes('∅|b.ts'), 'textless turn previews the joined call summary') + assert.ok(tags.includes('∅|(empty reply)'), 'no join, no calls → empty marker') + assert.ok(tags.includes('∅|Calls '), 'empty call list previews as a bare Calls label (nodeText)') + + await click(rowOf('full cascade')) + const content = query(m.container, '.lc-br-content') + const heads = queryAll(content, '.lc-ts-card-head').map(el => text(el)) + assert.ok(heads.some(s => s.includes('Response'))) + assert.ok(heads.some(s => s.includes('Reasoning'))) + assert.ok(heads.some(s => s.includes('→ bash'))) + assert.ok(heads.some(s => s.includes('→ broken'))) + assert.ok(heads.some(s => s.includes('→ ?')), 'nameless call card') + assert.ok(heads.some(s => s.includes('→ noargs'))) + assert.ok(heads.some(s => s.includes('Result')), 'nested tool-result text section') + assert.ok(heads.filter(s => s.includes('Other content')).length === 5, 'unknown blocks render raw JSON') + assert.ok(heads.some(s => s.includes('Images'))) + // Call arg rows: string, number and object values. + const argVals = queryAll(content, '.lc-ts-arg-row').map(el => text(el)) + assert.ok(argVals.some(s => s.includes('command') && s.includes('ls'))) + assert.ok(argVals.some(s => s.includes('timeout') && s.includes('30'))) + assert.ok(argVals.some(s => s.includes('opts') && s.includes('{"a":1}'))) + // Unparseable args show raw; absent args show nothing. + assert.ok(text(content).includes('not json')) + assert.ok(text(content).includes('inner result')) + await flush() + // b1 rejects (error), b2 resolves. + assert.equal(queryAll(content, '.lc-att-err').length, 1) + assert.equal(queryAll(content, '.lc-att-thumb img').length, 1) + await m.unmount() + }) + + test('assistant with a legacy content array renders the generic content section', async () => { + const m = await mountBrowser() + await click(catRow(m, 'assistant')) + await click(elemRows(m).find(r => text(r).includes('legacy')) as HTMLElement) + const content = query(m.container, '.lc-br-content') + assert.ok(text(content).includes('Content')) + assert.ok(text(content).includes('legacy body')) + await m.unmount() + }) + + test('assistant without a join shows only the window note', async () => { + const m = await mountBrowser() + await click(catRow(m, 'assistant')) + const row = elemRows(m).find(r => text(r).includes('(empty reply)') && !text(r).includes('read')) as HTMLElement + await click(row) + const content = query(m.container, '.lc-br-content') + assert.ok(text(content).includes('outside the loaded message window')) + await m.unmount() + }) + + test('compaction nodes: summary text, null and empty summaries', async () => { + const m = await mountBrowser() + await click(catRow(m, 'user')) + const rows = elemRows(m) + // Newest first: 72 (empty summary), 71 (null summary), 70 (summary node). + await click(rows[2]) + let content = query(m.container, '.lc-br-content') + assert.ok(text(content).includes('Summary')) + assert.ok(text(content).includes('SUMMARY BODY')) + // Null summary → empty body. + await click(rows[1]) + content = query(m.container, '.lc-br-content') + assert.equal(text(content), '') + // Empty-string summary → empty body. + await click(rows[0]) + content = query(m.container, '.lc-br-content') + assert.equal(text(content), '') + await m.unmount() + }) + + test('inject rows: form tags, snapshot prefix, skill injects', async () => { + const m = await mountBrowser() + await click(catRow(m, 'inject')) + const rows = elemRows(m) + const tags = rows.map(r => { + const tag = r.querySelector('.lc-br-tag') + return `${tag === null ? '∅' : text(tag)}|${text(query(r, '.lc-br-preview'))}` + }) + assert.ok(tags.includes('State Snapshot|Snapshot: state')) + assert.ok(tags.includes('Notice|heads up')) + assert.ok(tags.includes('∅|Skill: code-review'), 'skill injects keep the node-text label') + assert.ok(tags.includes('Catalog Update|Catalog Update'), 'textless inject previews its form') + assert.ok(tags.includes('Notice|Notice'), 'empty-string text keeps the form label') + assert.ok(tags.includes('Context Injection|no form'), 'formless inject defaults to the context label') + assert.ok(tags.includes('Agent Relay|relay body')) + // Expanded inject joins the conversation content. + await click(rows.find(r => text(r).includes('relay body')) as HTMLElement) + const content = query(m.container, '.lc-br-content') + assert.ok(text(content).includes('relay body full')) + await m.unmount() + }) +}) + +describe('ContextBrowser tool results', () => { + // The tail-status matrix: dsh settles failing commands as completed calls, + // so trailing markers are the failure signal. + const cases: { seq: number; tail: string; err: boolean; label: string }[] = [ + { seq: 80, tail: 'boom\n[exit code: 3]', err: true, label: 'Failed · exit 3' }, + { seq: 81, tail: '[killed by signal: SIGKILL]', err: true, label: 'Failed' }, + { seq: 82, tail: '[shell killed by signal: SIGTERM]', err: true, label: 'Failed' }, + { seq: 83, tail: '[shell exited: code 2]', err: true, label: 'Failed · exit 2' }, + { seq: 84, tail: '[shell exited: code 0]', err: false, label: 'OK' }, + { seq: 85, tail: '[shell exited]', err: false, label: 'OK' }, + { seq: 86, tail: 'oops [exit code: 7]\n[shell killed by signal: SIGTERM]', err: true, label: 'Failed · exit 7' }, + { seq: 87, tail: '[status: killed]', err: true, label: 'Failed' }, + { seq: 88, tail: '[status: failed, boom]', err: true, label: 'Failed' }, + { seq: 89, tail: 'all good', err: false, label: 'OK' }, + { seq: 90, tail: 'a quoted [exit code: 9] marker mid-text\nmore output', err: false, label: 'OK' }, + ] + const convNodes: ConversationNodeLike[] = cases.map(c => ({ + kind: 'tool-result', seq: c.seq, + call: { name: 'bash', argsRaw: '{"description":"run it"}' }, + content: [{ type: 'image', attachment: { attachmentId: 'x' + String(c.seq) } }, null, { type: 'text', text: c.tail }], + })) + convNodes.push( + // Fold-stamped failure without any marker. + { kind: 'tool-result', seq: 91, call: { name: 'read', argsRaw: '{}' }, content: [{ type: 'text', text: 'denied' }], isError: true }, + // No call record → no call card. + { kind: 'tool-result', seq: 92, call: null, content: [{ type: 'text', text: 'orphan' }] }, + // Content that is not an array → no body sections. + { kind: 'tool-result', seq: 93, call: { name: 'bash', argsRaw: '' }, content: 'raw-text' as never }, + ) + const nodes: SurfaceNode[] = [ + ...cases.map(c => node({ seq: c.seq, cat: 'tool', tokens: 9, tool: 'bash' })), + node({ seq: 91, cat: 'tool', tokens: 9, tool: 'read' }), + node({ seq: 92, cat: 'tool', tokens: 9, tool: 'bash' }), + node({ seq: 93, cat: 'tool', tokens: 9, tool: 'bash' }), + // Fold-stamped err flag, no join. + node({ seq: 94, cat: 'tool', tokens: 9, tool: 'write', err: true }), + // No join at all → placeholder preview; missing tool name → '?'. + node({ seq: 95, cat: 'tool', tokens: 9, tool: 'bash' }), + node({ seq: 96, cat: 'tool', tokens: 9 }), + // A skill result is labeled by skill name. + node({ seq: 97, cat: 'tool', tokens: 9, tool: 'skill', skill: 'grilling' }), + ] + convNodes.push({ kind: 'tool-result', seq: 97, call: { name: 'skill', argsRaw: '{"description":"grill the plan"}' }, content: [{ type: 'text', text: 'skill body' }] }) + + const data = tl({ + current: { system: 0, tools: 0, user: 0, inject: 0, assistant: 0, tool: 162, total: 162 }, + nodes, + }) + + test('tail-status matrix drives the error dot; tags and previews render on collapsed rows', async () => { + const m = await mount(h(Browser, props({ data, useSession: sess({ nodes: convNodes }) }))) + await click(catRow(m, 'tool')) + const rows = elemRows(m) + assert.equal(rows.length, nodes.length) + // Dot presence per case (collapsed rows): every marker failure + the two + // fold-stamped failures (isError, err flag) — nothing else. + const dots = queryAll(m.container, '.lc-br-err-dot') + assert.equal(dots.length, 9, 'one red dot per failing result') + // Newest-first order: rows[0] is seq 97 (skill), then 96, 95, 94, 93, ... + assert.ok(text(rows[0]).includes('Skill · grilling'), 'skill results label by name') + assert.ok(text(rows[0]).includes('grill the plan'), 'call summary previews') + assert.ok(text(rows[1]).includes('?'), 'missing tool name tags as ?') + assert.ok(text(rows[1]).includes('Tool Result'), 'no join → placeholder preview') + assert.ok(text(rows[2]).includes('Tool Result')) + assert.ok(text(rows[3]).includes('write')) + const byIdx = Object.fromEntries([97, 96, 95, 94, 93, 92, 91, 90, 89, 88, 87, 86, 85, 84, 83, 82, 81, 80].map((s, i) => [s, i])) + for (const c of cases) { + const hasDot = rows[byIdx[c.seq]].querySelector('.lc-br-err-dot') !== null + assert.equal(hasDot, c.err, `seq ${c.seq}: dot ${c.err ? 'shown' : 'hidden'} for tail ${JSON.stringify(c.tail)}`) + } + assert.ok(rows[byIdx[91]].querySelector('.lc-br-err-dot') !== null, 'isError stamps the dot') + assert.ok(rows[byIdx[94]].querySelector('.lc-br-err-dot') !== null, 'the fold err flag stamps the dot') + assert.ok(rows[byIdx[93]].querySelector('.lc-br-err-dot') === null, 'non-array content has no markers') + await click(rows[0]) + const content = query(m.container, '.lc-br-content') + assert.ok(text(content).includes('OK')) + assert.ok(text(content).includes('skill body')) + await m.unmount() + }) + + test('clean results render the OK state; orphan results render no call card', async () => { + const m = await mount(h(Browser, props({ data, useSession: sess({ nodes: convNodes }) }))) + await click(catRow(m, 'tool')) + const rows = elemRows(m) + // Newest first: index map by seq (97, 96, 95, 94, 93, 92, 91, 90..80). + const byIdx = Object.fromEntries([97, 96, 95, 94, 93, 92, 91, 90, 89, 88, 87, 86, 85, 84, 83, 82, 81, 80].map((s, i) => [s, i])) + const expand = async (seq: number) => { + await click(rows[byIdx[seq]]) + return query(m.container, '.lc-br-content') + } + let content = await expand(89) + assert.ok(text(content).includes('OK')) + assert.ok(!queryAll(content, '.lc-br-err-dot').length) + await click(elemRows(m)[byIdx[89]]) + // Exit-code failure with the pill. + content = await expand(80) + assert.ok(text(content).includes('Failed · exit 3')) + await click(elemRows(m)[byIdx[80]]) + // Killed by signal: failure without an exit code. + content = await expand(81) + assert.ok(text(content).includes('Failed')) + assert.ok(!text(content).includes('exit')) + await click(elemRows(m)[byIdx[81]]) + // Shell killed after a failing command: the command's code wins. + content = await expand(86) + assert.ok(text(content).includes('Failed · exit 7')) + await click(elemRows(m)[byIdx[86]]) + // Orphan result: no call card, but the result body renders. + content = await expand(92) + assert.ok(!text(content).includes('→'), 'no call card without a call record') + assert.ok(text(content).includes('orphan')) + await click(elemRows(m)[byIdx[92]]) + // Non-array content: call card only. + content = await expand(93) + assert.ok(text(content).includes('OK')) + assert.ok(!text(content).includes('raw-text')) + await m.unmount() + }) +}) + +describe('ContextBrowser history auto-load', () => { + const data = tl({ + current: { system: 0, tools: 0, user: 10, inject: 0, assistant: 0, tool: 0, total: 10 }, + nodes: [node({ seq: 5, tokens: 5, text: 'pageable' }), node({ seq: 6, tokens: 5, text: 'also pageable' })], + }) + + const openFirstRow = async (m: Mounted) => { + await click(catRow(m, 'user')) + await click(elemRows(m)[1]) // seq 5 (newest first: 6, 5) + } + + test('missing join pulls one older page, then the joined content renders', async () => { + let snap: Snap = { nodes: [], hasMore: true, loadingOlder: false } + let calls = 0 + const loadOlderHistory = async (): Promise => { calls += 1 } + const el = () => h(Browser, props({ + data, useSession: liveSess(() => snap), loadOlderHistory, + })) + const m = await mount(el()) + await openFirstRow(m) + assert.equal(calls, 1, 'one page requested') + assert.ok(text(query(m.container, '.lc-br-content')).includes('Loading full content from older session history')) + // The page lands: the seq joins the window. + snap = { nodes: [{ kind: 'user', seq: 5, content: [{ type: 'text', text: 'FULL BODY' }] }], hasMore: false, loadingOlder: false } + await m.update(el()) + assert.ok(text(query(m.container, '.lc-br-content')).includes('FULL BODY')) + assert.equal(calls, 1, 'no further pages once the join lands') + await m.unmount() + }) + + test('a page already in flight does not double-fire', async () => { + const snap: Snap = { nodes: [], hasMore: true, loadingOlder: true } + let calls = 0 + const m = await mount(h(Browser, props({ + data, + useSession: liveSess(() => snap), + loadOlderHistory: async () => { calls += 1 }, + }))) + await openFirstRow(m) + assert.equal(calls, 0) + assert.ok(text(query(m.container, '.lc-br-content')).includes('Loading full content')) + await m.unmount() + }) + + test('history end latches the static note; absent loader degrades the same way', async () => { + const snap: Snap = { nodes: [], hasMore: false, loadingOlder: false } + let calls = 0 + const m = await mount(h(Browser, props({ + data, + useSession: liveSess(() => snap), + loadOlderHistory: async () => { calls += 1 }, + }))) + await openFirstRow(m) + assert.equal(calls, 0, 'no pages left to pull') + assert.ok(text(query(m.container, '.lc-br-content')).includes('outside the loaded message window')) + await m.unmount() + + // Older host without the pagination verb. + const snap2: Snap = { nodes: [], hasMore: true, loadingOlder: false } + const m2 = await mount(h(Browser, props({ + data, + useSession: liveSess(() => snap2), + }))) + await openFirstRow(m2) + assert.ok(text(query(m2.container, '.lc-br-content')).includes('outside the loaded message window')) + await m2.unmount() + }) + + test('the auto-load ceiling stops paging and opening another element re-arms', async () => { + const snap: Snap = { nodes: [], hasMore: true, loadingOlder: false } + let calls = 0 + const loadOlderHistory = async (): Promise => { calls += 1 } + const el = () => h(Browser, props({ + data, useSession: liveSess(() => snap), loadOlderHistory, + })) + const m = await mount(el()) + await openFirstRow(m) + assert.equal(calls, 1) + // Each landed page (loadingOlder true→false) without the seq re-arms the pull. + for (let i = 0; i < 19; i++) { + snap.loadingOlder = true + await m.update(el()) + snap.loadingOlder = false + await m.update(el()) + } + assert.equal(calls, 20, 'twenty pages pulled') + // The next pull hits the cap: exhausted latches, the note falls back. + snap.loadingOlder = true + await m.update(el()) + snap.loadingOlder = false + await m.update(el()) + assert.equal(calls, 20, 'the ceiling stops auto-paging') + assert.ok(text(query(m.container, '.lc-br-content')).includes('outside the loaded message window')) + // Opening another element resets the budget. + await click(elemRows(m)[0]) // seq 6 + assert.equal(calls, 21, 'a new element re-arms the auto-load') + await m.unmount() + }) +}) + +describe('ContextBrowser focus bridges', () => { + const headers: ContextHeaders = { + headers: [{ + seq: 1, time: 1, system: 'SYS', + tools: [ + { name: 'beta', tokens: 50, description: 'the beta tool', schema: { input_schema: { properties: { x: { type: 'string' } } } } }, + { name: 'alpha', tokens: 10, description: 'the alpha tool' }, + ], + }], + } + const data = tl({ + current: { system: 10, tools: 60, user: 10, inject: 0, assistant: 0, tool: 0, total: 80 }, + requests: [req({ seq: 10, turn: 1, step: 0, total: 80 }), req({ seq: 20, turn: 1, step: 1, total: 80 })], + nodes: [node({ seq: 1, tokens: 5, text: 'focusable' }), node({ seq: 11, cat: 'assistant', tokens: 5, text: 'answer' })], + }) + const convNodes: ConversationNodeLike[] = [{ kind: 'user', seq: 1, content: [{ type: 'text', text: 'focusable' }] }] + + test('toolFocus applies once, opens the tool row, and clears via the callback', async () => { + let handled = 0 + const m = await mount(h(Browser, props({ data, headers }))) + await m.update(h(Browser, props({ data, headers, toolFocus: { tool: 'beta' }, onToolFocusHandled: () => { handled += 1 } }))) + assert.equal(handled, 1) + const body = query(m.container, '.lc-br-body') + assert.ok(text(body).includes('beta')) + const onRows = queryAll(m.container, '.lc-br-elem-on') + assert.equal(onRows.length, 1) + assert.ok(text(onRows[0]).includes('beta'), 'the named tool row expanded') + assert.ok(text(m.container).includes('the beta tool')) + // Handed back (null): the section stays as applied. + await m.update(h(Browser, props({ data, headers, toolFocus: null, onToolFocusHandled: () => { handled += 1 } }))) + assert.equal(handled, 1) + assert.equal(queryAll(m.container, '.lc-br-body').length, 1) + // Category-only focus: section opens, no row expands. + await m.update(h(Browser, props({ data, headers, toolFocus: {}, onToolFocusHandled: () => { handled += 1 } }))) + assert.equal(handled, 2) + assert.equal(queryAll(m.container, '.lc-br-elem-on').length, 0) + assert.equal(queryAll(m.container, '.lc-br-body').length, 1) + await m.unmount() + }) + + test('toolFocus without the handled callback still applies', async () => { + const m = await mount(h(Browser, props({ data, headers }))) + await m.update(h(Browser, props({ data, headers, toolFocus: { tool: 'alpha' } }))) + assert.equal(queryAll(m.container, '.lc-br-elem-on').length, 1) + await m.unmount() + }) + + test('nodeFocus selects the step, opens the node, scrolls it into view, and clears', async () => { + const scrolls: unknown[] = [] + const orig = Element.prototype.scrollIntoView + Element.prototype.scrollIntoView = function (this: Element, arg?: unknown) { scrolls.push(arg) } as never + try { + let handled = 0 + const m = await mount(h(Browser, props({ data, headers, useSession: sess({ nodes: convNodes }) }))) + await m.update(h(Browser, props({ + data, headers, useSession: sess({ nodes: convNodes }), + nodeFocus: { step: 'live', seq: 1, cat: 'user' }, + onNodeFocusHandled: () => { handled += 1 }, + }))) + assert.equal(handled, 1) + const onRows = queryAll(m.container, '.lc-br-elem-on') + assert.equal(onRows.length, 1) + assert.ok(text(onRows[0]).includes('focusable')) + assert.deepEqual(scrolls, [{ block: 'nearest' }], 'the open row scrolled into view') + // A step focus switches the assembled view. + await m.update(h(Browser, props({ + data, headers, useSession: sess({ nodes: convNodes }), + nodeFocus: { step: 20, seq: 1, cat: 'user' }, + onNodeFocusHandled: () => { handled += 1 }, + }))) + assert.ok(text(query(m.container, '.lc-br-meta')).includes('Turn 1 · Step 1')) + assert.equal(handled, 2) + // A node outside the assembled surface leaves nothing to scroll to. + await m.update(h(Browser, props({ + data, headers, useSession: sess({ nodes: convNodes }), + nodeFocus: { step: 'live', seq: 999, cat: 'user' }, + }))) + assert.equal(queryAll(m.container, '.lc-br-elem-on').length, 0) + await m.unmount() + } finally { + Element.prototype.scrollIntoView = orig + } + }) + + test('pinSeq selects the pinned step and unpinning returns to live (accordion resets)', async () => { + const m = await mount(h(Browser, props({ data, headers }))) + await click(catRow(m, 'user')) + assert.equal(queryAll(m.container, '.lc-br-body').length, 1) + await m.update(h(Browser, props({ data, headers, pinSeq: 10 }))) + assert.ok(text(query(m.container, '.lc-br-meta')).includes('Turn 1 · Step 0')) + assert.equal(queryAll(m.container, '.lc-br-body').length, 0, 'pinning resets the accordion') + assert.equal(query(m.container, 'select.lc-br-pick').value, '10') + await m.update(h(Browser, props({ data, headers, pinSeq: null }))) + assert.ok(text(query(m.container, '.lc-br-meta')).includes('Live · Next Request')) + await m.unmount() + }) +}) diff --git a/tests/client/components/contextModal.spec.ts b/tests/client/components/contextModal.spec.ts new file mode 100644 index 0000000..309650b --- /dev/null +++ b/tests/client/components/contextModal.spec.ts @@ -0,0 +1,362 @@ +// ContextModal (src/client/components/contextModal.tsx) — the /context +// dialog rendered through the REGISTERED composition (makeContextModal over +// a TestClientCtx), with the per-session modal store driving open/close, +// real sessions faces for the consume-token bail, and real projections. + +import assert from 'node:assert/strict' +import { act } from 'react' +import { afterEach, describe, test, vi } from 'vitest' +import { React, h } from '../../../src/client/react' +import { makeContextModal } from '../../../src/client/components/contextModal' +import { modalStoreOf, setPendingConsume, takePendingConsume } from '../../../src/client/modalStore' +import type { ContextTimeline } from '../../../src/shared/types' +import { DICT_EN } from '../../../src/client/i18n' +import { TestClientCtx, TestSessions, asClientCtx } from '../helpers/harness' +import { click, hover, keydown, makeKit, mount, query, queryAll, text, unhover } from '../helpers/kit' + +const kit = makeKit() + + +function timeline(over: Record = {}): ContextTimeline { + return { + ok: true, + current: { system: 1, tools: 2, user: 3, inject: 4, assistant: 5, tool: 6, total: 21 }, + toolList: [], + requests: [], + events: [], + nodes: [], + droppedNodes: 0, + archive: [], + ...over, + } as ContextTimeline +} + +/** A useContextModal hook really bound to the per-session store. */ +function boundModalHook(sessionId: string) { + const store = modalStoreOf(sessionId) + return (sel: (open: boolean) => boolean): boolean => + React.useSyncExternalStore(store.subscribe, () => sel(store.getSnapshot())) +} + +const OPEN = (): boolean => true + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('ContextModal', () => { + test('renders null without a useContextModal hook or while closed', async () => { + const ctx = new TestClientCtx({ services: { sessions: new TestSessions() } }) + const ContextModal = makeContextModal(asClientCtx(ctx), kit) + + const m1 = await mount(h(ContextModal, { sessionId: 'sm-none', useProjection: () => timeline() })) + assert.equal(m1.container.childElementCount, 0) + await m1.unmount() + + const m2 = await mount(h(ContextModal, { + sessionId: 'sm-closed', + useContextModal: () => false, + useProjection: () => timeline(), + })) + assert.equal(m2.container.childElementCount, 0) + await m2.unmount() + }) + + test('open with no projection hook (or an empty one) shows the loading view', async () => { + const ctx = new TestClientCtx({ services: { sessions: new TestSessions() } }) + const ContextModal = makeContextModal(asClientCtx(ctx), kit) + + const m1 = await mount(h(ContextModal, { sessionId: 'sm-load1', useContextModal: OPEN })) + assert.ok(text(m1.container).includes(DICT_EN.loading)) + assert.ok(m1.container.querySelector('.lc-modal-card') !== null) + await m1.unmount() + + const m2 = await mount(h(ContextModal, { + sessionId: 'sm-load2', + useContextModal: OPEN, + useProjection: () => undefined, + })) + assert.ok(text(m2.container).includes(DICT_EN.loading)) + await m2.unmount() + }) + + test('a corrupt timeline projection is sanitized and still renders the composition', async () => { + const ctx = new TestClientCtx({ services: { sessions: new TestSessions() } }) + const ContextModal = makeContextModal(asClientCtx(ctx), kit) + const m = await mount(h(ContextModal, { + sessionId: 'sm-garbage', + useContextModal: OPEN, + useProjection: (key: string) => + key === 'contextTimeline' ? { current: 'junk', requests: 'nope', nodes: 7, archive: null, toolList: 1 } : undefined, + })) + assert.ok(!text(m.container).includes(DICT_EN.loading)) + assert.ok(text(m.container).includes(DICT_EN['overview.title'])) + assert.ok(m.container.querySelector('.lc-br-cats') !== null) + await m.unmount() + }) + + test('full render: subtitle variants, hover link, and tool focus through the composed browser', async () => { + const sessions = new TestSessions() + const ctx = new TestClientCtx({ services: { sessions } }) + const ContextModal = makeContextModal(asClientCtx(ctx), kit) + const projections: Record = { + contextTimeline: timeline({ + model: 'deepseek-v4-flash', + provider: 'deepseek', + toolList: [{ name: 'bash', tokens: 50 }], + }), + contextPressure: { projectedTokens: 100, contextWindow: 128000 }, + contextBreakdown: { systemTokens: 1, toolsTokens: 2, messageTokens: 18 }, + contextHeaders: { + headers: [{ seq: 1, time: 0, system: 'SYS', tools: [{ name: 'bash', tokens: 12, description: 'run' }] }], + }, + } + const m = await mount(h(ContextModal, { + sessionId: 'sm-full', + useContextModal: OPEN, + useProjection: (key: string) => projections[key], + })) + assert.ok(text(m.container).includes('deepseek-v4-flash · deepseek')) + + // Hovering a composition segment flows into hoverCat (dim + legend chip). + const compCard = query(m.container, '.lc-modal-card .lc-card') + const seg = query(compCard, '.lc-stacked-seg') + await hover(seg) + assert.ok(query(compCard, '.lc-stacked').className.includes('lc-stacked-dim')) + assert.ok(queryAll(compCard, '.lc-chip').some(c => c.className.includes('lc-chip-on'))) + await unhover(query(compCard, '.lc-stacked')) + assert.ok(!query(compCard, '.lc-stacked').className.includes('lc-stacked-dim')) + + // A tool chip asks the browser to reveal the tool; the browser consumes the + // one-shot focus (onToolFocusHandled) after applying it. + await click(query(compCard, '.lc-tool-chip')) + const openElem = query(m.container, '.lc-br-elem-on') + assert.ok(text(openElem).includes('bash')) + await m.unmount() + }) + + test('subtitle degrades to the model alone, then to nothing', async () => { + const ctx = new TestClientCtx({ services: { sessions: new TestSessions() } }) + const ContextModal = makeContextModal(asClientCtx(ctx), kit) + + const m1 = await mount(h(ContextModal, { + sessionId: 'sm-sub1', + useContextModal: OPEN, + useProjection: (key: string) => key === 'contextTimeline' ? timeline({ model: 'only-model' }) : undefined, + })) + assert.ok(text(m1.container).includes('only-model')) + assert.ok(!text(m1.container).includes('only-model ·')) + await m1.unmount() + + const m2 = await mount(h(ContextModal, { + sessionId: 'sm-sub2', + useContextModal: OPEN, + useProjection: (key: string) => key === 'contextTimeline' ? timeline() : undefined, + })) + const card = query(m2.container, '.lc-modal-card .lc-card') + assert.equal(card.querySelector('.lc-card-sub'), null) + await m2.unmount() + }) + + test('backdrop click without a sessionId is a no-op', async () => { + const sessions = new TestSessions() + const ctx = new TestClientCtx({ services: { sessions } }) + const ContextModal = makeContextModal(asClientCtx(ctx), kit) + const m = await mount(h(ContextModal, { useContextModal: OPEN, useProjection: () => undefined })) + await click(query(m.container, '.lc-modal-backdrop')) + assert.ok(m.container.querySelector('.lc-modal-backdrop') !== null) + assert.equal(sessions.bails.length, 0) + await m.unmount() + }) + + test('clicking the card itself does not close (stopPropagation)', async () => { + const sessions = new TestSessions() + const ctx = new TestClientCtx({ services: { sessions } }) + const ContextModal = makeContextModal(asClientCtx(ctx), kit) + const m = await mount(h(ContextModal, { sessionId: 'sm-card', useContextModal: OPEN, useProjection: () => undefined })) + await click(query(m.container, '.lc-modal-card')) + assert.ok(m.container.querySelector('.lc-modal-backdrop') !== null) + assert.equal(sessions.bails.length, 0) + await m.unmount() + }) + + test('close flips the store and bails a pending consume through the session scope', async () => { + const sessions = new TestSessions() + const ctx = new TestClientCtx({ services: { sessions } }) + const ContextModal = makeContextModal(asClientCtx(ctx), kit) + const sid = 'sm-close' + modalStoreOf(sid).set(true) + setPendingConsume(sid, { kind: 'bare-token', token: '/context' }) + const m = await mount(h(ContextModal, { + sessionId: sid, + useContextModal: boundModalHook(sid), + useProjection: () => undefined, + })) + assert.ok(m.container.querySelector('.lc-modal-backdrop') !== null) + + await click(query(m.container, '.lc-modal-close')) + assert.equal(modalStoreOf(sid).getSnapshot(), false) + assert.equal(m.container.querySelector('.lc-modal-backdrop'), null) + assert.equal(sessions.bails.length, 1) + assert.equal(sessions.bails[0].event, 'slash/input-consume-token') + assert.deepEqual(sessions.bails[0].payload, { guard: { kind: 'bare-token', token: '/context' } }) + // The guard was taken, not left behind. + assert.equal(takePendingConsume(sid), undefined) + await m.unmount() + }) + + test('close without a pending consume sets the store but bails nothing', async () => { + const sessions = new TestSessions() + const ctx = new TestClientCtx({ services: { sessions } }) + const ContextModal = makeContextModal(asClientCtx(ctx), kit) + const sid = 'sm-noguard' + modalStoreOf(sid).set(true) + const m = await mount(h(ContextModal, { + sessionId: sid, + useContextModal: boundModalHook(sid), + useProjection: () => undefined, + })) + await click(query(m.container, '.lc-modal-backdrop')) + assert.equal(modalStoreOf(sid).getSnapshot(), false) + assert.equal(sessions.bails.length, 0) + await m.unmount() + }) + + test('a pending consume without the sessions service closes quietly', async () => { + const ctx = new TestClientCtx() + const ContextModal = makeContextModal(asClientCtx(ctx), kit) + const sid = 'sm-nosvc' + modalStoreOf(sid).set(true) + setPendingConsume(sid, { kind: 'bare-token', token: '/context' }) + const m = await mount(h(ContextModal, { + sessionId: sid, + useContextModal: boundModalHook(sid), + useProjection: () => undefined, + })) + await click(query(m.container, '.lc-modal-backdrop')) + assert.equal(modalStoreOf(sid).getSnapshot(), false) + assert.equal(m.container.querySelector('.lc-modal-backdrop'), null) + await m.unmount() + }) + + test('a pending consume whose scope is gone closes quietly', async () => { + const scoped: string[] = [] + const sessions = { + scope: (id: string) => { + scoped.push(id) + return undefined + }, + } + const ctx = new TestClientCtx({ services: { sessions } }) + const ContextModal = makeContextModal(asClientCtx(ctx), kit) + const sid = 'sm-noscope' + modalStoreOf(sid).set(true) + setPendingConsume(sid, { kind: 'span', span: { start: 0, end: 8, draftRev: 3 } }) + const m = await mount(h(ContextModal, { + sessionId: sid, + useContextModal: boundModalHook(sid), + useProjection: () => undefined, + })) + await click(query(m.container, '.lc-modal-backdrop')) + assert.equal(modalStoreOf(sid).getSnapshot(), false) + assert.deepEqual(scoped, [sid]) + await m.unmount() + }) + + test('Escape closes; other keys do not; the listener is removed on close', async () => { + const sessions = new TestSessions() + const ctx = new TestClientCtx({ services: { sessions } }) + const ContextModal = makeContextModal(asClientCtx(ctx), kit) + const sid = 'sm-esc' + modalStoreOf(sid).set(true) + const m = await mount(h(ContextModal, { + sessionId: sid, + useContextModal: boundModalHook(sid), + useProjection: () => undefined, + })) + await keydown('a') + assert.ok(m.container.querySelector('.lc-modal-backdrop') !== null) + + await keydown('Escape') + assert.equal(modalStoreOf(sid).getSnapshot(), false) + assert.equal(m.container.querySelector('.lc-modal-backdrop'), null) + + // Listener removed on close: another Escape must not run close() again — + // with a pending consume armed, a live listener would bail. + setPendingConsume(sid, { kind: 'bare-token', token: '/context' }) + await keydown('Escape') + assert.equal(sessions.bails.length, 0) + assert.notEqual(takePendingConsume(sid), undefined) + await m.unmount() + }) + + test('closing restores focus to the previously focused element', async () => { + const ctx = new TestClientCtx({ services: { sessions: new TestSessions() } }) + const ContextModal = makeContextModal(asClientCtx(ctx), kit) + const sid = 'sm-focus' + const btn = document.createElement('button') + document.body.appendChild(btn) + btn.focus() + assert.equal(document.activeElement, btn) + + const m = await mount(h(ContextModal, { + sessionId: sid, + useContextModal: boundModalHook(sid), + useProjection: () => undefined, + })) + await act(async () => { + modalStoreOf(sid).set(true) + }) + const closeBtn = query(m.container, '.lc-modal-close') + closeBtn.focus() + assert.equal(document.activeElement, closeBtn) + + await click(closeBtn) + assert.equal(document.activeElement, btn) + btn.remove() + await m.unmount() + }) + + test('closing skips the focus restore when the previous element left the document', async () => { + const ctx = new TestClientCtx({ services: { sessions: new TestSessions() } }) + const ContextModal = makeContextModal(asClientCtx(ctx), kit) + const sid = 'sm-focus-gone' + const btn = document.createElement('button') + document.body.appendChild(btn) + btn.focus() + + const m = await mount(h(ContextModal, { + sessionId: sid, + useContextModal: boundModalHook(sid), + useProjection: () => undefined, + })) + await act(async () => { + modalStoreOf(sid).set(true) + }) + btn.remove() + await click(query(m.container, '.lc-modal-close')) + assert.equal(modalStoreOf(sid).getSnapshot(), false) + assert.ok(!document.contains(btn)) + await m.unmount() + }) + + test('no focus bookkeeping when nothing was focused (activeElement not an element)', async () => { + const ctx = new TestClientCtx({ services: { sessions: new TestSessions() } }) + const ContextModal = makeContextModal(asClientCtx(ctx), kit) + const sid = 'sm-focus-null' + modalStoreOf(sid).set(true) + // Environment control (not a plugin fake): jsdom always reports body; + // the harness browser can report null before the body exists. + vi.spyOn(document, 'activeElement', 'get').mockReturnValue(null) + const m = await mount(h(ContextModal, { + sessionId: sid, + useContextModal: boundModalHook(sid), + useProjection: () => undefined, + })) + vi.restoreAllMocks() + await click(query(m.container, '.lc-modal-close')) + assert.equal(modalStoreOf(sid).getSnapshot(), false) + assert.equal(document.activeElement, document.body) + await m.unmount() + }) +}) diff --git a/tests/client/components/contextView.spec.ts b/tests/client/components/contextView.spec.ts new file mode 100644 index 0000000..d29b52a --- /dev/null +++ b/tests/client/components/contextView.spec.ts @@ -0,0 +1,567 @@ +// ContextView (src/client/components/contextView.tsx) — the Context tab root +// rendered for real: stats, composition, trend chart, events, nodes and the +// composed Context browser, driven by real projection values and real +// plugin settings. Covers the view's own branches (projections absent / +// garbage / well-formed, granularity/trend-mode state, brief→browser +// locate bridge, kind filter, scroll ledger, locale arms, error boundary). + +import assert from 'node:assert/strict' +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, describe, test, vi } from 'vitest' +import { h } from '../../../src/client/react' +import { makeContextView } from '../../../src/client/components/contextView' +import { createContextSettings } from '../../../src/client/settings' +import type { SettingsScopeLike } from '../../../src/client/settings' +import type { UseSessionLike } from '../../../src/client/services' +import type { ContextTimeline } from '../../../src/shared/types' +import { DICT_EN } from '../../../src/client/i18n' +import { TestClientCtx, TestLocale, asClientCtx } from '../helpers/harness' +import { click, flush, hover, makeKit, mount, query, queryAll, text, unhover } from '../helpers/kit' + +// pluginInfo's npm-registry probe stays inert (and '0.0.0-dev' short-circuits +// it anyway). +vi.stubGlobal('fetch', () => Promise.resolve({ ok: false } as Response)) + +const kit = makeKit() + +afterEach(() => { + vi.restoreAllMocks() +}) + + +function timeline(over: Record = {}): ContextTimeline { + return { + ok: true, + current: { system: 100, tools: 200, user: 300, inject: 50, assistant: 400, tool: 150, total: 1200 }, + toolList: [], + requests: [], + events: [], + nodes: [], + droppedNodes: 0, + archive: [], + ...over, + } as ContextTimeline +} + +const T0 = 1700000000000 + +/** Two steps of one turn plus a turn-less trailing step, with their nodes. */ +function richTimeline(over: Record = {}): ContextTimeline { + return timeline({ + model: 'deepseek-v4-flash', + provider: 'deepseek', + contextWindow: 128000, + toolList: [{ name: 'bash', tokens: 120 }, { name: 'read', tokens: 80 }], + toolCalls: 3, + images: 1, + requests: [ + { seq: 2, turn: 1, step: 1, time: T0 + 1000, system: 100, tools: 200, user: 10, inject: 0, assistant: 20, tool: 0, total: 330, prompt: 350, output: 20, cacheRead: 100 }, + { seq: 4, turn: 1, step: 2, time: T0 + 3000, system: 100, tools: 200, user: 10, inject: 0, assistant: 60, tool: 30, total: 400 }, + { seq: 6, time: T0 + 5000, system: 100, tools: 200, user: 10, inject: 0, assistant: 80, tool: 30, total: 420 }, + ], + events: [ + { seq: 3, time: T0 + 2000, kind: 'compaction', count: 2, turn: 1, step: 2, fromTurn: 1, fromStep: 1, tokens: 500 }, + { seq: 5, time: T0 + 4000, kind: 'inject', form: 'notice', name: 'heads-up', tokens: 12, turn: 1, step: 3 }, + ], + nodes: [ + { seq: 1, cat: 'user', tokens: 10, text: 'hello there', time: T0 + 500 }, + { seq: 2, cat: 'assistant', tokens: 20, text: 'reply one', time: T0 + 1000 }, + { seq: 3, cat: 'tool', tokens: 30, tool: 'bash', text: 'file output', time: T0 + 2000 }, + { seq: 4, cat: 'assistant', tokens: 60, text: 'reply two', time: T0 + 3000 }, + { seq: 6, cat: 'assistant', tokens: 80, text: 'reply three', time: T0 + 5000 }, + ], + droppedNodes: 2, + ...over, + }) +} + +function projectionsFor(data: ContextTimeline, extra: Record = {}) { + const projections: Record = { contextTimeline: data, ...extra } + return (key: string) => projections[key] +} + +function makeView(ctx: TestClientCtx, settings = createContextSettings()) { + return makeContextView(asClientCtx(ctx), kit, settings) +} + +/** A button whose text matches (case-sensitively) the given label. */ +function buttonByText(container: ParentNode, label: string): HTMLElement { + const hit = queryAll(container, 'button').find(b => text(b) === label) + if (hit === undefined) throw new Error(`button not found: ${label}`) + return hit +} + +describe('ContextView — projection guards', () => { + test('loading screen while useProjection is absent, empty, or the session id is missing', async () => { + const View = makeView(new TestClientCtx()) + + const m1 = await mount(h(View, { sessionId: 'sv-none' })) + assert.ok(text(m1.container).includes(DICT_EN.loading)) + await m1.unmount() + + const m2 = await mount(h(View, { sessionId: '', useProjection: () => undefined })) + assert.ok(text(m2.container).includes(DICT_EN.loading)) + await m2.unmount() + + const m3 = await mount(h(View, { useProjection: () => undefined })) + assert.ok(text(m3.container).includes(DICT_EN.loading)) + await m3.unmount() + }) + + test('a corrupt timeline is sanitized and the whole tab still renders empty-section states', async () => { + const View = makeView(new TestClientCtx()) + const m = await mount(h(View, { + sessionId: 'sv-garbage', + useProjection: projectionsFor(timeline(), {}), + })) + // Well-formed-but-empty: every section renders its empty state. + assert.ok(text(m.container).includes(DICT_EN['overview.title'])) + assert.ok(text(m.container).includes(DICT_EN['stats.title'])) + assert.ok(text(m.container).includes(DICT_EN['trend.empty'])) + assert.ok(text(m.container).includes(DICT_EN['events.empty'])) + assert.ok(text(m.container).includes(DICT_EN['nodes.empty'])) + assert.ok(text(m.container).includes(DICT_EN['footer'])) + assert.ok(m.container.querySelector('.lc-br-cats') !== null) + // No model/provider: the composition card carries no subtitle. + const comp = queryAll(m.container, '.lc-card').find(c => text(c).includes(DICT_EN['overview.title'])) + assert.ok(comp !== undefined && comp.querySelector('.lc-card-sub') === null) + await m.unmount() + }) + + test('companion projections feed the headline, stats, and browser headers', async () => { + const View = makeView(new TestClientCtx()) + const m = await mount(h(View, { + sessionId: 'sv-proj', + useProjection: projectionsFor(timeline({ model: 'm-only' }), { + contextPressure: { projectedTokens: 100, contextWindow: 128000 }, + tokenUsage: { uncachedInputTokens: 100, outputTokens: 50, cacheReadTokens: 200, cacheWriteTokens: 0 }, + contextBreakdown: { systemTokens: 100, toolsTokens: 200, messageTokens: 900 }, + contextHeaders: { headers: [{ seq: 1, time: T0, system: 'SYS', tools: [{ name: 'bash', tokens: 12 }] }] }, + }), + })) + // Anchored headline: projected 100 of a 128k window. + assert.ok(text(m.container).includes(DICT_EN['overview.used'])) + // Cache-hit cell from the official tokenUsage projection (200 / 300). + assert.ok(text(m.container).includes('66.66%')) + assert.ok(text(m.container).includes('m-only')) + await m.unmount() + }) +}) + +describe('ContextView — interactions', () => { + async function mountRich(sessionId: string) { + const View = makeView(new TestClientCtx()) + const m = await mount(h(View, { + sessionId, + useProjection: projectionsFor(richTimeline(), { + contextHeaders: { headers: [{ seq: 1, time: T0, system: 'SYS', tools: [{ name: 'bash', tokens: 12, description: 'run' }] }] }, + }), + useSession: (sel => sel({ nodes: [], hasMore: false, loadingOlder: false })) as UseSessionLike, + })) + return m + } + + test('renders the rich tab: stats, chart bars, markers, events, nodes, browser', async () => { + const m = await mountRich('sv-rich') + assert.ok(text(m.container).includes('deepseek-v4-flash · deepseek')) + assert.equal(queryAll(m.container, '.lc-bar').length, 3) + // The compaction event lands as the ✂ marker on the first request after it. + assert.equal(queryAll(m.container, '.lc-bar-marker').length, 1) + assert.equal(queryAll(m.container, '.lc-event').length, 2) + assert.ok(text(m.container).includes(DICT_EN['nodes.more'].replace('{n}', '2'))) + assert.ok(text(m.container).includes(DICT_EN['browser.liveNow'])) + // Turn strip partitions the two turn groups (turn 1 + the turn-less 0). + assert.deepEqual(queryAll(m.container, '.lc-turn-label').map(el => text(el)), ['T1', 'T0']) + await m.unmount() + }) + + test('bar hover previews in the browser; hover loss on a granularity switch falls back', async () => { + const m = await mountRich('sv-hover') + const chart = query(m.container, '.lc-chart') + const bar2 = query(m.container, '.lc-bar[data-seq="2"]') + await hover(bar2) + assert.ok(bar2.className.includes('lc-bar-hovered')) + // The browser mirrors the hover as a transient preview of that step. + assert.ok(text(m.container).includes(DICT_EN['browser.preview'])) + // The hovered bar's turn lights the strip even without strip hover. + assert.ok(query(m.container, '.lc-chart-scroll').className.includes('lc-chart-dim')) + + // Switching to turn granularity drops seq 2 (turn 1 keeps its LAST step): + // the stale hovered seq matches nothing and the active bar falls back. + await click(buttonByText(m.container, DICT_EN['gran.turn'])) + assert.equal(queryAll(m.container, '.lc-bar').length, 2) + assert.equal(queryAll(m.container, '.lc-bar-hovered').length, 0) + await unhover(chart) + await click(buttonByText(m.container, DICT_EN['gran.step'])) + assert.equal(queryAll(m.container, '.lc-bar').length, 3) + await m.unmount() + }) + + test('a request without a turn highlights nothing on hover', async () => { + const m = await mountRich('sv-noturn') + const chart = query(m.container, '.lc-chart') + await hover(query(m.container, '.lc-bar[data-seq="6"]')) + assert.ok(query(m.container, '.lc-bar[data-seq="6"]').className.includes('lc-bar-hovered')) + assert.ok(!query(m.container, '.lc-chart-scroll').className.includes('lc-chart-dim')) + await unhover(chart) + await m.unmount() + }) + + test('turn strip hover dims, strip click focuses the turn in turn granularity', async () => { + const m = await mountRich('sv-strip') + const turns = queryAll(m.container, '.lc-turn') + assert.equal(turns.length, 2) + await hover(turns[0]) + assert.ok(query(m.container, '.lc-chart-scroll').className.includes('lc-chart-dim')) + await unhover(query(m.container, '.lc-turns')) + assert.ok(!query(m.container, '.lc-chart-scroll').className.includes('lc-chart-dim')) + + // Strip click: granularity switches to turn and the focus is consumed once. + await click(turns[0]) + assert.ok(buttonByText(m.container, DICT_EN['gran.turn']).className.includes('lc-gran-on')) + assert.equal(queryAll(m.container, '.lc-bar').length, 2) + await click(buttonByText(m.container, DICT_EN['gran.step'])) + await m.unmount() + }) + + test('pinning a bar selects its step in the browser; unpinning returns to live', async () => { + const m = await mountRich('sv-pin') + const pick = query(m.container, 'select.lc-br-pick') + assert.equal(pick.value, 'live') + + const bar4 = query(m.container, '.lc-bar[data-seq="4"]') + await click(bar4) + assert.ok(bar4.className.includes('lc-bar-selected')) + assert.equal(pick.value, '4') + // The pinned step carries the compaction marker chip in the detail head. + assert.ok(query(m.container, '.lc-detail').textContent?.includes('✂') === true) + + await click(bar4) + assert.ok(!bar4.className.includes('lc-bar-selected')) + assert.equal(pick.value, 'live') + await m.unmount() + }) + + test('brief rows locate their node in the browser (input, mid response, live response)', async () => { + const m = await mountRich('sv-brief') + const pick = query(m.container, 'select.lc-br-pick') + + // Pin the second bar: brief = opener + inputs (node 3) + response (node 4). + await click(query(m.container, '.lc-bar[data-seq="4"]')) + const briefRows = queryAll(m.container, '.lc-brief-row') + assert.equal(briefRows.length, 3) + + // The In row reveals node 3 (tool) inside the step's OWN surface. + const inRow = briefRows.find(r => text(r).includes(DICT_EN['brief.input'])) + assert.ok(inRow !== undefined) + await click(inRow) + assert.equal(pick.value, '4') + assert.ok(text(query(m.container, '.lc-br-elem-on')).includes('file output')) + + // The response of a middle bar first appears in the NEXT step's surface. + const replyRow = briefRows.find(r => text(r).includes('reply two')) + assert.ok(replyRow !== undefined) + await click(replyRow) + assert.equal(pick.value, '6') + assert.ok(text(query(m.container, '.lc-br-elem-on')).includes('reply two')) + + // The last bar's response lands on the LIVE surface. + await click(query(m.container, '.lc-bar[data-seq="6"]')) + const lastReply = queryAll(m.container, '.lc-brief-row').find(r => text(r).includes('reply three')) + assert.ok(lastReply !== undefined) + await click(lastReply) + assert.equal(pick.value, 'live') + assert.ok(text(query(m.container, '.lc-br-elem-on')).includes('reply three')) + await m.unmount() + }) + + test('composition legend hover lights the linked browser category; tool chips focus the browser', async () => { + const m = await mountRich('sv-link') + const comp = queryAll(m.container, '.lc-card').find(c => text(c).includes(DICT_EN['overview.title'])) + assert.ok(comp !== undefined) + + const chip = query(comp, '.lc-legend .lc-chip') + await hover(chip) + assert.ok(query(m.container, '.lc-br-cat-row').className.includes('lc-br-cat-on')) + await unhover(chip) + assert.ok(!query(m.container, '.lc-br-cat-row').className.includes('lc-br-cat-on')) + + // A tool chip opens the tools category with that tool's row expanded; the + // label chip opens the category without an element. + await click(query(comp, '.lc-tool-chip')) + assert.ok(text(query(m.container, '.lc-br-elem-on')).includes('bash')) + await click(query(comp, '.lc-tools-label')) + assert.equal(queryAll(m.container, '.lc-br-elem-on').length, 0) + assert.ok(m.container.querySelector('.lc-br-body') !== null) + await m.unmount() + }) + + test('delta mode pairs the detail with the previous record; first bar has none', async () => { + const m = await mountRich('sv-delta') + const chart = query(m.container, '.lc-chart') + await click(buttonByText(m.container, DICT_EN['gran.delta'])) + // Default active bar is the newest: detail is the signed change vs its predecessor. + assert.ok(queryAll(m.container, '.lc-detail-tag').some(el => text(el) === DICT_EN['gran.delta'])) + // Hovering the FIRST bar pairs it with null (change from zero). + await hover(query(m.container, '.lc-bar[data-seq="2"]')) + assert.ok(query(m.container, '.lc-bar[data-seq="2"]').className.includes('lc-bar-hovered')) + assert.ok(queryAll(m.container, '.lc-detail-tag').some(el => text(el) === DICT_EN['gran.delta'])) + await unhover(chart) + await click(buttonByText(m.container, DICT_EN['gran.total'])) + assert.ok(!queryAll(m.container, '.lc-detail-tag').some(el => text(el) === DICT_EN['gran.delta'])) + await m.unmount() + }) + + test('event-kind filter narrows, unions, drops, and resets', async () => { + const m = await mountRich('sv-kinds') + const countEvents = () => queryAll(m.container, '.lc-event').length + assert.equal(countEvents(), 2) + + await click(buttonByText(m.container, DICT_EN['kind.inject'])) + assert.equal(countEvents(), 1) + await click(buttonByText(m.container, DICT_EN['kind.compaction'])) + assert.equal(countEvents(), 2) + await click(buttonByText(m.container, DICT_EN['kind.compaction'])) + assert.equal(countEvents(), 1) + await click(buttonByText(m.container, DICT_EN['kind.inject'])) + assert.equal(countEvents(), 2) + await m.unmount() + }) +}) + +describe('ContextView — history paging and image loading', () => { + test('opening an uncached node pulls older history through loadOlderHistory', async () => { + const View = makeView(new TestClientCtx()) + const loads: number[] = [] + const m = await mount(h(View, { + sessionId: 'sv-page', + useProjection: projectionsFor(timeline({ nodes: [{ seq: 1, cat: 'user', tokens: 5, text: 'old msg' }] })), + useSession: (sel => sel({ nodes: [], hasMore: true, loadingOlder: false })) as UseSessionLike, + loadOlderHistory: async () => { + loads.push(1) + }, + })) + const catRow = queryAll(m.container, '.lc-br-cat-row').find(r => text(r).includes(DICT_EN['cat.user'])) + assert.ok(catRow !== undefined) + await click(catRow) + await click(query(m.container, '.lc-br-elem-row')) + await flush() + assert.ok(loads.length >= 1) + assert.ok(text(m.container).includes(DICT_EN['browser.loading'])) + await m.unmount() + }) + + test('without loadOlderHistory an uncached node shows the static note', async () => { + const View = makeView(new TestClientCtx()) + const m = await mount(h(View, { + sessionId: 'sv-nopage', + useProjection: projectionsFor(timeline({ nodes: [{ seq: 1, cat: 'user', tokens: 5, text: 'old msg' }] })), + useSession: (sel => sel({ nodes: [], hasMore: false })) as UseSessionLike, + })) + const catRow = queryAll(m.container, '.lc-br-cat-row').find(r => text(r).includes(DICT_EN['cat.user'])) + assert.ok(catRow !== undefined) + await click(catRow) + await click(query(m.container, '.lc-br-elem-row')) + assert.ok(text(m.container).includes(DICT_EN['browser.noContent'])) + await m.unmount() + }) + + test('image attachments resolve through the conversation service loader', async () => { + const resolved: [string, unknown][] = [] + const ctx = new TestClientCtx({ + services: { + conversation: { + resolveImage: (sessionId: string, attachment: unknown) => { + resolved.push([sessionId, attachment]) + return Promise.resolve('blob:pic') + }, + }, + }, + }) + const View = makeView(ctx) + const m = await mount(h(View, { + sessionId: 'sv-img', + useProjection: projectionsFor(timeline({ + images: 1, + nodes: [{ seq: 1, cat: 'user', tokens: 400, text: 'see this', imgs: 1 }], + })), + useSession: (sel => + sel({ + nodes: [{ + kind: 'user', + seq: 1, + content: [{ type: 'image', attachment: { attachmentId: 'att-1', name: 'pic.png', bytes: 2048, width: 640, height: 480 } }], + }], + hasMore: false, + loadingOlder: false, + })) as UseSessionLike, + })) + const catRow = queryAll(m.container, '.lc-br-cat-row').find(r => text(r).includes(DICT_EN['cat.user'])) + assert.ok(catRow !== undefined) + await click(catRow) + await click(query(m.container, '.lc-br-elem-row')) + await flush() + assert.equal(resolved.length, 1) + assert.equal(resolved[0][0], 'sv-img') + assert.equal((resolved[0][1] as { attachmentId: string }).attachmentId, 'att-1') + assert.ok(m.container.querySelector('.lc-att-item img') !== null) + await m.unmount() + }) + + test('a conversation service without resolveImage degrades quietly', async () => { + const ctx = new TestClientCtx({ services: { conversation: {} } }) + const View = makeView(ctx) + const m = await mount(h(View, { + sessionId: 'sv-noimg', + useProjection: projectionsFor(timeline()), + })) + assert.ok(text(m.container).includes(DICT_EN['overview.title'])) + await m.unmount() + }) +}) + +describe('ContextView — scroll ledger', () => { + async function mountInScroller(el: React.ReactElement, scroller: HTMLElement) { + const inner = document.createElement('div') + scroller.appendChild(inner) + document.body.appendChild(scroller) + const root = createRoot(inner) + await act(async () => { + root.render(el) + }) + return { + async update(next: React.ReactElement) { + await act(async () => { + root.render(next) + }) + }, + async unmount() { + await act(async () => { + root.unmount() + }) + scroller.remove() + }, + } + } + + test('restores the saved position per session and re-applies only once per mount', async () => { + const View = makeView(new TestClientCtx()) + const props = { + sessionId: 'sv-scroll', + useProjection: undefined as ((key: string) => unknown) | undefined, + } + + // First visit: no ledger entry → the shared scroller is re-anchored at top. + const scroller1 = document.createElement('div') + scroller1.setAttribute('data-conversation-scroll', '') + const m1 = await mountInScroller( + h(View, { ...props, useProjection: projectionsFor(richTimeline()) }), + scroller1, + ) + assert.equal(scroller1.scrollTop, 0) + + // A data refresh re-runs the effect but the position is applied once. + scroller1.scrollTop = 17 + await m1.update(h(View, { ...props, useProjection: projectionsFor(richTimeline({ droppedNodes: 3 })) })) + assert.equal(scroller1.scrollTop, 17) + + // Unmount saves the position into the module ledger. + scroller1.scrollTop = 42 + await m1.unmount() + + // Remounting the same session restores the saved position. + const scroller2 = document.createElement('div') + scroller2.setAttribute('data-conversation-scroll', '') + const m2 = await mountInScroller( + h(View, { ...props, useProjection: projectionsFor(richTimeline()) }), + scroller2, + ) + assert.equal(scroller2.scrollTop, 42) + await m2.unmount() + }) +}) + +describe('ContextView — locale and settings', () => { + const costed = timeline({ + cost: { flash: { off: { uncached: 1000000, output: 500000, cacheRead: 0, cacheWrite: 0 } } }, + }) + + test('cost prices in USD by default (no locale service), CNY under zh', async () => { + const m1 = await mount(h(makeView(new TestClientCtx()), { + sessionId: 'sv-usd', + useProjection: projectionsFor(costed), + })) + assert.ok(text(m1.container).includes('$')) + assert.ok(!text(m1.container).includes('¥')) + await m1.unmount() + + const ctxZh = new TestClientCtx({ services: { locale: new TestLocale('zh') } }) + const m2 = await mount(h(makeView(ctxZh), { + sessionId: 'sv-cny', + useProjection: projectionsFor(costed), + })) + assert.ok(text(m2.container).includes('¥')) + await m2.unmount() + + // A locale service without getLocale falls back to USD. + const ctxBare = new TestClientCtx({ services: { locale: {} } }) + const m3 = await mount(h(makeView(ctxBare), { + sessionId: 'sv-bare', + useProjection: projectionsFor(costed), + })) + assert.ok(text(m3.container).includes('$')) + await m3.unmount() + }) + + test('mount-time granularity/trend defaults come from the bound settings scope', async () => { + const settings = createContextSettings() + const scope: SettingsScopeLike = { + getSnapshot: () => ({ status: 'ready', writable: true, value: { defaultGranularity: 'turn', defaultTrendMode: 'delta' } }), + subscribe: () => () => {}, + set: async () => {}, + } + const detach = settings.attach(scope) + const View = makeView(new TestClientCtx(), settings) + const m = await mount(h(View, { + sessionId: 'sv-settings', + useProjection: projectionsFor(richTimeline()), + })) + assert.ok(buttonByText(m.container, DICT_EN['gran.turn']).className.includes('lc-gran-on')) + assert.ok(buttonByText(m.container, DICT_EN['gran.delta']).className.includes('lc-gran-on')) + // Turn aggregation applies at mount: two bars (turn 1 aggregate + turn-less). + assert.equal(queryAll(m.container, '.lc-bar').length, 2) + await m.unmount() + detach() + }) +}) + +describe('ContextView — error boundary', () => { + test('a render failure degrades to the error card and Retry recovers', async () => { + const real = createContextSettings() + // Flag-driven (not call-counted): React 18 dev replays a failed unit of + // work and retries an errored concurrent pass synchronously, so the + // failure must persist until the boundary catches, then clear for Retry. + let fail = true + const flaky = { + ...real, + defaultGranularity: (): 'step' | 'turn' => { + if (fail) throw new Error('boom-settings') + return real.defaultGranularity() + }, + } + const View = makeView(new TestClientCtx(), flaky) + const m = await mount(h(View, { + sessionId: 'sv-err', + useProjection: projectionsFor(richTimeline()), + })) + assert.ok(text(m.container).includes(DICT_EN.error)) + assert.ok(text(m.container).includes('boom-settings')) + + fail = false + await click(query(m.container, '.lc-error-retry')) + assert.ok(text(m.container).includes(DICT_EN['overview.title'])) + assert.ok(!text(m.container).includes(DICT_EN.error)) + await m.unmount() + }) +}) diff --git a/tests/client/components/currentComposition.spec.ts b/tests/client/components/currentComposition.spec.ts new file mode 100644 index 0000000..d870334 --- /dev/null +++ b/tests/client/components/currentComposition.spec.ts @@ -0,0 +1,102 @@ +// CurrentComposition (src/client/components/currentComposition.tsx) rendered +// with real React over the real StackedBar/Legend pair. + +import assert from 'node:assert/strict' +import { describe, test } from 'vitest' +import { h } from '../../../src/client/react' +import { makeCurrentComposition } from '../../../src/client/components/currentComposition' +import { makeLegend, makeStackedBar } from '../../../src/client/components/stackedBar' +import type { Headline } from '../../../src/client/headline' +import { click, hover, makeKit, mount, query, queryAll, text } from '../helpers/kit' + +const kit = makeKit() +const CurrentComposition = makeCurrentComposition(kit, makeStackedBar(kit), makeLegend(kit)) + +const PARTS = [{ key: 'user', color: '#123456', value: 5000 }] + +function headOf(over: Partial = {}): Headline { + return { tokens: 5000, pct: null, parts: PARTS, ...over } +} + +describe('CurrentComposition header', () => { + test('windowed head: tokens/window figure, used percentage, reserve band, subtitle', async () => { + const m = await mount(h(CurrentComposition, { + head: headOf({ window: 10000, pct: 50 }), + subtitle: 'deepseek-v4-flash', + })) + assert.ok(text(m.container).includes('Current Context')) + assert.equal(query(m.container, '.lc-card-sub').textContent, 'deepseek-v4-flash') + const num = query(m.container, '.lc-overview-num') + assert.ok(text(num).includes('5.0k / 10.0k tokens')) + assert.equal(query(num, '.lc-overview-pct b').textContent, '50%') + assert.ok(text(num).includes('of context used')) + // The reserve band mirrors the 80% auto-compaction threshold. + const band = query(m.container, '.lc-reserve') + await hover(band) + const tip = query(m.container, '.lc-bar-tip') + assert.ok(tip.textContent!.includes('Auto-compaction reserve')) + assert.ok(tip.textContent!.includes('80%')) + await m.unmount() + }) + + test('windowless head: estimate label, no percentage, no reserve, no subtitle', async () => { + const m = await mount(h(CurrentComposition, { head: headOf() })) + assert.ok(text(m.container).includes('tokens (estimated)')) + assert.equal(queryAll(m.container, '.lc-overview-pct').length, 0) + assert.equal(queryAll(m.container, '.lc-reserve').length, 0) + assert.equal(queryAll(m.container, '.lc-card-sub').length, 0) + // An empty subtitle renders nothing either; a zero window stays reserve-free. + await m.update(h(CurrentComposition, { head: headOf({ window: 0 }), subtitle: '' })) + assert.equal(queryAll(m.container, '.lc-card-sub').length, 0) + assert.equal(queryAll(m.container, '.lc-reserve').length, 0) + assert.ok(text(m.container).includes('tokens (estimated)')) + await m.unmount() + }) +}) + +describe('CurrentComposition tools row', () => { + test('undefined or empty tools: no tools row', async () => { + const m = await mount(h(CurrentComposition, { head: headOf() })) + assert.equal(queryAll(m.container, '.lc-tools').length, 0) + await m.update(h(CurrentComposition, { head: headOf(), tools: [] })) + assert.equal(queryAll(m.container, '.lc-tools').length, 0) + await m.unmount() + }) + + test('up to five tools: chips sorted by tokens, no more button', async () => { + const m = await mount(h(CurrentComposition, { + head: headOf(), + tools: [{ name: 'read', tokens: 100 }, { name: 'bash', tokens: 300 }, { name: 'grep', tokens: 200 }], + })) + const chips = queryAll(m.container, '.lc-tool-chip') + assert.deepEqual(chips.map(c => c.textContent), ['bash 300', 'grep 200', 'read 100']) + assert.equal(queryAll(m.container, '.lc-tools-more').length, 0) + assert.equal(query(m.container, '.lc-tools-label').textContent, 'Top Tool Schemas:') + await m.unmount() + }) + + test('more than five tools: top five chips plus the count button; clicks request focus', async () => { + const tools = [1, 2, 3, 4, 5, 6, 7].map(n => ({ name: 'tool' + n, tokens: n * 10 })) + const seen: ({ tool?: string } | null)[] = [] + const m = await mount(h(CurrentComposition, { head: headOf(), tools, onToolFocus: f => seen.push(f) })) + const chips = queryAll(m.container, '.lc-tool-chip') + assert.equal(chips.length, 5) + assert.equal(chips[0].textContent, 'tool7 70') + const more = query(m.container, '.lc-tools-more') + assert.equal(more.textContent, 'of 7') + await click(chips[1]) // tool6 + await click(query(m.container, '.lc-tools-label')) + await click(more) + assert.deepEqual(seen, [{ tool: 'tool6' }, {}, {}]) + await m.unmount() + }) + + test('clicks without onToolFocus are no-ops and never throw', async () => { + const tools = [1, 2, 3, 4, 5, 6].map(n => ({ name: 'tool' + n, tokens: n })) + const m = await mount(h(CurrentComposition, { head: headOf(), tools })) + await click(queryAll(m.container, '.lc-tool-chip')[0]) + await click(query(m.container, '.lc-tools-label')) + await click(query(m.container, '.lc-tools-more')) + await m.unmount() + }) +}) diff --git a/tests/client/components/errorBoundary.spec.ts b/tests/client/components/errorBoundary.spec.ts new file mode 100644 index 0000000..c47974f --- /dev/null +++ b/tests/client/components/errorBoundary.spec.ts @@ -0,0 +1,60 @@ +// ErrorBoundary (src/client/components/errorBoundary.tsx): real subtree +// errors degrade to the styled error card; Retry resets the boundary. +// React logs caught errors to console — silenced with a spy, never mocked. + +import assert from 'node:assert/strict' +import { afterEach, describe, test, vi } from 'vitest' +import { h } from '../../../src/client/react' +import { makeErrorBoundary } from '../../../src/client/components/errorBoundary' +import { click, makeKit, mount, query, text } from '../helpers/kit' + +const kit = makeKit() +const ErrorBoundary = makeErrorBoundary(kit.t) + +let consoleSpy: ReturnType | null = null + +afterEach(() => { + consoleSpy?.mockRestore() + consoleSpy = null +}) + +function silenceConsoleError(): void { + consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) +} + +describe('ErrorBoundary', () => { + test('passes children through when nothing throws', async () => { + const m = await mount(h(ErrorBoundary, {}, h('div', { className: 'healthy' }, 'all good'))) + assert.equal(text(m.container), 'all good') + await m.unmount() + }) + + test('a render error degrades to the error card; Retry resumes a healthy child', async () => { + silenceConsoleError() + let shouldThrow = true + function Bomb() { + if (shouldThrow) throw new Error('kaboom') + return h('div', { className: 'healthy' }, 'recovered') + } + const m = await mount(h(ErrorBoundary, {}, h(Bomb, {}))) + const card = query(m.container, '.lc-error') + assert.ok(text(card).includes('Failed to read context data:')) + assert.equal(query(card, '.lc-error-msg').textContent, 'kaboom') + const retry = query(card, '.lc-error-retry') + assert.equal(retry.textContent, 'Retry') + shouldThrow = false + await click(retry) + assert.equal(text(m.container), 'recovered') + await m.unmount() + }) + + test('a non-Error throw is stringified into the card', async () => { + silenceConsoleError() + function Bomb(): never { + throw 'string failure' // deliberate non-Error throw + } + const m = await mount(h(ErrorBoundary, {}, h(Bomb, {}))) + assert.equal(query(m.container, '.lc-error-msg').textContent, 'string failure') + await m.unmount() + }) +}) diff --git a/tests/client/components/events.spec.ts b/tests/client/components/events.spec.ts new file mode 100644 index 0000000..4fb9db7 --- /dev/null +++ b/tests/client/components/events.spec.ts @@ -0,0 +1,162 @@ +// events.tsx — makeEventText pure helpers plus EventList rendered with real +// React (real shared icon components), including the truncation layout effect +// and the empty→non-empty hook-count regression (issue #12). + +import assert from 'node:assert/strict' +import { describe, test } from 'vitest' +import { act } from 'react' +import { h } from '../../../src/client/react' +import { makeEventList, makeEventText } from '../../../src/client/components/events' +import { fmtTime } from '../../../src/client/format' +import type { ContextEventRecord } from '../../../src/shared/types' +import { flush, makeKit, mount, query, queryAll, text } from '../helpers/kit' + +const kit = makeKit() +const { eventLabel, eventAt } = makeEventText(kit.t) +const EventList = makeEventList(kit) + +function ev(over: Partial & { kind: ContextEventRecord['kind'] }): ContextEventRecord { + return { seq: 1, time: 0, ...over } +} + +describe('makeEventText.eventLabel', () => { + test('compaction counts the replaced messages (absent count folds to 0)', () => { + assert.equal(eventLabel(ev({ kind: 'compaction', count: 3 })), 'Context compacted (summary replaced 3 messages)') + assert.equal(eventLabel(ev({ kind: 'compaction' })), 'Context compacted (summary replaced 0 messages)') + }) + + test('prune has a fixed label', () => { + assert.equal(eventLabel(ev({ kind: 'prune' })), 'Tool output pruned') + }) + + test('model switch interpolates from/to, ? for a missing side', () => { + assert.equal(eventLabel(ev({ kind: 'model', from: 'a', to: 'b' })), 'Model switched: a → b') + assert.equal(eventLabel(ev({ kind: 'model', from: 'a' })), 'Model switched: a → ?') + assert.equal(eventLabel(ev({ kind: 'model', to: 'b' })), 'Model switched: ? → b') + }) + + test('mode labels resolve per name; a missing name resolves to the raw key', () => { + assert.equal(eventLabel(ev({ kind: 'mode', name: 'plan.on' })), 'Plan mode on') + assert.equal(eventLabel(ev({ kind: 'mode', name: 'plan.off' })), 'Plan mode off') + assert.equal(eventLabel(ev({ kind: 'mode' })), 'ev.mode.?') + }) + + test('skill injects name the skill, ? when missing', () => { + assert.equal(eventLabel(ev({ kind: 'inject', sub: 'skill', name: 'code-review' })), 'Skill injected (code-review)') + assert.equal(eventLabel(ev({ kind: 'inject', sub: 'skill' })), 'Skill injected (?)') + }) + + test('plain injects label their form, then append name and detail', () => { + assert.equal(eventLabel(ev({ kind: 'inject' })), 'Context Injection') + assert.equal(eventLabel(ev({ kind: 'inject', form: 'notice' })), 'Notice') + assert.equal(eventLabel(ev({ kind: 'inject', form: 'notice', name: 'hook' })), 'Notice · hook') + assert.equal(eventLabel(ev({ kind: 'inject', detail: 'from the producer' })), 'Context Injection · from the producer') + assert.equal(eventLabel(ev({ kind: 'inject', form: 'notice', name: 'hook', detail: 'd' })), 'Notice · hook · d') + }) +}) + +describe('makeEventText.eventAt', () => { + test('boundary events label the gap: same-turn range, cross-turn range, single point', () => { + assert.equal(eventAt(ev({ kind: 'compaction', turn: 2, step: 4, fromTurn: 2, fromStep: 3 })), 'Turn 2 · Step 3→4') + assert.equal(eventAt(ev({ kind: 'prune', turn: 51, step: 1, fromTurn: 50, fromStep: 8 })), 'Turn 50 · Step 8 → Turn 51 · Step 1') + assert.equal(eventAt(ev({ kind: 'compaction', turn: 2, step: 3 })), 'Turn 2 · Step 3') + // A partial from-side falls back to the single point. + assert.equal(eventAt(ev({ kind: 'compaction', turn: 2, step: 3, fromTurn: 2 })), 'Turn 2 · Step 3') + }) + + test('missing turn/step yields null; non-boundary kinds keep their single point', () => { + assert.equal(eventAt(ev({ kind: 'compaction' })), null) + assert.equal(eventAt(ev({ kind: 'prune', turn: 1 })), null) + assert.equal(eventAt(ev({ kind: 'inject', turn: 1, step: 2 })), 'Turn 1 · Step 2') + assert.equal(eventAt(ev({ kind: 'model' })), null) + }) +}) + +describe('EventList', () => { + test('renders the empty state', async () => { + const m = await mount(h(EventList, { events: [] })) + assert.ok(text(m.container).includes('No context events yet')) + await m.unmount() + }) + + test('rows render newest first with icons, kinds, labels, positions, tokens, and times', async () => { + const time = new Date(2024, 0, 2, 3, 4, 5).getTime() + const events: ContextEventRecord[] = [ + ev({ seq: 1, time, kind: 'inject', form: 'notice', tokens: 50, turn: 1, step: 1 }), + ev({ seq: 2, time, kind: 'model', from: 'a', to: 'b' }), + ev({ seq: 3, time, kind: 'compaction', count: 2, tokens: 100 }), + ev({ seq: 4, time, kind: 'mystery' as never }), + ev({ seq: 5, time, kind: 'inject', tokens: 0 }), + ] + const m = await mount(h(EventList, { events })) + const rows = queryAll(m.container, '.lc-event') + assert.equal(rows.length, 5) + + // Newest first: seq 5 leads. tokens 0 renders no token span. + assert.ok(text(rows[0]).includes('Context Injection')) + assert.equal(queryAll(rows[0], '.lc-event-tokens').length, 0) + + // Foreign kind: EVENT_ICONS miss → '•' fallback, and the kind label + // resolves through the locale chain to the key itself. + assert.equal(query(rows[1], '.lc-event-icon').textContent, '•') + assert.equal(query(rows[1], '.lc-kind').textContent, 'kind.mystery') + + // Compaction: ✂ glyph, negative token delta, no position (no turn/step). + assert.equal(query(rows[2], '.lc-event-icon').textContent, '✂') + const down = query(rows[2], '.lc-event-tokens') + assert.ok(down.className.includes('lc-down')) + assert.equal(down.textContent, '−100') + assert.equal(queryAll(rows[2], '.lc-event-at').length, 0) + + // Model switch: real IconBranchOutline16 svg. + assert.ok(query(rows[3], '.lc-event-icon svg') instanceof SVGElement) + assert.ok(text(rows[3]).includes('Model switched: a → b')) + assert.equal(query(rows[3], '.lc-kind').textContent, 'Switch') + + // Inject: real IconPlusOutline16 svg, positive token delta, position, time. + assert.ok(query(rows[4], '.lc-event-icon svg') instanceof SVGElement) + const up = query(rows[4], '.lc-event-tokens') + assert.ok(up.className.includes('lc-up')) + assert.equal(up.textContent, '+50') + assert.equal(query(rows[4], '.lc-event-at').textContent, 'Turn 1 · Step 1') + assert.equal(query(rows[4], '.lc-event-time').textContent, fmtTime(time)) + await m.unmount() + }) + + test('truncation effect: jsdom widths are 0 so titles stay empty; a forced overflow syncs the title', async () => { + const events = [ + ev({ seq: 1, kind: 'inject', form: 'notice' }), + ev({ seq: 2, kind: 'prune' }), + ] + const m = await mount(h(EventList, { events })) + const labels = queryAll(m.container, '.lc-event-label') + assert.equal(labels[0].getAttribute('title'), '') + assert.equal(labels[1].getAttribute('title'), '') + + // Newest first: labels[0] is the prune, labels[1] the inject. + // Force an empty-text overflow on the prune label (the + // `el.textContent || ''` fallback arm) and a real overflow on the + // inject label, then resize. + Object.defineProperty(labels[0], 'scrollWidth', { value: 100, configurable: true }) + Object.defineProperty(labels[1], 'scrollWidth', { value: 100, configurable: true }) + labels[0].textContent = '' + await act(async () => { + window.dispatchEvent(new Event('resize')) + }) + assert.equal(labels[0].getAttribute('title'), '') + assert.equal(labels[1].getAttribute('title'), 'Notice') + await flush() + await m.unmount() + }) + + test('empty → non-empty transition keeps the hook count stable (issue #12)', async () => { + const m = await mount(h(EventList, { events: [] })) + assert.ok(text(m.container).includes('No context events yet')) + await m.update(h(EventList, { events: [ev({ seq: 1, kind: 'prune' })] })) + assert.equal(queryAll(m.container, '.lc-event').length, 1) + assert.ok(text(m.container).includes('Tool output pruned')) + await m.update(h(EventList, { events: [] })) + assert.ok(text(m.container).includes('No context events yet')) + await m.unmount() + }) +}) diff --git a/tests/client/components/images.spec.ts b/tests/client/components/images.spec.ts new file mode 100644 index 0000000..8a03e60 --- /dev/null +++ b/tests/client/components/images.spec.ts @@ -0,0 +1,277 @@ +// images.tsx — imageRefOf narrowing (pure) plus ImageCard and the +// AttachmentLightbox rendered with real React (portal to document.body). + +import assert from 'node:assert/strict' +import { describe, test, vi } from 'vitest' +import { act } from 'react' +import { h } from '../../../src/client/react' +import { imageRefOf, makeImageCard } from '../../../src/client/components/images' +import type { ImageRefLike } from '../../../src/client/services' +import { estimateImageTokens } from '../../../src/shared/imageTokens' +import { click, flush, keydown, makeKit, mount, query, queryAll } from '../helpers/kit' + +const kit = makeKit() +const ImageCard = makeImageCard(kit) + +async function mousedown(el: Element): Promise { + await act(async () => { + el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })) + }) +} + +describe('imageRefOf', () => { + test('rejects non-objects and non-image blocks', () => { + assert.equal(imageRefOf(null), null) + assert.equal(imageRefOf('image'), null) + assert.equal(imageRefOf(42), null) + assert.equal(imageRefOf({}), null) + assert.equal(imageRefOf({ type: 'text', attachment: { attachmentId: 'a' } }), null) + }) + + test('accepts both type:image and kind:image shapes', () => { + const attachment = { attachmentId: 'a1' } + assert.deepEqual(imageRefOf({ type: 'image', attachment }), { attachmentId: 'a1' }) + assert.deepEqual(imageRefOf({ kind: 'image', attachment }), { attachmentId: 'a1' }) + }) + + test('rejects a missing or malformed attachment', () => { + assert.equal(imageRefOf({ type: 'image' }), null) + assert.equal(imageRefOf({ type: 'image', attachment: null }), null) + assert.equal(imageRefOf({ type: 'image', attachment: 'x' }), null) + assert.equal(imageRefOf({ type: 'image', attachment: {} }), null) + assert.equal(imageRefOf({ type: 'image', attachment: { attachmentId: '' } }), null) + assert.equal(imageRefOf({ type: 'image', attachment: { attachmentId: 42 } }), null) + }) + + test('keeps only well-formed optional facts', () => { + assert.deepEqual( + imageRefOf({ + type: 'image', + attachment: { + attachmentId: 'a1', + name: '', + bytes: '500', + width: NaN, + height: 0, + originalDimensions: 'big', + }, + }), + { attachmentId: 'a1' }, + ) + assert.deepEqual( + imageRefOf({ type: 'image', attachment: { attachmentId: 'a1', bytes: -5, originalDimensions: null } }), + { attachmentId: 'a1' }, + ) + assert.deepEqual( + imageRefOf({ type: 'image', attachment: { attachmentId: 'a1', originalDimensions: { width: 100 } } }), + { attachmentId: 'a1' }, + ) + assert.deepEqual( + imageRefOf({ type: 'image', attachment: { attachmentId: 'a1', originalDimensions: { width: 0, height: 10 } } }), + { attachmentId: 'a1' }, + ) + }) + + test('passes a fully populated attachment through', () => { + assert.deepEqual( + imageRefOf({ + kind: 'image', + attachment: { + attachmentId: 'a1', + name: 'photo.png', + bytes: 500, + width: 100, + height: 100, + originalDimensions: { width: 4000, height: 3000 }, + }, + }), + { + attachmentId: 'a1', + name: 'photo.png', + bytes: 500, + width: 100, + height: 100, + originalDimensions: { width: 4000, height: 3000 }, + }, + ) + }) +}) + +const FULL: ImageRefLike = { + attachmentId: 'a1', + name: 'photo.png', + bytes: 500, + width: 100, + height: 100, + originalDimensions: { width: 4000, height: 3000 }, +} + +describe('ImageCard metadata', () => { + test('without a loader: placeholder tile, metadata rows, click is a no-op', async () => { + const m = await mount(h(ImageCard, { attachment: FULL })) + const card = query(m.container, '.lc-att-item') + assert.equal(card.getAttribute('title'), 'Open full image') + assert.equal(query(m.container, '.lc-att-ph').textContent, '🖼') + assert.equal(query(m.container, '.lc-att-name').textContent, 'photo.png') + const rows = queryAll(m.container, '.lc-att-row') + assert.equal(rows.length, 3) + assert.equal(rows[0].textContent, 'Raw4000×3000') + assert.equal(rows[1].textContent, 'Sent100×100 · 500 B') + assert.equal(rows[2].textContent, `Token≈${estimateImageTokens(100, 100)}`) + assert.ok(rows[2].getAttribute('title')!.includes('384')) + assert.equal(rows[0].getAttribute('title'), null) + await click(card) + assert.equal(queryAll(document.body, '.lc-att-lightbox').length, 0) + await m.unmount() + }) + + test('without a name the generic label applies; without dimensions no rows render', async () => { + const m = await mount(h(ImageCard, { attachment: { attachmentId: 'a2' } })) + const name = query(m.container, '.lc-att-name') + assert.equal(name.textContent, 'Image') + assert.equal(name.getAttribute('title'), 'Image') + assert.equal(queryAll(m.container, '.lc-att-row').length, 0) + await m.unmount() + }) + + test('sent row without a byte size shows dimensions only', async () => { + const m = await mount(h(ImageCard, { attachment: { attachmentId: 'a3', name: 'n.png', width: 200, height: 50 } })) + const rows = queryAll(m.container, '.lc-att-row') + assert.equal(rows.length, 2) + assert.equal(rows[0].textContent, 'Sent200×50') + assert.equal(rows[1].textContent, `Token≈${estimateImageTokens(200, 50)}`) + await m.unmount() + }) +}) + +describe('ImageCard loading', () => { + test('a resolving loader swaps the placeholder for the image', async () => { + const load = vi.fn((_att: ImageRefLike) => Promise.resolve('blob:x')) + const m = await mount(h(ImageCard, { attachment: FULL, load })) + await flush() + const img = query(m.container, '.lc-att-thumb img') + assert.equal(img.getAttribute('src'), 'blob:x') + assert.equal(img.getAttribute('alt'), 'photo.png') + assert.equal(load.mock.calls.length, 1) + assert.deepEqual(load.mock.calls[0][0], FULL) + await m.unmount() + }) + + test('a pending loader shows the loading glyph', async () => { + const load = () => new Promise(() => {}) + const m = await mount(h(ImageCard, { attachment: FULL, load })) + assert.equal(query(m.container, '.lc-att-ph').textContent, '…') + await m.unmount() + }) + + test('a rejecting loader shows the error tile; clicking retries the load', async () => { + const load = vi.fn() + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValue('blob:y') + const m = await mount(h(ImageCard, { attachment: FULL, load })) + await flush() + assert.equal(query(m.container, '.lc-att-err').textContent, '⚠') + assert.equal(query(m.container, '.lc-att-item').getAttribute('title'), 'Load failed · click to retry') + await click(query(m.container, '.lc-att-item')) + await flush() + assert.equal(load.mock.calls.length, 2) + const img = query(m.container, '.lc-att-thumb img') + assert.equal(img.getAttribute('src'), 'blob:y') + assert.equal(query(m.container, '.lc-att-item').getAttribute('title'), 'Open full image') + await m.unmount() + }) + + test('a late answer after unmount is dropped (resolve and reject alike)', async () => { + let resolveLoad: ((url: string) => void) | null = null + const m = await mount(h(ImageCard, { attachment: FULL, load: () => new Promise((r) => { resolveLoad = r }) })) + await m.unmount() + resolveLoad!('blob:z') + await flush() + + let rejectLoad: ((err: unknown) => void) | null = null + const m2 = await mount(h(ImageCard, { + attachment: FULL, + load: () => new Promise((_, rej) => { rejectLoad = rej }), + })) + await m2.unmount() + rejectLoad!(new Error('late')) + await flush() + }) +}) + +describe('AttachmentLightbox', () => { + async function mountOpenable() { + const load = () => Promise.resolve('blob:x') + const m = await mount(h(ImageCard, { attachment: FULL, load })) + await flush() + return m + } + + test('click opens the body portal; Escape closes it and restores focus', async () => { + const m = await mountOpenable() + const card = query(m.container, '.lc-att-item') + card.focus() + assert.equal(document.activeElement, card) + await click(card) + const box = query(document.body, '.lc-att-lightbox') + assert.equal(box.getAttribute('role'), 'dialog') + assert.equal(box.getAttribute('aria-label'), 'Image preview') + assert.equal(query(box, '.lc-att-lightbox-img').getAttribute('src'), 'blob:x') + const close = query(box, '.lc-att-lightbox-close') + assert.equal(close.getAttribute('aria-label'), 'Close') + assert.ok(query(box, 'svg') instanceof SVGElement) // real IconCloseOutline16 + assert.equal(document.activeElement, close) // focus moved into the dialog + await keydown('Enter') // non-Escape keys keep it open + assert.equal(queryAll(document.body, '.lc-att-lightbox').length, 1) + await keydown('Escape') + assert.equal(queryAll(document.body, '.lc-att-lightbox').length, 0) + assert.equal(document.activeElement, card) // focus restored to the opener + await m.unmount() + }) + + test('mask mousedown closes the lightbox', async () => { + const m = await mountOpenable() + await click(query(m.container, '.lc-att-item')) + await mousedown(query(document.body, '.lc-att-lightbox-mask')) + assert.equal(queryAll(document.body, '.lc-att-lightbox').length, 0) + await m.unmount() + }) + + test('the close button closes the lightbox', async () => { + const m = await mountOpenable() + await click(query(m.container, '.lc-att-item')) + await click(query(document.body, '.lc-att-lightbox-close')) + assert.equal(queryAll(document.body, '.lc-att-lightbox').length, 0) + await m.unmount() + }) + + test('without a focused opener there is nothing to restore to', async () => { + // jsdom always reports as activeElement; force the null arm of the + // `instanceof HTMLElement` guard. + const descriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'activeElement') + Object.defineProperty(document, 'activeElement', { get: () => null, configurable: true }) + try { + const m = await mountOpenable() + await click(query(m.container, '.lc-att-item')) + assert.equal(queryAll(document.body, '.lc-att-lightbox').length, 1) + await keydown('Escape') + assert.equal(queryAll(document.body, '.lc-att-lightbox').length, 0) + await m.unmount() + } finally { + delete (document as { activeElement?: unknown }).activeElement + if (descriptor) Object.defineProperty(Document.prototype, 'activeElement', descriptor) + } + }) + + test('switching the attachment while open closes the preview', async () => { + const load = () => Promise.resolve('blob:x') + const m = await mount(h(ImageCard, { attachment: FULL, load })) + await flush() + await click(query(m.container, '.lc-att-item')) + assert.equal(queryAll(document.body, '.lc-att-lightbox').length, 1) + // New attachment with a pending load: src resets, preview drops out. + await m.update(h(ImageCard, { attachment: { ...FULL, attachmentId: 'a2' }, load: () => new Promise(() => {}) })) + assert.equal(queryAll(document.body, '.lc-att-lightbox').length, 0) + await m.unmount() + }) +}) diff --git a/tests/client/components/nodes.spec.ts b/tests/client/components/nodes.spec.ts new file mode 100644 index 0000000..59969ca --- /dev/null +++ b/tests/client/components/nodes.spec.ts @@ -0,0 +1,75 @@ +// NodeList (src/client/components/nodes.tsx) rendered with real React, plus +// the makeNodeText label cascade (pure, driven directly). + +import assert from 'node:assert/strict' +import { describe, test } from 'vitest' +import { h } from '../../../src/client/react' +import { makeNodeList, makeNodeText } from '../../../src/client/components/nodes' +import { makeKit, mount, queryAll, text } from '../helpers/kit' + +const kit = makeKit() +const nodeText = makeNodeText(kit) +const NodeList = makeNodeList(kit) + +describe('makeNodeText', () => { + test('tool results name the tool; failures carry the warning glyph', () => { + assert.equal(nodeText({ seq: 1, cat: 'tool', tokens: 1, tool: 'bash' }), 'Tool Result ← bash') + assert.equal(nodeText({ seq: 1, cat: 'tool', tokens: 1 }), 'Tool Result') + assert.equal(nodeText({ seq: 1, cat: 'tool', tokens: 1, tool: 'bash', err: true }), 'Tool Result ← bash ⚠') + }) + + test('skill-tagged nodes lead with the skill name', () => { + // The skill branch follows the tool branch: skill results keep their + // tool-result label; the skill name leads on injected nodes. + assert.equal(nodeText({ seq: 1, cat: 'tool', tokens: 1, skill: 'code-review' }), 'Tool Result') + assert.equal(nodeText({ seq: 1, cat: 'inject', tokens: 1, skill: 'code-review' }), 'Skill: code-review') + }) + + test('assistant replies show text, a calls breadcrumb, or the empty marker', () => { + assert.equal(nodeText({ seq: 1, cat: 'assistant', tokens: 1, text: 'hi' }), 'hi') + assert.equal(nodeText({ seq: 1, cat: 'assistant', tokens: 1, calls: ['bash', 'read'] }), 'Calls bash, read') + assert.equal(nodeText({ seq: 1, cat: 'assistant', tokens: 1 }), '(empty reply)') + }) + + test('snapshot-form text gets the snapshot prefix; plain text shows as-is', () => { + assert.equal(nodeText({ seq: 1, cat: 'inject', tokens: 1, form: 'snapshot', text: 'a, b' }), 'Snapshot: a, b') + assert.equal(nodeText({ seq: 1, cat: 'user', tokens: 1, text: 'plain' }), 'plain') + }) + + test('textless injects label their form; anything else is a non-text message', () => { + assert.equal(nodeText({ seq: 1, cat: 'inject', tokens: 1, form: 'notice' }), 'Notice') + assert.equal(nodeText({ seq: 1, cat: 'inject', tokens: 1 }), 'Context Injection') + assert.equal(nodeText({ seq: 1, cat: 'user', tokens: 1 }), '(non-text message)') + }) +}) + +describe('NodeList', () => { + test('renders the empty state', async () => { + const m = await mount(h(NodeList, { nodes: [], dropped: 0 })) + assert.ok(text(m.container).includes('No model-visible messages')) + await m.unmount() + }) + + test('renders newest first with colors, times, and tokens; omitted-count row when dropped', async () => { + const m = await mount(h(NodeList, { + nodes: [ + { seq: 1, cat: 'user', tokens: 10, text: 'first', time: 1000 }, + { seq: 2, cat: 'assistant', tokens: 2048, text: 'second' }, + ], + dropped: 3, + })) + const rows = queryAll(m.container, '.lc-node') + assert.equal(rows.length, 2) + assert.ok(text(rows[0]).includes('second')) // newest first + assert.ok(text(rows[0]).includes('2.0k')) + assert.ok(text(m.container).includes('3 earlier messages omitted')) + await m.unmount() + }) + + test('unknown categories fall back to the grey swatch', async () => { + const m = await mount(h(NodeList, { nodes: [{ seq: 1, cat: 'mystery' as never, tokens: 1 }], dropped: 0 })) + const swatch = m.container.querySelector('i') + assert.ok(swatch?.style.background.includes('rgb(153, 153, 153)') || swatch?.style.background === '#999') + await m.unmount() + }) +}) diff --git a/tests/client/components/pluginInfo.spec.ts b/tests/client/components/pluginInfo.spec.ts new file mode 100644 index 0000000..c92c265 --- /dev/null +++ b/tests/client/components/pluginInfo.spec.ts @@ -0,0 +1,101 @@ +// PluginInfo (src/client/components/pluginInfo.tsx): static metadata rows +// plus the live npm latest-version check. fetch is stubbed per test and the +// modules are re-imported fresh (vi.resetModules) so the baked-in version +// define and the fetch TTL cache both start clean. + +import assert from 'node:assert/strict' +import { afterEach, describe, test, vi } from 'vitest' +import { h } from '../../../src/client/react' +import type { makePluginInfo as makePluginInfoFn } from '../../../src/client/components/pluginInfo' +import { flush, makeKit, mount, query, queryAll, text } from '../helpers/kit' + +const kit = makeKit() + +type FetchBody = { version?: unknown } + +function stubFetchOk(body: FetchBody) { + const fetchMock = vi.fn((_input: unknown) => Promise.resolve({ ok: true, json: () => Promise.resolve(body) })) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +/** Fresh latestVersion + pluginInfo modules; `version` emulates the tsdown define. */ +async function loadPluginInfo(version?: string): Promise> { + vi.resetModules() + if (version === undefined) delete (globalThis as Record).__DSH_CTX_VERSION__ + else (globalThis as Record).__DSH_CTX_VERSION__ = version + await import('../../../src/client/latestVersion') + const mod = await import('../../../src/client/components/pluginInfo') + return mod.makePluginInfo(kit) +} + +afterEach(() => { + vi.unstubAllGlobals() + delete (globalThis as Record).__DSH_CTX_VERSION__ +}) + +describe('PluginInfo', () => { + test('dev build renders the static rows and never checks the registry', async () => { + const fetchMock = stubFetchOk({ version: '9.9.9' }) + const PluginInfo = await loadPluginInfo() // PLUGIN_VERSION = 0.0.0-dev + const m = await mount(h(PluginInfo, {})) + await flush() + assert.equal(fetchMock.mock.calls.length, 0) + assert.ok(text(m.container).includes('Plugin Info')) + const rows = queryAll(m.container, '.lc-pi-row') + assert.equal(rows.length, 2) + assert.equal(query(rows[0], '.lc-pi-label').textContent, 'Plugin') + assert.equal(query(rows[0], '.lc-pi-value').textContent, 'dsh-context (v0.0.0-dev)') + assert.equal(rows[0].getAttribute('href'), 'https://github.com/bowenliang123/dsh-context/releases') + assert.equal(rows[0].getAttribute('target'), '_blank') + assert.equal(query(rows[1], '.lc-pi-label').textContent, 'GitHub') + assert.equal(query(rows[1], '.lc-pi-value').textContent, 'bowenliang123/dsh-context') + assert.equal(rows[1].getAttribute('href'), 'https://github.com/bowenliang123/dsh-context') + assert.equal(queryAll(m.container, '.lc-pi-update').length, 0) + await m.unmount() + }) + + test('release build with a newer registry version appends the update chip', async () => { + const fetchMock = stubFetchOk({ version: '1.1.0' }) + const PluginInfo = await loadPluginInfo('1.0.0') + const m = await mount(h(PluginInfo, {})) + await flush() + assert.equal(fetchMock.mock.calls.length, 1) + assert.equal(fetchMock.mock.calls[0][0], 'https://registry.npmjs.org/dsh-context/latest') + assert.ok(text(m.container).includes('dsh-context (v1.0.0)')) + assert.equal(query(m.container, '.lc-pi-update').textContent, '↑ v1.1.0') + await m.unmount() + }) + + test('a registry version that is not newer renders no chip', async () => { + stubFetchOk({ version: '1.0.0' }) + const PluginInfo = await loadPluginInfo('1.0.0') + const m = await mount(h(PluginInfo, {})) + await flush() + assert.equal(queryAll(m.container, '.lc-pi-update').length, 0) + await m.unmount() + }) + + test('a failed or malformed registry answer narrows to null and renders no chip', async () => { + const fetchMock = vi.fn(() => Promise.resolve({ ok: false, json: () => Promise.resolve(null) })) + vi.stubGlobal('fetch', fetchMock) + const PluginInfo = await loadPluginInfo('1.0.0') + const m = await mount(h(PluginInfo, {})) + await flush() + assert.equal(fetchMock.mock.calls.length, 1) + assert.equal(queryAll(m.container, '.lc-pi-update').length, 0) + await m.unmount() + }) + + test('unmounting before the answer lands drops the late result', async () => { + let resolveFetch: ((res: { ok: boolean; json: () => Promise }) => void) | null = null + vi.stubGlobal('fetch', vi.fn(() => new Promise((resolve) => { resolveFetch = resolve }))) + const PluginInfo = await loadPluginInfo('1.0.0') + const m = await mount(h(PluginInfo, {})) + await m.unmount() + resolveFetch!({ ok: true, json: () => Promise.resolve({ version: '2.0.0' }) }) + await flush() + // No state update after unmount, no act warning, no chip anywhere. + assert.equal(queryAll(document.body, '.lc-pi-update').length, 0) + }) +}) diff --git a/tests/client/components/requestDetail.spec.ts b/tests/client/components/requestDetail.spec.ts new file mode 100644 index 0000000..d9868a1 --- /dev/null +++ b/tests/client/components/requestDetail.spec.ts @@ -0,0 +1,436 @@ +// RequestDetail (src/client/components/requestDetail.tsx) rendered with real +// React in jsdom: header chips, delta mode, per-category rows, and the step +// brief (chipParts/nodeLine cascades, locate linkage). + +import assert from 'node:assert/strict' +import { describe, test } from 'vitest' +import { h } from '../../../src/client/react' +import { makeRequestDetail } from '../../../src/client/components/requestDetail' +import { makeStackedBar } from '../../../src/client/components/stackedBar' +import type { RequestRecord, ContextEventRecord, SurfaceNode } from '../../../src/shared/types' +import type { ConversationNodeLike } from '../../../src/client/services' +import type { StepBrief } from '../../../src/client/brief' +import { makeKit, mount, click, queryAll, query, text } from '../helpers/kit' + +const kit = makeKit() +const RequestDetail = makeRequestDetail(kit, makeStackedBar(kit)) + +function req(over: Partial): RequestRecord { + return { + time: 1_700_000_000_000, seq: 10, turn: 1, step: 1, + system: 100, tools: 200, user: 300, inject: 50, assistant: 400, tool: 250, total: 1300, + ...over, + } +} + +function node(over: Partial & { seq: number }): SurfaceNode { + return { cat: 'user', tokens: 5, ...over } +} + +/** Full-category fixture so every row renders a non-zero figure. */ +const FULL = { system: 100, tools: 200, user: 300, inject: 50, assistant: 400, tool: 250, total: 1300 } + +describe('RequestDetail header', () => { + test('null request renders nothing', async () => { + const m = await mount(h(RequestDetail, { request: null })) + assert.equal(m.container.innerHTML, '') + await m.unmount() + }) + + test('step detail: title, time, usage chips, per-category rows', async () => { + const m = await mount(h(RequestDetail, { + request: req({ prompt: 1000, output: 120, cacheRead: 500 }), + })) + const head = text(query(m.container, '.lc-detail-head')) + assert.ok(head.includes('Turn 1 · Step 1')) + assert.ok(!head.includes('Last Step'), 'single-step request carries no last-step tag') + assert.ok(!head.includes('Delta'), 'cumulative mode carries no delta tag') + assert.ok(head.includes('Actual Prompt 1.0k')) + assert.ok(head.includes('Output 120')) + assert.ok(head.includes('Cache 50.00%'), 'real cacheHitPercent: 500/1000 truncated to two decimals') + const rows = queryAll(m.container, '.lc-detail-row') + assert.equal(rows.length, 6) + assert.ok(text(rows[0]).includes('System Prompt')) + assert.ok(text(rows[0]).includes('≈100')) + const pcts = queryAll(m.container, '.lc-detail-pct').map(el => text(el)) + assert.ok(pcts.some(p => p.endsWith('%')), 'percentages rendered when total > 0') + const fills = queryAll(m.container, '.lc-bar-fill') + assert.equal(fills.length, 6, 'one fill per row in cumulative mode') + assert.ok(fills[0].style.width !== '0%', 'non-zero categories fill the track') + await m.unmount() + }) + + test('usage chips drop out when the figures are absent; cache falls back to a dash when nothing was billed', async () => { + const m = await mount(h(RequestDetail, { request: req({}) })) + const head = text(query(m.container, '.lc-detail-head')) + assert.ok(!head.includes('Actual Prompt')) + assert.ok(!head.includes('Output')) + assert.ok(!head.includes('Cache')) + await m.unmount() + + const m2 = await mount(h(RequestDetail, { request: req({ prompt: 0, cacheRead: 0 }) })) + const head2 = text(query(m2.container, '.lc-detail-head')) + assert.ok(head2.includes('Cache —%'), 'prompt 0 → cacheHitPercent null → dash') + assert.ok(!head2.includes('Output')) + await m2.unmount() + }) + + test('turn aggregate shows the step count and the last-step tag; stepCount 1 stays a step', async () => { + const m = await mount(h(RequestDetail, { request: req({ turn: 2, stepCount: 3 }) })) + const head = text(query(m.container, '.lc-detail-head')) + assert.ok(head.includes('Turn 2 · 3 steps')) + assert.ok(head.includes('Last Step')) + await m.unmount() + + const m2 = await mount(h(RequestDetail, { request: req({ turn: 2, step: 4, stepCount: 1 }) })) + const head2 = text(query(m2.container, '.lc-detail-head')) + assert.ok(head2.includes('Turn 2 · Step 4')) + assert.ok(!head2.includes('Last Step')) + await m2.unmount() + + // Requests without turn/step stamps degrade to zeroes. + const bare: RequestRecord = { + time: 1_700_000_000_000, seq: 3, + system: 1, tools: 1, user: 1, inject: 0, assistant: 1, tool: 0, total: 4, + } + const m3 = await mount(h(RequestDetail, { request: bare })) + assert.ok(text(query(m3.container, '.lc-detail-head')).includes('Turn 0 · Step 0')) + await m3.unmount() + const m4 = await mount(h(RequestDetail, { request: { ...bare, stepCount: 2 } })) + assert.ok(text(query(m4.container, '.lc-detail-head')).includes('Turn 0 · 2 steps')) + await m4.unmount() + }) + + test('boundary marker shows where the event happened; in-flight markers hide', async () => { + const marker: ContextEventRecord = { + seq: 9, time: 1_699_999_999_000, kind: 'compaction', count: 3, + fromTurn: 2, fromStep: 3, turn: 2, step: 4, + } + const m = await mount(h(RequestDetail, { request: req({}), marker })) + const chip = query(m.container, '.lc-detail-marker') + assert.ok(text(chip).includes('✂ Turn 2 · Step 3→4')) + assert.equal(chip.getAttribute('title'), 'Context compacted (summary replaced 3 messages)') + await m.unmount() + + // Cross-turn gap. + const m2 = await mount(h(RequestDetail, { + request: req({}), + marker: { seq: 9, time: 1, kind: 'prune', fromTurn: 2, fromStep: 8, turn: 3, step: 1 }, + })) + assert.ok(text(query(m2.container, '.lc-detail-marker')).includes('Turn 2 · Step 8 → Turn 3 · Step 1')) + await m2.unmount() + + // Same-request boundary (no from-stamps). + const m3 = await mount(h(RequestDetail, { + request: req({}), + marker: { seq: 9, time: 1, kind: 'compaction', count: 2, turn: 4, step: 2 }, + })) + assert.ok(text(query(m3.container, '.lc-detail-marker')).includes('✂ Turn 4 · Step 2')) + await m3.unmount() + + // No turn/step stamps (event in flight) → no marker chip. + const m4 = await mount(h(RequestDetail, { + request: req({}), + marker: { seq: 9, time: 1, kind: 'prune' }, + })) + assert.equal(queryAll(m4.container, '.lc-detail-marker').length, 0) + await m4.unmount() + + const m5 = await mount(h(RequestDetail, { request: req({}), marker: null })) + assert.equal(queryAll(m5.container, '.lc-detail-marker').length, 0) + await m5.unmount() + }) +}) + +describe('RequestDetail delta mode', () => { + test('signed deltas vs the previous record: net chip, diverging rows, no usage chips', async () => { + const request = req({ system: 150, tools: 200, user: 250, inject: 0, assistant: 300, tool: 350, total: 1250, prompt: 900 }) + const prev = req({ ...FULL, inject: 0 }) + const m = await mount(h(RequestDetail, { request, prev })) + const head = text(query(m.container, '.lc-detail-head')) + assert.ok(head.includes('Delta'), 'delta tag shown') + assert.ok(!head.includes('Actual Prompt'), 'usage chips drop out in delta mode') + const metric = query(m.container, '.lc-detail-metric') + // +50 -50 -100 +100 → net 0 → neutral chip. + assert.ok(text(metric).includes('Δ 0')) + assert.ok(!metric.className.includes('lc-detail-metric-up')) + assert.ok(!metric.className.includes('lc-detail-metric-down')) + const zeros = queryAll(m.container, '.lc-bar-zero') + assert.equal(zeros.length, 6, 'every delta row carries the zero line') + const upFills = queryAll(m.container, '.lc-bar-fill-up') + const downFills = queryAll(m.container, '.lc-bar-fill-down') + assert.equal(upFills.length, 2, 'system +50, tool +100') + assert.equal(downFills.length, 2, 'user -50, assistant -100') + // maxAbs = 100 (assistant/tool): tool fills the full half-track, system half of it. + assert.equal((upFills[1] as HTMLElement).style.width, '50%') + assert.equal((upFills[0] as HTMLElement).style.width, '25%') + const nums = queryAll(m.container, '.lc-detail-num') + assert.ok(text(nums[0]).includes('+50')) + assert.ok(nums[0].className.includes('lc-detail-num-up')) + assert.ok(text(nums[2]).includes('-50')) + assert.ok(nums[2].className.includes('lc-detail-num-down')) + assert.ok(!nums[1].className.includes('lc-detail-num-up'), 'zero delta is neutral') + assert.ok(queryAll(m.container, '.lc-detail-pct').every(el => text(el) === ''), 'no percentages in delta mode') + await m.unmount() + }) + + test('delta with prev null reads against zeros (all up); shrink-only deltas go down', async () => { + const m = await mount(h(RequestDetail, { request: req({}), prev: null })) + const metric = query(m.container, '.lc-detail-metric') + assert.ok(metric.className.includes('lc-detail-metric-up')) + assert.ok(text(metric).includes('Δ +1.3k')) + assert.equal(queryAll(m.container, '.lc-bar-fill-down').length, 0) + await m.unmount() + + const m2 = await mount(h(RequestDetail, { + request: req({ system: 0, tools: 0, user: 0, inject: 0, assistant: 0, tool: 0, total: 0 }), + prev: req({ ...FULL }), + })) + const metric2 = query(m2.container, '.lc-detail-metric') + assert.ok(metric2.className.includes('lc-detail-metric-down')) + assert.ok(text(metric2).includes('Δ -1.3k')) + assert.equal(queryAll(m2.container, '.lc-bar-fill-up').length, 0) + assert.equal(queryAll(m2.container, '.lc-bar-fill-down').length, 6) + await m2.unmount() + }) + + test('cumulative rows with a zero total render zero-width fills and no percentages', async () => { + const m = await mount(h(RequestDetail, { + request: req({ system: 0, tools: 0, user: 0, inject: 0, assistant: 0, tool: 0, total: 0 }), + })) + for (const fill of queryAll(m.container, '.lc-bar-fill')) { + assert.equal(fill.style.width, '0%') + } + assert.ok(queryAll(m.container, '.lc-detail-pct').every(el => text(el) === '')) + await m.unmount() + }) +}) + +describe('RequestDetail brief section', () => { + const convOk: ConversationNodeLike = { + kind: 'tool-result', seq: 21, + call: { name: 'bash', argsRaw: '{"description":"list files"}' }, + content: [{ type: 'text', text: 'a.txt' }], + } + + test('absent/empty briefs render no section', async () => { + const m = await mount(h(RequestDetail, { request: req({}), brief: null })) + assert.equal(queryAll(m.container, '.lc-brief').length, 0) + await m.unmount() + + const empty: StepBrief = { inputs: [] } + const m2 = await mount(h(RequestDetail, { request: req({}), brief: empty })) + assert.equal(queryAll(m2.container, '.lc-brief').length, 0) + await m2.unmount() + }) + + test('opener, inputs with overflow, and reply rows render with tags and previews', async () => { + const brief: StepBrief = { + opener: node({ seq: 1, cat: 'user', text: 'please refactor this' }), + inputs: [ + node({ seq: 21, cat: 'tool', tool: 'bash' }), + node({ seq: 22, cat: 'inject', form: 'snapshot', text: 'state v2' }), + node({ seq: 23, cat: 'user', text: 'extra note', imgs: 3 }), + node({ seq: 24, cat: 'inject', form: 'notice' }), + ], + response: node({ seq: 25, cat: 'assistant', calls: ['bash', 'write'], text: 'done' }), + } + const m = await mount(h(RequestDetail, { + request: req({}), brief, + convOf: (seq) => seq === 21 ? convOk : undefined, + })) + const rows = queryAll(m.container, '.lc-brief-row') + assert.equal(rows.length, 3) + assert.ok(text(rows[0]).includes('please refactor this'), 'opener row carries the user text') + assert.ok(text(rows[0]).includes('User'), 'opener tagged as the turn opener') + // Inputs: three chips + the overflow pill. + const chips = queryAll(rows[1], '.lc-brief-chip') + assert.equal(chips.length, 3, 'inputs capped at MAX_CHIPS') + assert.ok(text(rows[1]).includes('+1'), 'overflow pill counts the hidden inputs') + assert.ok(text(chips[0]).includes('bash') && text(chips[0]).includes('list files'), 'tool chip: name tag + call summary') + assert.ok(text(chips[1]).includes('State Snapshot') && text(chips[1]).includes('Snapshot: state v2')) + assert.ok(text(chips[2]).includes('Image ×3'), 'user chip counts attachments') + // Rows are inert without onLocate. + assert.equal(rows[0].tagName, 'DIV') + assert.ok(!chips[0].className.includes('lc-brief-chip-link')) + // Reply: one grown chip with the call breadcrumb. + const replyChip = query(rows[2], '.lc-brief-chip') + assert.ok(replyChip.className.includes('lc-brief-chip-grow')) + assert.ok(text(replyChip).includes('bash › write')) + assert.ok(text(replyChip).includes('done')) + assert.equal(queryAll(m.container, '.lc-brief-tip').length, 3) + assert.equal(chips[0].getAttribute('title'), 'bash · list files') + await m.unmount() + }) + + test('locate linkage: row clicks locate their node; chip clicks locate their own node only', async () => { + const calls: { seq: number; isResponse: boolean }[] = [] + const onLocate = (n: SurfaceNode, isResponse: boolean): void => { + calls.push({ seq: n.seq, isResponse }) + } + const brief: StepBrief = { + opener: node({ seq: 1, cat: 'user', text: 'open' }), + inputs: [ + node({ seq: 21, cat: 'tool', tool: 'bash', err: true }), + node({ seq: 22, cat: 'inject', form: 'catalog', text: 'tools changed' }), + ], + response: node({ seq: 25, cat: 'assistant', text: 'reply text' }), + } + const m = await mount(h(RequestDetail, { + request: req({}), brief, onLocate, + convOf: (seq) => seq === 21 ? convOk : undefined, + })) + const rows = queryAll(m.container, '.lc-brief-row') + assert.equal(rows[0].tagName, 'BUTTON', 'rows become buttons when locate is wired') + // Error dot on the failed tool chip. + assert.equal(queryAll(rows[1], '.lc-br-err-dot').length, 1) + // Chip click: locates the chip's node, not the row's first input twice. + const chip = queryAll(rows[1], '.lc-brief-chip')[1] + assert.ok(chip.className.includes('lc-brief-chip-link')) + await click(chip) + assert.deepEqual(calls, [{ seq: 22, isResponse: false }], 'stopPropagation keeps the row handler out') + await click(rows[1] as HTMLElement) + assert.deepEqual(calls[1], { seq: 21, isResponse: false }) + await click(rows[2] as HTMLElement) + assert.deepEqual(calls[2], { seq: 25, isResponse: true }) + const replyChip = query(rows[2], '.lc-brief-chip') + assert.ok((replyChip.getAttribute('title') ?? '').includes('Reveal in Context Browser')) + await click(rows[0] as HTMLElement) + assert.deepEqual(calls[3], { seq: 1, isResponse: false }) + await m.unmount() + }) + + test('chipParts cascade: tool/assistant/inject/user kinds and the skill-inject fallback', async () => { + const inputs: SurfaceNode[] = [ + // tool: skill tag wins; summary from the join. + node({ seq: 30, cat: 'tool', skill: 'code-review', tool: 'bash' }), + // tool: name tag, no join → no text. + node({ seq: 31, cat: 'tool', tool: 'read' }), + // tool: no name, no join → untagged Tool Result placeholder. + node({ seq: 32, cat: 'tool' }), + // assistant: text + call list breadcrumb. + node({ seq: 33, cat: 'assistant', calls: ['bash'], text: 'looking' }), + // assistant: textless, block summary through the join. + node({ seq: 34, cat: 'assistant', calls: ['write'] }), + // assistant: no own calls; the breadcrumb is recovered from the join. + node({ seq: 35, cat: 'assistant' }), + // assistant: truly empty reply. + node({ seq: 36, cat: 'assistant', calls: [] }), + // inject: plain form with text. + node({ seq: 37, cat: 'inject', form: 'relay', text: 'from agent' }), + // inject: form absent → Context Injection. + node({ seq: 38, cat: 'inject', text: 'plain' }), + // user: single image, no text. + node({ seq: 39, cat: 'user', imgs: 1 }), + // user: plain text. + node({ seq: 40, cat: 'user', text: 'hi' }), + ] + const convs: Record = { + 30: { kind: 'tool-result', seq: 30, call: { name: 'bash', argsRaw: '{"description":"load skill"}' } }, + 34: { kind: 'assistant', seq: 34, blocks: [{ kind: 'tool-call', name: 'write', argsRaw: '{"file_path":"a.ts"}' }] }, + 35: { kind: 'assistant', seq: 35, blocks: [{ kind: 'tool-call', name: 'read', argsRaw: '{}' }] }, + 44: { kind: 'assistant', seq: 44, blocks: [{ kind: 'tool-call', name: 'write', argsRaw: '{"file_path":"a.ts"}' }] }, + } + // One row per input would need 11 rows; instead render them as one inputs + // list — chips are the unit under test (3 shown + overflow), so render + // batches through separate mounts. + const render = async (list: SurfaceNode[]) => { + const m = await mount(h(RequestDetail, { + request: req({}), + brief: { inputs: list }, + convOf: (seq) => convs[seq], + })) + return m + } + const chipText = (m: Awaited>, i: number) => + text(queryAll(m.container, '.lc-brief-chip')[i]) + + const m1 = await render(inputs.slice(0, 3)) + assert.ok(chipText(m1, 0).includes('Skill · code-review') && chipText(m1, 0).includes('load skill')) + assert.equal(chipText(m1, 1), 'read', 'tool name tag without a join leaves no text') + assert.equal(chipText(m1, 2), 'Tool Result', 'untagged tool falls back to the placeholder text') + await m1.unmount() + + const m2 = await render(inputs.slice(3, 6)) + assert.ok(chipText(m2, 0).includes('bash') && chipText(m2, 0).includes('looking')) + assert.ok(chipText(m2, 1).includes('write') && chipText(m2, 1).includes('a.ts'), 'block summary through the join') + assert.equal(chipText(m2, 2), 'read', 'breadcrumb from the join, no preview text') + await m2.unmount() + + const m3 = await render(inputs.slice(6, 9)) + assert.equal(chipText(m3, 0), '(empty reply)') + assert.ok(chipText(m3, 1).includes('Agent Relay') && chipText(m3, 1).includes('from agent')) + assert.ok(chipText(m3, 2).includes('Context Injection') && chipText(m3, 2).includes('plain')) + await m3.unmount() + + const m4 = await render(inputs.slice(9)) + assert.equal(chipText(m4, 0), 'Image', 'single image: no count suffix') + assert.equal(chipText(m4, 1), 'hi') + await m4.unmount() + + // chipParts fallback (inject carrying a skill): nodeLine drives the text. + const m5 = await render([node({ seq: 41, cat: 'inject', skill: 'ponytail' })]) + assert.equal(chipText(m5, 0), 'Skill · ponytail', 'skill inject falls back to nodeLine') + await m5.unmount() + const m6 = await render([node({ seq: 42, cat: 'inject', skill: 'ponytail', text: 'notes inside' })]) + assert.equal(chipText(m6, 0), 'notes inside', 'nodeLine prefers the node text over the skill label') + await m6.unmount() + + // A category outside the fold's vocabulary (host drift) still renders: + // nodeLine labels the calls breadcrumb, else the non-text placeholder. + const m7 = await render([node({ seq: 43, cat: 'mystery' as never, calls: ['bash', 'read'] })]) + assert.equal(chipText(m7, 0), 'bash › read') + await m7.unmount() + const m8 = await render([node({ seq: 44, cat: 'mystery' as never, calls: ['write'] })]) + assert.equal(chipText(m8, 0), 'write · a.ts', 'block summary joins the breadcrumb') + await m8.unmount() + const m9 = await render([node({ seq: 45, cat: 'mystery' as never, calls: [] })]) + assert.equal(chipText(m9, 0), '(non-text message)') + await m9.unmount() + const m10 = await render([node({ seq: 46, cat: 'mystery' as never })]) + assert.equal(chipText(m10, 0), '(non-text message)', 'no calls list at all') + await m10.unmount() + + // Skill-tagged tool without a join: tag only, no summary text. + const m11 = await render([node({ seq: 47, cat: 'tool', skill: 'grilling' })]) + assert.equal(chipText(m11, 0), 'Skill · grilling') + await m11.unmount() + // Textless injection: the form tag carries the chip alone. + const m12 = await render([node({ seq: 48, cat: 'inject', form: 'notice' })]) + assert.equal(chipText(m12, 0), 'Notice') + await m12.unmount() + }) + + test('opener without tag or text renders a bare row; inputs-only brief has no opener row', async () => { + const brief: StepBrief = { + opener: node({ seq: 1, cat: 'user' }), + inputs: [], + } + const m = await mount(h(RequestDetail, { request: req({}), brief })) + const rows = queryAll(m.container, '.lc-brief-row') + assert.equal(rows.length, 1, 'opener-only brief') + assert.equal(queryAll(rows[0], '.lc-brief-fact').length, 0, 'no fact tag on a plain user opener') + assert.equal(queryAll(rows[0], '.lc-brief-text').length, 0, 'no text span without preview') + await m.unmount() + + const m2 = await mount(h(RequestDetail, { + request: req({}), + brief: { inputs: [node({ seq: 5, cat: 'user', text: 'just input' })] }, + })) + const rows2 = queryAll(m2.container, '.lc-brief-row') + assert.equal(rows2.length, 1) + assert.ok(text(rows2[0]).includes('In'), 'the single row is the inputs row') + assert.equal(queryAll(m2.container, '.lc-brief-more').length, 0, 'no overflow pill at the cap') + await m2.unmount() + + // Opener carrying an image upload: the fact tag shows on the opener row. + const m3 = await mount(h(RequestDetail, { + request: req({}), + brief: { opener: node({ seq: 7, cat: 'user', text: 'see attached', imgs: 2 }), inputs: [] }, + })) + const fact = query(m3.container, '.lc-brief-fact') + assert.equal(text(fact), 'Image ×2') + assert.ok(text(query(m3.container, '.lc-brief-text')).includes('see attached')) + await m3.unmount() + }) +}) diff --git a/tests/client/components/richText.spec.ts b/tests/client/components/richText.spec.ts new file mode 100644 index 0000000..5f3299e --- /dev/null +++ b/tests/client/components/richText.spec.ts @@ -0,0 +1,79 @@ +// RichText/RichSwitch/useRichMode (src/client/components/richText.tsx): +// markdown mode renders through the REAL shared MarkdownText; raw mode is an +// exact-text
; the switch drives the mode hook.
+
+import assert from 'node:assert/strict'
+import { describe, test } from 'vitest'
+import { h } from '../../../src/client/react'
+import { makeRichText } from '../../../src/client/components/richText'
+import type { RichMode } from '../../../src/client/components/richText'
+import { click, makeKit, mount, query, queryAll } from '../helpers/kit'
+
+const kit = makeKit()
+const { RichText, RichSwitch, useRichMode } = makeRichText(kit)
+
+const SAMPLE = '# Title\n\nsome **bold** text'
+
+/** Harness wiring the hook to the switch and the body, like the detail cards do. */
+function Harness(props: { text: string }) {
+  const [mode, setMode] = useRichMode()
+  return h('div', {}, h(RichSwitch, { mode, onPick: setMode }), h(RichText, { text: props.text, mode }))
+}
+
+describe('RichText', () => {
+  test('md mode renders real markdown through the shared MarkdownText', async () => {
+    const m = await mount(h(RichText, { text: SAMPLE, mode: 'md' }))
+    const box = query(m.container, '.lc-ts-desc-md')
+    assert.equal(query(box, 'h1').textContent, 'Title')
+    assert.equal(query(box, 'strong').textContent, 'bold')
+    await m.unmount()
+  })
+
+  test('raw mode renders the exact source in a 
', async () => {
+    const m = await mount(h(RichText, { text: SAMPLE, mode: 'raw' }))
+    const pre = query(m.container, 'pre.lc-ts-desc-body')
+    assert.equal(pre.textContent, SAMPLE)
+    assert.equal(queryAll(m.container, 'h1').length, 0)
+    await m.unmount()
+  })
+})
+
+describe('RichSwitch', () => {
+  test('two segments with titles; the active mode carries the on class; clicks report the pick', async () => {
+    const picks: RichMode[] = []
+    const m = await mount(h(RichSwitch, { mode: 'md', onPick: m2 => picks.push(m2) }))
+    const buttons = queryAll(m.container, '.lc-rich-seg-btn')
+    assert.equal(buttons.length, 2)
+    assert.equal(buttons[0].textContent, 'Raw')
+    assert.equal(buttons[0].getAttribute('title'), 'View Raw Text')
+    assert.ok(!buttons[0].className.includes('lc-rich-seg-on'))
+    assert.equal(buttons[1].textContent, 'Markdown')
+    assert.equal(buttons[1].getAttribute('title'), 'View as Markdown')
+    assert.ok(buttons[1].className.includes('lc-rich-seg-on'))
+    await click(buttons[0])
+    await click(buttons[1])
+    assert.deepEqual(picks, ['raw', 'md'])
+    await m.unmount()
+  })
+
+  test('raw mode marks the raw segment active instead', async () => {
+    const m = await mount(h(RichSwitch, { mode: 'raw', onPick: () => {} }))
+    const buttons = queryAll(m.container, '.lc-rich-seg-btn')
+    assert.ok(buttons[0].className.includes('lc-rich-seg-on'))
+    assert.ok(!buttons[1].className.includes('lc-rich-seg-on'))
+    await m.unmount()
+  })
+})
+
+describe('useRichMode', () => {
+  test('defaults to markdown and flips to raw via the switch', async () => {
+    const m = await mount(h(Harness, { text: SAMPLE }))
+    assert.ok(query(m.container, '.lc-ts-desc-md'))
+    const buttons = queryAll(m.container, '.lc-rich-seg-btn')
+    await click(buttons[0]) // Raw
+    assert.equal(query(m.container, 'pre.lc-ts-desc-body').textContent, SAMPLE)
+    await click(queryAll(m.container, '.lc-rich-seg-btn')[1]) // back to Markdown
+    assert.equal(query(m.container, '.lc-ts-desc-md h1').textContent, 'Title')
+    await m.unmount()
+  })
+})
diff --git a/tests/client/components/settingsCard.spec.ts b/tests/client/components/settingsCard.spec.ts
new file mode 100644
index 0000000..624ceff
--- /dev/null
+++ b/tests/client/components/settingsCard.spec.ts
@@ -0,0 +1,149 @@
+// SettingsCard (src/client/components/settingsCard.tsx) rendered with real
+// React against the real DICT_EN strings; the select rows open the REAL
+// Menu primitive (portaled into document.body) and pick through it.
+
+import assert from 'node:assert/strict'
+import { describe, test } from 'vitest'
+import { h } from '../../../src/client/react'
+import { makeSettingsCard } from '../../../src/client/components/settingsCard'
+import type { SettingsState } from '../../../src/client/settings'
+import { DICT_EN } from '../../../src/client/i18n'
+import { click, keydown, makeKit, mount, query, queryAll, text } from '../helpers/kit'
+
+const kit = makeKit()
+const SettingsCard = makeSettingsCard(kit)
+
+function hookFor(state: SettingsState) {
+  return (sel: (s: SettingsState) => T): T => sel(state)
+}
+
+function stateOf(partial: Partial = {}): SettingsState {
+  return { status: 'ready', granularity: 'step', mode: 'total', writable: true, ...partial }
+}
+
+/** Menu items portaled into document.body while a select is open. */
+function menuItems(): HTMLElement[] {
+  return queryAll(document.body, '[role="menu"] [role="menuitem"]')
+}
+
+describe('SettingsCard', () => {
+  test('renders nothing without a settings hook or when the namespace is unavailable', async () => {
+    const m1 = await mount(h(SettingsCard, {}))
+    assert.equal(m1.container.childElementCount, 0)
+    await m1.unmount()
+
+    const m2 = await mount(h(SettingsCard, { useContextSettings: hookFor(stateOf({ status: 'unavailable' })) }))
+    assert.equal(m2.container.childElementCount, 0)
+    await m2.unmount()
+  })
+
+  test('loading state renders the card with disabled selects; the head toggles the body', async () => {
+    const m = await mount(h(SettingsCard, { useContextSettings: hookFor(stateOf({ status: 'loading', writable: false })) }))
+    const card = query(m.container, '.lc-settings-card')
+    assert.ok(!card.className.includes('lc-settings-open'))
+    const head = query(m.container, '.lc-settings-head')
+    assert.equal(head.getAttribute('aria-expanded'), 'false')
+    assert.equal(head.getAttribute('aria-label'), `${DICT_EN['settings.expand']}: ${DICT_EN['settings.title']}`)
+    assert.ok(text(m.container).includes(DICT_EN['settings.desc']))
+    assert.equal(queryAll(m.container, '.lc-settings-select').length, 0)
+
+    await click(head)
+    assert.equal(head.getAttribute('aria-expanded'), 'true')
+    assert.equal(head.getAttribute('aria-label'), `${DICT_EN['settings.collapse']}: ${DICT_EN['settings.title']}`)
+    assert.ok(card.className.includes('lc-settings-open'))
+    const selects = queryAll(m.container, '.lc-settings-select')
+    assert.equal(selects.length, 2)
+    assert.ok(selects.every(s => (s as HTMLButtonElement).disabled))
+    // Loading is not ready: no read-only note.
+    assert.equal(m.container.querySelector('.lc-settings-note'), null)
+
+    await click(head)
+    assert.equal(head.getAttribute('aria-expanded'), 'false')
+    assert.ok(!card.className.includes('lc-settings-open'))
+    assert.equal(queryAll(m.container, '.lc-settings-select').length, 0)
+    await m.unmount()
+  })
+
+  test('ready+writable: enabled selects pick through the real portaled Menu', async () => {
+    const calls: [string, string][] = []
+    const m = await mount(h(SettingsCard, {
+      useContextSettings: hookFor(stateOf()),
+      set: (field, value) => { calls.push([field, value]) },
+    }))
+    await click(query(m.container, '.lc-settings-head'))
+    const selects = queryAll(m.container, '.lc-settings-select')
+    assert.ok(selects.every(s => !s.disabled))
+    assert.equal(m.container.querySelector('.lc-settings-note'), null)
+    // Active option labels resolve through the options list.
+    assert.ok(text(selects[0]).includes(DICT_EN['gran.step']))
+    assert.ok(text(selects[1]).includes(DICT_EN['gran.total']))
+
+    await click(selects[0])
+    assert.equal(selects[0].getAttribute('aria-expanded'), 'true')
+    const items = menuItems()
+    assert.equal(items.length, 2)
+    assert.deepEqual(items.map(i => text(i)), [DICT_EN['gran.step'], DICT_EN['gran.turn']])
+
+    await click(items[1]) // 'Turn'
+    assert.deepEqual(calls, [['defaultGranularity', 'turn']])
+    assert.equal(selects[0].getAttribute('aria-expanded'), 'false')
+    assert.equal(document.body.querySelector('[role="menu"]'), null)
+
+    // The trend-mode row writes the other field.
+    await click(selects[1])
+    const modeItems = menuItems()
+    assert.deepEqual(modeItems.map(i => text(i)), [DICT_EN['gran.total'], DICT_EN['gran.delta']])
+    await click(modeItems[1]) // 'Delta'
+    assert.deepEqual(calls, [['defaultGranularity', 'turn'], ['defaultTrendMode', 'delta']])
+    assert.equal(document.body.querySelector('[role="menu"]'), null)
+
+    // Anchor toggles shut too (setOpen(v => !v) back edge).
+    await click(selects[0])
+    assert.equal(selects[0].getAttribute('aria-expanded'), 'true')
+    await click(selects[0])
+    assert.equal(selects[0].getAttribute('aria-expanded'), 'false')
+    assert.equal(document.body.querySelector('[role="menu"]'), null)
+    await m.unmount()
+  })
+
+  test('Menu onClose (Escape) closes an open select without picking', async () => {
+    const calls: [string, string][] = []
+    const m = await mount(h(SettingsCard, {
+      useContextSettings: hookFor(stateOf()),
+      set: (field, value) => { calls.push([field, value]) },
+    }))
+    await click(query(m.container, '.lc-settings-head'))
+    const select = query(m.container, '.lc-settings-select')
+    await click(select)
+    assert.equal(select.getAttribute('aria-expanded'), 'true')
+    assert.equal(menuItems().length, 2)
+    await keydown('Escape', document.body)
+    assert.equal(select.getAttribute('aria-expanded'), 'false')
+    assert.equal(document.body.querySelector('[role="menu"]'), null)
+    assert.deepEqual(calls, [])
+    await m.unmount()
+  })
+
+  test('ready+readonly: disabled selects and the read-only note when open', async () => {
+    const m = await mount(h(SettingsCard, { useContextSettings: hookFor(stateOf({ writable: false })) }))
+    // Note only renders once the body is open.
+    await click(query(m.container, '.lc-settings-head'))
+    const note = query(m.container, '.lc-settings-note')
+    assert.equal(note.getAttribute('role'), 'status')
+    assert.equal(text(note), DICT_EN['settings.readOnly'])
+    assert.ok(queryAll(m.container, '.lc-settings-select').every(s => s.disabled))
+    await m.unmount()
+  })
+
+  test('a value matching no option falls back to the raw id; a missing set never throws', async () => {
+    const m = await mount(h(SettingsCard, { useContextSettings: hookFor(stateOf({ granularity: 'weird' as never })) }))
+    await click(query(m.container, '.lc-settings-head'))
+    const selects = queryAll(m.container, '.lc-settings-select')
+    assert.ok(text(selects[0]).includes('weird'))
+    // props.set undefined: picking still closes the menu, no throw.
+    await click(selects[0])
+    await click(menuItems()[1])
+    assert.equal(document.body.querySelector('[role="menu"]'), null)
+    await m.unmount()
+  })
+})
diff --git a/tests/client/components/stackedBar.spec.ts b/tests/client/components/stackedBar.spec.ts
new file mode 100644
index 0000000..3756f76
--- /dev/null
+++ b/tests/client/components/stackedBar.spec.ts
@@ -0,0 +1,245 @@
+// StackedBar + Legend (src/client/components/stackedBar.tsx) rendered with
+// real React through a state-holding hover harness, plus direct mounts for
+// the scale/free/reserve/tooltip branch matrix.
+
+import assert from 'node:assert/strict'
+import { describe, test } from 'vitest'
+import { h, React } from '../../../src/client/react'
+import { makeLegend, makeStackedBar } from '../../../src/client/components/stackedBar'
+import type { PartsPart } from '../../../src/client/categories'
+import { hover, makeKit, mount, query, queryAll, unhover } from '../helpers/kit'
+
+const kit = makeKit()
+const StackedBar = makeStackedBar(kit)
+const Legend = makeLegend(kit)
+
+function part(key: string, value: number, raw?: number): PartsPart {
+  return { key, color: '#123456', value, ...(raw !== undefined ? { raw } : {}) }
+}
+
+/** A parent that really holds the hover key, so hovering re-renders the bar. */
+function HoverHarness(props: { parts: PartsPart[]; max?: number; reserve?: { ratio: number; label: string } }) {
+  const [hoverKey, setHoverKey] = React.useState(null)
+  return h(StackedBar, { parts: props.parts, max: props.max, hoverKey, onHoverKey: setHoverKey, reserve: props.reserve })
+}
+
+describe('StackedBar layout', () => {
+  test('empty parts: no segments, default height, zero-width occupied box, idle tooltip slot', async () => {
+    const m = await mount(h(StackedBar, { parts: [] }))
+    const stack = query(m.container, '.lc-stacked')
+    assert.equal(stack.style.height, '14px')
+    assert.equal(queryAll(m.container, '.lc-stacked-seg').length, 0)
+    assert.equal(queryAll(m.container, '.lc-stacked-free').length, 0)
+    assert.equal(queryAll(m.container, '.lc-reserve').length, 0)
+    const box = query(m.container, '.lc-occupied-box')
+    assert.equal(box.className, 'lc-occupied-box')
+    assert.equal(box.style.width, '0%')
+    const tip = query(m.container, '.lc-bar-tip')
+    assert.equal(tip.className, 'lc-bar-tip')
+    assert.equal(tip.textContent, '')
+    assert.equal(tip.style.left, '50%')
+    await m.unmount()
+  })
+
+  test('max undefined: segments span the full bar, no free track', async () => {
+    const m = await mount(h(StackedBar, { parts: [part('system', 700), part('user', 300)] }))
+    const segs = queryAll(m.container, '.lc-stacked-seg')
+    assert.equal(segs.length, 2)
+    assert.equal(segs[0].style.width, '70%')
+    assert.equal(segs[1].style.width, '30%')
+    assert.equal(queryAll(m.container, '.lc-stacked-free').length, 0)
+    assert.equal(query(m.container, '.lc-stacked').className, 'lc-stacked')
+    await m.unmount()
+  })
+
+  test('max <= total: window ignored for widths, no free track', async () => {
+    const m = await mount(h(StackedBar, { parts: [part('system', 100), part('user', 100)], max: 100 }))
+    const segs = queryAll(m.container, '.lc-stacked-seg')
+    assert.equal(segs[0].style.width, '50%')
+    assert.equal(queryAll(m.container, '.lc-stacked-free').length, 0)
+    await m.unmount()
+  })
+
+  test('max > total: free track fills the rest of the window', async () => {
+    const m = await mount(h(StackedBar, { parts: [part('user', 1000)], max: 2000 }))
+    assert.equal(query(m.container, '.lc-stacked-seg').style.width, '50%')
+    assert.equal(query(m.container, '.lc-stacked-free').style.width, '50%')
+    await m.unmount()
+  })
+
+  test('zero-value parts are skipped; custom height applies', async () => {
+    const m = await mount(h(StackedBar, { parts: [part('user', 0), part('system', 100)], height: 22 }))
+    assert.equal(queryAll(m.container, '.lc-stacked-seg').length, 1)
+    assert.equal(query(m.container, '.lc-stacked').style.height, '22px')
+    await m.unmount()
+  })
+
+  test('tip={false} drops the tooltip slot entirely', async () => {
+    const m = await mount(h(StackedBar, { parts: [part('user', 100)], tip: false, hoverKey: 'user' }))
+    assert.equal(queryAll(m.container, '.lc-bar-tip').length, 0)
+    await m.unmount()
+  })
+})
+
+describe('StackedBar hover link', () => {
+  test('hovering a segment lights it, dims the stack, frames the occupied box, and floats the tooltip', async () => {
+    const m = await mount(h(HoverHarness, { parts: [part('system', 100, 120), part('user', 900)], max: 2000 }))
+    const segs = queryAll(m.container, '.lc-stacked-seg')
+    await hover(segs[1])
+    assert.ok(query(m.container, '.lc-stacked').className.includes('lc-stacked-dim'))
+    assert.ok(segs[1].className.includes('lc-stacked-seg-on'))
+    assert.ok(!segs[0].className.includes('lc-stacked-seg-on'))
+    const box = query(m.container, '.lc-occupied-box')
+    assert.ok(box.className.includes('lc-occupied-box-on'))
+    assert.equal(box.style.width, '50%')
+    const tip = query(m.container, '.lc-bar-tip')
+    assert.ok(tip.className.includes('lc-bar-tip-on'))
+    assert.equal(tip.textContent, 'User Messages ≈900 (88%) of used context')
+    assert.equal(tip.style.left, '27.5%')
+    await unhover(query(m.container, '.lc-stacked'))
+    assert.equal(query(m.container, '.lc-stacked').className, 'lc-stacked')
+    assert.equal(query(m.container, '.lc-bar-tip').className, 'lc-bar-tip')
+    assert.equal(query(m.container, '.lc-occupied-box').className, 'lc-occupied-box')
+    await m.unmount()
+  })
+
+  test('the first tiny segment clamps the tooltip to the 12% edge; raw count drives the tooltip figure', async () => {
+    const m = await mount(h(HoverHarness, { parts: [part('system', 100, 120), part('user', 900)], max: 2000 }))
+    await hover(queryAll(m.container, '.lc-stacked-seg')[0])
+    const tip = query(m.container, '.lc-bar-tip')
+    assert.equal(tip.textContent, 'System Prompt ≈120 (12%) of used context')
+    assert.equal(tip.style.left, '12%')
+    await m.unmount()
+  })
+
+  test('hovering the free track shows the free-window tooltip', async () => {
+    const m = await mount(h(HoverHarness, { parts: [part('user', 1000)], max: 2000 }))
+    await hover(query(m.container, '.lc-stacked-free'))
+    assert.ok(query(m.container, '.lc-stacked-free').className.includes('lc-stacked-free-on'))
+    const tip = query(m.container, '.lc-bar-tip')
+    assert.ok(tip.className.includes('lc-bar-tip-on'))
+    assert.equal(tip.textContent, 'Free Window 1.0k (50%)')
+    assert.equal(tip.style.left, '75%')
+    await unhover(query(m.container, '.lc-stacked'))
+    assert.equal(query(m.container, '.lc-bar-tip').className, 'lc-bar-tip')
+    await m.unmount()
+  })
+
+  test('hoverKey free without a free track: dimmed but no tooltip', async () => {
+    const m = await mount(h(StackedBar, { parts: [part('user', 1000)], hoverKey: 'free' }))
+    assert.ok(query(m.container, '.lc-stacked').className.includes('lc-stacked-dim'))
+    assert.equal(queryAll(m.container, '.lc-stacked-free').length, 0)
+    assert.equal(query(m.container, '.lc-bar-tip').className, 'lc-bar-tip')
+    await m.unmount()
+  })
+
+  test('hoverKey on a zero-value part: no tooltip (scale 0 and value 0 arms)', async () => {
+    const m = await mount(h(StackedBar, { parts: [part('zero', 0)], hoverKey: 'zero' }))
+    assert.equal(query(m.container, '.lc-bar-tip').className, 'lc-bar-tip')
+    await m.unmount()
+  })
+
+  test('rawTotal 0 (raw explicitly zero): tooltip percentage falls back to 0%', async () => {
+    const m = await mount(h(StackedBar, { parts: [part('user', 100, 0)], hoverKey: 'user' }))
+    const tip = query(m.container, '.lc-bar-tip')
+    assert.ok(tip.className.includes('lc-bar-tip-on'))
+    assert.equal(tip.textContent, 'User Messages ≈0 (0%) of used context')
+    await m.unmount()
+  })
+
+  test('degenerate negative counts pin scale to 0 while a free track opens: free tooltip shows 0%', async () => {
+    // Token counts are never negative in production; this guards the scale>0
+    // fallback in the free-tooltip math against corrupt input.
+    const m = await mount(h(StackedBar, { parts: [part('user', -5)], max: 0, hoverKey: 'free' }))
+    const tip = query(m.container, '.lc-bar-tip')
+    assert.ok(tip.className.includes('lc-bar-tip-on'))
+    assert.equal(tip.textContent, 'Free Window 5 (0%)')
+    await m.unmount()
+  })
+
+  test('hover without onHoverKey is a no-op and never throws', async () => {
+    const m = await mount(h(StackedBar, { parts: [part('user', 100)], max: 200 }))
+    await hover(query(m.container, '.lc-stacked-seg'))
+    await hover(query(m.container, '.lc-stacked-free'))
+    await unhover(query(m.container, '.lc-stacked'))
+    assert.equal(query(m.container, '.lc-stacked').className, 'lc-stacked')
+    await m.unmount()
+  })
+})
+
+describe('StackedBar reserve band', () => {
+  test('rendered over the rightmost window slice; hover shows its label and yields the hover link', async () => {
+    const m = await mount(h(HoverHarness, {
+      parts: [part('user', 100)],
+      max: 1000,
+      reserve: { ratio: 0.8, label: 'reserved headroom' },
+    }))
+    const band = query(m.container, '.lc-reserve')
+    assert.equal(band.style.left, '80%')
+    assert.equal(band.style.width, '20%')
+    await hover(band)
+    const tip = query(m.container, '.lc-bar-tip')
+    assert.ok(tip.className.includes('lc-bar-tip-on'))
+    assert.equal(tip.textContent, 'reserved headroom')
+    assert.equal(tip.style.left, '88%') // clamped: 80 + 20/2 = 90 → 88
+    assert.equal(query(m.container, '.lc-stacked').className, 'lc-stacked') // hoverKey cleared
+    await unhover(band)
+    assert.equal(query(m.container, '.lc-bar-tip').className, 'lc-bar-tip')
+    // Stack-level mouseleave also clears an active reserve hover.
+    await hover(band)
+    assert.ok(query(m.container, '.lc-bar-tip').className.includes('lc-bar-tip-on'))
+    await unhover(query(m.container, '.lc-stacked'))
+    assert.equal(query(m.container, '.lc-bar-tip').className, 'lc-bar-tip')
+    await m.unmount()
+  })
+
+  test('not rendered without a positive window, even when configured', async () => {
+    const reserve = { ratio: 0.8, label: 'reserved headroom' }
+    const m = await mount(h(StackedBar, { parts: [part('user', 100)], reserve }))
+    assert.equal(queryAll(m.container, '.lc-reserve').length, 0)
+    await m.update(h(StackedBar, { parts: [part('user', 100)], max: 0, reserve }))
+    assert.equal(queryAll(m.container, '.lc-reserve').length, 0)
+    await m.unmount()
+  })
+})
+
+describe('Legend', () => {
+  test('chips show raw counts and used-share percentages', async () => {
+    const m = await mount(h(Legend, { parts: [part('system', 100, 120), part('user', 900)] }))
+    const chips = queryAll(m.container, '.lc-chip')
+    assert.equal(chips.length, 2)
+    assert.equal(chips[0].getAttribute('title'), 'of used context')
+    assert.equal(query(chips[0], '.lc-chip-label').textContent, 'System Prompt')
+    assert.equal(query(chips[0], '.lc-chip-nums').textContent, '≈12012%')
+    assert.equal(query(chips[1], '.lc-chip-nums').textContent, '≈90088%')
+    await m.unmount()
+  })
+
+  test('total 0: chips render without the percentage figure', async () => {
+    const m = await mount(h(Legend, { parts: [part('user', 0)] }))
+    assert.equal(query(m.container, '.lc-chip-nums').textContent, '≈0')
+    assert.equal(queryAll(m.container, 'em').length, 0)
+    await m.unmount()
+  })
+
+  test('chip hover drives the shared hover key; matching chip lights up', async () => {
+    const seen: (string | null)[] = []
+    const m = await mount(h(Legend, { parts: [part('system', 100), part('user', 900)], hoverKey: 'user', onHoverKey: k => seen.push(k) }))
+    const chips = queryAll(m.container, '.lc-chip')
+    assert.ok(chips[1].className.includes('lc-chip-on'))
+    assert.ok(!chips[0].className.includes('lc-chip-on'))
+    await hover(chips[0])
+    await unhover(chips[0])
+    assert.deepEqual(seen, ['system', null])
+    await m.unmount()
+  })
+
+  test('chip hover without onHoverKey is a no-op', async () => {
+    const m = await mount(h(Legend, { parts: [part('user', 10)], hoverKey: null }))
+    const chip = query(m.container, '.lc-chip')
+    assert.ok(!chip.className.includes('lc-chip-on'))
+    await hover(chip)
+    await unhover(chip)
+    await m.unmount()
+  })
+})
diff --git a/tests/client/components/statsBoard.spec.ts b/tests/client/components/statsBoard.spec.ts
new file mode 100644
index 0000000..e5af477
--- /dev/null
+++ b/tests/client/components/statsBoard.spec.ts
@@ -0,0 +1,104 @@
+// StatsBoard (src/client/components/statsBoard.tsx) rendered with real React:
+// counts folded from requests/events, cache-hit share, and the priced cost
+// cell with its rate tooltip in both locales.
+
+import assert from 'node:assert/strict'
+import { describe, test } from 'vitest'
+import { h } from '../../../src/client/react'
+import { makeStatsBoard } from '../../../src/client/components/statsBoard'
+import type { ContextEventRecord, RequestRecord, SessionCostUsage, TokenUsage } from '../../../src/shared/types'
+import { makeKit, mount, query, queryAll, text } from '../helpers/kit'
+
+const kit = makeKit()
+const kitZh = makeKit('zh')
+const StatsBoard = makeStatsBoard(kit)
+const StatsBoardZh = makeStatsBoard(kitZh)
+
+function req(turn?: number): RequestRecord {
+  return {
+    time: 0, seq: 0, system: 0, tools: 0, user: 0, inject: 0, assistant: 0, tool: 0, total: 0,
+    ...(turn !== undefined ? { turn } : {}),
+  }
+}
+
+function ev(kind: ContextEventRecord['kind']): ContextEventRecord {
+  return { seq: 0, time: 0, kind }
+}
+
+const USAGE: TokenUsage = { uncachedInputTokens: 100, outputTokens: 50, cacheReadTokens: 300, cacheWriteTokens: 0 }
+const COST: SessionCostUsage = { flash: { peak: { uncached: 1_000_000, cacheRead: 0, cacheWrite: 0, output: 0 } } }
+
+function statValues(container: HTMLElement): string[] {
+  return queryAll(container, '.lc-stat-value').map(el => el.textContent ?? '')
+}
+
+describe('StatsBoard counts and cells', () => {
+  test('folds turns/steps/events, usage buckets, tool calls, images, and cost (usd)', async () => {
+    const m = await mount(h(StatsBoard, {
+      // Two steps in turn 1, one in turn 2, and one without a turn (folds as turn 0).
+      requests: [req(1), req(1), req(2), req()],
+      events: [ev('compaction'), ev('prune'), ev('inject'), ev('inject'), ev('mystery' as never)],
+      usage: USAGE,
+      toolCalls: 3,
+      images: 2,
+      cost: COST,
+      locale: 'en',
+    }))
+    assert.ok(text(m.container).includes('Context Stats'))
+    assert.deepEqual(statValues(m.container), ['3', '4', '2', '1', '1', '3', '2', '75.00%', '$0.44'])
+    await m.unmount()
+  })
+
+  test('null usage and absent cost/counters degrade to dashes and zeros', async () => {
+    const m = await mount(h(StatsBoard, { requests: [], events: [], usage: null, locale: 'en' }))
+    assert.deepEqual(statValues(m.container), ['0', '0', '0', '0', '0', '0', '0', '—', '—'])
+    // Usage with nothing billed also shows the dash (no division by zero).
+    await m.update(h(StatsBoard, {
+      requests: [],
+      events: [],
+      usage: { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
+      locale: 'en',
+    }))
+    assert.deepEqual(statValues(m.container), ['0', '0', '0', '0', '0', '0', '0', '—', '—'])
+    await m.unmount()
+  })
+
+  test('zh locale prices the cost cell in CNY', async () => {
+    const m = await mount(h(StatsBoardZh, { requests: [], events: [], usage: null, cost: COST, locale: 'zh' }))
+    const values = statValues(m.container)
+    assert.equal(values[8], '¥3.00')
+    await m.unmount()
+  })
+})
+
+describe('StatsBoard cost tooltip', () => {
+  test('only the cost cell is tipped; it lists both model families with peak/off rates', async () => {
+    const m = await mount(h(StatsBoard, { requests: [], events: [], usage: null, cost: COST, locale: 'en' }))
+    const tips = queryAll(m.container, '.lc-stat-tip')
+    assert.equal(tips.length, 1)
+    assert.equal(queryAll(m.container, '.lc-stat-q').length, 1)
+    const costCell = query(m.container, '.lc-stat-tipped')
+    assert.ok(query(costCell, '.lc-stat-label').textContent!.startsWith('Cost'))
+    const tip = text(tips[0])
+    assert.ok(tip.includes('Per-1M-token rates'))
+    assert.ok(tip.includes('deepseek-v4-flash'))
+    assert.ok(tip.includes('deepseek-v4-pro'))
+    // flash: hit $0.014/$0.007 · miss $0.44/$0.22 · output $1.32/$0.66
+    assert.ok(tip.includes('hit $0.014/$0.007'))
+    assert.ok(tip.includes('miss $0.44/$0.22'))
+    assert.ok(tip.includes('output $1.32/$0.66'))
+    // pro: miss $1.32/$0.66 · output $3.96/$1.98
+    assert.ok(tip.includes('miss $1.32/$0.66'))
+    assert.ok(tip.includes('output $3.96/$1.98'))
+    await m.unmount()
+  })
+
+  test('zh locale prints CNY rates in the tooltip', async () => {
+    const m = await mount(h(StatsBoardZh, { requests: [], events: [], usage: null, cost: COST, locale: 'zh' }))
+    const tip = text(query(m.container, '.lc-stat-tip'))
+    // flash zh: hit ¥0.1/¥0.05 · miss ¥3/¥1.5 · output ¥9/¥4.5
+    assert.ok(tip.includes('未命中 ¥3/¥1.5'))
+    assert.ok(tip.includes('输出 ¥9/¥4.5'))
+    await m.unmount()
+  })
+})
diff --git a/tests/client/components/trendChart.spec.ts b/tests/client/components/trendChart.spec.ts
new file mode 100644
index 0000000..4e64a2e
--- /dev/null
+++ b/tests/client/components/trendChart.spec.ts
@@ -0,0 +1,663 @@
+/**
+ * TrendChart (src/client/components/trendChart.tsx) rendered with the REAL React 18 tree in jsdom: stacked step bars,
+ * turn aggregation, total/delta geometry, compaction/prune markers, hover tooltips, scroll edge fades, and turn-label
+ * centering. The pure helpers (aggregateByTurn/attachMarkers) are also driven directly.
+ *
+ * jsdom reports zero layout metrics, so beforeAll overrides them (scrollWidth follows the bar count, clientWidth is
+ * test-controlled, the scrollLeft setter dispatches a real scroll event in a microtask) — the overflow/edge-fade
+ * logic runs FOR REAL — and afterAll restores the originals.
+ *
+ * Note: the 'trend.empty' panel and the defaultGranularity/defaultTrendMode settings reads live in the PARENT
+ * (contextView.tsx); TrendChart itself always renders the chart frame, so the empty-history arm is asserted as an
+ * empty frame here.
+ */
+
+import assert from 'node:assert/strict'
+import { afterAll, beforeAll, describe, test } from 'vitest'
+import { act } from 'react'
+import { h } from '../../../src/client/react'
+import { aggregateByTurn, attachMarkers, makeTrendChart, type TrendChartProps } from '../../../src/client/components/trendChart'
+import { CATS } from '../../../src/client/categories'
+import { replyTipsOf } from '../../../src/client/brief'
+import type { ContextEventRecord, RequestRecord, SurfaceNode } from '../../../src/shared/types'
+import { click, flush, hover, makeKit, mount, query, queryAll, unhover } from '../helpers/kit'
+
+const kit = makeKit()
+const TrendChart = makeTrendChart(kit)
+
+const CHART_H = 112
+const BAR_CELL = 16 // BAR_W 14 + BAR_GAP 2
+
+/** Module-level default client width; tests may retarget it (with try/finally) before mounting. */
+let CLIENT_W = 400
+
+type LayoutEl = HTMLElement & { __clientW?: number; __scrollL?: number; __scrollW?: number }
+
+let saved: [string, PropertyDescriptor | undefined][] = []
+
+beforeAll(() => {
+  saved = (['scrollWidth', 'clientWidth', 'scrollLeft'] as const)
+    .map((name): [string, PropertyDescriptor | undefined] => [name, Object.getOwnPropertyDescriptor(HTMLElement.prototype, name)])
+  Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {
+    configurable: true,
+    get(this: LayoutEl): number {
+      if (this.classList && this.classList.contains('lc-chart-scroll')) {
+        return Math.max(this.clientWidth, this.querySelectorAll('.lc-bar').length * BAR_CELL)
+      }
+      return this.__scrollW ?? 0
+    },
+  })
+  Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
+    configurable: true,
+    get(this: LayoutEl): number { return this.__clientW ?? CLIENT_W },
+  })
+  Object.defineProperty(HTMLElement.prototype, 'scrollLeft', {
+    configurable: true,
+    get(this: LayoutEl): number { return this.__scrollL ?? 0 },
+    set(this: LayoutEl, v: number) {
+      this.__scrollL = Math.max(0, Math.min(v, Math.max(0, this.scrollWidth - this.clientWidth)))
+      const el = this
+      queueMicrotask(() => { if (el.isConnected) el.dispatchEvent(new Event('scroll')) })
+    },
+  })
+})
+
+afterAll(() => {
+  for (const [name, desc] of saved) {
+    if (desc === undefined) delete (HTMLElement.prototype as unknown as Record)[name]
+    else Object.defineProperty(HTMLElement.prototype, name, desc)
+  }
+})
+
+function req(seq: number, over: Partial = {}): RequestRecord {
+  return {
+    time: 1700000000000 + seq * 60000, seq, turn: 1, step: seq - 1,
+    system: 100, tools: 50, user: 30, inject: 20, assistant: 40, tool: 60, total: 300,
+    ...over,
+  }
+}
+
+interface Spies {
+  select: (number | null)[]
+  hover: (number | null)[]
+  hoverTurn: (number | null)[]
+  pickTurn: number[]
+  focusHandled: number
+}
+
+function makeSpies(): { spies: Spies; handlers: Pick } {
+  const spies: Spies = { select: [], hover: [], hoverTurn: [], pickTurn: [], focusHandled: 0 }
+  return {
+    spies,
+    handlers: {
+      onSelect: (s) => { spies.select.push(s) },
+      onHover: (s) => { spies.hover.push(s) },
+      onHoverTurn: (t) => { spies.hoverTurn.push(t) },
+      onPickTurn: (t) => { spies.pickTurn.push(t) },
+      onFocusTurnHandled: () => { spies.focusHandled++ },
+    },
+  }
+}
+
+function propsOf(requests: RequestRecord[], over: Partial = {}): TrendChartProps {
+  const { handlers } = makeSpies()
+  return {
+    requests,
+    markers: requests.map(() => undefined),
+    selectedSeq: null,
+    hoveredSeq: null,
+    activeTurn: null,
+    granularity: 'step',
+    mode: 'total',
+    focusTurn: null,
+    ...handlers,
+    ...over,
+  }
+}
+
+function bars(container: HTMLElement): HTMLElement[] {
+  return queryAll(container, '.lc-bar')
+}
+
+/** jsdom/cssstyle may keep hex or normalize to rgb(); accept the exact color either way. */
+function assertColor(actual: string, hex: string): void {
+  const n = parseInt(hex.slice(1), 16)
+  const rgb = `rgb(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255})`
+  assert.ok(actual === hex || actual === rgb, `expected ${actual} to be ${hex}`)
+}
+
+
+async function scrollTo(el: LayoutEl, v: number): Promise {
+  await act(async () => { el.scrollLeft = v })
+  await flush()
+}
+
+async function scrollEvent(el: Element): Promise {
+  await act(async () => { el.dispatchEvent(new Event('scroll')) })
+  await flush()
+}
+
+describe('TrendChart empty history', () => {
+  test('renders the chart frame with no bars, no turn blocks, and no edge fades', async () => {
+    // The 'trend.empty' placeholder is the parent's (contextView) render arm; with zero requests the chart itself
+    // renders an empty frame: unit axis (maxTotal floors at 1), empty scroll content, empty turn strip.
+    const m = await mount(h(TrendChart, propsOf([])))
+    assert.equal(bars(m.container).length, 0)
+    assert.equal(queryAll(m.container, '.lc-turn').length, 0)
+    assert.equal(queryAll(m.container, '.lc-chart-fade').length, 0)
+    assert.equal(query(m.container, '.lc-axis-top').textContent, '1')
+    assert.equal(query(m.container, '.lc-axis-mid').textContent, '1')
+    assert.equal(query(m.container, '.lc-axis-bot').textContent, '0')
+    assert.ok(query(m.container, '.lc-grid-mid'), 'total mode keeps the dashed mid grid')
+    await m.unmount()
+  })
+})
+
+describe('TrendChart step granularity, total mode', () => {
+  test('stacks per-category segments in CATS order with real colors and proportional px heights', async () => {
+    const r1 = req(1, { turn: 1, step: 0 })
+    const r2 = req(2, { turn: 1, step: 1, system: 200, tools: 100, user: 60, inject: 40, assistant: 80, tool: 120, total: 600 })
+    const r3 = req(3, { turn: undefined, step: 0, user: 0, total: 280 })
+    const m = await mount(h(TrendChart, propsOf([r1, r2, r3])))
+
+    const bs = bars(m.container)
+    assert.equal(bs.length, 3)
+    assert.deepEqual(bs.map(b => b.getAttribute('data-seq')), ['1', '2', '3'])
+
+    // Six priced categories, in CATS order, with the shipped colors; max bar segments scale against maxTotal=600.
+    const segs1 = queryAll(bs[0], '.lc-bar-stack > div')
+    assert.equal(segs1.length, 6)
+    for (let i = 0; i < CATS.length; i++) assertColor(segs1[i].style.background, CATS[i].color)
+    assert.equal(segs1[0].style.height, `${Math.round(100 / 600 * CHART_H)}px`)
+    assert.equal(segs1[5].style.height, `${Math.round(60 / 600 * CHART_H)}px`)
+    const segs2 = queryAll(bs[1], '.lc-bar-stack > div')
+    assert.equal(segs2[0].style.height, `${Math.round(200 / 600 * CHART_H)}px`)
+
+    // Zero-value categories are skipped entirely (r3.user = 0 → five segments, no user-green segment).
+    const segs3 = queryAll(bs[2], '.lc-bar-stack > div')
+    assert.equal(segs3.length, 5)
+    assert.ok(![...segs3].some(s => s.style.background.includes('34, 197, 94') || s.style.background === '#22c55e'))
+
+    // Total-mode axis: top = maxTotal, mid = half, bottom = 0.
+    assert.equal(query(m.container, '.lc-axis-top').textContent, '600')
+    assert.equal(query(m.container, '.lc-axis-mid').textContent, '300')
+
+    // Turn strip: T1 spans two step columns (2*16-2 = 30px), the turnless request lands in group T0 (14px);
+    // zebra fills alternate and stay disjoint from the category palette.
+    const turns = queryAll(m.container, '.lc-turn')
+    assert.equal(turns.length, 2)
+    assert.equal(turns[0].style.width, '30px')
+    assert.equal(turns[1].style.width, '14px')
+    assert.ok(turns[0].style.background.includes('0.12'))
+    assert.ok(turns[1].style.background.includes('0.26'))
+    assert.deepEqual(turns.map(t => t.textContent), ['T1', 'T0'])
+    await m.unmount()
+  })
+
+  test('provider prompt anchors bar height; zero/absent prompt and zero total fall back honestly', async () => {
+    const r1 = req(1, { turn: 1, step: 0 })
+    const r2 = req(2, { turn: 1, step: 1, system: 200, tools: 100, user: 60, inject: 40, assistant: 80, tool: 120, total: 600, prompt: 1200 })
+    const r3 = req(3, { turn: 2, step: 0, prompt: 0 }) // prompt 0 → not an anchor, total drives
+    const r4 = req(4, { turn: 3, step: 0, system: 0, tools: 0, user: 0, inject: 0, assistant: 0, tool: 0, total: 0, prompt: 500 })
+    const m = await mount(h(TrendChart, propsOf([r1, r2, r3, r4])))
+
+    // maxTotal follows the provider prompt (1200), not the heuristic sum.
+    assert.equal(query(m.container, '.lc-axis-top').textContent, '1.2k')
+    const bs = bars(m.container)
+    assert.equal(bs.length, 4)
+    // r2 anchor = 1200/600 = 2: the system segment rides 200*2 against the 1200 max.
+    const segs2 = queryAll(bs[1], '.lc-bar-stack > div')
+    assert.equal(segs2[0].style.height, `${Math.round(200 * 2 / 1200 * CHART_H)}px`)
+    // r4 anchors to prompt (500) but every category is zero → the stack renders no segments.
+    assert.equal(queryAll(bs[3], '.lc-bar-stack > div').length, 0)
+
+    // The tooltip reports the heuristic total plus the provider-anchored actual.
+    const { spies, handlers } = makeSpies()
+    await m.update(h(TrendChart, { ...propsOf([r1, r2, r3, r4]), ...handlers, hoveredSeq: 2 }))
+    const tip = query(m.container, '.lc-chart-tip')
+    assert.equal(
+      tip.textContent,
+      kit.t('tip.step', { t: 1, s: 1 }) + ' · ' + kit.fmtTime(r2.time) + ' · '
+        + kit.t('tip.total', { n: '600' }) + ' · ' + kit.t('tip.actual', { n: '1.2k' }),
+    )
+    assert.equal(tip.style.left, '23px') // idx 1 * 16 + BAR_W/2
+    assert.ok(spies.hover.length === 0, 'hover callback only fires from real mouseover')
+    await m.unmount()
+  })
+
+  test('a zero-only history keeps the unit scale (maxTotal floors at 1)', async () => {
+    const zero = req(1, { turn: 1, step: 0, system: 0, tools: 0, user: 0, inject: 0, assistant: 0, tool: 0, total: 0 })
+    const m = await mount(h(TrendChart, propsOf([zero])))
+    assert.equal(query(m.container, '.lc-axis-top').textContent, '1')
+    assert.equal(queryAll(bars(m.container)[0], '.lc-bar-stack > div').length, 0)
+    await m.unmount()
+  })
+})
+
+describe('TrendChart delta mode', () => {
+  const base = req(1, { turn: 1, step: 0 })
+  const grown = req(2, { turn: 1, step: 1, system: 110, tools: 60, user: 40, inject: 30, assistant: 50, tool: 70, total: 360 })
+  const shrunk = req(3, { turn: 2, step: 0, system: 90, tools: 40, user: 20, inject: 10, assistant: 30, tool: 50, total: 240 })
+  const zeroReq = req(9, { turn: 3, step: 0, system: 0, tools: 0, user: 0, inject: 0, assistant: 0, tool: 0, total: 0 })
+
+  test('diverging stacks pile positive deltas up and hang negatives down off a solid zero line', async () => {
+    const nodes: SurfaceNode[] = [{ seq: 2, cat: 'assistant', tokens: 5, text: 'did work' }]
+    const m = await mount(h(TrendChart, propsOf([base, grown, shrunk], { mode: 'delta', replyTips: replyTipsOf(nodes) })))
+
+    // maxUp=60 (grown: +10 x6), maxDown=120 (shrunk: -20 x6) → scale 112/180, zero line at upPx=37.
+    assert.equal(query(m.container, '.lc-axis-top').textContent, '+60')
+    assert.equal(query(m.container, '.lc-axis-mid').textContent, '0')
+    assert.equal(query(m.container, '.lc-axis-mid').style.top, `${13 + 37}px`)
+    assert.equal(query(m.container, '.lc-axis-bot').textContent, '-120')
+    assert.equal(query(m.container, '.lc-grid-zero').style.top, `${18 + 37}px`)
+
+    const bs = bars(m.container)
+    // First bar starts from zero: both diverging stacks render empty.
+    assert.equal(queryAll(bs[0], '.lc-bar-up > div').length, 0)
+    assert.equal(queryAll(bs[0], '.lc-bar-down > div').length, 0)
+    // Grown bar: six +10 segments above the zero line, nothing below.
+    const up2 = queryAll(bs[1], '.lc-bar-up > div')
+    assert.equal(up2.length, 6)
+    assert.equal(up2[0].style.height, `${Math.round(10 * CHART_H / 180)}px`)
+    assertColor(up2[0].style.background, CATS[0].color)
+    assert.equal(queryAll(bs[1], '.lc-bar-down > div').length, 0)
+    // Shrunk bar: six -20 segments below, nothing above.
+    const down3 = queryAll(bs[2], '.lc-bar-down > div')
+    assert.equal(down3.length, 6)
+    assert.equal(down3[0].style.height, `${Math.round(20 * CHART_H / 180)}px`)
+    assert.equal(queryAll(bs[2], '.lc-bar-up > div').length, 0)
+    // The up stack rides downPx up from the bottom, the down stack hangs upPx from the zero line.
+    assert.equal(query(bs[1], '.lc-bar-up').style.bottom, '75px')
+    assert.equal(query(bs[2], '.lc-bar-down').style.top, '37px')
+
+    // Delta tooltip: signed net change, '+' only for positive nets; the reply tail still appends.
+    await m.update(h(TrendChart, propsOf([base, grown, shrunk], { mode: 'delta', replyTips: replyTipsOf(nodes), hoveredSeq: 2 })))
+    assert.equal(
+      query(m.container, '.lc-chart-tip').textContent,
+      kit.t('tip.step', { t: 1, s: 1 }) + ' · ' + kit.fmtTime(grown.time) + ' · ' + kit.t('tip.delta', { n: '+60' }) + ' · “did work”',
+    )
+    await m.update(h(TrendChart, propsOf([base, grown, shrunk], { mode: 'delta', hoveredSeq: 3 })))
+    assert.ok(query(m.container, '.lc-chart-tip').textContent!.includes(kit.t('tip.delta', { n: '-120' })))
+    await m.update(h(TrendChart, propsOf([base, grown, shrunk], { mode: 'delta', hoveredSeq: 1 })))
+    assert.ok(query(m.container, '.lc-chart-tip').textContent!.includes(kit.t('tip.delta', { n: '0' })))
+    await m.unmount()
+  })
+
+  test('growth-only history zeroes the negative axis arm; shrink-only zeroes the positive arm', async () => {
+    const up = await mount(h(TrendChart, propsOf([zeroReq, base], { mode: 'delta' })))
+    assert.equal(query(up.container, '.lc-axis-top').textContent, '+300')
+    assert.equal(query(up.container, '.lc-axis-bot').textContent, '0')
+    const upSegs = queryAll(bars(up.container)[1], '.lc-bar-up > div')
+    assert.equal(upSegs.length, 6)
+    assert.equal(upSegs[0].style.height, `${Math.round(100 * CHART_H / 300)}px`)
+    await up.unmount()
+
+    const down = await mount(h(TrendChart, propsOf([base, zeroReq], { mode: 'delta' })))
+    assert.equal(query(down.container, '.lc-axis-top').textContent, '0')
+    assert.equal(query(down.container, '.lc-axis-bot').textContent, '-300')
+    assert.equal(queryAll(bars(down.container)[1], '.lc-bar-down > div').length, 6)
+    await down.unmount()
+  })
+})
+
+describe('TrendChart turn granularity', () => {
+  const t1s0 = req(1, { turn: 1, step: 0 })
+  const t1s1 = req(2, { turn: 1, step: 1, total: 360 })
+  const t2s0 = req(3, { turn: 2, step: 0, total: 280 })
+
+  test('turn aggregates render one bar per turn with aggregated labels and step-count tooltips', async () => {
+    const agg = aggregateByTurn([t1s0, t1s1, t2s0])
+    const m = await mount(h(TrendChart, propsOf(agg, { granularity: 'turn' })))
+    const bs = bars(m.container)
+    assert.equal(bs.length, 2)
+    const turns = queryAll(m.container, '.lc-turn')
+    assert.equal(turns.length, 2)
+    // Aggregated groups always occupy exactly one column.
+    assert.deepEqual(turns.map(t => t.style.width), ['14px', '14px'])
+    assert.deepEqual(turns.map(t => t.textContent), ['T1', 'T2'])
+
+    // Multi-step aggregate → tip.turn; single-step aggregate falls back to tip.step.
+    await m.update(h(TrendChart, propsOf(agg, { granularity: 'turn', hoveredSeq: t1s1.seq })))
+    assert.ok(query(m.container, '.lc-chart-tip').textContent!.includes(kit.t('tip.turn', { t: 1, n: 2 })))
+    await m.update(h(TrendChart, propsOf(agg, { granularity: 'turn', hoveredSeq: t2s0.seq })))
+    assert.ok(query(m.container, '.lc-chart-tip').textContent!.includes(kit.t('tip.step', { t: 2, s: 0 })))
+    await m.unmount()
+
+    // A multi-step aggregate of TURNLESS requests reports turn 0.
+    const turnless = aggregateByTurn([req(10, { turn: undefined }), req(11, { turn: undefined })])
+    const m2 = await mount(h(TrendChart, propsOf(turnless, { granularity: 'turn', hoveredSeq: 11 })))
+    assert.ok(query(m2.container, '.lc-chart-tip').textContent!.includes(kit.t('tip.turn', { t: 0, n: 2 })))
+    await m2.unmount()
+  })
+
+  test('turn strip hover/click drive the turn callbacks; activeTurn dims the chart and lights the block', async () => {
+    const { spies, handlers } = makeSpies()
+    const agg = aggregateByTurn([t1s0, t1s1, t2s0])
+    const m = await mount(h(TrendChart, propsOf(agg, { ...handlers, granularity: 'turn' })))
+
+    const turns = queryAll(m.container, '.lc-turn')
+    await hover(turns[0])
+    assert.deepEqual(spies.hoverTurn, [1])
+    await click(turns[1])
+    assert.deepEqual(spies.pickTurn, [2])
+    await unhover(turns[0])
+    assert.deepEqual(spies.hoverTurn, [1, null])
+
+    await m.update(h(TrendChart, propsOf(agg, { ...handlers, granularity: 'turn', activeTurn: 1 })))
+    assert.ok(query(m.container, '.lc-chart-scroll').className.includes('lc-chart-dim'))
+    const bs = bars(m.container)
+    assert.ok(bs[0].className.includes('lc-bar-in-turn'))
+    assert.ok(!bs[1].className.includes('lc-bar-in-turn'))
+    assert.ok(queryAll(m.container, '.lc-turn')[0].className.includes('lc-turn-on'))
+    assert.ok(!queryAll(m.container, '.lc-turn')[1].className.includes('lc-turn-on'))
+
+    await m.update(h(TrendChart, propsOf(agg, { ...handlers, granularity: 'turn', activeTurn: null })))
+    assert.ok(!query(m.container, '.lc-chart-scroll').className.includes('lc-chart-dim'))
+    assert.ok(!bars(m.container)[0].className.includes('lc-bar-in-turn'))
+    await m.unmount()
+  })
+})
+
+describe('TrendChart selection and hover linking', () => {
+  test('clicking a bar picks it, clicking the picked bar clears it; turnless bars match activeTurn 0', async () => {
+    const { spies, handlers } = makeSpies()
+    const x1 = req(1, { turn: undefined, step: undefined })
+    const x2 = req(2, { turn: 2, step: 0 })
+    const m = await mount(h(TrendChart, propsOf([x1, x2], handlers)))
+
+    await click(bars(m.container)[0])
+    assert.deepEqual(spies.select, [1])
+    await m.update(h(TrendChart, propsOf([x1, x2], { ...handlers, selectedSeq: 1 })))
+    assert.ok(bars(m.container)[0].className.includes('lc-bar-selected'))
+    assert.ok(!bars(m.container)[1].className.includes('lc-bar-selected'))
+
+    await click(bars(m.container)[0])
+    assert.deepEqual(spies.select, [1, null])
+    await m.update(h(TrendChart, propsOf([x1, x2], { ...handlers, selectedSeq: 2 })))
+    assert.ok(bars(m.container)[1].className.includes('lc-bar-selected'))
+
+    // activeTurn against a turnless bar: 0 (its ?? fallback) mismatches turn 5, matches turn 0.
+    await m.update(h(TrendChart, propsOf([x1, x2], { ...handlers, selectedSeq: 2, activeTurn: 5 })))
+    assert.ok(!bars(m.container)[0].className.includes('lc-bar-in-turn'))
+    await m.update(h(TrendChart, propsOf([x1, x2], { ...handlers, selectedSeq: 2, activeTurn: 0 })))
+    assert.ok(bars(m.container)[0].className.includes('lc-bar-in-turn'))
+    assert.ok(!bars(m.container)[1].className.includes('lc-bar-in-turn'))
+    await m.unmount()
+  })
+})
+
+describe('TrendChart markers', () => {
+  test('compaction/prune markers render the ✂ glyph with a positioned or bare title', async () => {
+    const reqs = [req(1, { turn: 1, step: 0 }), req(2, { turn: 1, step: 1 }), req(3, { turn: 2, step: 0 })]
+    const compaction: ContextEventRecord = { seq: 2, time: 1700000000000, kind: 'compaction', count: 5, fromTurn: 1, fromStep: 0, turn: 1, step: 1 }
+    const prune: ContextEventRecord = { seq: 3, time: 1700000060000, kind: 'prune' }
+    const markers = attachMarkers(reqs, [compaction, prune])
+    assert.equal(markers[1], compaction)
+    assert.equal(markers[2], prune)
+
+    const m = await mount(h(TrendChart, propsOf(reqs, { markers })))
+    const glyph = queryAll(m.container, '.lc-bar-marker')
+    assert.equal(glyph.length, 2)
+    assert.equal(glyph[0].textContent, '✂')
+    assert.equal(
+      glyph[0].getAttribute('title'),
+      '✂ ' + kit.t('events.range', { t: 1, a: 0, b: 1 }) + ' — ' + kit.t('ev.compaction', { n: 5 }),
+    )
+    // No host-stamped position → bare label title.
+    assert.equal(glyph[1].getAttribute('title'), '✂ ' + kit.t('ev.prune'))
+    await m.unmount()
+  })
+})
+
+describe('TrendChart tooltips', () => {
+  const r1 = req(1, { turn: 1, step: 0 })
+  const r2 = req(2, { turn: 1, step: 1, total: 360 })
+  const r3 = req(3, { turn: 2, step: 0 })
+  const r4 = req(4, { turn: undefined, step: undefined })
+  const nodes: SurfaceNode[] = [
+    { seq: 1, cat: 'assistant', tokens: 5, text: 'fixed the bug' },
+    { seq: 2, cat: 'assistant', tokens: 5, text: 'a'.repeat(60) },
+    { seq: 3, cat: 'assistant', tokens: 5, calls: ['bash', 'read'] },
+  ]
+
+  test('hover floats a positioned tip with step identity, total, and reply preview variants', async () => {
+    const { spies, handlers } = makeSpies()
+    const reqs = [r1, r2, r3, r4]
+    const m = await mount(h(TrendChart, propsOf(reqs, handlers)))
+
+    // No replyTips prop at all: the tip renders without a tail.
+    const bs = bars(m.container)
+    await hover(bs[0])
+    assert.deepEqual(spies.hover, [1])
+    await m.update(h(TrendChart, propsOf(reqs, { ...handlers, hoveredSeq: 1 })))
+    assert.equal(
+      query(m.container, '.lc-chart-tip').textContent,
+      kit.t('tip.step', { t: 1, s: 0 }) + ' · ' + kit.fmtTime(r1.time) + ' · ' + kit.t('tip.total', { n: '300' }),
+    )
+    assert.equal(query(m.container, '.lc-chart-tip').style.left, '7px')
+
+    // With replyTips (built by the real replyTipsOf): short reply lands verbatim.
+    const withTips = (hoveredSeq: number | null): TrendChartProps =>
+      propsOf(reqs, { ...handlers, hoveredSeq, replyTips: replyTipsOf(nodes) })
+    await m.update(h(TrendChart, withTips(1)))
+    assert.ok(query(m.container, '.lc-chart-tip').textContent!.endsWith(' · “fixed the bug”'))
+
+    // Long replies truncate to 48 chars plus the ellipsis; call breadcrumbs join with ›.
+    await m.update(h(TrendChart, withTips(2)))
+    assert.ok(query(m.container, '.lc-chart-tip').textContent!.endsWith(' · “' + 'a'.repeat(48) + '…”'))
+    await m.update(h(TrendChart, withTips(3)))
+    assert.ok(query(m.container, '.lc-chart-tip').textContent!.endsWith(' · “bash › read”'))
+
+    // No reply row → no tail; turnless/step-less bars fall back to Turn 0 · Step 0.
+    await m.update(h(TrendChart, withTips(4)))
+    const tip4 = query(m.container, '.lc-chart-tip').textContent!
+    assert.ok(tip4.startsWith(kit.t('tip.step', { t: 0, s: 0 })))
+    assert.ok(!tip4.includes('“'))
+
+    // A hoveredSeq outside the rendered list floats no tip.
+    await m.update(h(TrendChart, withTips(999)))
+    assert.equal(queryAll(m.container, '.lc-chart-tip').length, 0)
+
+    // Leaving the chart clears the hover and hides the tip.
+    await m.update(h(TrendChart, withTips(1)))
+    assert.equal(queryAll(m.container, '.lc-chart-tip').length, 1)
+    await unhover(query(m.container, '.lc-chart'))
+    assert.deepEqual(spies.hover, [1, null])
+    await m.update(h(TrendChart, withTips(null)))
+    assert.equal(queryAll(m.container, '.lc-chart-tip').length, 0)
+    await m.unmount()
+  })
+})
+
+describe('TrendChart edge fades and scroll anchoring', () => {
+  function manySteps(): RequestRecord[] {
+    const out: RequestRecord[] = []
+    for (let i = 0; i < 40; i++) {
+      out.push(req(i + 1, { turn: 1 + Math.floor(i / 10), step: i % 10 }))
+    }
+    return out
+  }
+
+  test('overflow drives both/neither fades for real; the end-anchor sticks only near the end', async () => {
+    const reqs = manySteps()
+    const { handlers } = makeSpies()
+    const m = await mount(h(TrendChart, propsOf(reqs, handlers)))
+    await flush()
+    const scroll = query(m.container, '.lc-chart-scroll')
+
+    // scrollWidth 640 vs clientWidth 400: mount anchors to the newest (right) edge → left fade only.
+    assert.equal(scroll.scrollLeft, 240)
+    assert.equal(queryAll(m.container, '.lc-chart-fade-l').length, 1)
+    assert.equal(queryAll(m.container, '.lc-chart-fade-r').length, 0)
+
+    // Left edge: right fade only. Middle: both. A repeat scroll event with unchanged geometry keeps state.
+    await scrollTo(scroll, 0)
+    assert.equal(queryAll(m.container, '.lc-chart-fade-l').length, 0)
+    assert.equal(queryAll(m.container, '.lc-chart-fade-r').length, 1)
+    await scrollTo(scroll, 100)
+    assert.equal(queryAll(m.container, '.lc-chart-fade-l').length, 1)
+    assert.equal(queryAll(m.container, '.lc-chart-fade-r').length, 1)
+    await scrollEvent(scroll)
+    assert.equal(queryAll(m.container, '.lc-chart-fade-l').length, 1)
+    assert.equal(queryAll(m.container, '.lc-chart-fade-r').length, 1)
+
+    // An unrelated update mid-scroll does NOT re-anchor (100 + 400 < 640 - 24).
+    await m.update(h(TrendChart, propsOf(reqs, { ...handlers, selectedSeq: 1 })))
+    assert.equal(scroll.scrollLeft, 100)
+    // …but near the end (230 + 400 >= 640 - 24) the next render sticks to the newest edge.
+    await scrollTo(scroll, 230)
+    await m.update(h(TrendChart, propsOf(reqs, { ...handlers, selectedSeq: 2 })))
+    await flush()
+    assert.equal(scroll.scrollLeft, 240)
+    assert.equal(queryAll(m.container, '.lc-chart-fade-l').length, 1)
+    assert.equal(queryAll(m.container, '.lc-chart-fade-r').length, 0)
+
+    // Turn labels re-center within their visible slice: T2 is half-clipped at the left → shifted right;
+    // T3 is fully visible and centered → no transform; T1 is fully out of view → untouched.
+    const labels = queryAll(m.container, '.lc-turn-label')
+    assert.equal(labels.length, 4)
+    assert.equal(labels[1].style.transform, 'translateX(40px)')
+    assert.equal(labels[2].style.transform, '')
+    assert.equal(labels[0].style.transform, '')
+    await scrollTo(scroll, 0)
+    assert.equal(labels[1].style.transform, '', 'back at the left edge every block centers natively')
+    await m.unmount()
+  })
+
+  test('a granularity switch re-anchors and flips overflow for real (React #185 regression)', async () => {
+    const reqs = manySteps()
+    const { handlers } = makeSpies()
+    const m = await mount(h(TrendChart, propsOf(reqs, { ...handlers, granularity: 'step' })))
+    await flush()
+    assert.equal(queryAll(m.container, '.lc-chart-fade-l').length, 1)
+
+    // Step → turn: 4 aggregated bars fit the viewport — the stale left edge must not survive the switch.
+    await m.update(h(TrendChart, propsOf(aggregateByTurn(reqs), { ...handlers, granularity: 'turn' })))
+    await flush()
+    assert.equal(bars(m.container).length, 4)
+    assert.equal(queryAll(m.container, '.lc-chart-fade').length, 0)
+
+    // Turn → step re-anchors to the newest bars again.
+    await m.update(h(TrendChart, propsOf(reqs, { ...handlers, granularity: 'step' })))
+    await flush()
+    assert.equal(bars(m.container).length, 40)
+    assert.equal(query(m.container, '.lc-chart-scroll').scrollLeft, 240)
+    assert.equal(queryAll(m.container, '.lc-chart-fade-l').length, 1)
+    await m.unmount()
+  })
+
+  test('focusTurn scroll-centers the target turn bar once; unknown turns still consume the focus', async () => {
+    const savedW = CLIENT_W
+    CLIENT_W = 30
+    try {
+      // Six aggregated turns (scrollWidth 96): the focus target sits far from the near-end stick zone.
+      const agg = aggregateByTurn([...manySteps(), ...manySteps().map(r => ({ ...r, seq: r.seq + 40, turn: (r.turn ?? 0) + 4 }))])
+      assert.equal(agg.length, 8)
+      const { spies, handlers } = makeSpies()
+      const m = await mount(h(TrendChart, propsOf(agg, { ...handlers, granularity: 'turn', focusTurn: 2 })))
+      await flush()
+      const scroll = query(m.container, '.lc-chart-scroll')
+      assert.ok(spies.focusHandled >= 1)
+      assert.equal(scroll.scrollLeft, 8) // 1 * 16 + 7 - 30/2
+      assert.equal(queryAll(m.container, '.lc-chart-fade-l').length, 1)
+      assert.equal(queryAll(m.container, '.lc-chart-fade-r').length, 1)
+
+      const before = spies.focusHandled
+      await m.update(h(TrendChart, propsOf(agg, { ...handlers, granularity: 'turn', focusTurn: 99 })))
+      await flush()
+      assert.ok(spies.focusHandled > before, 'the focus is consumed even when the turn is absent')
+      assert.equal(scroll.scrollLeft, 8, 'no focus target and not near the end → the scroll position holds')
+      await m.unmount()
+    } finally {
+      CLIENT_W = savedW
+    }
+  })
+
+  test('turn labels clear their shift for blocks narrower than the measured label', async () => {
+    const reqs: RequestRecord[] = []
+    for (let i = 0; i < 16; i++) reqs.push(req(i + 1, { turn: 1 + Math.floor(i / 8), step: i % 8 }))
+    const { handlers } = makeSpies()
+    const m = await mount(h(TrendChart, propsOf(reqs, handlers)))
+    const scroll = query(m.container, '.lc-chart-scroll')
+    const labels = queryAll(m.container, '.lc-turn-label')
+    assert.equal(labels.length, 2) // T1 off 0 w 126, T2 off 128 w 126; scrollWidth 256
+
+    scroll.__clientW = 60
+    await scrollTo(scroll, 150)
+    // T2 visible slice [150, 210] → center 52 vs block center 63 → shift left; T1 fully out of view → untouched.
+    assert.equal(labels[1].style.transform, 'translateX(-11px)')
+    assert.equal(labels[0].style.transform, '')
+
+    // A measured label wider than its block never shifts (block stays put).
+    Object.defineProperty(labels[1], 'offsetWidth', { configurable: true, get: () => 200 })
+    await scrollTo(scroll, 150)
+    assert.equal(labels[1].style.transform, '')
+
+    // A real measured label: clamped to keep the label inside its block on both sides.
+    Object.defineProperty(labels[1], 'offsetWidth', { configurable: true, get: () => 40 })
+    await scrollTo(scroll, 196)
+    assert.equal(labels[1].style.transform, 'translateX(34px)')
+    await scrollTo(scroll, 100)
+    assert.equal(labels[1].style.transform, 'translateX(-43px)')
+    await scrollTo(scroll, 0)
+    assert.equal(labels[1].style.transform, '', 'fully out of view → shift cleared')
+    await scrollEvent(scroll)
+    assert.equal(labels[1].style.transform, '', 'repeat scroll with unchanged geometry writes nothing')
+    await m.unmount()
+  })
+})
+
+describe('aggregateByTurn', () => {
+  test('collapses a run of same-turn steps into its last record tagged with the step count', () => {
+    const agg = aggregateByTurn([
+      req(1, { turn: 1, step: 0 }),
+      req(2, { turn: 1, step: 1, total: 500 }),
+      req(3, { turn: 2, step: 0 }),
+    ])
+    assert.equal(agg.length, 2)
+    assert.equal(agg[0].seq, 2, 'the turn bar is its LAST step')
+    assert.equal(agg[0].stepCount, 2)
+    assert.equal(agg[0].total, 500)
+    assert.equal(agg[1].stepCount, 1)
+  })
+
+  test('turnless requests share turn 0; a turnless run breaks at the first stamped turn', () => {
+    const agg = aggregateByTurn([req(1, { turn: undefined }), req(2, { turn: undefined })])
+    assert.equal(agg.length, 1)
+    assert.equal(agg[0].stepCount, 2)
+    const mixed = aggregateByTurn([req(1, { turn: undefined }), req(2, { turn: 1 })])
+    assert.equal(mixed.length, 2)
+  })
+
+  test('an empty history aggregates to nothing', () => {
+    assert.deepEqual(aggregateByTurn([]), [])
+  })
+})
+
+describe('attachMarkers', () => {
+  test('attaches each boundary event to the first request logged after it', () => {
+    const reqs = [req(5), req(10)]
+    const ev: ContextEventRecord = { seq: 7, time: 0, kind: 'compaction' }
+    const markers = attachMarkers(reqs, [ev])
+    assert.equal(markers.length, 2)
+    assert.equal(markers[0], undefined)
+    assert.equal(markers[1], ev)
+  })
+
+  test('events after the whole log, and non-boundary kinds, attach nowhere', () => {
+    const reqs = [req(5), req(10)]
+    // `new Array(n)` leaves holes: an event that never matches assigns no index at all.
+    assert.equal(Object.keys(attachMarkers(reqs, [{ seq: 99, time: 0, kind: 'prune' }])).length, 0)
+    assert.equal(Object.keys(attachMarkers(reqs, [{ seq: 1, time: 0, kind: 'inject' }])).length, 0)
+    assert.equal(attachMarkers([], [{ seq: 1, time: 0, kind: 'compaction' }]).length, 0)
+  })
+
+  test('the first event to claim a request index wins', () => {
+    const reqs = [req(5), req(10)]
+    const first: ContextEventRecord = { seq: 1, time: 0, kind: 'compaction' }
+    const second: ContextEventRecord = { seq: 2, time: 0, kind: 'prune' }
+    const markers = attachMarkers(reqs, [first, second])
+    assert.equal(markers[0], first)
+    assert.equal(markers[1], undefined)
+  })
+})
diff --git a/tests/client/cost.spec.ts b/tests/client/cost.spec.ts
new file mode 100644
index 0000000..41cb0b5
--- /dev/null
+++ b/tests/client/cost.spec.ts
@@ -0,0 +1,114 @@
+// Session-cost estimate (src/client/cost.ts): the hardcoded DeepSeek V4
+// price lookup over family × period buckets, the null degradations, the
+// numOf coercion of garbage bucket fields, and the money/rate formatting.
+
+import assert from 'node:assert/strict'
+import { describe, test } from 'vitest'
+import { estimateSessionCost, formatCost, formatPriceRate, sessionPrices } from '../../src/client/cost'
+import type { CostBucketTotals } from '../../src/shared/types'
+
+const M = 1_000_000
+
+function bucket(cacheRead: number, uncached: number, cacheWrite: number, output: number): CostBucketTotals {
+  return { cacheRead, uncached, cacheWrite, output }
+}
+
+function close(actual: number | null, expected: number): void {
+  assert.ok(actual !== null && Math.abs(actual - expected) < 1e-9, `expected ~${expected}, got ${actual}`)
+}
+
+describe('estimateSessionCost', () => {
+  test('null or undefined usage prices to null', () => {
+    assert.equal(estimateSessionCost(null, 'usd'), null)
+    assert.equal(estimateSessionCost(undefined, 'cny'), null)
+  })
+
+  test('usage without any priced bucket returns null', () => {
+    assert.equal(estimateSessionCost({}, 'usd'), null)
+  })
+
+  test('prices all four family × period buckets with the USD table', () => {
+    const usage = {
+      flash: { peak: bucket(M, M, M, M), off: bucket(M, M, M, M) },
+      pro: { peak: bucket(M, M, M, M), off: bucket(M, M, M, M) },
+    }
+    // flash peak 0.014 + 2×0.44 + 1.32, flash off 0.007 + 2×0.22 + 0.66,
+    // pro peak 0.044 + 2×1.32 + 3.96, pro off 0.022 + 2×0.66 + 1.98.
+    close(estimateSessionCost(usage, 'usd'), 2.214 + 1.107 + 6.644 + 3.322)
+  })
+
+  test('prices with the CNY table', () => {
+    const usage = { flash: { peak: bucket(M, M, M, M) } }
+    close(estimateSessionCost(usage, 'cny'), 0.10 + 2 * 3.0 + 9.0)
+  })
+
+  test('a missing model family is skipped', () => {
+    const usage = { pro: { peak: bucket(0, M, 0, 0) } }
+    close(estimateSessionCost(usage, 'usd'), 1.32)
+  })
+
+  test('a missing pricing period is skipped', () => {
+    const usage = { flash: { off: bucket(0, 0, 0, M) } }
+    close(estimateSessionCost(usage, 'usd'), 0.66)
+  })
+
+  test('non-number bucket fields are coerced to zero by numOf', () => {
+    const garbage = { cacheRead: NaN, uncached: 'x', cacheWrite: undefined, output: Infinity } as unknown as CostBucketTotals
+    assert.equal(estimateSessionCost({ flash: { peak: garbage } }, 'usd'), 0)
+  })
+
+  test('garbage fields degrade while real fields still price', () => {
+    const mixed = { cacheRead: M, uncached: NaN, cacheWrite: M / 2, output: 'junk' } as unknown as CostBucketTotals
+    close(estimateSessionCost({ flash: { peak: mixed } }, 'usd'), 0.014 + 0.5 * 0.44)
+  })
+})
+
+describe('formatCost', () => {
+  test('amounts of at least 1 use fixed two-decimal notation', () => {
+    assert.equal(formatCost(3.456, 'usd'), '$3.46')
+    assert.equal(formatCost(1, 'usd'), '$1.00')
+  })
+
+  test('amounts below 1 use two-significant-digit precision', () => {
+    assert.equal(formatCost(0.014, 'usd'), '$0.014')
+    assert.equal(formatCost(0.5, 'usd'), '$0.50')
+  })
+
+  test('the CNY currency uses the yen symbol', () => {
+    assert.equal(formatCost(12.3, 'cny'), '¥12.30')
+    assert.equal(formatCost(0.66, 'cny'), '¥0.66')
+  })
+})
+
+describe('sessionPrices', () => {
+  test('lists flash before pro with their peak and off-peak triples (USD)', () => {
+    assert.deepEqual(sessionPrices('usd'), [
+      { family: 'deepseek-v4-flash', peak: { hit: 0.014, miss: 0.44, out: 1.32 }, off: { hit: 0.007, miss: 0.22, out: 0.66 } },
+      { family: 'deepseek-v4-pro', peak: { hit: 0.044, miss: 1.32, out: 3.96 }, off: { hit: 0.022, miss: 0.66, out: 1.98 } },
+    ])
+  })
+
+  test('lists the CNY table for the CNY currency', () => {
+    assert.deepEqual(sessionPrices('cny'), [
+      { family: 'deepseek-v4-flash', peak: { hit: 0.10, miss: 3.0, out: 9.0 }, off: { hit: 0.05, miss: 1.5, out: 4.5 } },
+      { family: 'deepseek-v4-pro', peak: { hit: 0.30, miss: 9.0, out: 27.0 }, off: { hit: 0.15, miss: 4.5, out: 13.5 } },
+    ])
+  })
+})
+
+describe('formatPriceRate', () => {
+  test('trims trailing zeros from a fixed-notation figure', () => {
+    assert.equal(formatPriceRate(3.0, 'cny'), '¥3')
+    assert.equal(formatPriceRate(4.5, 'cny'), '¥4.5')
+  })
+
+  test('trims trailing zeros from a precision-notation figure', () => {
+    assert.equal(formatPriceRate(0.007, 'usd'), '$0.007')
+    assert.equal(formatPriceRate(0.1, 'usd'), '$0.1')
+  })
+
+  test('strips the dot left behind when every decimal was a zero', () => {
+    assert.equal(formatPriceRate(9.0, 'cny'), '¥9')
+    assert.equal(formatPriceRate(1.5, 'cny'), '¥1.5')
+  })
+})
diff --git a/tests/client/format.spec.ts b/tests/client/format.spec.ts
new file mode 100644
index 0000000..0e9bd56
--- /dev/null
+++ b/tests/client/format.spec.ts
@@ -0,0 +1,69 @@
+// Display formatting (src/client/format.ts): the k/M suffix style, byte
+// sizes, the cache-hit truncation, and locale-safe time.
+
+import assert from 'node:assert/strict'
+import { describe, test } from 'vitest'
+import { cacheHitPercent, fmt, fmtBytes, fmtTime } from '../../src/client/format'
+
+describe('fmt', () => {
+  test('missing/NaN input shows the dash', () => {
+    assert.equal(fmt(undefined), '—')
+    assert.equal(fmt(null), '—')
+    assert.equal(fmt(NaN), '—')
+  })
+
+  test('small numbers round to integers', () => {
+    assert.equal(fmt(0), '0')
+    assert.equal(fmt(999.4), '999')
+  })
+
+  test('thousands and millions take suffixes with one decimal', () => {
+    assert.equal(fmt(1000), '1.0k')
+    assert.equal(fmt(1534), '1.5k')
+    assert.equal(fmt(1_000_000), '1.0M')
+    assert.equal(fmt(128_000), '128.0k')
+  })
+
+  test('negatives keep their sign', () => {
+    assert.equal(fmt(-1500), '-1.5k')
+    assert.equal(fmt(-12), '-12')
+  })
+})
+
+describe('fmtBytes', () => {
+  test('missing/NaN/negative input shows the dash', () => {
+    assert.equal(fmtBytes(undefined), '—')
+    assert.equal(fmtBytes(null), '—')
+    assert.equal(fmtBytes(NaN), '—')
+    assert.equal(fmtBytes(-1), '—')
+  })
+
+  test('B/kB/MB at decimal thresholds', () => {
+    assert.equal(fmtBytes(512), '512 B')
+    assert.equal(fmtBytes(2048), '2.0 kB')
+    assert.equal(fmtBytes(3_500_000), '3.5 MB')
+  })
+})
+
+describe('cacheHitPercent', () => {
+  test('null when nothing was billed', () => {
+    assert.equal(cacheHitPercent(5, 0), null)
+    assert.equal(cacheHitPercent(0, -1), null)
+  })
+
+  test('truncates to two decimals (cut, not round)', () => {
+    assert.equal(cacheHitPercent(1, 3), '33.33')
+    assert.equal(cacheHitPercent(82499, 83000), '99.39')
+    assert.equal(cacheHitPercent(83000, 83000), '100.00')
+  })
+})
+
+describe('fmtTime', () => {
+  test('invalid dates show the dash instead of throwing', () => {
+    assert.equal(fmtTime(NaN), '—')
+  })
+
+  test('valid timestamps format as 24-hour HH:MM:SS', () => {
+    assert.match(fmtTime(1000), /^\d{2}:\d{2}:\d{2}$/)
+  })
+})
diff --git a/tests/client/headline.spec.ts b/tests/client/headline.spec.ts
new file mode 100644
index 0000000..806658f
--- /dev/null
+++ b/tests/client/headline.spec.ts
@@ -0,0 +1,134 @@
+// Headline derivation (src/client/headline.ts): the provider-anchored
+// occupancy (projected → derived → heuristic), the window/pct pairing, and
+// the anchored composition parts.
+
+import assert from 'node:assert/strict'
+import { describe, test } from 'vitest'
+import { headlineOf } from '../../src/client/headline'
+import type { ContextTimeline, RequestRecord } from '../../src/shared/types'
+
+function timeline(over: Partial = {}): ContextTimeline {
+  return {
+    ok: true,
+    current: { system: 10, tools: 20, user: 30, inject: 0, assistant: 40, tool: 0, total: 100 },
+    toolList: [],
+    requests: [],
+    events: [],
+    nodes: [],
+    droppedNodes: 0,
+    archive: [],
+    ...over,
+  }
+}
+
+function req(over: Partial = {}): RequestRecord {
+  return { time: 0, seq: 5, system: 0, tools: 0, user: 0, inject: 0, assistant: 0, tool: 0, total: 80, ...over }
+}
+
+describe('headlineOf occupancy', () => {
+  test('the official projection supplies the anchored total and window', () => {
+    const h = headlineOf(timeline(), { projectedTokens: 200, contextWindow: 1000 })
+    assert.equal(h.tokens, 200)
+    assert.equal(h.window, 1000)
+    assert.equal(h.pct, 20)
+  })
+
+  test('the projection wins over the derived request anchor', () => {
+    const data = timeline({ requests: [req({ prompt: 150 })] })
+    const h = headlineOf(data, { projectedTokens: 200 })
+    assert.equal(h.tokens, 200)
+  })
+
+  test('a non-number projectedTokens falls through to the derived anchor', () => {
+    const data = timeline({ requests: [req({ prompt: 150 })] })
+    const h = headlineOf(data, { projectedTokens: 'junk' as unknown as number })
+    // lastReq.prompt + surface movement since: 150 + (100 - 80).
+    assert.equal(h.tokens, 170)
+  })
+
+  test('without a projection the anchor derives from the last request', () => {
+    const data = timeline({ requests: [req({ prompt: 150, total: 80 })] })
+    const h = headlineOf(data, null)
+    assert.equal(h.tokens, 170)
+  })
+
+  test('empty requests yield no derived anchor', () => {
+    const h = headlineOf(timeline(), null)
+    assert.equal(h.tokens, 100)
+  })
+
+  test('a request without a numeric prompt yields no derived anchor', () => {
+    const data = timeline({ requests: [req()] })
+    const h = headlineOf(data, null)
+    assert.equal(h.tokens, 100)
+  })
+
+  test('tokens fall back to the heuristic total without any anchor', () => {
+    const h = headlineOf(timeline())
+    assert.equal(h.tokens, 100)
+  })
+})
+
+describe('headlineOf window and pct', () => {
+  test('the pressure window wins over the timeline window', () => {
+    const data = timeline({ contextWindow: 1000 })
+    const h = headlineOf(data, { contextWindow: 500 })
+    assert.equal(h.window, 500)
+    assert.equal(h.pct, 20)
+  })
+
+  test('the timeline window serves when the pressure has none', () => {
+    const h = headlineOf(timeline({ contextWindow: 200 }), { contextWindow: 'junk' as unknown as number })
+    assert.equal(h.window, 200)
+    assert.equal(h.pct, 50)
+  })
+
+  test('without any window the pct is null', () => {
+    const h = headlineOf(timeline(), null)
+    assert.equal(h.window, undefined)
+    assert.equal(h.pct, null)
+  })
+
+  test('a non-positive window yields a null pct', () => {
+    const h = headlineOf(timeline({ contextWindow: 0 }), null)
+    assert.equal(h.pct, null)
+  })
+
+  test('the pct clamps at 100', () => {
+    const h = headlineOf(timeline(), { projectedTokens: 250, contextWindow: 100 })
+    assert.equal(h.pct, 100)
+  })
+})
+
+describe('headlineOf parts', () => {
+  test('a known positive occupancy anchors the parts to it', () => {
+    const h = headlineOf(timeline(), { projectedTokens: 200 })
+    // Raw composition sums to 100 (10/20/30/0/40/0); anchored to 200.
+    assert.deepEqual(
+      h.parts.map(p => [p.key, p.value, p.raw]),
+      [
+        ['system', 20, 10],
+        ['tools', 40, 20],
+        ['user', 60, 30],
+        ['inject', 0, 0],
+        ['assistant', 80, 40],
+        ['tool', 0, 0],
+      ],
+    )
+  })
+
+  test('without an occupancy the parts stay unanchored (value equals raw)', () => {
+    const h = headlineOf(timeline(), null)
+    assert.deepEqual(h.parts.map(p => [p.value, p.raw]), [
+      [10, 10], [20, 20], [30, 30], [0, 0], [40, 40], [0, 0],
+    ])
+  })
+
+  test('a zero occupancy does not anchor the parts', () => {
+    const h = headlineOf(timeline(), { projectedTokens: 0 })
+    assert.equal(h.tokens, 0)
+    assert.deepEqual(h.parts.map(p => [p.value, p.raw]), [
+      [10, 10], [20, 20], [30, 30], [0, 0], [40, 40], [0, 0],
+    ])
+  })
+})
diff --git a/tests/client/helpers/harness.ts b/tests/client/helpers/harness.ts
new file mode 100644
index 0000000..6492c83
--- /dev/null
+++ b/tests/client/helpers/harness.ts
@@ -0,0 +1,147 @@
+// A faithful in-memory implementation of the harness client context the
+// plugin's `apply` consumes. Every member implements the DOCUMENTED contract
+// (cordis effect/inject semantics, locale fallback chain, slot registry,
+// sessions provide channel) rather than a test-specific stub — registrations
+// and disposals behave the way the real harness's do, so specs assert through
+// the same seams the production runtime uses.
+
+import type { ClientCtx, LocaleService, SessionProvideDescriptorLike, SlotRegistration, SlotsService } from '../../../src/client/services'
+import { makeTranslate } from './kit'
+
+export interface SlotEntry {
+  registration: SlotRegistration
+  component: (props: { sessionId?: string } & Record) => unknown
+}
+
+export class TestSlots implements SlotsService {
+  readonly entries: SlotEntry[] = []
+
+  inject(_name: string, callback: () => unknown): unknown {
+    // slots.inject is a declaration injection riding the caller's effect:
+    // the callback runs synchronously and returns the register() disposer.
+    return callback()
+  }
+
+  register(registration: SlotRegistration, component: (props: { sessionId?: string } & Record) => unknown): unknown {
+    const entry: SlotEntry = { registration, component }
+    this.entries.push(entry)
+    return () => {
+      const i = this.entries.indexOf(entry)
+      if (i >= 0) this.entries.splice(i, 1)
+    }
+  }
+
+  /** Every registration for a slot name (list slots keep several). */
+  of(name: string): SlotEntry[] {
+    return this.entries.filter(e => e.registration.name === name)
+  }
+}
+
+export class TestLocale implements LocaleService {
+  readonly namespaces = new Map>>()
+
+  constructor(readonly active = 'en') {}
+
+  register(ns: string, dicts: Record>): () => void {
+    this.namespaces.set(ns, dicts)
+    return () => {
+      this.namespaces.delete(ns)
+    }
+  }
+
+  bind(ns: string): (key: string, params?: Record) => string {
+    return makeTranslate(this.active as 'en' | 'zh', this.namespaces.get(ns))
+  }
+
+  getLocale(): { active: string } {
+    return { active: this.active }
+  }
+}
+
+export interface TestClientCtxOptions {
+  locale?: 'en' | 'zh'
+  services?: Record
+}
+
+/**
+ * A client ctx with cordis semantics: `inject` runs its callback once every
+ * requested service exists (services are armed via options.services or
+ * setService — arming later replays pending callbacks, like cordis), and
+ * `effect` collects disposers that `dispose()` runs LIFO.
+ */
+export class TestClientCtx {
+  readonly slots = new TestSlots()
+  readonly locale: TestLocale
+  private readonly services = new Map()
+  private readonly pending: { deps: string[]; cb: (ctx: TestClientCtx) => void }[] = []
+  private readonly disposers: (() => void)[] = []
+
+  constructor(options: TestClientCtxOptions = {}) {
+    this.locale = new TestLocale(options.locale ?? 'en')
+    for (const [k, v] of Object.entries(options.services ?? {})) this.setService(k, v)
+  }
+
+  get(name: string): unknown {
+    return this.services.get(name)
+  }
+
+  setService(name: string, service: unknown): void {
+    this.services.set(name, service)
+    // Cordis surfaces services as context properties (ctx.settingsScope …).
+    if (!(name in this)) {
+      Object.defineProperty(this, name, { get: () => this.services.get(name), configurable: true })
+    }
+    // Cordis replays waiting injects once their dependency list completes.
+    for (let i = this.pending.length - 1; i >= 0; i--) {
+      const p = this.pending[i]
+      if (p.deps.every(d => this.services.has(d))) {
+        this.pending.splice(i, 1)
+        p.cb(this)
+      }
+    }
+  }
+
+  inject(deps: string[] | Record, cb: (ctx: TestClientCtx) => void): void {
+    const list = Array.isArray(deps) ? deps : Object.keys(deps)
+    if (list.every(d => this.services.has(d))) cb(this)
+    else this.pending.push({ deps: list, cb })
+  }
+
+  effect(fn: () => (() => void) | void, _label?: string): () => void {
+    const dispose = fn()
+    const d = typeof dispose === 'function' ? dispose : () => {}
+    this.disposers.push(d)
+    return d
+  }
+
+  dispose(): void {
+    while (this.disposers.length > 0) this.disposers.pop()?.()
+  }
+}
+
+export function asClientCtx(ctx: TestClientCtx): ClientCtx {
+  return ctx as unknown as ClientCtx
+}
+
+/** A real sessions face with the documented provide/scope contract. */
+export class TestSessions {
+  readonly provided: SessionProvideDescriptorLike[] = []
+  readonly bails: { scope: unknown; event: string; payload: unknown }[] = []
+
+  provide(descriptor: SessionProvideDescriptorLike): () => void {
+    this.provided.push(descriptor)
+    return () => {
+      const i = this.provided.indexOf(descriptor)
+      if (i >= 0) this.provided.splice(i, 1)
+    }
+  }
+
+  scope(_id: string): { bail(subject: unknown, event: string, payload: unknown): boolean } | undefined {
+    return {
+      bail: (subject, event, payload) => {
+        this.bails.push({ scope: subject, event, payload })
+        return true
+      },
+    }
+  }
+}
diff --git a/tests/client/helpers/kit.ts b/tests/client/helpers/kit.ts
new file mode 100644
index 0000000..098a57a
--- /dev/null
+++ b/tests/client/helpers/kit.ts
@@ -0,0 +1,114 @@
+// Client test kit: real dictionaries, real React rendering, and a faithful
+// harness-context implementation (the documented cordis/locale/slots
+// contracts, not mocks of plugin code). Shared by every client spec so each
+// test file stays small and stateless.
+
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import type { ReactElement, ReactNode } from 'react'
+import { DICT_EN, DICT_ZH } from '../../../src/client/i18n'
+import type { Translate } from '../../../src/client/i18n'
+import { makeViewKit, type ViewKit } from '../../../src/client/viewkit'
+
+/**
+ * The harness locale chain: active-locale dictionary → en → the key itself,
+ * with `{name}` interpolation. Built over the plugin's REAL dictionaries, so
+ * assertions exercise the shipped strings rather than a test copy.
+ */
+export function makeTranslate(active: 'en' | 'zh' = 'en', dicts?: Record>): Translate {
+  const table = dicts ?? { zh: DICT_ZH, en: DICT_EN }
+  return (key, params) => {
+    let s = table[active]?.[key] ?? table.en[key] ?? key
+    if (params) for (const k in params) s = s.replace('{' + k + '}', String(params[k]))
+    return s
+  }
+}
+
+export function makeKit(active: 'en' | 'zh' = 'en'): ViewKit {
+  return makeViewKit(makeTranslate(active))
+}
+
+export interface Mounted {
+  container: HTMLElement
+  root: Root
+  /** Re-render with new props (act-wrapped). */
+  update(el: ReactElement): Promise
+  unmount(): Promise
+}
+
+/** Mount a real React tree in jsdom, effects flushed through act. */
+export async function mount(el: ReactElement): Promise {
+  const container = document.createElement('div')
+  document.body.appendChild(container)
+  const root = createRoot(container)
+  await act(async () => {
+    root.render(el)
+  })
+  return {
+    container,
+    root,
+    async update(next) {
+      await act(async () => {
+        root.render(next)
+      })
+    },
+    async unmount() {
+      await act(async () => {
+        root.unmount()
+      })
+      container.remove()
+    },
+  }
+}
+
+/** Flush pending promises/effects (image loads, deferred setState). */
+export async function flush(): Promise {
+  await act(async () => {})
+}
+
+export function text(container: HTMLElement): string {
+  return container.textContent ?? ''
+}
+
+export function query(container: ParentNode, selector: string): T {
+  const el = container.querySelector(selector)
+  if (el === null) throw new Error(`element not found: ${selector}`)
+  return el
+}
+
+export function queryAll(container: ParentNode, selector: string): T[] {
+  return [...container.querySelectorAll(selector)]
+}
+
+/** Click through act so React processes the dispatch synchronously. */
+export async function click(el: HTMLElement): Promise {
+  await act(async () => {
+    el.click()
+  })
+}
+
+/**
+ * Pointer hover. React 18 synthesizes onMouseEnter/onMouseLeave from the
+ * BUBBLING mouseover/mouseout pair — dispatching raw mouseenter/leave (they
+ * do not bubble) never reaches React's handlers.
+ */
+export async function hover(el: Element): Promise {
+  await act(async () => {
+    el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }))
+  })
+}
+
+export async function unhover(el: Element): Promise {
+  await act(async () => {
+    el.dispatchEvent(new MouseEvent('mouseout', { bubbles: true }))
+  })
+}
+
+/** Dispatch a real keydown (Escape etc.) on window, act-wrapped. */
+export async function keydown(key: string, target: HTMLElement | Window = window): Promise {
+  await act(async () => {
+    target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
+  })
+}
+
+export type { ReactElement, ReactNode }
diff --git a/tests/client/i18n.spec.ts b/tests/client/i18n.spec.ts
new file mode 100644
index 0000000..cecdce2
--- /dev/null
+++ b/tests/client/i18n.spec.ts
@@ -0,0 +1,39 @@
+// Dictionaries (src/client/i18n.ts): both locales carry exactly the same
+// keys, every value is a non-empty string, and interpolation placeholders
+// agree between locales for every parametrized key.
+
+import assert from 'node:assert/strict'
+import { describe, test } from 'vitest'
+import { DICT_EN, DICT_ZH } from '../../src/client/i18n'
+
+function placeholders(s: string): string[] {
+  return [...s.matchAll(/\{(\w+)\}/g)].map(m => m[1]).sort()
+}
+
+describe('dictionaries', () => {
+  test('zh and en carry exactly the same key set', () => {
+    assert.deepEqual(Object.keys(DICT_ZH).sort(), Object.keys(DICT_EN).sort())
+  })
+
+  test('every value is a non-empty string', () => {
+    for (const dict of [DICT_ZH, DICT_EN]) {
+      for (const [key, value] of Object.entries(dict)) {
+        assert.equal(typeof value, 'string', key)
+        assert.ok(value.length > 0, key)
+      }
+    }
+  })
+
+  test('interpolation placeholders agree between locales for every key', () => {
+    for (const key of Object.keys(DICT_EN)) {
+      // 'block.line' is the one documented asymmetry: the en singular form
+      // is the fixed literal '1 line' while zh keeps the {n} measure word.
+      if (key === 'block.line') {
+        assert.deepEqual(placeholders(DICT_ZH[key]), ['n'])
+        assert.deepEqual(placeholders(DICT_EN[key]), [])
+        continue
+      }
+      assert.deepEqual(placeholders(DICT_ZH[key]), placeholders(DICT_EN[key]), key)
+    }
+  })
+})
diff --git a/tests/client/index.spec.ts b/tests/client/index.spec.ts
new file mode 100644
index 0000000..8e8a026
--- /dev/null
+++ b/tests/client/index.spec.ts
@@ -0,0 +1,327 @@
+// Client entry (src/client/index.ts): the plugin's apply() wiring asserted
+// through the faithful harness-context seams — dictionaries, slots, the
+// sessions provide channel, the /context trigger source, and the deferred
+// settingsScope inject — plus real renders of the registered components.
+
+import assert from 'node:assert/strict'
+import type { ReactElement } from 'react'
+import { describe, test } from 'vitest'
+import { DICT_EN, DICT_ZH } from '../../src/client/i18n'
+import { modalStoreOf, type ModalStore } from '../../src/client/modalStore'
+import { h } from '../../src/client/react'
+import type { SessionProvideDescriptorLike } from '../../src/client/services'
+import type { SettingsField, SettingsScopeLike, SettingsState } from '../../src/client/settings'
+import { TestClientCtx, TestSessions, asClientCtx } from './helpers/harness'
+import { click, mount, query, queryAll } from './helpers/kit'
+
+// The entry ships via `module.exports` (bundle handoff shape); its runtime
+// exports are the plugin triple, opaque to the static import type.
+const { name, inject, apply } = (await import('../../src/client/index')) as unknown as {
+  name: string
+  inject: string[]
+  apply: (ctx: unknown) => void
+}
+
+function applyTo(ctx: TestClientCtx): void {
+  apply(asClientCtx(ctx))
+}
+
+function makeScope(snapshot: { status: string; value: unknown; writable: boolean }): SettingsScopeLike & {
+  subscribes: number
+  sets: { field: string; value: unknown }[]
+} {
+  const rec = {
+    subscribes: 0,
+    sets: [] as { field: string; value: unknown }[],
+    getSnapshot: () => snapshot,
+    subscribe: (_listener: () => void) => {
+      rec.subscribes += 1
+      return () => {}
+    },
+    set: async (field: string, value: unknown) => {
+      rec.sets.push({ field, value })
+    },
+  }
+  return rec
+}
+
+describe('client entry: constants', () => {
+  test('name and inject declare the plugin identity and hard dependencies', () => {
+    assert.equal(name, 'dsh-context')
+    assert.deepEqual(inject, ['slots', 'locale'])
+  })
+})
+
+describe('client entry: dictionaries', () => {
+  test('apply registers the real zh/en dicts; dispose removes them', () => {
+    const ctx = new TestClientCtx()
+    applyTo(ctx)
+    const dicts = ctx.locale.namespaces.get('dsh-context')
+    assert.ok(dicts)
+    assert.equal(dicts.zh, DICT_ZH)
+    assert.equal(dicts.en, DICT_EN)
+    // The bound translate resolves through the active-locale → en chain.
+    assert.equal(ctx.locale.bind('dsh-context')('tab'), 'Context')
+    ctx.dispose()
+    assert.equal(ctx.locale.namespaces.has('dsh-context'), false)
+  })
+
+  test('the zh active locale binds the zh dictionary arm', () => {
+    const ctx = new TestClientCtx({ locale: 'zh' })
+    applyTo(ctx)
+    assert.equal(ctx.locale.bind('dsh-context')('tab'), '上下文')
+    ctx.dispose()
+  })
+})
+
+describe('client entry: conversation.view slot', () => {
+  test('registers the Context tab whose label follows the locale and whose component renders', async () => {
+    const ctx = new TestClientCtx()
+    applyTo(ctx)
+    const entries = ctx.slots.of('conversation.view')
+    assert.equal(entries.length, 1)
+    const { registration, component } = entries[0]
+    assert.equal(registration.name, 'conversation.view')
+    assert.equal(registration.id, 'context')
+    assert.equal(registration.order, 20)
+    assert.equal(registration.locale, 'dsh-context')
+    assert.equal(registration.label?.(), 'Context')
+
+    const el = component({ sessionId: 's1', useProjection: () => undefined }) as ReactElement
+    assert.equal(typeof el.type, 'function')
+    assert.equal((el.type as { name: string }).name, 'ContextView')
+    const m = await mount(el)
+    assert.equal(query(m.container, '.lc-empty').textContent, 'Reading the session log…')
+    await m.unmount()
+    ctx.dispose()
+  })
+
+  test('the tab label translates to zh under an zh locale', () => {
+    const ctx = new TestClientCtx({ locale: 'zh' })
+    applyTo(ctx)
+    assert.equal(ctx.slots.of('conversation.view')[0].registration.label?.(), '上下文')
+    ctx.dispose()
+  })
+})
+
+describe('client entry: sessions.provide effect', () => {
+  test('absent sessions service: apply is a noop for the provide channel', () => {
+    const ctx = new TestClientCtx()
+    applyTo(ctx)
+    ctx.dispose()
+  })
+
+  test('sessions without the provide channel: noop, nothing thrown', () => {
+    const ctx = new TestClientCtx({ services: { sessions: { scope: () => undefined } } })
+    applyTo(ctx)
+    ctx.dispose()
+  })
+
+  test('sessions present: the loadOlderHistory descriptor resolves to session.loadOlder', async () => {
+    const sessions = new TestSessions()
+    const ctx = new TestClientCtx({ services: { sessions } })
+    applyTo(ctx)
+    assert.equal(sessions.provided.length, 1)
+    const descriptor = sessions.provided[0]
+    assert.deepEqual(descriptor.props, ['loadOlderHistory'])
+    let calls = 0
+    const resolved = descriptor.resolve({
+      session: {
+        loadOlder: async () => {
+          calls += 1
+        },
+      },
+    })
+    const loadOlderHistory = resolved.props?.loadOlderHistory as () => Promise
+    await loadOlderHistory()
+    assert.equal(calls, 1)
+    ctx.dispose()
+    assert.equal(sessions.provided.length, 0)
+  })
+
+  test('a throwing provide (duplicate refusal) fails soft', () => {
+    const sessions = {
+      scope: () => undefined,
+      provide: (_descriptor: SessionProvideDescriptorLike): never => {
+        throw new Error('duplicate')
+      },
+    }
+    const ctx = new TestClientCtx({ services: { sessions } })
+    applyTo(ctx)
+    ctx.dispose()
+  })
+})
+
+describe('client entry: /context command effect', () => {
+  test('absent inputTriggers service: no source, nothing thrown', () => {
+    const ctx = new TestClientCtx()
+    applyTo(ctx)
+    ctx.dispose()
+  })
+
+  test('inputTriggers present: the source registers and dispose unregisters it', () => {
+    const sources: { trigger: string; name: string }[] = []
+    const inputTriggers = {
+      registerSource(src: { trigger: string; name: string }): () => void {
+        sources.push(src)
+        return () => {
+          const i = sources.indexOf(src)
+          if (i >= 0) sources.splice(i, 1)
+        }
+      },
+    }
+    const ctx = new TestClientCtx({ services: { inputTriggers } })
+    applyTo(ctx)
+    assert.equal(sources.length, 1)
+    assert.equal(sources[0].trigger, '/')
+    assert.equal(sources[0].name, 'context')
+    ctx.dispose()
+    assert.equal(sources.length, 0)
+  })
+})
+
+describe('client entry: conversation.input.overlay slot', () => {
+  test('registers the modal overlay; inject binds the per-session modal store; closed renders null', async () => {
+    const ctx = new TestClientCtx()
+    applyTo(ctx)
+    const entries = ctx.slots.of('conversation.input.overlay')
+    assert.equal(entries.length, 1)
+    const { registration, component } = entries[0]
+    assert.equal(registration.name, 'conversation.input.overlay')
+    assert.equal(registration.id, 'context-modal')
+    assert.equal(registration.order, 10)
+    assert.equal(registration.locale, 'dsh-context')
+
+    const face = registration.inject?.('sess-1') as { hooks: { contextModal: ModalStore } }
+    assert.equal(face.hooks.contextModal, modalStoreOf('sess-1'))
+
+    const el = component({
+      sessionId: 'sess-1',
+      useContextModal: (sel: (open: boolean) => boolean) => sel(modalStoreOf('sess-1').getSnapshot()),
+    }) as ReactElement
+    assert.equal(typeof el.type, 'function')
+    assert.equal((el.type as { name: string }).name, 'ContextModal')
+    const m = await mount(el)
+    assert.equal(m.container.textContent, '')
+    await m.unmount()
+    ctx.dispose()
+  })
+})
+
+describe('client entry: settingsScope inject', () => {
+  test('absent at apply time: the inject stays pending — no settings.plugin.item slot', () => {
+    const ctx = new TestClientCtx()
+    applyTo(ctx)
+    assert.equal(ctx.slots.of('settings.plugin.item').length, 0)
+    ctx.dispose()
+  })
+
+  test('armed later: the pending inject runs — scope attached and card slot registered', () => {
+    const ctx = new TestClientCtx()
+    applyTo(ctx)
+    const scope = makeScope({ status: 'ready', value: {}, writable: true })
+    const specs: { namespace: string }[] = []
+    ctx.setService('settingsScope', {
+      bind: (spec: { namespace: string }) => {
+        specs.push(spec)
+        return scope
+      },
+    })
+    assert.deepEqual(specs, [{ namespace: 'dsh-context' }])
+    assert.equal(scope.subscribes, 1)
+    assert.equal(ctx.slots.of('settings.plugin.item').length, 1)
+    ctx.dispose()
+  })
+
+  test('defensive arm: service key present but undefined — early return, no slot, no throw', () => {
+    const ctx = new TestClientCtx()
+    ctx.setService('settingsScope', undefined)
+    applyTo(ctx)
+    assert.equal(ctx.slots.of('settings.plugin.item').length, 0)
+    ctx.dispose()
+  })
+})
+
+describe('client entry: settings card slot', () => {
+  function setup(): {
+    ctx: TestClientCtx
+    scope: ReturnType
+    registration: { name: string; key?: string; locale?: string; inject?: () => unknown }
+    component: (props: Record) => unknown
+  } {
+    const scope = makeScope({ status: 'loading', value: null, writable: false })
+    const ctx = new TestClientCtx({
+      services: { settingsScope: { bind: () => scope } },
+    })
+    applyTo(ctx)
+    const entry = ctx.slots.of('settings.plugin.item')[0]
+    return {
+      ctx,
+      scope,
+      registration: entry.registration as never,
+      component: entry.component as never,
+    }
+  }
+
+  test('registers the keyed card; inject exposes the settings store and a set verb', () => {
+    const { ctx, scope, registration } = setup()
+    assert.equal(registration.name, 'settings.plugin.item')
+    assert.equal(registration.key, 'dsh-context')
+    assert.equal(registration.locale, 'dsh-context')
+
+    const face = registration.inject?.() as {
+      hooks: { contextSettings: { getSnapshot(): SettingsState } }
+      set: (field: SettingsField, value: string) => void
+    }
+    assert.equal(face.hooks.contextSettings.getSnapshot().granularity, 'step')
+    face.set('defaultGranularity', 'turn')
+    assert.equal(face.hooks.contextSettings.getSnapshot().granularity, 'turn')
+    assert.deepEqual(scope.sets, [{ field: 'defaultGranularity', value: 'turn' }])
+    ctx.dispose()
+  })
+
+  test('the component renders the card DOM (loading status → disabled selects)', async () => {
+    const { ctx, component } = setup()
+    const el = component({}) as ReactElement
+    assert.equal(typeof el.type, 'function')
+    assert.equal((el.type as { name: string }).name, 'SettingsCard')
+    const store = (ctx.slots.of('settings.plugin.item')[0].registration.inject?.() as {
+      hooks: { contextSettings: { getSnapshot(): SettingsState } }
+    }).hooks.contextSettings
+    const m = await mount(h(el.type as never, {
+      useContextSettings: (sel: (state: SettingsState) => T): T => sel(store.getSnapshot()),
+    }))
+    assert.ok(query(m.container, '.lc-settings-card'))
+    await click(query(m.container, '.lc-settings-head'))
+    const selects = queryAll(m.container, '.lc-settings-select')
+    assert.equal(selects.length, 2)
+    for (const s of selects) assert.ok((s as HTMLButtonElement).disabled)
+    await m.unmount()
+    ctx.dispose()
+  })
+})
+
+describe('client entry: dispose', () => {
+  test('a full apply unwinds dictionaries, the provide descriptor, and the command source', () => {
+    const sessions = new TestSessions()
+    const sources: unknown[] = []
+    const inputTriggers = {
+      registerSource(src: unknown): () => void {
+        sources.push(src)
+        return () => {
+          const i = sources.indexOf(src)
+          if (i >= 0) sources.splice(i, 1)
+        }
+      },
+    }
+    const ctx = new TestClientCtx({ services: { sessions, inputTriggers } })
+    applyTo(ctx)
+    assert.ok(ctx.locale.namespaces.has('dsh-context'))
+    assert.equal(sessions.provided.length, 1)
+    assert.equal(sources.length, 1)
+    ctx.dispose()
+    assert.equal(ctx.locale.namespaces.has('dsh-context'), false)
+    assert.equal(sessions.provided.length, 0)
+    assert.equal(sources.length, 0)
+  })
+})
diff --git a/tests/client/latestVersion.spec.ts b/tests/client/latestVersion.spec.ts
new file mode 100644
index 0000000..6d4ad95
--- /dev/null
+++ b/tests/client/latestVersion.spec.ts
@@ -0,0 +1,112 @@
+// Latest-version check (src/client/latestVersion.ts): registry fetch with a
+// 1h TTL and the semver comparator. fetch is stubbed — the suite never hits
+// the network — and each fetch test re-imports the module (vi.resetModules)
+// to reset the module-level cache.
+
+import assert from 'node:assert/strict'
+import { afterEach, describe, test, vi } from 'vitest'
+import { isNewerVersion } from '../../src/client/latestVersion'
+
+const HOUR_MS = 60 * 60 * 1000
+
+afterEach(() => {
+  vi.unstubAllGlobals()
+  vi.useRealTimers()
+  vi.resetModules()
+})
+
+async function freshModule(): Promise {
+  vi.resetModules()
+  return await import('../../src/client/latestVersion')
+}
+
+describe('fetchLatestVersion', () => {
+  test('an ok response with a string version resolves to it', async () => {
+    vi.stubGlobal('fetch', async () => ({ ok: true, json: async () => ({ version: '9.9.9' }) }))
+    const { fetchLatestVersion } = await freshModule()
+    assert.equal(await fetchLatestVersion(), '9.9.9')
+  })
+
+  test('an ok response without a version resolves to null', async () => {
+    vi.stubGlobal('fetch', async () => ({ ok: true, json: async () => ({}) }))
+    const { fetchLatestVersion } = await freshModule()
+    assert.equal(await fetchLatestVersion(), null)
+  })
+
+  test('an ok response with a non-string version resolves to null', async () => {
+    vi.stubGlobal('fetch', async () => ({ ok: true, json: async () => ({ version: 9 }) }))
+    const { fetchLatestVersion } = await freshModule()
+    assert.equal(await fetchLatestVersion(), null)
+  })
+
+  test('a non-ok response resolves to null', async () => {
+    vi.stubGlobal('fetch', async () => ({ ok: false, json: async () => ({ version: '9.9.9' }) }))
+    const { fetchLatestVersion } = await freshModule()
+    assert.equal(await fetchLatestVersion(), null)
+  })
+
+  test('a rejected fetch resolves to null', async () => {
+    vi.stubGlobal('fetch', async () => { throw new Error('offline') })
+    const { fetchLatestVersion } = await freshModule()
+    assert.equal(await fetchLatestVersion(), null)
+  })
+
+  test('a second call within the TTL does not refetch', async () => {
+    vi.useFakeTimers()
+    vi.setSystemTime(1_000_000)
+    let fetches = 0
+    vi.stubGlobal('fetch', async () => {
+      fetches++
+      return { ok: true, json: async () => ({ version: '1.0.0' }) }
+    })
+    const { fetchLatestVersion } = await freshModule()
+    assert.equal(await fetchLatestVersion(), '1.0.0')
+    vi.setSystemTime(1_000_000 + HOUR_MS - 1)
+    assert.equal(await fetchLatestVersion(), '1.0.0')
+    assert.equal(fetches, 1)
+  })
+
+  test('a call after the TTL expires refetches', async () => {
+    vi.useFakeTimers()
+    vi.setSystemTime(1_000_000)
+    let fetches = 0
+    vi.stubGlobal('fetch', async () => {
+      fetches++
+      return { ok: true, json: async () => ({ version: '1.0.' + fetches }) }
+    })
+    const { fetchLatestVersion } = await freshModule()
+    assert.equal(await fetchLatestVersion(), '1.0.1')
+    vi.setSystemTime(1_000_000 + HOUR_MS)
+    assert.equal(await fetchLatestVersion(), '1.0.2')
+    assert.equal(fetches, 2)
+  })
+})
+
+describe('isNewerVersion', () => {
+  test('a strictly newer version is newer', () => {
+    assert.equal(isNewerVersion('1.2.3', '1.2.2'), true)
+  })
+
+  test('equal and lower versions are not newer', () => {
+    assert.equal(isNewerVersion('1.2.3', '1.2.3'), false)
+    assert.equal(isNewerVersion('1.2.2', '1.2.3'), false)
+  })
+
+  test('a v prefix is stripped', () => {
+    assert.equal(isNewerVersion('v1.3.0', '1.2.9'), true)
+  })
+
+  test('a pre-release suffix is ignored', () => {
+    assert.equal(isNewerVersion('1.3.0-rc.1', '1.3.0'), false)
+    assert.equal(isNewerVersion('1.3.0-rc.1', '1.2.0'), true)
+  })
+
+  test('missing segments compare as zero', () => {
+    assert.equal(isNewerVersion('1.2', '1.2.1'), false)
+    assert.equal(isNewerVersion('1.2.1', '1.2'), true)
+  })
+
+  test('garbage segments parse as zero', () => {
+    assert.equal(isNewerVersion('x.y', '0.0.0'), false)
+  })
+})
diff --git a/tests/client/meta.spec.ts b/tests/client/meta.spec.ts
new file mode 100644
index 0000000..a8d5809
--- /dev/null
+++ b/tests/client/meta.spec.ts
@@ -0,0 +1,50 @@
+// Plugin self-metadata (src/client/meta.ts): the build-time define fallbacks
+// and the define-armed variants. Each variant sets the globals BEFORE its own
+// dynamic import (vi.resetModules clears the module cache so the constants
+// are re-evaluated), then restores the globals.
+
+import assert from 'node:assert/strict'
+import { afterEach, describe, test, vi } from 'vitest'
+
+const globals = globalThis as Record
+
+afterEach(() => {
+  delete globals.__DSH_CTX_VERSION__
+  delete globals.__DSH_CTX_REPO__
+  vi.resetModules()
+})
+
+async function importMeta(): Promise {
+  vi.resetModules()
+  return await import('../../src/client/meta')
+}
+
+describe('meta', () => {
+  test('without build defines the dev/test fallbacks apply', async () => {
+    const meta = await importMeta()
+    assert.equal(meta.PLUGIN_NAME, 'dsh-context')
+    assert.equal(meta.PLUGIN_VERSION, '0.0.0-dev')
+    assert.equal(meta.PLUGIN_REPO, 'https://github.com/bowenliang123/dsh-context')
+    assert.equal(meta.PLUGIN_REPO_SHORT, 'bowenliang123/dsh-context')
+  })
+
+  test('a version define wins over the dev fallback', async () => {
+    globals.__DSH_CTX_VERSION__ = '9.9.9'
+    const meta = await importMeta()
+    assert.equal(meta.PLUGIN_VERSION, '9.9.9')
+    assert.equal(meta.PLUGIN_REPO, 'https://github.com/bowenliang123/dsh-context')
+  })
+
+  test('a repo define wins and the short form strips the github prefix', async () => {
+    globals.__DSH_CTX_REPO__ = 'https://github.com/acme/widget'
+    const meta = await importMeta()
+    assert.equal(meta.PLUGIN_REPO, 'https://github.com/acme/widget')
+    assert.equal(meta.PLUGIN_REPO_SHORT, 'acme/widget')
+  })
+
+  test('a non-github repo keeps its full URL as the short form', async () => {
+    globals.__DSH_CTX_REPO__ = 'http://git.example.com/acme/widget'
+    const meta = await importMeta()
+    assert.equal(meta.PLUGIN_REPO_SHORT, 'http://git.example.com/acme/widget')
+  })
+})
diff --git a/tests/client/modalStore.spec.ts b/tests/client/modalStore.spec.ts
new file mode 100644
index 0000000..2f6a581
--- /dev/null
+++ b/tests/client/modalStore.spec.ts
@@ -0,0 +1,72 @@
+// Per-session modal stores (src/client/modalStore.ts): identity, observable
+// set semantics, and the deferred token-consume guards. Session ids are
+// unique per test so the module-level Maps never leak between cases.
+
+import assert from 'node:assert/strict'
+import { describe, test } from 'vitest'
+import { modalStoreOf, setPendingConsume, takePendingConsume, type ConsumeGuard } from '../../src/client/modalStore'
+
+describe('modalStoreOf', () => {
+  test('returns the same store per session id and distinct stores across ids', () => {
+    assert.equal(modalStoreOf('modal-same-a'), modalStoreOf('modal-same-a'))
+    assert.notEqual(modalStoreOf('modal-same-a'), modalStoreOf('modal-same-b'))
+  })
+
+  test('starts closed and reports state through getSnapshot', () => {
+    const store = modalStoreOf('modal-initial')
+    assert.equal(store.getSnapshot(), false)
+  })
+
+  test('set with the same value does not notify', () => {
+    const store = modalStoreOf('modal-noop')
+    let calls = 0
+    store.subscribe(() => { calls++ })
+    store.set(false)
+    assert.equal(calls, 0)
+  })
+
+  test('set with a flipped value notifies once per change', () => {
+    const store = modalStoreOf('modal-flip')
+    let calls = 0
+    store.subscribe(() => { calls++ })
+    store.set(true)
+    assert.equal(calls, 1)
+    assert.equal(store.getSnapshot(), true)
+    store.set(true)
+    assert.equal(calls, 1)
+    store.set(false)
+    assert.equal(calls, 2)
+    assert.equal(store.getSnapshot(), false)
+  })
+
+  test('unsubscribe stops notifications', () => {
+    const store = modalStoreOf('modal-unsub')
+    let calls = 0
+    const unsubscribe = store.subscribe(() => { calls++ })
+    store.set(true)
+    assert.equal(calls, 1)
+    unsubscribe()
+    store.set(false)
+    assert.equal(calls, 1)
+  })
+})
+
+describe('pending consume guards', () => {
+  test('take returns the guard once, then undefined', () => {
+    const guard: ConsumeGuard = { kind: 'bare-token', token: '/context' }
+    setPendingConsume('consume-once', guard)
+    assert.equal(takePendingConsume('consume-once'), guard)
+    assert.equal(takePendingConsume('consume-once'), undefined)
+  })
+
+  test('take on an absent guard returns undefined', () => {
+    assert.equal(takePendingConsume('consume-absent'), undefined)
+  })
+
+  test('guards are isolated per session', () => {
+    const guard: ConsumeGuard = { kind: 'span', span: { start: 0, end: 8, draftRev: 1 } }
+    setPendingConsume('consume-iso-a', guard)
+    assert.equal(takePendingConsume('consume-iso-b'), undefined)
+    assert.equal(takePendingConsume('consume-iso-a'), guard)
+  })
+})
diff --git a/tests/client/react.spec.ts b/tests/client/react.spec.ts
new file mode 100644
index 0000000..862b6c5
--- /dev/null
+++ b/tests/client/react.spec.ts
@@ -0,0 +1,31 @@
+// The platform React module table (src/client/react.ts): the plugin's React
+// is the real react module, h creates elements, and ReactDOM exposes portals.
+
+import assert from 'node:assert/strict'
+import { createElement } from 'react'
+import { describe, test } from 'vitest'
+import { h, React, ReactDOM } from '../../src/client/react'
+
+describe('react module table', () => {
+  test('React is the real react module', () => {
+    assert.equal(React.createElement, createElement)
+  })
+
+  test('h creates elements', () => {
+    // Viewed structurally: React's public types hide $$typeof and the
+    // children prop slot, but both are part of the real element shape.
+    const el = h('div', { className: 'box' }, 'hi') as unknown as {
+      $$typeof: symbol
+      type: unknown
+      props: Record
+    }
+    assert.equal(el.$$typeof, Symbol.for('react.element'))
+    assert.equal(el.type, 'div')
+    assert.equal(el.props.className, 'box')
+    assert.equal(el.props.children, 'hi')
+  })
+
+  test('ReactDOM exposes createPortal', () => {
+    assert.equal(typeof ReactDOM.createPortal, 'function')
+  })
+})
diff --git a/tests/client/services.spec.ts b/tests/client/services.spec.ts
new file mode 100644
index 0000000..6ce75ad
--- /dev/null
+++ b/tests/client/services.spec.ts
@@ -0,0 +1,238 @@
+// Projection narrowing (src/client/services.ts): the no-white-screen wire
+// guards — numOf, timelineOf fast/slow paths, contextPressureOf,
+// contextBreakdownOf, tokenUsageOf, headersOf.
+
+import assert from 'node:assert/strict'
+import { describe, test } from 'vitest'
+import {
+  contextBreakdownOf,
+  contextPressureOf,
+  headersOf,
+  numOf,
+  timelineOf,
+  tokenUsageOf,
+} from '../../src/client/services'
+
+describe('numOf', () => {
+  test('finite numbers pass through', () => {
+    assert.equal(numOf(42), 42)
+    assert.equal(numOf(0), 0)
+    assert.equal(numOf(-1.5), -1.5)
+  })
+
+  test('NaN/Infinity degrade to 0', () => {
+    assert.equal(numOf(NaN), 0)
+    assert.equal(numOf(Infinity), 0)
+    assert.equal(numOf(-Infinity), 0)
+  })
+
+  test('non-numbers and missing values degrade to 0', () => {
+    assert.equal(numOf('7'), 0)
+    assert.equal(numOf(undefined), 0)
+    assert.equal(numOf(null), 0)
+    assert.equal(numOf({}), 0)
+  })
+})
+
+describe('timelineOf', () => {
+  const current = { system: 1, tools: 2, user: 3, inject: 4, assistant: 5, tool: 6, total: 7 }
+
+  test('non-records stay null', () => {
+    assert.equal(timelineOf(null), null)
+    assert.equal(timelineOf(undefined), null)
+    assert.equal(timelineOf(5), null)
+    assert.equal(timelineOf('x'), null)
+  })
+
+  test('a well-formed wire value passes through by reference', () => {
+    const wire = {
+      ok: true,
+      model: 'deepseek-v4-flash',
+      provider: 'deepseek',
+      contextWindow: 128000,
+      current,
+      toolList: [{ name: 'bash', tokens: 10 }],
+      requests: [],
+      events: [],
+      nodes: [],
+      archive: [],
+      droppedNodes: 0,
+    }
+    assert.equal(timelineOf(wire), wire)
+  })
+
+  test('current missing/non-object rebuilds a zeroed breakdown', () => {
+    for (const bad of [{}, { current: null }, { current: 7 }]) {
+      assert.deepEqual(timelineOf(bad), {
+        ok: true,
+        current: { system: 0, tools: 0, user: 0, inject: 0, assistant: 0, tool: 0, total: 0 },
+        toolList: [],
+        requests: [],
+        events: [],
+        nodes: [],
+        droppedNodes: 0,
+        archive: [],
+      })
+    }
+  })
+
+  test('current with some non-number fields is numOf-coerced', () => {
+    const out = timelineOf({ current: { system: 12, tools: 'x', user: undefined } })
+    assert.ok(out !== null)
+    assert.deepEqual(out.current, { system: 12, tools: 0, user: 0, inject: 0, assistant: 0, tool: 0, total: 0 })
+  })
+
+  test('non-array collections become empty lists', () => {
+    const out = timelineOf({
+      current,
+      requests: 'nope',
+      events: 7,
+      nodes: null,
+      archive: undefined,
+      toolList: {},
+    })
+    assert.ok(out !== null)
+    assert.deepEqual(out.requests, [])
+    assert.deepEqual(out.events, [])
+    assert.deepEqual(out.nodes, [])
+    assert.deepEqual(out.archive, [])
+    assert.deepEqual(out.toolList, [])
+  })
+
+  test('null/non-object collection entries are dropped', () => {
+    const node = { kind: 'message', seq: 1 }
+    const out = timelineOf({ current: 1, nodes: [node, null, 42, 's'] })
+    assert.ok(out !== null)
+    assert.deepEqual(out.nodes, [node])
+  })
+
+  test('model/provider/contextWindow: wrong-typed dropped, right-typed kept', () => {
+    const kept = timelineOf({ current: 1, model: 'm', provider: 'p', contextWindow: 100 })
+    assert.ok(kept !== null)
+    assert.equal(kept.model, 'm')
+    assert.equal(kept.provider, 'p')
+    assert.equal(kept.contextWindow, 100)
+    const dropped = timelineOf({ current: 1, model: 5, provider: {}, contextWindow: 'x' })
+    assert.ok(dropped !== null)
+    assert.ok(!('model' in dropped))
+    assert.ok(!('provider' in dropped))
+    assert.ok(!('contextWindow' in dropped))
+  })
+
+  test('images/toolCalls/surfaceFloor/archiveFloor are kept only when numbers', () => {
+    const kept = timelineOf({ current: 1, images: 3, toolCalls: 2, surfaceFloor: 10, archiveFloor: 4 })
+    assert.ok(kept !== null)
+    assert.equal(kept.images, 3)
+    assert.equal(kept.toolCalls, 2)
+    assert.equal(kept.surfaceFloor, 10)
+    assert.equal(kept.archiveFloor, 4)
+    const dropped = timelineOf({ current: 1, images: 'n', toolCalls: {}, surfaceFloor: null, archiveFloor: true })
+    assert.ok(dropped !== null)
+    assert.ok(!('images' in dropped))
+    assert.ok(!('toolCalls' in dropped))
+    assert.ok(!('surfaceFloor' in dropped))
+    assert.ok(!('archiveFloor' in dropped))
+  })
+
+  test('cost is kept only when a plain non-array object', () => {
+    const cost = { 'deepseek-v4-flash': { peak: { input: 1 } } }
+    const kept = timelineOf({ current: 1, cost })
+    assert.ok(kept !== null)
+    assert.equal(kept.cost, cost)
+    for (const bad of [[], null, 5]) {
+      const out = timelineOf({ current: 1, cost: bad })
+      assert.ok(out !== null)
+      assert.ok(!('cost' in out))
+    }
+  })
+
+  test('droppedNodes is numOf-coerced', () => {
+    assert.equal(timelineOf({ current: 1, droppedNodes: 4 })?.droppedNodes, 4)
+    assert.equal(timelineOf({ current: 1, droppedNodes: 'x' })?.droppedNodes, 0)
+  })
+})
+
+describe('contextPressureOf', () => {
+  test('records pass through', () => {
+    const value = { pressureTokens: 10, surfaceTokens: 20 }
+    assert.equal(contextPressureOf(value), value)
+  })
+
+  test('non-records degrade to null', () => {
+    assert.equal(contextPressureOf(null), null)
+    assert.equal(contextPressureOf(undefined), null)
+    assert.equal(contextPressureOf(42), null)
+  })
+})
+
+describe('contextBreakdownOf', () => {
+  test('all three finite numbers pass through as a value', () => {
+    assert.deepEqual(contextBreakdownOf({ systemTokens: 1, toolsTokens: 2, messageTokens: 3 }), {
+      systemTokens: 1,
+      toolsTokens: 2,
+      messageTokens: 3,
+    })
+  })
+
+  test('non-records degrade to null', () => {
+    assert.equal(contextBreakdownOf(null), null)
+    assert.equal(contextBreakdownOf('x'), null)
+  })
+
+  test('a missing/NaN/non-finite field degrades the whole value to null', () => {
+    assert.equal(contextBreakdownOf({ toolsTokens: 2, messageTokens: 3 }), null)
+    assert.equal(contextBreakdownOf({ systemTokens: NaN, toolsTokens: 2, messageTokens: 3 }), null)
+    assert.equal(contextBreakdownOf({ systemTokens: 1, toolsTokens: 'x', messageTokens: 3 }), null)
+    assert.equal(contextBreakdownOf({ systemTokens: 1, toolsTokens: Infinity, messageTokens: 3 }), null)
+    assert.equal(contextBreakdownOf({ systemTokens: 1, toolsTokens: 2, messageTokens: undefined }), null)
+    assert.equal(contextBreakdownOf({ systemTokens: 1, toolsTokens: 2, messageTokens: -Infinity }), null)
+  })
+})
+
+describe('tokenUsageOf', () => {
+  test('records pass through', () => {
+    const value = { total: { input: 100 } }
+    assert.equal(tokenUsageOf(value), value)
+  })
+
+  test('non-records degrade to null', () => {
+    assert.equal(tokenUsageOf(null), null)
+    assert.equal(tokenUsageOf(undefined), null)
+    assert.equal(tokenUsageOf(3), null)
+  })
+})
+
+describe('headersOf', () => {
+  test('non-records degrade to null', () => {
+    assert.equal(headersOf(null), null)
+    assert.equal(headersOf(7), null)
+  })
+
+  test('a non-array headers field degrades to null', () => {
+    assert.equal(headersOf({}), null)
+    assert.equal(headersOf({ headers: 'x' }), null)
+  })
+
+  test('a null/non-object entry degrades the whole value to null', () => {
+    assert.equal(headersOf({ headers: [null] }), null)
+    assert.equal(headersOf({ headers: ['s'] }), null)
+  })
+
+  test('an entry with a non-array tools list degrades the whole value to null', () => {
+    assert.equal(headersOf({ headers: [{ tools: 'x' }] }), null)
+  })
+
+  test('an entry with a defined non-string system degrades the whole value to null', () => {
+    assert.equal(headersOf({ headers: [{ tools: [], system: 5 }] }), null)
+  })
+
+  test('a valid value passes through by reference', () => {
+    const value = {
+      headers: [
+        { seq: 1, time: 1000, tools: [], system: 'prompt' },
+        { seq: 2, time: 2000, tools: [{ name: 'bash', tokens: 10 }] },
+      ],
+    }
+    assert.equal(headersOf(value), value)
+  })
+})
diff --git a/tests/client/settings.spec.ts b/tests/client/settings.spec.ts
new file mode 100644
index 0000000..0198863
--- /dev/null
+++ b/tests/client/settings.spec.ts
@@ -0,0 +1,215 @@
+// Settings binding (src/client/settings.ts): defaults, the observable store,
+// scope attach/sync, preference parsing, and the local-echo set path.
+
+import assert from 'node:assert/strict'
+import { describe, test } from 'vitest'
+import { createContextSettings, type SettingsScopeLike } from '../../src/client/settings'
+
+/**
+ * A faithful in-memory settings scope (the harness settingsScope.bind
+ * contract: getSnapshot/subscribe/set), not a mock of plugin code.
+ */
+class TestSettingsScope implements SettingsScopeLike {
+  private snapshot: { status: string; value: unknown; writable: boolean }
+  private readonly listeners = new Set<() => void>()
+  readonly sets: { field: string; value: unknown }[] = []
+
+  constructor(snapshot: { status: string; value: unknown; writable: boolean }) {
+    this.snapshot = snapshot
+  }
+
+  getSnapshot(): { status: string; value: unknown; writable: boolean } {
+    return this.snapshot
+  }
+
+  subscribe(listener: () => void): () => void {
+    this.listeners.add(listener)
+    return () => { this.listeners.delete(listener) }
+  }
+
+  set(field: string, value: unknown): Promise {
+    this.sets.push({ field, value })
+    return Promise.resolve()
+  }
+
+  /** Push a new snapshot, like the Host delivering a section update. */
+  emit(snapshot: { status: string; value: unknown; writable: boolean }): void {
+    this.snapshot = snapshot
+    for (const listener of this.listeners) listener()
+  }
+}
+
+describe('createContextSettings defaults', () => {
+  test('starts loading with schema defaults and not writable', () => {
+    const s = createContextSettings()
+    assert.deepEqual(s.store.getSnapshot(), {
+      status: 'loading',
+      granularity: 'step',
+      mode: 'total',
+      writable: false,
+    })
+    assert.equal(s.defaultGranularity(), 'step')
+    assert.equal(s.defaultTrendMode(), 'total')
+  })
+})
+
+describe('store', () => {
+  test('subscribers are notified on change and unsubscribe stops', () => {
+    const s = createContextSettings()
+    let calls = 0
+    const unsubscribe = s.store.subscribe(() => { calls++ })
+    s.set('defaultTrendMode', 'delta')
+    assert.equal(calls, 1)
+    assert.equal(s.store.getSnapshot().mode, 'delta')
+    unsubscribe()
+    s.set('defaultTrendMode', 'total')
+    assert.equal(calls, 1)
+  })
+})
+
+describe('set', () => {
+  test('without attach it echoes locally and never throws', () => {
+    const s = createContextSettings()
+    s.set('defaultGranularity', 'turn')
+    assert.equal(s.defaultGranularity(), 'turn')
+  })
+
+  test('an unchanged value does not notify listeners', () => {
+    const s = createContextSettings()
+    let calls = 0
+    s.store.subscribe(() => { calls++ })
+    s.set('defaultGranularity', 'step')
+    assert.equal(calls, 0)
+  })
+
+  test('an invalid value is dropped without notifying', () => {
+    const s = createContextSettings()
+    let calls = 0
+    s.store.subscribe(() => { calls++ })
+    s.set('defaultGranularity', 'bogus')
+    assert.equal(s.defaultGranularity(), 'step')
+    assert.equal(calls, 0)
+  })
+
+  test('with attach it echoes locally and writes through the scope', () => {
+    const s = createContextSettings()
+    const scope = new TestSettingsScope({ status: 'ready', value: {}, writable: true })
+    s.attach(scope)
+    s.set('defaultTrendMode', 'delta')
+    assert.equal(s.defaultTrendMode(), 'delta')
+    assert.deepEqual(scope.sets, [{ field: 'defaultTrendMode', value: 'delta' }])
+  })
+})
+
+describe('attach', () => {
+  test('syncs a ready snapshot with parsed preferences', () => {
+    const s = createContextSettings()
+    const scope = new TestSettingsScope({
+      status: 'ready',
+      value: { defaultGranularity: 'turn', defaultTrendMode: 'delta' },
+      writable: true,
+    })
+    s.attach(scope)
+    assert.deepEqual(s.store.getSnapshot(), {
+      status: 'ready',
+      granularity: 'turn',
+      mode: 'delta',
+      writable: true,
+    })
+  })
+
+  test('an unavailable snapshot keeps parsed preferences', () => {
+    const s = createContextSettings()
+    const scope = new TestSettingsScope({ status: 'unavailable', value: {}, writable: false })
+    s.attach(scope)
+    assert.equal(s.store.getSnapshot().status, 'unavailable')
+  })
+
+  test('any other snapshot status reads as loading', () => {
+    const s = createContextSettings()
+    const scope = new TestSettingsScope({ status: 'pending', value: {}, writable: false })
+    s.attach(scope)
+    assert.equal(s.store.getSnapshot().status, 'loading')
+  })
+
+  test('null/non-object section values keep the defaults', () => {
+    for (const value of [null, 42]) {
+      const s = createContextSettings()
+      s.attach(new TestSettingsScope({ status: 'ready', value, writable: false }))
+      assert.deepEqual(s.store.getSnapshot(), {
+        status: 'ready',
+        granularity: 'step',
+        mode: 'total',
+        writable: false,
+      })
+    }
+  })
+
+  test('invalid preference values keep the defaults', () => {
+    const s = createContextSettings()
+    s.attach(new TestSettingsScope({
+      status: 'ready',
+      value: { defaultGranularity: 'bogus', defaultTrendMode: 7 },
+      writable: false,
+    }))
+    assert.equal(s.defaultGranularity(), 'step')
+    assert.equal(s.defaultTrendMode(), 'total')
+  })
+
+  test('explicit schema-default values are accepted', () => {
+    const s = createContextSettings()
+    s.attach(new TestSettingsScope({
+      status: 'ready',
+      value: { defaultGranularity: 'step', defaultTrendMode: 'total' },
+      writable: false,
+    }))
+    assert.equal(s.defaultGranularity(), 'step')
+    assert.equal(s.defaultTrendMode(), 'total')
+  })
+
+  test('missing fields keep the current state', () => {
+    const s = createContextSettings()
+    s.attach(new TestSettingsScope({ status: 'ready', value: { defaultGranularity: 'turn' }, writable: false }))
+    assert.equal(s.defaultGranularity(), 'turn')
+    assert.equal(s.defaultTrendMode(), 'total')
+  })
+
+  test('scope updates republish to subscribers', () => {
+    const s = createContextSettings()
+    const scope = new TestSettingsScope({ status: 'ready', value: {}, writable: true })
+    s.attach(scope)
+    let calls = 0
+    s.store.subscribe(() => { calls++ })
+    scope.emit({ status: 'ready', value: { defaultGranularity: 'turn' }, writable: true })
+    assert.equal(calls, 1)
+    assert.equal(s.defaultGranularity(), 'turn')
+    scope.emit({ status: 'ready', value: { defaultGranularity: 'turn', defaultTrendMode: 'delta' }, writable: true })
+    assert.equal(calls, 2)
+    assert.equal(s.defaultTrendMode(), 'delta')
+    scope.emit({ status: 'ready', value: { defaultGranularity: 'turn', defaultTrendMode: 'delta' }, writable: false })
+    assert.equal(calls, 3)
+    assert.equal(s.store.getSnapshot().writable, false)
+  })
+
+  test('an identical scope snapshot does not notify listeners', () => {
+    const s = createContextSettings()
+    const scope = new TestSettingsScope({ status: 'ready', value: { defaultGranularity: 'turn' }, writable: true })
+    s.attach(scope)
+    let calls = 0
+    s.store.subscribe(() => { calls++ })
+    scope.emit({ status: 'ready', value: { defaultGranularity: 'turn' }, writable: true })
+    assert.equal(calls, 0)
+  })
+
+  test('the returned disposer detaches the scope subscription', () => {
+    const s = createContextSettings()
+    const scope = new TestSettingsScope({ status: 'ready', value: {}, writable: true })
+    const detach = s.attach(scope)
+    detach()
+    let calls = 0
+    s.store.subscribe(() => { calls++ })
+    scope.emit({ status: 'ready', value: { defaultGranularity: 'turn' }, writable: true })
+    assert.equal(calls, 0)
+    assert.equal(s.defaultGranularity(), 'step')
+  })
+})
diff --git a/tests/client/setup.ts b/tests/client/setup.ts
new file mode 100644
index 0000000..ae46399
--- /dev/null
+++ b/tests/client/setup.ts
@@ -0,0 +1,9 @@
+// Client project setup: React 18 `act` requires this flag to flush effects
+// synchronously in tests; every jsdom spec shares the real React runtime.
+;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+
+// jsdom does not implement scrollIntoView; the plugin calls it when focusing
+// a browser row. A no-op polyfill stands in for the platform (layout-free).
+if (typeof Element !== 'undefined' && Element.prototype.scrollIntoView === undefined) {
+  Element.prototype.scrollIntoView = () => {}
+}
diff --git a/tests/client/viewkit.spec.ts b/tests/client/viewkit.spec.ts
new file mode 100644
index 0000000..290f456
--- /dev/null
+++ b/tests/client/viewkit.spec.ts
@@ -0,0 +1,39 @@
+// The view kit (src/client/viewkit.ts): the dependency bag exposes the
+// translate/format helpers and the event-text delegates (full event-text
+// coverage lives in the events spec — here only one delegation spot-check).
+
+import assert from 'node:assert/strict'
+import { describe, test } from 'vitest'
+import { fmt, fmtTime } from '../../src/client/format'
+import { DICT_EN } from '../../src/client/i18n'
+import { makeViewKit } from '../../src/client/viewkit'
+import type { ContextEventRecord } from '../../src/shared/types'
+import { makeKit, makeTranslate } from './helpers/kit'
+
+describe('makeViewKit', () => {
+  test('exposes t, fmt, and fmtTime', () => {
+    const t = makeTranslate()
+    const kit = makeViewKit(t)
+    assert.equal(kit.t, t)
+    assert.equal(kit.fmt, fmt)
+    assert.equal(kit.fmtTime, fmtTime)
+  })
+
+  test('catLabel maps through the dictionary', () => {
+    const kit = makeKit()
+    assert.equal(kit.catLabel('system'), DICT_EN['cat.system'])
+    assert.equal(kit.catLabel('tool'), DICT_EN['cat.tool'])
+  })
+
+  test('eventLabel delegates to makeEventText (compaction with a count)', () => {
+    const kit = makeKit()
+    const ev: ContextEventRecord = { seq: 1, time: 1000, kind: 'compaction', count: 3, turn: 2, step: 4 }
+    assert.equal(kit.eventLabel(ev), DICT_EN['ev.compaction'].replace('{n}', '3'))
+  })
+
+  test('eventAt is null for an event without a turn/step anchor', () => {
+    const kit = makeKit()
+    const ev: ContextEventRecord = { seq: 2, time: 1000, kind: 'inject', form: 'notice' }
+    assert.equal(kit.eventAt(ev), null)
+  })
+})
diff --git a/tests/host/config.spec.ts b/tests/host/config.spec.ts
new file mode 100644
index 0000000..3ca3d17
--- /dev/null
+++ b/tests/host/config.spec.ts
@@ -0,0 +1,42 @@
+// The cordis `config:` block contract (src/host/config.ts): defaults fill,
+// strict keys, integer/lower-bound validation — exercised through the REAL
+// Standard-Schema validator cordis itself runs before apply.
+
+import assert from 'node:assert/strict'
+import { describe, test } from 'vitest'
+import { Config, DEFAULT_BOUNDS, resolveBounds } from '../../src/host/config'
+
+describe('Config validator', () => {
+  test('an absent config (a loader row without config:) resolves to defaults', () => {
+    assert.deepEqual(Config.parse(undefined), DEFAULT_BOUNDS)
+    assert.deepEqual(resolveBounds(undefined), DEFAULT_BOUNDS)
+    assert.deepEqual(resolveBounds({}), DEFAULT_BOUNDS)
+  })
+
+  test('each field overrides independently', () => {
+    assert.equal(resolveBounds({ maxRequestSteps: 7 }).maxRequestSteps, 7)
+    assert.equal(resolveBounds({ maxKeptTurns: 7 }).maxKeptTurns, 7)
+    assert.equal(resolveBounds({ maxEvents: 7 }).maxEvents, 7)
+    assert.equal(resolveBounds({ maxNodes: 7 }).maxNodes, 7)
+    assert.equal(resolveBounds({ maxArchiveNodes: 7 }).maxArchiveNodes, 7)
+    // Untouched fields keep their defaults.
+    assert.equal(resolveBounds({ maxNodes: 7 }).maxEvents, DEFAULT_BOUNDS.maxEvents)
+  })
+
+  test('rejects zero/negative bounds (min 1)', () => {
+    assert.throws(() => Config.parse({ maxRequestSteps: 0 }))
+    assert.throws(() => Config.parse({ maxNodes: -1 }))
+  })
+
+  test('rejects non-integer bounds', () => {
+    assert.throws(() => Config.parse({ maxEvents: 1.5 }))
+  })
+
+  test('rejects non-number bounds', () => {
+    assert.throws(() => Config.parse({ maxKeptTurns: '300' }))
+  })
+
+  test('strict: unknown keys fail loudly', () => {
+    assert.throws(() => Config.parse({ unknown: 1 }))
+  })
+})
diff --git a/tests/host/fold-events.spec.ts b/tests/host/fold-events.spec.ts
new file mode 100644
index 0000000..507c2fc
--- /dev/null
+++ b/tests/host/fold-events.spec.ts
@@ -0,0 +1,512 @@
+// applyTimeline switch-case paths of the fold (src/host/fold.ts): every
+// durable event type's record-keeping (headers, context, tool calls,
+// injections, skill tagging, request records, plan mode, compaction arming)
+// plus the session-cost accumulation (model family match, Beijing peak
+// windows, per-period running totals). No mocks: the real fold runs.
+
+import assert from 'node:assert/strict'
+import { describe, test } from 'vitest'
+import type { TimelineEvent } from '../../src/host/fold'
+import {
+  assistantMessage,
+  at,
+  compaction,
+  foreign,
+  header,
+  planMode,
+  requestContext,
+  toolCall,
+  toolResult,
+  userMessage,
+} from './helpers/events'
+import { assertPlainJson, assertStable, driveTimeline, timelineDef } from './helpers/projection'
+
+const text = (t: string) => [{ type: 'text', text: t }]
+
+describe('request/header', () => {
+  test('an absent data payload still re-prices the envelope to zero', () => {
+    const def = timelineDef({})
+    const next = def.apply(def.init(), { type: 'request/header', seq: 1, time: at() } as TimelineEvent)
+    assert.deepEqual(next.toolList, [])
+    assert.equal(next.toolsTokens, 0)
+    assert.equal(next.systemTokens, 0)
+    assert.equal(next.model, undefined)
+  })
+
+  test('an absent header field behaves like an empty header', () => {
+    const def = timelineDef({})
+    const next = def.apply(def.init(), { type: 'request/header', seq: 1, time: at(), data: { reason: 'initial' } } as TimelineEvent)
+    assert.deepEqual(next.toolList, [])
+    assert.equal(next.lastModel, undefined)
+  })
+
+  test('a non-array tools field prices as no tools', () => {
+    const { state } = driveTimeline([header(1, { tools: 'nope' as never, model: 'm' })])
+    assert.deepEqual(state.toolList, [])
+    assert.equal(state.toolsTokens, 0)
+  })
+
+  test('named tools list with their schema prices', () => {
+    const tools = [{ name: 'bash', description: 'run a command' }, { name: 'read' }]
+    const { state } = driveTimeline([header(1, { tools, model: 'm' })])
+    assert.deepEqual(state.toolList.map(t => t.name), ['bash', 'read'])
+    assert.ok(state.toolList.every(t => t.tokens > 0))
+    assert.ok(state.toolsTokens > 0)
+  })
+
+  test('a tool with a non-string name lists as ?', () => {
+    const { state } = driveTimeline([header(1, { tools: [{ name: 42 }], model: 'm' })])
+    assert.equal(state.toolList.length, 1)
+    assert.equal(state.toolList[0].name, '?')
+    assert.ok(state.toolList[0].tokens > 0)
+  })
+
+  test('a header without config sets neither model nor provider, and a change stays silent', () => {
+    const def = timelineDef({})
+    const next = def.apply(def.init(), {
+      type: 'request/header', seq: 1, time: at(),
+      data: { header: {}, reason: 'change' },
+    } as TimelineEvent)
+    assert.equal(next.model, undefined)
+    assert.equal(next.provider, undefined)
+    assert.equal(next.lastModel, undefined, 'no model → lastModel never set')
+    assert.equal(next.events.length, 0, 'no model event without a current model')
+  })
+
+  test('non-string model/provider values are ignored', () => {
+    const { state } = driveTimeline([header(1, { model: 42, provider: 'p' })])
+    assert.equal(state.model, undefined)
+    assert.equal(state.provider, 'p')
+    assert.equal(state.lastModel, undefined)
+
+    const { state: swapped } = driveTimeline([header(1, { model: 'm', provider: 7 })])
+    assert.equal(swapped.model, 'm')
+    assert.equal(swapped.provider, undefined)
+    assert.equal(swapped.lastModel, 'm')
+  })
+
+  test('a change as the FIRST header fires no model event (no previous model)', () => {
+    const { state } = driveTimeline([header(1, { model: 'a', reason: 'change' })])
+    assert.equal(state.events.length, 0)
+    assert.equal(state.lastModel, 'a')
+  })
+
+  test('a change to the SAME model fires no model event', () => {
+    const { state } = driveTimeline([
+      header(1, { model: 'a' }),
+      header(2, { model: 'a', reason: 'change' }),
+    ])
+    assert.equal(state.events.length, 0)
+  })
+
+  test('a change to a new model records from → to', () => {
+    const { state } = driveTimeline([
+      header(1, { model: 'a' }),
+      header(2, { model: 'b', reason: 'change' }),
+    ])
+    assert.deepEqual(state.events, [
+      { seq: 2, time: state.events[0].time, kind: 'model', from: 'a', to: 'b' },
+    ])
+    assert.equal(state.model, 'b')
+    assert.equal(state.lastModel, 'b')
+  })
+
+  test('a resume carrying a different model records the switch too', () => {
+    const { state } = driveTimeline([
+      header(1, { model: 'a' }),
+      header(2, { model: 'b', reason: 'resume' }),
+    ])
+    assert.equal(state.events.length, 1)
+    assert.equal(state.events[0].kind, 'model')
+    assert.equal(state.events[0].to, 'b')
+  })
+})
+
+describe('request/context', () => {
+  test('route and capacity metadata land on the state', () => {
+    const { state } = driveTimeline([
+      header(1, { model: 'm' }),
+      requestContext(2, { contextWindow: 128000, model: 'm2', provider: 'p2' }),
+    ])
+    assert.equal(state.contextWindow, 128000)
+    assert.equal(state.model, 'm2')
+    assert.equal(state.provider, 'p2')
+  })
+
+  test('non-number contextWindow and non-string route fields are ignored', () => {
+    const { state } = driveTimeline([
+      header(1, { model: 'm', provider: 'p' }),
+      requestContext(2, { contextWindow: 'x', model: 5, provider: 9 }),
+    ])
+    assert.equal(state.contextWindow, undefined)
+    assert.equal(state.model, 'm')
+    assert.equal(state.provider, 'p')
+  })
+
+  test('an absent data payload changes nothing (but still folds)', () => {
+    const def = timelineDef({})
+    const prev = def.init()
+    const next = def.apply(prev, requestContext(1))
+    assert.ok(next !== prev, 'request/context always re-seals the state')
+    assert.equal(next.contextWindow, undefined)
+    assert.equal(next.model, undefined)
+  })
+})
+
+describe('tool/call', () => {
+  test('a valid call arms the callNames entry', () => {
+    const { state } = driveTimeline([toolCall(1, { callId: 'c1', name: 'bash' })])
+    assert.deepEqual(state.callNames, { c1: 'bash' })
+  })
+
+  test('a non-string callId returns the same state reference', () => {
+    const { state } = driveTimeline([])
+    assertStable(state, toolCall(1, { callId: 5, name: 'bash' }))
+  })
+
+  test('a non-string name returns the same state reference', () => {
+    const { state } = driveTimeline([])
+    assertStable(state, toolCall(1, { callId: 'c1', name: 42 }))
+  })
+
+  test('an absent data payload returns the same state reference', () => {
+    const { state } = driveTimeline([])
+    assertStable(state, { type: 'tool/call', seq: 1, time: at() } as TimelineEvent)
+  })
+})
+
+describe('user/message injection records', () => {
+  test('a source without a form records the context default', () => {
+    const { state } = driveTimeline([userMessage(1, text('ctx'), { kind: 'plugin' })])
+    assert.equal(state.events.length, 1)
+    assert.equal(state.events[0].form, 'context')
+    assert.equal(state.events[0].name, 'plugin')
+  })
+
+  test('skill-invocation records sub-skill with its name', () => {
+    const { state } = driveTimeline([
+      userMessage(1, text('run'), { kind: 'skill-invocation', form: 'skill', name: 'code-review' }),
+    ])
+    assert.equal(state.events[0].sub, 'skill')
+    assert.equal(state.events[0].name, 'code-review')
+    assert.equal(state.events[0].form, 'skill')
+  })
+
+  test('a nameless skill-invocation records ?', () => {
+    const { state } = driveTimeline([
+      userMessage(1, text('run'), { kind: 'skill-invocation', form: 'skill' }),
+    ])
+    assert.equal(state.events[0].name, '?')
+  })
+
+  test('a source with no readable identity records no name; a notice carries its summary', () => {
+    const { state } = driveTimeline([
+      userMessage(1, text('note'), { kind: '', form: 'notice', summary: 'heads up' }),
+    ])
+    assert.equal(state.events.length, 1)
+    assert.ok(!('name' in state.events[0]), 'empty producer label stays absent')
+    assert.equal(state.events[0].detail, 'heads up')
+  })
+
+  test('a notice with an empty summary records no detail', () => {
+    const { state } = driveTimeline([
+      userMessage(1, text('note'), { kind: 'plugin', plugin: 'dsh-x', form: 'notice', summary: '' }),
+    ])
+    assert.equal(state.events[0].name, 'dsh-x')
+    assert.ok(!('detail' in state.events[0]))
+  })
+
+  test('a non-notice form records no detail', () => {
+    const { state } = driveTimeline([
+      userMessage(1, text('catalog'), { kind: 'skill-catalog', form: 'catalog' }),
+    ])
+    assert.equal(state.events[0].name, 'skill-catalog')
+    assert.ok(!('detail' in state.events[0]))
+  })
+
+  test('a non-injection user message records no event', () => {
+    const { state } = driveTimeline([userMessage(1, text('just me'))])
+    assert.equal(state.events.length, 0)
+    assert.equal(state.surface.length, 1)
+  })
+})
+
+describe('tool/result skill tagging', () => {
+  const skillBody = (name: string) => text(`instructions`)
+
+  test('a skill-tool result carrying skill content tags the node and records the inject', () => {
+    const { state } = driveTimeline([
+      toolCall(1, { callId: 'c1', name: 'skill' }),
+      toolResult(2, { callId: 'c1', content: skillBody('pdf') }),
+    ])
+    const node = state.surface.at(-1)
+    assert.equal(node?.tool, 'skill')
+    assert.equal(node?.skill, 'pdf')
+    assert.deepEqual(state.events, [
+      { seq: 2, time: state.events[0].time, kind: 'inject', form: 'instructions', sub: 'skill', name: 'pdf', tokens: node?.tokens },
+    ])
+  })
+
+  test('an untraced result (tool/call gone) tags from the wrapper alone', () => {
+    // No tool/call armed: node.tool resolves to undefined — the content
+    // wrapper is trusted (a missed tag is worse than a content guess).
+    const { state } = driveTimeline([toolResult(1, { callId: 'zz', content: skillBody('xlsx') })])
+    assert.equal(state.surface.at(-1)?.skill, 'xlsx')
+    assert.equal(state.events[0].name, 'xlsx')
+  })
+
+  test('a skill-tool result without the wrapper records nothing', () => {
+    const { state } = driveTimeline([
+      toolCall(1, { callId: 'c1', name: 'skill' }),
+      toolResult(2, { callId: 'c1', content: text('plain output') }),
+    ])
+    assert.equal(state.surface.at(-1)?.skill, undefined)
+    assert.equal(state.events.length, 0)
+  })
+
+  test('a NON-skill tool result is never tagged, wrapper or not', () => {
+    const { state } = driveTimeline([
+      toolCall(1, { callId: 'c1', name: 'bash' }),
+      toolResult(2, { callId: 'c1', content: skillBody('pdf') }),
+    ])
+    assert.equal(state.surface.at(-1)?.tool, 'bash')
+    assert.equal(state.surface.at(-1)?.skill, undefined)
+    assert.equal(state.events.length, 0)
+  })
+
+  test('skill content nested two tool-result levels deep still tags', () => {
+    const ev: TimelineEvent = {
+      type: 'tool/result', seq: 1, time: at(),
+      data: {
+        message: {
+          content: [{ type: 'tool-result', content: [{ type: 'tool-result', content: skillBody('deep') }] }],
+        },
+      },
+      surfaceOp: 'append',
+    }
+    const { state } = driveTimeline([ev])
+    assert.equal(state.surface.at(-1)?.skill, 'deep')
+    assert.equal(state.events[0].name, 'deep')
+  })
+
+  test('nested empty text yields no tag', () => {
+    const ev: TimelineEvent = {
+      type: 'tool/result', seq: 1, time: at(),
+      data: { message: { content: [{ type: 'tool-result', toolCallId: 'e', content: [{ type: 'text', text: '' }] }] } },
+      surfaceOp: 'append',
+    }
+    const { state } = driveTimeline([ev])
+    assert.equal(state.surface.at(-1)?.skill, undefined)
+    assert.equal(state.events.length, 0)
+  })
+
+  test('a message without a content array yields no tag', () => {
+    const ev: TimelineEvent = {
+      type: 'tool/result', seq: 1, time: at(),
+      data: { message: {} },
+      surfaceOp: 'append',
+    }
+    const { state } = driveTimeline([ev])
+    assert.equal(state.surface.length, 1)
+    assert.equal(state.surface[0].cat, 'tool')
+    assert.equal(state.events.length, 0)
+  })
+})
+
+describe('assistant/message request records', () => {
+  test('turn and step land on the record', () => {
+    const { state } = driveTimeline([assistantMessage(1, { turn: 2, step: 3 })])
+    assert.equal(state.requests[0].turn, 2)
+    assert.equal(state.requests[0].step, 3)
+  })
+
+  test('absent turn/step never materialize undefined properties', () => {
+    const { state } = driveTimeline([assistantMessage(1, {})])
+    assertPlainJson(state)
+    assert.ok(!('turn' in state.requests[0]))
+    assert.ok(!('step' in state.requests[0]))
+  })
+
+  test('absent usage leaves the billed fields off the record', () => {
+    const { state } = driveTimeline([assistantMessage(1, { turn: 1, step: 1 })])
+    assert.ok(!('prompt' in state.requests[0]))
+    assert.equal(state.cost, undefined)
+  })
+
+  test('usage without inputTokens is not a billing sample', () => {
+    const { state } = driveTimeline([assistantMessage(1, { usage: { outputTokens: 5 } })])
+    assert.ok(!('prompt' in state.requests[0]))
+    assert.ok(!('output' in state.requests[0]))
+    assert.equal(state.cost, undefined)
+  })
+
+  test('input-only usage bills prompt = input, without cache/output fields', () => {
+    const { state } = driveTimeline([assistantMessage(1, { usage: { inputTokens: 100 } })])
+    const rec = state.requests[0]
+    assert.equal(rec.prompt, 100)
+    assert.ok(!('cacheRead' in rec))
+    assert.ok(!('output' in rec))
+  })
+
+  test('full usage bills prompt = input + cacheRead + cacheWrite, with cache and output splits', () => {
+    const { state } = driveTimeline([
+      assistantMessage(1, { usage: { inputTokens: 10, cacheReadTokens: 20, cacheWriteTokens: 30, outputTokens: 40 } }),
+    ])
+    const rec = state.requests[0]
+    assert.equal(rec.prompt, 60)
+    assert.equal(rec.cacheRead, 20)
+    assert.equal(rec.output, 40)
+  })
+
+  test('an empty-content assistant message projects to a zero-token node', () => {
+    const { state } = driveTimeline([assistantMessage(1, { content: [], usage: { inputTokens: 7 } })])
+    const node = state.surface.at(-1)
+    assert.equal(node?.cat, 'assistant')
+    assert.equal(node?.tokens, 0, 'usage-only events project to no message')
+    assert.equal(state.requests.length, 1, 'the request record still lands')
+  })
+})
+
+describe('plan/mode', () => {
+  test('active true records plan.on', () => {
+    const { state } = driveTimeline([planMode(1, { active: true })])
+    assert.deepEqual(state.events.map(e => [e.kind, e.name]), [['mode', 'plan.on']])
+  })
+
+  test('active false records plan.off', () => {
+    const { state } = driveTimeline([planMode(1, { active: false })])
+    assert.deepEqual(state.events.map(e => [e.kind, e.name]), [['mode', 'plan.off']])
+  })
+
+  test('absent data returns the same state reference', () => {
+    const { state } = driveTimeline([])
+    assertStable(state, planMode(1))
+  })
+
+  test('a non-boolean active returns the same state reference', () => {
+    const { state } = driveTimeline([])
+    assertStable(state, planMode(1, { active: 'yes' }))
+  })
+})
+
+describe('compaction metering events', () => {
+  test('summary with shadowedSeqs arms the claim and records the count', () => {
+    const { state } = driveTimeline([
+      compaction(1, 'summary', { shadowedTokenCount: 500, shadowedSeqs: [3, 4] }),
+    ])
+    assert.deepEqual(state.pendingShadowedSeqs, [3, 4])
+    assert.equal(state.pendingShadowEventSeq, 1)
+    assert.equal(state.events[0].kind, 'compaction')
+    assert.equal(state.events[0].tokens, 500)
+    assert.equal(state.events[0].count, 2)
+  })
+
+  test('prune with shadowedSeqs arms the claim but records no count', () => {
+    const { state } = driveTimeline([
+      compaction(1, 'prune', { shadowedTokenCount: 5, shadowedSeqs: [9] }),
+    ])
+    assert.deepEqual(state.pendingShadowedSeqs, [9])
+    assert.equal(state.events[0].kind, 'prune')
+    assert.equal(state.events[0].tokens, 5)
+    assert.ok(!('count' in state.events[0]))
+  })
+
+  test('without shadowedSeqs nothing is armed', () => {
+    const { state } = driveTimeline([compaction(1, 'summary', { shadowedTokenCount: 7 })])
+    assert.ok(!('pendingShadowedSeqs' in state))
+    assert.equal(state.events[0].tokens, 7)
+    assert.ok(!('count' in state.events[0]), 'count rides the shadowedSeqs array only')
+  })
+
+  test('a missing shadowedTokenCount records zero tokens', () => {
+    const { state } = driveTimeline([compaction(1, 'summary', { shadowedSeqs: [1] })])
+    assert.equal(state.events[0].tokens, 0)
+  })
+
+  test('non-number shadowedSeqs entries are filtered out of the claim', () => {
+    const { state } = driveTimeline([
+      compaction(1, 'summary', { shadowedTokenCount: 1, shadowedSeqs: [1, 'x', null, 2] }),
+    ])
+    assert.deepEqual(state.pendingShadowedSeqs, [1, 2])
+    assert.equal(state.events[0].count, 4, 'the count mirrors the durable array verbatim')
+  })
+
+  test('absent data records a zero-token event without arming', () => {
+    const { state } = driveTimeline([compaction(1, 'prune')])
+    assert.ok(!('pendingShadowedSeqs' in state))
+    assert.equal(state.events[0].kind, 'prune')
+    assert.equal(state.events[0].tokens, 0)
+  })
+})
+
+describe('unknown event types', () => {
+  test('events outside the fold vocabulary return the same state reference', () => {
+    const { state } = driveTimeline([])
+    assertStable(state, foreign(1))
+    assertStable(state, foreign(2, 'todo/update'))
+  })
+})
+
+describe('session-cost accumulation', () => {
+  // DeepSeek peak windows: weekdays 09:00-12:00 and 14:00-18:00 Beijing
+  // (UTC+8). 2024-01-01 is a Monday; 2024-01-06/07 the weekend.
+  const usage = { inputTokens: 100 }
+
+  test('no model, non-V4 model, and bare deepseek-v4 are never priced', () => {
+    assert.equal(driveTimeline([assistantMessage(1, { usage })]).state.cost, undefined)
+    assert.equal(
+      driveTimeline([header(1, { model: 'deepseek-v3' }), assistantMessage(2, { usage })]).state.cost,
+      undefined,
+    )
+    assert.equal(
+      driveTimeline([header(1, { model: 'deepseek-v4' }), assistantMessage(2, { usage })]).state.cost,
+      undefined,
+      'v4 without a flash/pro suffix matches no family',
+    )
+  })
+
+  test('v4-flash usage lands in the flash family; v4-pro in pro', () => {
+    const flash = driveTimeline([header(1, { model: 'deepseek-v4-flash' }), assistantMessage(2, { usage })]).state.cost
+    assert.equal(flash?.flash?.off?.uncached, 100)
+    assert.equal(flash?.pro, undefined)
+    const pro = driveTimeline([header(1, { model: 'deepseek-v4-pro' }), assistantMessage(2, { usage })]).state.cost
+    assert.equal(pro?.pro?.off?.uncached, 100)
+    assert.equal(pro?.flash, undefined)
+  })
+
+  test('peak-window boundaries and weekends split the periods', () => {
+    const at100 = (seq: number, time: number) => assistantMessage(seq, { usage, time })
+    const { state } = driveTimeline([
+      header(1, { model: 'deepseek-v4-flash' }),
+      at100(2, Date.UTC(2024, 0, 1, 1, 0, 0)), // Mon 09:00 BJT — peak opens
+      at100(3, Date.UTC(2024, 0, 1, 4, 0, 0)), // Mon 12:00 BJT — peak closed
+      at100(4, Date.UTC(2024, 0, 1, 6, 0, 0)), // Mon 14:00 BJT — peak reopens
+      at100(5, Date.UTC(2024, 0, 1, 10, 0, 0)), // Mon 18:00 BJT — peak closed
+      at100(6, Date.UTC(2024, 0, 6, 2, 0, 0)), // Sat 10:00 BJT — weekend off-peak
+      at100(7, Date.UTC(2024, 0, 7, 2, 0, 0)), // Sun 10:00 BJT — weekend off-peak
+    ])
+    const flash = state.cost?.flash
+    assert.equal(flash?.peak?.uncached, 200, 'two same-period samples accumulate (09:00 + 14:00)')
+    assert.equal(flash?.off?.uncached, 400, '12:00, 18:00, Saturday and Sunday are off-peak')
+    assertPlainJson(state)
+  })
+
+  test('missing usage buckets accumulate as zero', () => {
+    const { state } = driveTimeline([
+      header(1, { model: 'deepseek-v4-flash' }),
+      assistantMessage(2, { usage: { inputTokens: 100 }, time: Date.UTC(2024, 0, 1, 1, 0, 0) }),
+    ])
+    assert.deepEqual(state.cost?.flash?.peak, { uncached: 100, cacheRead: 0, cacheWrite: 0, output: 0 })
+  })
+
+  test('full usage buckets accumulate per bucket', () => {
+    const { state } = driveTimeline([
+      header(1, { model: 'deepseek-v4-pro' }),
+      assistantMessage(2, {
+        usage: { inputTokens: 10, cacheReadTokens: 20, cacheWriteTokens: 30, outputTokens: 40 },
+        time: Date.UTC(2024, 0, 1, 1, 0, 0),
+      }),
+    ])
+    assert.deepEqual(state.cost?.pro?.peak, { uncached: 10, cacheRead: 20, cacheWrite: 30, output: 40 })
+  })
+})
diff --git a/tests/host/fold-retention.spec.ts b/tests/host/fold-retention.spec.ts
new file mode 100644
index 0000000..34af2c1
--- /dev/null
+++ b/tests/host/fold-retention.spec.ts
@@ -0,0 +1,159 @@
+// Retention tests for the timeline fold (src/host/fold.ts): trimToLastTurns
+// unit behavior plus trimState's turn-run / step / event / archive bounds,
+// driven through the real projection unit with tiny config bounds. The
+// restored-state defenses (a projection-cache restore can carry archived
+// entries without `gone` — the state schema leaves it optional) are exercised
+// by feeding hand-crafted states through def.apply, exactly what a cache
+// restore seeds.
+
+import assert from 'node:assert/strict'
+import { describe, test } from 'vitest'
+import { createTimelineState, trimToLastTurns } from '../../src/host/fold'
+import type { RequestRecord } from '../../src/shared/types'
+import { assistantMessage, compaction, planMode, userMessage } from './helpers/events'
+import { assertStatesPlainJson, driveTimeline, timelineDef } from './helpers/projection'
+
+function req(seq: number, turn?: number, step?: number): RequestRecord {
+  const r: RequestRecord = { seq, time: seq * 1000, system: 0, tools: 0, user: 0, inject: 0, assistant: 0, tool: 0, total: 0 }
+  if (turn !== undefined) r.turn = turn
+  if (step !== undefined) r.step = step
+  return r
+}
+
+describe('trimToLastTurns', () => {
+  test('an empty list stays empty', () => {
+    assert.deepEqual(trimToLastTurns([], 2), [])
+  })
+
+  test('turn-less records form one run and survive any cap', () => {
+    const requests = [req(1), req(2), req(3)]
+    assert.deepEqual(trimToLastTurns(requests, 1), requests)
+  })
+
+  test('a leading turn-less run is cut before the newest turns', () => {
+    const requests = [req(1), req(2), req(3, 1)]
+    assert.deepEqual(trimToLastTurns(requests, 1).map(r => r.seq), [3])
+  })
+
+  test('exactly at the cap keeps everything', () => {
+    const requests = [req(1, 1), req(2, 1), req(3, 2)]
+    assert.deepEqual(trimToLastTurns(requests, 2), requests)
+  })
+
+  test('over the cap trims whole turns from the front', () => {
+    const requests = [req(1, 1), req(2, 1), req(3, 2), req(4, 2), req(5, 3), req(6, 3)]
+    assert.deepEqual(trimToLastTurns(requests, 2).map(r => r.seq), [3, 4, 5, 6])
+  })
+
+  test('a turn straddling the cut is kept whole', () => {
+    const requests = [req(1, 1), req(2, 2), req(3, 2), req(4, 2), req(5, 3)]
+    const kept = trimToLastTurns(requests, 2)
+    assert.deepEqual(kept.map(r => r.seq), [2, 3, 4, 5])
+    assert.equal(kept[0], requests[1], 'a slice keeps record identity')
+  })
+})
+
+describe('trimState request/event bounds', () => {
+  test('the turn-run trim fires when the run count crosses the cap, under the step cap', () => {
+    const drive = driveTimeline([
+      assistantMessage(1, { turn: 1, step: 1 }),
+      assistantMessage(2, { turn: 2, step: 1 }),
+      assistantMessage(3, { turn: 3, step: 1 }),
+    ], { maxKeptTurns: 2, maxRequestSteps: 10 })
+    assert.deepEqual(drive.state.requests.map(r => r.seq), [2, 3])
+    assertStatesPlainJson(drive)
+  })
+
+  test('a small log under every bound is kept whole', () => {
+    const drive = driveTimeline([
+      planMode(1, { active: true }),
+      assistantMessage(2, { turn: 1, step: 1 }),
+    ], { maxKeptTurns: 2, maxRequestSteps: 5, maxEvents: 3 })
+    assert.deepEqual(drive.state.requests.map(r => r.seq), [2])
+    assert.deepEqual(drive.state.events.map(e => e.seq), [1])
+    assert.equal(drive.state.archived.length, 0)
+  })
+
+  test('maxRequestSteps is the hard backstop for a pathological many-step turn', () => {
+    const drive = driveTimeline(
+      [1, 2, 3, 4, 5].map(i => assistantMessage(i, { turn: 1, step: i })),
+      { maxKeptTurns: 10, maxRequestSteps: 3 },
+    )
+    // One turn-run: the turn trim cannot cut mid-turn, so the step backstop
+    // slices the newest tail.
+    assert.deepEqual(drive.state.requests.map(r => r.seq), [3, 4, 5])
+  })
+
+  test('maxEvents keeps the newest tail', () => {
+    const drive = driveTimeline([
+      planMode(1, { active: true }),
+      planMode(2, { active: false }),
+      planMode(3, { active: true }),
+      planMode(4, { active: false }),
+      planMode(5, { active: true }),
+    ], { maxEvents: 3 })
+    assert.deepEqual(drive.state.events.map(e => e.seq), [3, 4, 5])
+  })
+})
+
+describe('trimState archive pruning', () => {
+  test('removals at or before the oldest retained request are dropped, recording archiveFloor', () => {
+    const drive = driveTimeline([
+      assistantMessage(1, { turn: 1, step: 1 }),
+      compaction(2, 'prune', { shadowedSeqs: [1], shadowedTokenCount: 9 }),
+      assistantMessage(3, { turn: 1, step: 2, surfaceOp: { op: 'replace', start: 1, end: 1 } }),
+      assistantMessage(4, { turn: 2, step: 1 }),
+      assistantMessage(5, { turn: 3, step: 1 }),
+    ], { maxKeptTurns: 2 })
+    assert.equal(drive.states[4].archived.length, 1, 'the removal postdates the oldest retained request: kept')
+    assert.deepEqual(drive.state.requests.map(r => r.seq), [4, 5], 'the request trim opens the pruning window')
+    assert.equal(drive.state.archived.length, 0, 'gone 3 <= oldest retained request seq 4')
+    assert.equal(drive.state.archiveFloor, 3, 'the floor names the newest dropped removal')
+    assertStatesPlainJson(drive)
+  })
+
+  test('maxArchiveNodes keeps the newest removals; repeat drops max the floor with the existing one', () => {
+    const drive = driveTimeline([
+      userMessage(1, [{ type: 'text', text: 'aaaa' }]),
+      compaction(2, 'prune', { shadowedSeqs: [1], shadowedTokenCount: 9 }),
+      userMessage(3, [{ type: 'text', text: 'b' }], undefined, { surfaceOp: { op: 'replace', start: 1, end: 1 } }),
+      userMessage(4, [{ type: 'text', text: 'cccc' }]),
+      compaction(5, 'prune', { shadowedSeqs: [4], shadowedTokenCount: 9 }),
+      userMessage(6, [{ type: 'text', text: 'd' }], undefined, { surfaceOp: { op: 'replace', start: 4, end: 4 } }),
+      userMessage(7, [{ type: 'text', text: 'eeee' }]),
+      compaction(8, 'prune', { shadowedSeqs: [7], shadowedTokenCount: 9 }),
+      userMessage(9, [{ type: 'text', text: 'f' }], undefined, { surfaceOp: { op: 'replace', start: 7, end: 7 } }),
+    ], { maxArchiveNodes: 1 })
+    assert.equal(drive.states[6].archiveFloor, 3, 'the first drop sets the floor (no existing one)')
+    assert.deepEqual(drive.state.archived.map(n => n.seq), [7], 'only the newest removal survives')
+    assert.equal(drive.state.archiveFloor, 6, 'the second drop takes the max with the existing floor')
+    assertStatesPlainJson(drive)
+  })
+})
+
+describe('trimState restored-state defenses', () => {
+  test('an archived entry without `gone` is never dropped by the request window', () => {
+    const def = timelineDef({ maxArchiveNodes: 2 })
+    const st = createTimelineState()
+    st.requests.push(req(5, 1, 1))
+    // A restored/legacy row without `gone`: `gone ?? Infinity` keeps it above
+    // any oldest retained request.
+    st.archived.push({ seq: 1, cat: 'user', tokens: 9 })
+    const next = def.apply(st, planMode(10, { active: true }))
+    assert.deepEqual(next.archived.map(n => n.seq), [1])
+    assert.equal(next.archiveFloor, undefined)
+  })
+
+  test('gone-less archived entries capped by count drop without touching archiveFloor', () => {
+    const def = timelineDef({ maxArchiveNodes: 1 })
+    const st = createTimelineState()
+    st.archived.push(
+      { seq: 1, cat: 'user', tokens: 9 },
+      { seq: 2, cat: 'user', tokens: 9 },
+      { seq: 3, cat: 'user', tokens: 9 },
+    )
+    const next = def.apply(st, planMode(10, { active: true }))
+    assert.deepEqual(next.archived.map(n => n.seq), [3], 'maxArchiveNodes keeps the newest removal')
+    assert.equal('archiveFloor' in next, false, 'the last dropped entry has no `gone`: no floor to record')
+  })
+})
diff --git a/tests/host/fold-surface.spec.ts b/tests/host/fold-surface.spec.ts
new file mode 100644
index 0000000..e27d016
--- /dev/null
+++ b/tests/host/fold-surface.spec.ts
@@ -0,0 +1,298 @@
+// applySurface paths of the fold (src/host/fold.ts): per-category node
+// projection (text/calls/tool/skill/plugin previews, image counts), the
+// callNames consume-once lifecycle, the shadow-price claim arm/consume
+// protocol, and every surfaceOp variant (append, range replace, shadowed-seq
+// replace with the net-freed event rewrite). No mocks: the real fold runs.
+
+import assert from 'node:assert/strict'
+import { describe, test } from 'vitest'
+import type { TimelineEvent, TimelineState } from '../../src/host/fold'
+import { assistantMessage, at, compaction, toolCall, toolResult, userMessage } from './helpers/events'
+import { assertPlainJson, driveTimeline, timelineDef } from './helpers/projection'
+
+const text = (t: string) => [{ type: 'text', text: t }]
+
+describe('applySurface assistant branch', () => {
+  test('assistant text lands on the node preview', () => {
+    const { state } = driveTimeline([assistantMessage(1, { content: text('hello world') })])
+    assert.equal(state.surface.at(-1)?.text, 'hello world')
+    assert.equal(state.surface.at(-1)?.cat, 'assistant')
+  })
+
+  test('a text-less assistant message lists its tool-call names, capped at three', () => {
+    const { state } = driveTimeline([assistantMessage(1, {
+      content: [
+        { type: 'tool-call', name: 'bash', arguments: '{}' },
+        { type: 'tool-call', name: 'read', arguments: '{}' },
+        { type: 'tool-call', name: 'edit', arguments: '{}' },
+        { type: 'tool-call', name: 'grep', arguments: '{}' },
+      ],
+    })])
+    const node = state.surface.at(-1)
+    assert.equal(node?.text, undefined)
+    assert.deepEqual(node?.calls, ['bash', 'read', 'edit'])
+  })
+
+  test('a text-less assistant message without call names carries neither preview', () => {
+    const { state } = driveTimeline([assistantMessage(1, { content: [{ type: 'tool-call', arguments: '{}' }] })])
+    const node = state.surface.at(-1)
+    assert.equal(node?.text, undefined)
+    assert.equal(node?.calls, undefined)
+  })
+})
+
+describe('applySurface tool/result branch', () => {
+  test('a source callId armed by tool/call names the node and is consumed', () => {
+    const { state } = driveTimeline([
+      toolCall(1, { callId: 'c1', name: 'bash' }),
+      toolResult(2, { callId: 'c1', content: text('ok') }),
+    ])
+    const node = state.surface.at(-1)
+    assert.equal(node?.tool, 'bash')
+    assert.equal(node?.err, undefined, 'no error flag without data.error')
+    assert.deepEqual(state.callNames, {}, 'the consumed entry leaves the persisted map')
+  })
+
+  test('an unknown source callId falls through to the content block id', () => {
+    // Neither id is armed: the node ends up tool-less (a replay window where
+    // the tool/call event is gone). Note the fold assigns the lookup miss
+    // onto the node, so this state is not assertPlainJson-able.
+    const { state } = driveTimeline([toolResult(1, { callId: 'ghost', content: text('ok') })])
+    assert.equal(state.surface.at(-1)?.tool, undefined)
+    assert.deepEqual(state.callNames, {})
+  })
+
+  test('a block toolCallId hit names the node when the source id is unknown', () => {
+    const ev: TimelineEvent = {
+      type: 'tool/result', seq: 2, time: at(),
+      data: {
+        callId: 'x',
+        message: {
+          source: { kind: 'tool', callId: 'x' },
+          content: [{ type: 'tool-result', toolCallId: 'c1', content: text('ok') }],
+        },
+      },
+      surfaceOp: 'append',
+    }
+    const { state } = driveTimeline([toolCall(1, { callId: 'c1', name: 'bash' }), ev])
+    assert.equal(state.surface.at(-1)?.tool, 'bash')
+    assert.deepEqual(state.callNames, {}, 'both the source id and the block id are consumed')
+  })
+
+  test('consume-once rebuild keeps the other pending calls', () => {
+    const { state } = driveTimeline([
+      toolCall(1, { callId: 'c1', name: 'bash' }),
+      toolCall(2, { callId: 'c2', name: 'read' }),
+      toolResult(3, { callId: 'c1', content: text('ok') }),
+    ])
+    assert.deepEqual(state.callNames, { c2: 'read' }, 'the unanswered call stays armed')
+  })
+
+  test('a result with neither id leaves callNames untouched', () => {
+    const ev: TimelineEvent = {
+      type: 'tool/result', seq: 2, time: at(),
+      data: { message: { content: [{ type: 'tool-result', content: text('ok') }] } },
+      surfaceOp: 'append',
+    }
+    const { state } = driveTimeline([toolCall(1, { callId: 'keep', name: 'read' }), ev])
+    assert.equal(state.surface.at(-1)?.tool, undefined)
+    assert.deepEqual(state.callNames, { keep: 'read' })
+  })
+
+  test('data.error flags the node', () => {
+    const { state } = driveTimeline([
+      toolCall(1, { callId: 'c1', name: 'bash' }),
+      toolResult(2, { callId: 'c1', content: text('boom'), error: true }),
+    ])
+    assert.equal(state.surface.at(-1)?.err, true)
+  })
+})
+
+describe('applySurface user-message previews', () => {
+  test('skill-invocation sources carry the skill name', () => {
+    const { state } = driveTimeline([userMessage(1, text('run it'), { kind: 'skill-invocation', name: 'pdf' })])
+    assert.equal(state.surface.at(-1)?.skill, 'pdf')
+    assert.equal(state.surface.at(-1)?.cat, 'inject')
+  })
+
+  test('a nameless skill-invocation falls back to ?', () => {
+    const { state } = driveTimeline([userMessage(1, text('run it'), { kind: 'skill-invocation' })])
+    assert.equal(state.surface.at(-1)?.skill, '?')
+  })
+
+  test('plugin notice sources preview the summary', () => {
+    const { state } = driveTimeline([
+      userMessage(1, text('injected'), { kind: 'plugin', form: 'notice', plugin: 'dsh-x', summary: 'Skill injected (pdf)' }),
+    ])
+    const node = state.surface.at(-1)
+    assert.equal(node?.text, 'Skill injected (pdf)')
+    assert.equal(node?.form, 'notice')
+  })
+
+  test('plugin snapshot sources join section names, skipping nulls, truncated to 80', () => {
+    const sections = [{ name: 'a'.repeat(50) }, null, { name: 'b'.repeat(50) }, {}]
+    const { state } = driveTimeline([
+      userMessage(1, text('snapshot'), { kind: 'plugin', form: 'snapshot', sections }),
+    ])
+    const expected = `${'a'.repeat(50)}, ${'b'.repeat(50)}`.slice(0, 80)
+    assert.equal(expected.length, 80, 'fixture must actually exceed the truncation bound')
+    assert.equal(state.surface.at(-1)?.text, expected)
+  })
+
+  test('plugin sources of other forms preview the first text', () => {
+    const { state } = driveTimeline([
+      userMessage(1, text('plain body'), { kind: 'plugin', form: 'instructions' }),
+    ])
+    assert.equal(state.surface.at(-1)?.text, 'plain body')
+  })
+
+  test('plugin sources without text carry no preview', () => {
+    const { state } = driveTimeline([userMessage(1, [], { kind: 'plugin', form: 'instructions' })])
+    assert.equal(state.surface.at(-1)?.text, undefined)
+  })
+
+  test('a plain user message previews its text', () => {
+    const { state } = driveTimeline([userMessage(1, text('hi there'))])
+    const node = state.surface.at(-1)
+    assert.equal(node?.text, 'hi there')
+    assert.equal(node?.cat, 'user')
+    assert.equal(node?.form, undefined, 'no source, no form stamp')
+  })
+
+  test('a plain user message without text carries no preview', () => {
+    const { state } = driveTimeline([userMessage(1, [])])
+    assert.equal(state.surface.at(-1)?.text, undefined)
+  })
+
+  test('image blocks ride the node as imgs', () => {
+    const { state } = driveTimeline([
+      userMessage(1, [{ type: 'image', attachment: { width: 800, height: 600 } }]),
+    ])
+    assert.equal(state.surface.at(-1)?.imgs, 1)
+  })
+})
+
+describe('shadow-price claim lifecycle', () => {
+  test('the next surface event consumes and DELETES the armed claim', () => {
+    const { state } = driveTimeline([
+      compaction(1, 'summary', { shadowedTokenCount: 10, shadowedSeqs: [1] }),
+      userMessage(2, text('after')),
+    ])
+    assert.ok(!('pendingShadowedSeqs' in state), 'consumed claims are deleted, not undefined-valued')
+    assert.ok(!('pendingShadowEventSeq' in state))
+    assertPlainJson(state)
+  })
+})
+
+describe('surfaceOp variants', () => {
+  const big = 'a'.repeat(16) // 12 tokens: ceil(16/4) + 4 block + 4 role
+
+  test('a null surfaceOp appends', () => {
+    const ev: TimelineEvent = { type: 'user/message', seq: 1, time: at(), data: { content: text('x') }, surfaceOp: null }
+    const { state } = driveTimeline([ev])
+    assert.equal(state.surface.length, 1)
+  })
+
+  test('a non-object surfaceOp appends', () => {
+    const { state } = driveTimeline([userMessage(1, text('x'))]) // builder default: 'append'
+    assert.equal(state.surface.length, 1)
+  })
+
+  test('a non-replace op object appends', () => {
+    const { state } = driveTimeline([
+      userMessage(1, text('x'), undefined, { surfaceOp: { op: 'insert', start: 0, end: 0 } }),
+    ])
+    assert.equal(state.surface.length, 1)
+  })
+
+  test('a replace with an armed claim removes by seqs and rewrites the metering event net-freed', () => {
+    const { state } = driveTimeline([
+      userMessage(1, text(big)), // 12 tokens
+      userMessage(2, text(big)), // 12 tokens
+      userMessage(3, text(big)), // 12 tokens — NOT shadowed, survives the replace
+      compaction(4, 'summary', { shadowedTokenCount: 100, shadowedSeqs: [1, 2] }),
+      userMessage(5, text('bbbbbbbb'), undefined, { surfaceOp: { op: 'replace', start: 1, end: 2 } }), // 10 tokens
+    ])
+    assert.deepEqual(state.surface.map(n => n.seq), [3, 5])
+    assert.equal(state.sums.user, 22)
+    assert.deepEqual(state.archived.map(n => [n.seq, n.gone]), [[1, 5], [2, 5]], 'removed nodes archived as stamped copies')
+    // Gross 100 rewritten to the net freed amount: 24 removed - 10 re-added.
+    assert.equal(state.events[0].tokens, 14)
+    assertPlainJson(state)
+  })
+
+  test('the net-freed rewrite clamps at zero when the replacement outweighs the shadow', () => {
+    const { state } = driveTimeline([
+      userMessage(1, text(big)), // 12 tokens
+      compaction(2, 'prune', { shadowedTokenCount: 12, shadowedSeqs: [1] }),
+      userMessage(3, text('x'.repeat(400)), undefined, { surfaceOp: { op: 'replace', start: 1, end: 1 } }), // 108 tokens
+    ])
+    assert.equal(state.events[0].tokens, 0)
+    assert.equal(state.sums.user, 108)
+  })
+
+  test('an armed EMPTY claim falls through to the range path', () => {
+    const { state } = driveTimeline([
+      userMessage(1, text('aaaa')), // 9 tokens
+      userMessage(2, text('bbbb')), // 9 tokens
+      compaction(3, 'summary', { shadowedTokenCount: 50, shadowedSeqs: [] }),
+      userMessage(4, text('cccc'), undefined, { surfaceOp: { op: 'replace', start: 1, end: 2 } }),
+    ])
+    assert.deepEqual(state.surface.map(n => n.seq), [4], 'range replace splices the node in place')
+    assert.equal(state.sums.user, 9)
+    assert.deepEqual(state.archived.map(n => n.seq), [1, 2])
+    assert.equal(state.events[0].tokens, 50, 'the range path never rewrites the metering event')
+    assert.ok(!('pendingShadowedSeqs' in state))
+  })
+
+  test('a single-node range replace (start === end)', () => {
+    const { state } = driveTimeline([
+      userMessage(1, text('aaaa')),
+      userMessage(2, text('bbbb')),
+      userMessage(3, text('cccc')),
+      userMessage(4, text('dddddddd'), undefined, { surfaceOp: { op: 'replace', start: 2, end: 2 } }),
+    ])
+    assert.deepEqual(state.surface.map(n => n.seq), [1, 4, 3])
+    assert.deepEqual(state.archived.map(n => [n.seq, n.gone]), [[2, 4]])
+  })
+
+  test('a replace whose start seq is missing appends', () => {
+    const { state } = driveTimeline([
+      userMessage(1, text('aaaa')),
+      userMessage(2, text('bbbb'), undefined, { surfaceOp: { op: 'replace', start: 99, end: 100 } }),
+    ])
+    assert.deepEqual(state.surface.map(n => n.seq), [1, 2])
+  })
+
+  test('a replace whose end seq is missing appends', () => {
+    const { state } = driveTimeline([
+      userMessage(1, text('aaaa')),
+      userMessage(2, text('bbbb'), undefined, { surfaceOp: { op: 'replace', start: 1, end: 99 } }),
+    ])
+    assert.deepEqual(state.surface.map(n => n.seq), [1, 2])
+  })
+
+  test('restored state: claim without pendingShadowEventSeq skips the rewrite', () => {
+    // A checkpoint restore may carry the claim fields partially — def.apply
+    // accepts any TimelineState.
+    const def = timelineDef({})
+    const base = driveTimeline([userMessage(1, text(big))]).state
+    const restored: TimelineState = { ...base, pendingShadowedSeqs: [1] }
+    const next = def.apply(restored, userMessage(2, text('bbbb'), undefined, { surfaceOp: { op: 'replace', start: 1, end: 1 } }))
+    assert.deepEqual(next.surface.map(n => n.seq), [2])
+    assert.equal(next.archived.length, 1)
+    assert.equal(next.events.length, 0, 'no event to rewrite, no crash')
+    assert.ok(!('pendingShadowedSeqs' in next))
+    assertPlainJson(next)
+  })
+
+  test('restored state: a claim whose event seq matches no event skips the rewrite', () => {
+    const def = timelineDef({})
+    const base = driveTimeline([userMessage(1, text(big))]).state
+    const restored: TimelineState = { ...base, pendingShadowedSeqs: [1], pendingShadowEventSeq: 999 }
+    const next = def.apply(restored, userMessage(2, text('bbbb'), undefined, { surfaceOp: { op: 'replace', start: 1, end: 1 } }))
+    assert.deepEqual(next.surface.map(n => n.seq), [2])
+    assert.equal(next.events.length, 0)
+    assert.ok(!('pendingShadowEventSeq' in next))
+  })
+})
diff --git a/tests/host/fold-view.spec.ts b/tests/host/fold-view.spec.ts
new file mode 100644
index 0000000..1014d70
--- /dev/null
+++ b/tests/host/fold-view.spec.ts
@@ -0,0 +1,292 @@
+// Wire-view tests for buildTimelineView (src/host/fold.ts): counters,
+// non-aliasing copies, the cost buckets, the serving window (newest tail +
+// pinned injects + coverage floors), event→request attribution, and view
+// purity. Driven through the real projection unit (driveTimeline / def.view).
+
+import assert from 'node:assert/strict'
+import { describe, test } from 'vitest'
+import { createTimelineState } from '../../src/host/fold'
+import type { CostBucketTotals, SessionCostUsage, SurfaceNode } from '../../src/shared/types'
+import { assistantMessage, header, planMode, requestContext, toolCall, toolResult, userMessage } from './helpers/events'
+import { assertPlainJson, driveTimeline, timelineDef } from './helpers/projection'
+
+const bucket = (uncached: number, cacheRead: number, cacheWrite: number, output: number): CostBucketTotals =>
+  ({ uncached, cacheRead, cacheWrite, output })
+
+describe('buildTimelineView counters', () => {
+  test('images sums per-node image counts, absent counting as zero', () => {
+    const { view } = driveTimeline([
+      userMessage(1, [{ type: 'image', attachment: { width: 800, height: 600 } }]),
+      userMessage(2, [{ type: 'text', text: 'no image here' }]),
+    ])
+    assert.equal(view.images, 1)
+  })
+
+  test('toolCalls counts live tool-result nodes only', () => {
+    const { view } = driveTimeline([
+      toolCall(1, { callId: 'c1', name: 'bash' }),
+      toolResult(2, { callId: 'c1', content: [{ type: 'text', text: 'ok' }] }),
+      userMessage(3, [{ type: 'text', text: 'hi' }]),
+    ])
+    assert.equal(view.toolCalls, 1)
+  })
+})
+
+describe('buildTimelineView copies', () => {
+  test('requests, events, and archive entries are copies, never state aliases', () => {
+    const { state, view } = driveTimeline([
+      userMessage(1, [{ type: 'text', text: 'aaaa' }]),
+      planMode(2, { active: true }),
+      assistantMessage(3, { turn: 1, step: 1 }),
+    ])
+    assert.notEqual(view.requests[0], state.requests[0])
+    assert.deepEqual(view.requests[0], state.requests[0])
+    // Event copies are stamped with request attribution, so they differ by
+    // value — what matters is that they never alias the persisted records.
+    assert.notEqual(view.events[0], state.events[0])
+    // Mutating the served view must not leak into the persisted state.
+    view.requests[0].total = -1
+    view.events[0].name = 'mutated'
+    assert.notEqual(state.requests[0].total, -1)
+    assert.notEqual(state.events[0].name, 'mutated')
+  })
+
+  test('archive entries are copies too', () => {
+    const { state, view } = driveTimeline([
+      userMessage(1, [{ type: 'text', text: 'aaaa' }]),
+      userMessage(2, [{ type: 'text', text: 'b' }], undefined, { surfaceOp: { op: 'replace', start: 1, end: 1 } }),
+    ])
+    assert.equal(view.archive.length, 1)
+    assert.notEqual(view.archive[0], state.archived[0])
+    assert.deepEqual(view.archive[0], state.archived[0])
+  })
+})
+
+describe('buildTimelineView cost copy', () => {
+  test('a folded flash-only cost rides the wire as a detached copy', () => {
+    // 2024-01-04 00:00 UTC = 08:00 Beijing, a Thursday: off-peak.
+    const { state, view } = driveTimeline([
+      header(1, { model: 'deepseek-v4-flash', provider: 'deepseek' }),
+      assistantMessage(2, {
+        turn: 1,
+        step: 1,
+        usage: { inputTokens: 100, cacheReadTokens: 20, cacheWriteTokens: 5, outputTokens: 10 },
+        time: Date.UTC(2024, 0, 4, 0, 0, 0),
+      }),
+    ])
+    assert.deepEqual(view.cost, { flash: { off: bucket(100, 20, 5, 10) } })
+    assert.notEqual(view.cost?.flash?.off, state.cost?.flash?.off)
+    assert.equal(view.cost?.pro, undefined)
+  })
+
+  test('a pro-only cost with a peak-only bucket copies the set half only', () => {
+    const st = createTimelineState()
+    st.cost = { pro: { peak: bucket(1, 2, 3, 4) } } as SessionCostUsage
+    const view = timelineDef({}).view(st)
+    assert.deepEqual(view.cost, { pro: { peak: bucket(1, 2, 3, 4) } })
+    assert.notEqual(view.cost?.pro?.peak, st.cost.pro?.peak)
+    assert.equal(view.cost?.flash, undefined, 'an absent family stays absent')
+    assert.equal(view.cost?.pro?.off, undefined, 'an absent bucket stays absent')
+  })
+
+  test('both families with both buckets copy through', () => {
+    const st = createTimelineState()
+    st.cost = {
+      flash: { peak: bucket(1, 0, 0, 2), off: bucket(3, 0, 0, 4) },
+      pro: { peak: bucket(5, 6, 0, 7), off: bucket(8, 0, 9, 10) },
+    }
+    const view = timelineDef({}).view(st)
+    assert.deepEqual(view.cost, st.cost)
+    assert.notEqual(view.cost?.flash, st.cost.flash)
+    assert.notEqual(view.cost?.flash?.peak, st.cost.flash?.peak)
+    assert.notEqual(view.cost?.pro?.off, st.cost.pro?.off)
+  })
+
+  test('no cost in state omits the wire field entirely', () => {
+    const view = timelineDef({}).view(createTimelineState())
+    assert.equal('cost' in view, false)
+  })
+})
+
+describe('buildTimelineView serving window', () => {
+  test('overflow drops the oldest non-inject nodes and floors the newest dropped seq', () => {
+    const { view } = driveTimeline([
+      userMessage(1, [{ type: 'text', text: 'injected' }], { kind: 'plugin', form: 'context', plugin: 'dsh-test' }),
+      userMessage(2, [{ type: 'text', text: 'x1' }]),
+      userMessage(3, [{ type: 'text', text: 'x2' }]),
+      userMessage(4, [{ type: 'text', text: 'x3' }]),
+      userMessage(5, [{ type: 'text', text: 'x4' }]),
+    ], { maxNodes: 2 })
+    assert.deepEqual((view.nodes as SurfaceNode[]).map(n => n.seq), [1, 4, 5], 'the out-of-window inject is pinned ahead of the tail')
+    assert.equal(view.droppedNodes, 2, 'pinned injects are not counted as dropped')
+    assert.equal(view.surfaceFloor, 3, 'the floor is the newest unserved non-inject seq')
+  })
+
+  test('without overflow the served nodes are a plain tail copy', () => {
+    const { state, view } = driveTimeline([
+      userMessage(1, [{ type: 'text', text: 'a' }]),
+      userMessage(2, [{ type: 'text', text: 'b' }]),
+    ], { maxNodes: 4 })
+    assert.deepEqual((view.nodes as SurfaceNode[]).map(n => n.seq), [1, 2])
+    assert.notEqual(view.nodes, state.surface, 'the served slice never aliases the surface')
+    assert.equal(view.droppedNodes, 0)
+    assert.equal('surfaceFloor' in view, false)
+    assert.equal('archiveFloor' in view, false)
+  })
+
+  test('an overflow made only of injects drops nothing and sets no floor', () => {
+    const { view } = driveTimeline([
+      userMessage(1, [{ type: 'text', text: 'i1' }], { kind: 'plugin', form: 'context', plugin: 'p1' }),
+      userMessage(2, [{ type: 'text', text: 'i2' }], { kind: 'goal' }),
+    ], { maxNodes: 1 })
+    assert.deepEqual((view.nodes as SurfaceNode[]).map(n => n.seq), [1, 2], 'every inject is pinned')
+    assert.equal(view.droppedNodes, 0)
+    assert.equal('surfaceFloor' in view, false)
+  })
+
+  test('archiveFloor rides through when the state carries one', () => {
+    const st = createTimelineState()
+    st.archiveFloor = 7
+    assert.equal(timelineDef({}).view(st).archiveFloor, 7)
+  })
+})
+
+describe('buildTimelineView event attribution', () => {
+  test('an event before the first request takes the next side only', () => {
+    const { view } = driveTimeline([
+      userMessage(1, [{ type: 'text', text: 'ctx' }], { kind: 'plugin', form: 'notice', plugin: 'p', summary: 'hi' }),
+      assistantMessage(2, { turn: 1, step: 1 }),
+    ])
+    const ev = view.events[0]
+    assert.equal(ev.kind, 'inject')
+    assert.equal(ev.turn, 1)
+    assert.equal(ev.step, 1)
+    assert.equal(ev.fromTurn, undefined)
+    assert.equal(ev.fromStep, undefined)
+  })
+
+  test('an event between two requests of one turn takes both sides', () => {
+    const { view } = driveTimeline([
+      assistantMessage(1, { turn: 1, step: 1 }),
+      planMode(2, { active: true }),
+      assistantMessage(3, { turn: 1, step: 2 }),
+    ])
+    const ev = view.events[0]
+    assert.equal(ev.turn, 1)
+    assert.equal(ev.step, 2)
+    assert.equal(ev.fromTurn, 1)
+    assert.equal(ev.fromStep, 1)
+  })
+
+  test('an event between requests of different turns names the turn boundary', () => {
+    const { view } = driveTimeline([
+      assistantMessage(1, { turn: 1, step: 1 }),
+      planMode(2, { active: true }),
+      assistantMessage(3, { turn: 2, step: 1 }),
+    ])
+    const ev = view.events[0]
+    assert.equal(ev.fromTurn, 1)
+    assert.equal(ev.turn, 2)
+    assert.notEqual(ev.fromTurn, ev.turn)
+  })
+
+  test('an event after the last request takes the previous side only', () => {
+    const { view } = driveTimeline([
+      assistantMessage(1, { turn: 1, step: 1 }),
+      planMode(2, { active: false }),
+    ])
+    const ev = view.events[0]
+    assert.equal(ev.turn, undefined)
+    assert.equal(ev.step, undefined)
+    assert.equal(ev.fromTurn, 1)
+    assert.equal(ev.fromStep, 1)
+  })
+
+  test('an event with no requests at all takes neither side', () => {
+    const { view } = driveTimeline([planMode(1, { active: true })])
+    const ev = view.events[0]
+    assert.equal(ev.turn, undefined)
+    assert.equal(ev.fromTurn, undefined)
+  })
+
+  test('a turn-less next request (replay shape) stamps no turn/step', () => {
+    const { view } = driveTimeline([
+      planMode(1, { active: true }),
+      assistantMessage(2, {}), // no turn/step: a replayed log row
+    ])
+    const ev = view.events[0]
+    assert.equal(ev.turn, undefined)
+    assert.equal(ev.step, undefined)
+  })
+
+  test('a next request with a turn but no step stamps no turn/step', () => {
+    const { view } = driveTimeline([
+      planMode(1, { active: true }),
+      assistantMessage(2, { turn: 3 }),
+    ])
+    assert.equal(view.events[0].turn, undefined)
+  })
+
+  test('a turn-less previous request stamps no from-side', () => {
+    const { view } = driveTimeline([
+      assistantMessage(1, {}), // no turn/step: a replayed log row
+      planMode(2, { active: true }),
+      assistantMessage(3, { turn: 1, step: 1 }),
+    ])
+    const ev = view.events[0]
+    assert.equal(ev.fromTurn, undefined)
+    assert.equal(ev.fromStep, undefined)
+    assert.equal(ev.turn, 1)
+    assert.equal(ev.step, 1)
+  })
+
+  test('a previous request with a turn but no step stamps no from-side', () => {
+    const { view } = driveTimeline([
+      assistantMessage(1, { turn: 7 }),
+      planMode(2, { active: true }),
+      assistantMessage(3, { turn: 8, step: 1 }),
+    ])
+    const ev = view.events[0]
+    assert.equal(ev.fromTurn, undefined)
+    assert.equal(ev.turn, 8)
+  })
+})
+
+describe('buildTimelineView purity', () => {
+  test('repeated views are deep-equal but distinct, and the state is untouched', () => {
+    const { def, state } = driveTimeline([
+      header(1, { model: 'm', provider: 'p' }),
+      userMessage(2, [{ type: 'text', text: 'a' }]),
+      assistantMessage(3, { turn: 1, step: 1, usage: { inputTokens: 5, outputTokens: 2 } }),
+    ])
+    const before = assertPlainJson(state)
+    const v1 = def.view(state)
+    const v2 = def.view(state)
+    assert.notEqual(v1, v2)
+    assert.notEqual(v1.nodes, v2.nodes)
+    assert.notEqual(v1.requests, v2.requests)
+    assert.deepEqual(v1, v2)
+    assert.deepEqual(assertPlainJson(state), before, 'view() must not mutate the persisted state')
+  })
+})
+
+describe('buildTimelineView envelope', () => {
+  test('ok/model/provider/contextWindow pass through when known', () => {
+    const { view } = driveTimeline([
+      header(1, { model: 'deepseek-v4', provider: 'deepseek' }),
+      requestContext(2, { contextWindow: 128000 }),
+    ])
+    assert.equal(view.ok, true)
+    assert.equal(view.model, 'deepseek-v4')
+    assert.equal(view.provider, 'deepseek')
+    assert.equal(view.contextWindow, 128000)
+  })
+
+  test('unknown route fields read as undefined', () => {
+    const { view } = driveTimeline([planMode(1, { active: true })])
+    assert.equal(view.ok, true)
+    assert.equal(view.model, undefined)
+    assert.equal(view.provider, undefined)
+    assert.equal(view.contextWindow, undefined)
+  })
+})
diff --git a/tests/host/headers.spec.ts b/tests/host/headers.spec.ts
new file mode 100644
index 0000000..a61ce47
--- /dev/null
+++ b/tests/host/headers.spec.ts
@@ -0,0 +1,158 @@
+// Unit tests for the contextHeaders projection unit (src/host/headers.ts) —
+// the request-header CONTENT epochs behind the timeline's envelope figures.
+// The unit is pure init/apply/view: each case drives real event envelopes
+// through the real definition (no harness plumbing).
+
+import assert from 'node:assert/strict'
+import { describe, test } from 'vitest'
+import { createContextHeadersDefinition } from '../../src/host/headers'
+import type { HeadersState } from '../../src/host/headers'
+import { header, foreign } from './helpers/events'
+import type { TimelineEvent } from '../../src/host/fold'
+
+type Def = ReturnType
+
+/** The dual-contract fields ride the definition past its declared 0.1.0 return type. */
+function compat(def: Def) {
+  return def as unknown as {
+    stateSchema: { parse: (value: unknown) => HeadersState }
+    wire: {
+      viewSchema: { safeParse: (value: unknown) => { success: boolean } }
+      view: Def['view']
+    }
+  }
+}
+
+/** A raw request/header envelope with full control over the header payload. */
+function headerEvent(seq: number, rawHeader: unknown): TimelineEvent {
+  return { type: 'request/header', seq, time: seq * 1000, data: { header: rawHeader, reason: 'initial' } }
+}
+
+function fold(def: Def, events: TimelineEvent[]): HeadersState {
+  let state = def.init()
+  for (const ev of events) state = def.apply(state, ev as never)
+  return state
+}
+
+describe('createContextHeadersDefinition', () => {
+  test('init/apply/view fold header epochs', () => {
+    const def = createContextHeadersDefinition()
+    assert.equal(def.key, 'contextHeaders')
+    assert.equal(def.stateVersion, 1)
+
+    const init = def.init()
+    assert.deepEqual(init, { headers: [] })
+
+    const state = def.apply(init, header(1, {
+      system: 'You are an agent.',
+      tools: [{ name: 'bash', description: 'run a command' }],
+    }) as never)
+    assert.notEqual(state, init, 'a header event produces a new state')
+    assert.equal(state.headers.length, 1)
+    assert.equal(state.headers[0].seq, 1)
+    assert.equal(state.headers[0].system, 'You are an agent.')
+    assert.equal(state.headers[0].tools.length, 1)
+    assert.equal(state.headers[0].tools[0].name, 'bash')
+
+    const view = def.view(state)
+    assert.equal(view.headers.length, 1)
+    assert.equal(view.headers[0].tools[0].description, 'run a command')
+  })
+
+  test('a non-header event returns the same state reference', () => {
+    const def = createContextHeadersDefinition()
+    const state = fold(def, [header(1, { system: 'sys' })])
+    assert.equal(def.apply(state, foreign(2) as never), state)
+  })
+
+  test('null, undefined, and non-object headers return the same state reference', () => {
+    const def = createContextHeadersDefinition()
+    const state = fold(def, [header(1, { system: 'sys' })])
+    for (const [seq, raw] of [[2, null], [3, undefined], [4, 42]] as const) {
+      assert.equal(def.apply(state, headerEvent(seq, raw) as never), state, `header ${String(raw)} is not an epoch`)
+    }
+  })
+
+  test('a non-array tools field folds to an empty tool list', () => {
+    const def = createContextHeadersDefinition()
+    const view = def.view(fold(def, [headerEvent(1, { tools: 'nope' })]))
+    assert.deepEqual(view.headers[0].tools, [])
+  })
+
+  test('tool entries degrade bad names and omit bad descriptions', () => {
+    const def = createContextHeadersDefinition()
+    const view = def.view(fold(def, [headerEvent(1, {
+      tools: [
+        { name: 42, description: 'kept' }, // non-string name → '?'
+        { name: 'a', description: 7 }, // non-string description → omitted
+        { name: 'b', description: '' }, // empty description → omitted
+        { name: 'c', description: 'ok' },
+      ],
+    })]))
+    const tools = view.headers[0].tools
+    assert.equal(tools[0].name, '?')
+    assert.equal(tools[0].description, 'kept')
+    assert.ok(!('description' in tools[1]))
+    assert.ok(!('description' in tools[2]))
+    assert.equal(tools[3].description, 'ok')
+    for (const tool of tools) {
+      assert.ok(Number.isInteger(tool.tokens) && tool.tokens >= 0, 'tool tokens priced')
+      assert.ok('schema' in tool, 'the raw schema rides the record')
+    }
+  })
+
+  test('system is omitted unless a non-empty string', () => {
+    const def = createContextHeadersDefinition()
+    const view = def.view(fold(def, [
+      headerEvent(1, { system: 42 }),
+      headerEvent(2, { system: '' }),
+      headerEvent(3, { system: 'sys' }),
+    ]))
+    assert.ok(!('system' in view.headers[0]))
+    assert.ok(!('system' in view.headers[1]))
+    assert.equal(view.headers[2].system, 'sys')
+  })
+
+  test('the same epoch seq twice in a row returns the same state reference', () => {
+    const def = createContextHeadersDefinition()
+    const state = fold(def, [header(1, { system: 'a' })])
+    assert.equal(def.apply(state, header(1, { system: 'b' }) as never), state, 'duplicate epoch suppressed')
+    assert.equal(state.headers.length, 1)
+  })
+
+  test('retention caps at the 50 newest epochs', () => {
+    const def = createContextHeadersDefinition()
+    const events = Array.from({ length: 55 }, (_, i) => header(i + 1, { system: `s${i + 1}` }))
+    const state = fold(def, events)
+    assert.equal(state.headers.length, 50)
+    assert.equal(state.headers[0].seq, 6, 'the oldest five epochs dropped')
+    assert.equal(state.headers.at(-1)?.seq, 55)
+  })
+
+  test('view() copies records and tools off the state', () => {
+    const def = createContextHeadersDefinition()
+    const state = fold(def, [headerEvent(1, { system: 'sys', tools: [{ name: 'bash' }] })])
+    const view = def.view(state)
+    view.headers[0].tools[0].name = 'mutated'
+    view.headers[0].system = 'mutated'
+    assert.equal(state.headers[0].tools[0].name, 'bash', 'mutating the view must not alias state')
+    assert.equal(state.headers[0].system, 'sys')
+  })
+
+  test('schema, stateSchema, and the wire block validate a real folded view', () => {
+    const def = createContextHeadersDefinition()
+    const state = fold(def, [
+      header(1, {
+        system: 'You are an agent.',
+        tools: [{ name: 'bash', description: 'run a command', parameters: { type: 'object' } }],
+      }),
+    ])
+    const view = def.view(state)
+    assert.equal(def.schema.safeParse(view).success, true, 'wire schema accepts the folded view')
+
+    const c = compat(def)
+    assert.deepEqual(c.stateSchema.parse(structuredClone(state)), state, 'state schema round-trips the fold state')
+    assert.equal(c.wire.view, def.view, 'the wire block shares the view function')
+    assert.equal(c.wire.viewSchema.safeParse(c.wire.view(state)).success, true)
+  })
+})
diff --git a/tests/host/helpers/events.ts b/tests/host/helpers/events.ts
new file mode 100644
index 0000000..b65d1f8
--- /dev/null
+++ b/tests/host/helpers/events.ts
@@ -0,0 +1,104 @@
+// Durable session-event envelope builders for the fold specs. Shapes mirror
+// the harness's durable vocabulary (`SessionEventMap` in @deepseek-ai/dsh-session,
+// plus the dsh-compaction declaration-merged `compaction/*` family) — the same
+// envelopes a real session log carries, minus fields the fold never reads.
+
+import type { TimelineEvent } from '../../../src/host/fold'
+import type { ContentBlock, MessageSource } from '../../../src/host/pricing'
+
+let clock = 0
+
+/** Monotonic timestamps, so folded records stay time-ordered without thinking. */
+export function at(time?: number): number {
+  clock += 1000
+  return time ?? clock
+}
+
+export function header(seq: number, opts: {
+  system?: unknown
+  tools?: unknown[]
+  model?: unknown
+  provider?: unknown
+  reason?: 'initial' | 'resume' | 'change'
+  /** Override the whole config object (e.g. to omit model/provider). */
+  config?: unknown
+  time?: number
+}): TimelineEvent {
+  const h: Record = { config: opts.config ?? { model: opts.model, provider: opts.provider } }
+  if (opts.system !== undefined) h.system = opts.system
+  if (opts.tools !== undefined) h.tools = opts.tools
+  return { type: 'request/header', seq, time: at(opts.time), data: { header: h, reason: opts.reason ?? 'initial' } }
+}
+
+export function requestContext(seq: number, data?: Record): TimelineEvent {
+  return { type: 'request/context', seq, time: at(), ...(data === undefined ? {} : { data }) }
+}
+
+/** user/message: the durable payload IS the message (deriveEventMessage returns data). */
+export function userMessage(seq: number, content: ContentBlock[], source?: MessageSource | null, opts: {
+  time?: number
+  surfaceOp?: TimelineEvent['surfaceOp']
+} = {}): TimelineEvent {
+  const data: Record = { content }
+  if (source !== undefined) data.source = source
+  return { type: 'user/message', seq, time: at(opts.time), data, surfaceOp: opts.surfaceOp ?? 'append' }
+}
+
+export function toolCall(seq: number, opts: {
+  callId?: unknown
+  name?: unknown
+  arguments?: string
+  turn?: number
+  step?: number
+}): TimelineEvent {
+  return { type: 'tool/call', seq, time: at(), data: { callId: opts.callId, name: opts.name, arguments: opts.arguments ?? '{}' } }
+}
+
+/** tool/result: the model-visible message rides data.message with the tool source. */
+export function toolResult(seq: number, opts: {
+  callId: string
+  content: ContentBlock[]
+  error?: boolean
+  /** Drop the durable source (a legacy/foreign envelope). */
+  noSource?: boolean
+  /** Drop the envelope callId (source carries it). */
+  noEnvelopeId?: boolean
+  time?: number
+}): TimelineEvent {
+  const message: Record = {
+    content: [{ type: 'tool-result', toolCallId: opts.callId, content: opts.content }],
+  }
+  if (opts.noSource !== true) message.source = { kind: 'tool', callId: opts.callId }
+  const data: Record = { message }
+  if (opts.noEnvelopeId !== true) data.callId = opts.callId
+  if (opts.error === true) data.error = true
+  return { type: 'tool/result', seq, time: at(opts.time), data, surfaceOp: 'append' }
+}
+
+export function assistantMessage(seq: number, opts: {
+  turn?: number
+  step?: number
+  content?: ContentBlock[]
+  usage?: { inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number }
+  time?: number
+  surfaceOp?: TimelineEvent['surfaceOp']
+}): TimelineEvent {
+  const data: Record = { message: { content: opts.content ?? [{ type: 'text', text: 'reply' }] } }
+  if (opts.turn !== undefined) data.turn = opts.turn
+  if (opts.step !== undefined) data.step = opts.step
+  if (opts.usage !== undefined) data.usage = opts.usage
+  return { type: 'assistant/message', seq, time: at(opts.time), data, surfaceOp: opts.surfaceOp ?? 'append' }
+}
+
+export function compaction(seq: number, kind: 'summary' | 'prune', data?: Record): TimelineEvent {
+  return { type: `compaction/${kind}`, seq, time: at(), ...(data === undefined ? {} : { data }) }
+}
+
+export function planMode(seq: number, data?: Record): TimelineEvent {
+  return { type: 'plan/mode', seq, time: at(), ...(data === undefined ? {} : { data }) }
+}
+
+/** An event the fold does not care about (chunk, todo, …). */
+export function foreign(seq: number, type = 'assistant/chunk'): TimelineEvent {
+  return { type, seq, time: at(), data: {} }
+}
diff --git a/tests/host/helpers/projection.ts b/tests/host/helpers/projection.ts
new file mode 100644
index 0000000..7cbb515
--- /dev/null
+++ b/tests/host/helpers/projection.ts
@@ -0,0 +1,84 @@
+// Fold drivers for the host specs: run event envelopes through the REAL
+// projection units the plugin registers (no harness plumbing — the units are
+// pure init/apply/view), with the framework's own serializer pinned on every
+// intermediate state.
+
+import assert from 'node:assert/strict'
+import { snapshotJsonValue, type SessionEvent } from '@deepseek-ai/dsh-session'
+import type { Config } from '../../../src/host/config'
+import type { TimelineEvent, TimelineState } from '../../../src/host/fold'
+import type { HeadersState } from '../../../src/host/headers'
+import type { ContextHeaders, ContextTimeline } from '../../../src/shared/types'
+import { createContextTimelineDefinition } from '../../../src/host/timeline'
+import { createContextHeadersDefinition } from '../../../src/host/headers'
+
+/**
+ * The fold's declared contract takes the core `SessionEvent` union; the log
+ * also carries declaration-merged plugin events (compaction/*), so the fold
+ * widens to TimelineEvent (src/host/fold.ts). These widened faces perform the
+ * ONE documented cast inside the helper, keeping every spec call site clean.
+ */
+export interface TimelineDefLike {
+  key: string
+  init(): TimelineState
+  apply(state: TimelineState, event: TimelineEvent): TimelineState
+  view(state: TimelineState): ContextTimeline
+}
+
+export interface HeadersDefLike {
+  key: string
+  init(): HeadersState
+  apply(state: HeadersState, event: TimelineEvent): HeadersState
+  view(state: HeadersState): ContextHeaders
+}
+
+export function timelineDef(config?: Config): TimelineDefLike {
+  return createContextTimelineDefinition(config ?? {}) as unknown as TimelineDefLike
+}
+
+export function headersDef(): HeadersDefLike {
+  return createContextHeadersDefinition() as unknown as HeadersDefLike
+}
+
+/**
+ * The projection-cache precondition: every state the fold produces must be
+ * losslessly JSON-serializable (a single undefined property fails EVERY cache
+ * write for the session — see TimelineState). Returns the detached copy.
+ */
+export function assertPlainJson(state: T): T {
+  const copy = snapshotJsonValue(state)
+  assert.ok(copy !== undefined, 'fold state must be losslessly JSON-serializable')
+  return copy
+}
+
+export interface TimelineDrive {
+  def: TimelineDefLike
+  state: TimelineState
+  /** Every intermediate state, including init (index = events folded). */
+  states: TimelineState[]
+  view: ContextTimeline
+}
+
+/** Fold the whole log through the timeline unit and serve the wire view. */
+export function driveTimeline(events: TimelineEvent[], config?: Config): TimelineDrive {
+  const def = timelineDef(config)
+  let state = def.init()
+  const states = [state]
+  for (const ev of events) {
+    state = def.apply(state, ev)
+    states.push(state)
+  }
+  return { def, state, states, view: def.view(state) }
+}
+
+/** Pin the plain-JSON precondition on every intermediate fold state. */
+export function assertStatesPlainJson(drive: TimelineDrive): void {
+  for (const state of drive.states) assertPlainJson(state)
+}
+
+/** Reference-stability probe: an uninteresting event must return the SAME state. */
+export function assertStable(state: TimelineState, event: TimelineEvent, def = timelineDef()): void {
+  assert.equal(def.apply(state, event), state, 'uninteresting events must return the same reference')
+}
+
+export type { SessionEvent }
diff --git a/tests/host/index.spec.ts b/tests/host/index.spec.ts
new file mode 100644
index 0000000..f21db0c
--- /dev/null
+++ b/tests/host/index.spec.ts
@@ -0,0 +1,135 @@
+// Integration tests for the Host-half plugin module (src/host/index.ts)
+// against the REAL cordis registry, session store, and session-projection
+// registry — the dsh-canonical harness: real envelopes appended to a real
+// session, folded by the registered units, read back through the snapshot
+// cut and the change feed.
+
+import assert from 'node:assert/strict'
+import { describe, test } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import SessionStore from '@deepseek-ai/dsh-session'
+import type { Session } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import { apply, inject, name } from '../../src/host/index'
+import { createContextTimelineDefinition } from '../../src/host/timeline'
+import type {} from '../../src/shared/types'
+
+const plugin = { name, inject, apply } as never
+const noConfig = {} as never
+
+/** Poll until the pending plugin fiber has started and folded the log. */
+async function until(read: () => T | undefined, message: string): Promise {
+  for (let i = 0; i < 200; i++) {
+    const value = read()
+    if (value !== undefined) return value
+    await new Promise(resolve => setTimeout(resolve, 1))
+  }
+  assert.fail(message)
+}
+
+/** The minimal real envelope trio: header epoch, user message, metered assistant reply. */
+function appendRealEnvelopes(session: Session): void {
+  session.append('request/header', {
+    header: {
+      config: { model: 'deepseek-v4-flash', provider: 'deepseek' },
+      system: 'sys',
+      tools: [],
+    },
+    reason: 'initial',
+  })
+  session.append('user/message', {
+    content: [{ type: 'text', text: 'hi' }],
+    source: { kind: 'user' },
+  } as never, { surfaceOp: 'append' })
+  session.append('assistant/message', {
+    turn: 1,
+    step: 0,
+    message: { content: [{ type: 'text', text: 'hello' }] },
+    usage: { inputTokens: 10, outputTokens: 5 },
+  } as never, { surfaceOp: 'append', sourceEventSeqs: [] })
+}
+
+async function boot() {
+  const ctx = new Context()
+  await ctx.plugin(SessionStore)
+  await ctx.plugin(SessionProjectionRegistry)
+  const fiber = await ctx.plugin(plugin, noConfig)
+  return { ctx, fiber }
+}
+
+describe('dsh-context host plugin', () => {
+  test('module shape: name and the sessionProjections inject gate', () => {
+    assert.equal(name, 'dsh-context')
+    assert.deepEqual(inject, ['sessionProjections'])
+  })
+
+  test('registers both units and serves the folded views over real appends', async () => {
+    const { ctx } = await boot()
+    const session = ctx.sessions.create()
+    appendRealEnvelopes(session)
+
+    const snapshot = ctx.sessionProjections.snapshot(session)
+    const timeline = snapshot.values.contextTimeline
+    assert.ok(timeline !== undefined, 'contextTimeline served after real appends')
+    assert.equal(timeline.ok, true)
+    assert.equal(timeline.model, 'deepseek-v4-flash')
+    assert.equal(timeline.provider, 'deepseek')
+    assert.ok(timeline.current.total > 0, 'the folded totals are non-zero')
+    assert.equal(timeline.nodes.length, 2, 'user + assistant surface nodes')
+
+    const headers = snapshot.values.contextHeaders
+    assert.ok(headers !== undefined, 'contextHeaders served after real appends')
+    assert.equal(headers.headers.length, 1)
+    assert.equal(headers.headers[0].system, 'sys')
+  })
+
+  test('the change feed fires with the schema-validated view', async () => {
+    const { ctx } = await boot()
+    const session = ctx.sessions.create()
+    const seen: Array<{ key: string; value: unknown }> = []
+    ctx.sessionProjections.onChanged((changedSession, key, value) => {
+      if (changedSession === session) seen.push({ key, value })
+    })
+    appendRealEnvelopes(session)
+
+    const timeline = seen.filter(e => e.key === 'contextTimeline')
+    assert.ok(timeline.length > 0, 'contextTimeline changes notified')
+    const last = timeline.at(-1)?.value
+    assert.equal(
+      createContextTimelineDefinition({}).schema.safeParse(last).success,
+      true,
+      'the notified value is the validated wire view',
+    )
+    assert.ok(seen.some(e => e.key === 'contextHeaders'), 'contextHeaders changes notified')
+  })
+
+  test('disposing the plugin fiber removes both keys from later snapshots', async () => {
+    const { ctx, fiber } = await boot()
+    const session = ctx.sessions.create()
+    appendRealEnvelopes(session)
+    assert.ok(ctx.sessionProjections.snapshot(session).values.contextTimeline !== undefined)
+
+    await fiber.dispose()
+    const snapshot = ctx.sessionProjections.snapshot(session)
+    assert.equal(snapshot.values.contextTimeline, undefined, 'an unloaded plugin reads as capability absence')
+    assert.equal(snapshot.values.contextHeaders, undefined)
+  })
+
+  test('stays pending without the registry, starts when it arrives', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    const fiber = ctx.plugin(plugin, noConfig)
+    const session = ctx.sessions.create()
+    assert.doesNotThrow(() => appendRealEnvelopes(session), 'an absent registry leaves the plugin inert')
+    assert.equal(ctx.get('sessionProjections'), undefined)
+    assert.notEqual((fiber as unknown as { state: number }).state, 2, 'the fiber is not ACTIVE without its inject')
+
+    await ctx.plugin(SessionProjectionRegistry)
+    const timeline = await until(
+      () => ctx.sessionProjections.snapshot(session).values.contextTimeline,
+      'the plugin never started after the registry arrived',
+    )
+    assert.equal(timeline.ok, true, 'the late-mounted registry folds the already-appended log')
+    await fiber
+  })
+})
diff --git a/tests/host/pricing.spec.ts b/tests/host/pricing.spec.ts
new file mode 100644
index 0000000..bdb8a02
--- /dev/null
+++ b/tests/host/pricing.spec.ts
@@ -0,0 +1,248 @@
+// Unit tests for the token-pricing heuristic (src/host/pricing.ts) — a mirror
+// of the harness token-meter's fixed-density estimator with the image-block
+// refinement. Pure functions: each case constructs a real payload and prices it.
+
+import assert from 'node:assert/strict'
+import { describe, test } from 'vitest'
+import {
+  estimateMessage,
+  estimateSystem,
+  estimateToolSchema,
+  estimateToolsTotal,
+  firstText,
+  imageCountOf,
+  injectionSourceName,
+  isInjection,
+  toolCallNames,
+} from '../../src/host/pricing'
+import type { ContentBlock } from '../../src/host/pricing'
+
+describe('estimateToolsTotal', () => {
+  test('empty tool list prices zero', () => {
+    assert.equal(estimateToolsTotal([]), 0)
+  })
+
+  test('non-empty list prices the whole-array JSON plus one block', () => {
+    const tools = [{ name: 'bash', description: 'run a command' }]
+    assert.equal(estimateToolsTotal(tools), Math.ceil(JSON.stringify(tools).length / 4) + 4)
+  })
+})
+
+describe('estimateMessage', () => {
+  test('text blocks price chars/4 plus block and role overhead', () => {
+    // 16 chars → 4, +4 block, +4 role
+    assert.equal(estimateMessage({ content: [{ type: 'text', text: '0123456789abcdef' }] }), 12)
+  })
+
+  test('reasoning blocks price like text', () => {
+    assert.equal(estimateMessage({ content: [{ type: 'reasoning', text: '0123456789abcdef' }] }), 12)
+  })
+
+  test('tool-call blocks price name plus arguments', () => {
+    // 'bash' → 1, '{}' → 1, +4 block, +4 role
+    assert.equal(estimateMessage({ content: [{ type: 'tool-call', name: 'bash', arguments: '{}' }] }), 10)
+  })
+
+  test('tool-call blocks tolerate missing name/arguments', () => {
+    assert.equal(estimateMessage({ content: [{ type: 'tool-call' }] }), 8)
+  })
+
+  test('tool-result blocks price their nested content recursively', () => {
+    assert.equal(
+      estimateMessage({ content: [{ type: 'tool-result', content: [{ type: 'text', text: 'ok' }] }] }),
+      // inner text: ceil(2/4)+4 = 5; wrapper: +4; role: +4
+      13,
+    )
+  })
+
+  test('unknown block types fall back to the generic JSON price', () => {
+    const block = { type: 'mystery', payload: { a: 1 } }
+    assert.equal(
+      estimateMessage({ content: [block] }),
+      4 + Math.ceil(JSON.stringify(block).length / 4) + 4,
+    )
+  })
+
+  test('image blocks with known dimensions price through the vision calculator', () => {
+    // 800×600 → 341 (docs-verified) +4 block +4 role
+    assert.equal(
+      estimateMessage({ content: [{ type: 'image', attachment: { width: 800, height: 600 } }] }),
+      341 + 8,
+    )
+  })
+
+  test('image blocks with a null attachment fall back to the JSON price', () => {
+    const block = { type: 'image', attachment: null }
+    assert.equal(estimateMessage({ content: [block] }), Math.ceil(JSON.stringify(block).length / 4) + 8)
+  })
+
+  test('image blocks with a non-object attachment fall back to the JSON price', () => {
+    const block = { type: 'image', attachment: 'ref' } as unknown as ContentBlock
+    assert.equal(estimateMessage({ content: [block] }), Math.ceil(JSON.stringify(block).length / 4) + 8)
+  })
+
+  test('image blocks missing one dimension fall back to the JSON price', () => {
+    const block = { type: 'image', attachment: { width: 800 } }
+    assert.equal(estimateMessage({ content: [block] }), Math.ceil(JSON.stringify(block).length / 4) + 8)
+  })
+
+  test('image blocks with non-finite dimensions price the calculator null through the JSON fallback', () => {
+    // typeof NaN === 'number' passes the guard; the calculator rejects non-finite input.
+    const block = { type: 'image', attachment: { width: NaN, height: 100 } }
+    assert.equal(estimateMessage({ content: [block] }), Math.ceil(JSON.stringify(block).length / 4) + 8)
+  })
+
+  test('message without an array content pays role framing only', () => {
+    assert.equal(estimateMessage({}), 4)
+    assert.equal(estimateMessage({ content: 'not-an-array' as never }), 4)
+  })
+
+  test('emptyIsZero short-circuits null/undefined/empty messages to zero', () => {
+    assert.equal(estimateMessage(null, true), 0)
+    assert.equal(estimateMessage(undefined, true), 0)
+    assert.equal(estimateMessage({}, true), 0)
+    assert.equal(estimateMessage({ content: [] }, true), 0)
+    assert.equal(estimateMessage({ content: 'x' as never }, true), 0)
+    // …but a non-empty message still prices normally under emptyIsZero.
+    assert.equal(estimateMessage({ content: [{ type: 'text', text: 'abcd' }] }, true), 9)
+  })
+
+  test('emptyIsZero=false prices a null message as role framing only', () => {
+    assert.equal(estimateMessage(null), 4)
+    assert.equal(estimateMessage(undefined), 4)
+  })
+})
+
+describe('estimateSystem', () => {
+  test('non-strings and the empty string price zero', () => {
+    assert.equal(estimateSystem(undefined), 0)
+    assert.equal(estimateSystem(null), 0)
+    assert.equal(estimateSystem(42), 0)
+    assert.equal(estimateSystem(''), 0)
+  })
+
+  test('text prices chars/4 plus role framing', () => {
+    assert.equal(estimateSystem('abcd'), 5)
+  })
+})
+
+describe('estimateToolSchema', () => {
+  test('prices the tool JSON plus one block', () => {
+    const tool = { name: 'bash' }
+    assert.equal(estimateToolSchema(tool), Math.ceil(JSON.stringify(tool).length / 4) + 4)
+  })
+})
+
+describe('imageCountOf', () => {
+  test('counts image blocks, recursing into nested content', () => {
+    assert.equal(imageCountOf([
+      { type: 'image' },
+      { type: 'tool-result', content: [{ type: 'image' }, { type: 'text', text: 'x' }] },
+      { type: 'text', text: 'y' },
+    ]), 2)
+  })
+
+  test('non-array input counts zero', () => {
+    assert.equal(imageCountOf(undefined), 0)
+    assert.equal(imageCountOf('x' as never), 0)
+  })
+})
+
+describe('firstText', () => {
+  test('returns the first non-blank text block, whitespace-normalized and truncated to 80', () => {
+    const long = 'word '.repeat(30) // 150 chars
+    const out = firstText([{ type: 'text', text: long }])
+    assert.equal(out.length, 80)
+    assert.ok(!out.includes('  '))
+  })
+
+  test('skips non-text blocks, non-string text, and whitespace-only text', () => {
+    assert.equal(firstText([
+      { type: 'image' },
+      { type: 'text' },
+      { type: 'text', text: '   \n\t ' },
+      { type: 'text', text: '  real   text ' },
+    ]), 'real text')
+  })
+
+  test('non-array input yields the empty string', () => {
+    assert.equal(firstText(undefined), '')
+    assert.equal(firstText(null as never), '')
+  })
+})
+
+describe('toolCallNames', () => {
+  test('collects tool-call block names in order', () => {
+    assert.deepEqual(toolCallNames([
+      { type: 'tool-call', name: 'bash' },
+      { type: 'text', text: 'x' },
+      { type: 'tool-call', name: 'read' },
+      { type: 'tool-call' }, // no name: skipped
+    ]), ['bash', 'read'])
+  })
+
+  test('non-array input yields an empty list', () => {
+    assert.deepEqual(toolCallNames(undefined), [])
+  })
+})
+
+describe('injectionSourceName', () => {
+  test('agent-instructions sources name their deduped change paths', () => {
+    assert.equal(injectionSourceName({
+      kind: 'agent-instructions',
+      changes: [{ path: 'AGENTS.md' }, { path: 'AGENTS.md' }, { path: 'docs/TEAM.md' }],
+    }), 'AGENTS.md, docs/TEAM.md')
+  })
+
+  test('agent-instructions skips null entries and empty paths', () => {
+    assert.equal(injectionSourceName({
+      kind: 'agent-instructions',
+      changes: [null, { path: '' }, { path: 'A.md' }],
+    }), 'A.md')
+  })
+
+  test('agent-instructions with no usable path falls through to kind', () => {
+    assert.equal(injectionSourceName({ kind: 'agent-instructions', changes: [null] }), 'agent-instructions')
+    assert.equal(injectionSourceName({ kind: 'agent-instructions', changes: [] }), 'agent-instructions')
+  })
+
+  test('agent-instructions without a changes array falls through to kind', () => {
+    assert.equal(injectionSourceName({ kind: 'agent-instructions' }), 'agent-instructions')
+  })
+
+  test('plugin sources name the plugin id', () => {
+    assert.equal(injectionSourceName({ kind: 'plugin', plugin: 'dsh-agent-presets' }), 'dsh-agent-presets')
+  })
+
+  test('an empty plugin id falls through to kind', () => {
+    assert.equal(injectionSourceName({ kind: 'plugin', plugin: '' }), 'plugin')
+  })
+
+  test('any other producer names its own kind; an unreadable source yields empty', () => {
+    assert.equal(injectionSourceName({ kind: 'goal' }), 'goal')
+    assert.equal(injectionSourceName({ kind: '' }), '')
+    assert.equal(injectionSourceName({}), '')
+  })
+})
+
+describe('isInjection', () => {
+  test('null and undefined sources are not injections', () => {
+    assert.equal(isInjection(null), false)
+    assert.equal(isInjection(undefined), false)
+  })
+
+  test('a plain user source is not an injection', () => {
+    assert.equal(isInjection({ kind: 'user' }), false)
+    assert.equal(isInjection({}), false)
+  })
+
+  test('any non-user kind is an injection, even with a form', () => {
+    assert.equal(isInjection({ kind: 'plugin', form: 'notice' }), true)
+    // 'user-rpc' keeps kind "user" in the log but a transport form must not flip the class…
+    assert.equal(isInjection({ kind: 'user', form: 'rpc' }), true) // form fallback, per the fold comment
+  })
+
+  test('a foreign source declaring only a form classifies as an injection', () => {
+    assert.equal(isInjection({ form: 'notice' }), true)
+  })
+})
diff --git a/tests/host/settings.spec.ts b/tests/host/settings.spec.ts
new file mode 100644
index 0000000..53ccf28
--- /dev/null
+++ b/tests/host/settings.spec.ts
@@ -0,0 +1,99 @@
+// Integration tests for the plugin settings namespace (src/host/settings.ts)
+// against the REAL cordis context and the REAL dsh-settings provider base —
+// the dsh-canonical harness pattern: an in-memory SettingsProvider subclass,
+// mounted as a plugin, with installSettings layering the namespace on top.
+
+import assert from 'node:assert/strict'
+import { describe, test } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import { SettingsProvider, settingsNamespace } from '@deepseek-ai/dsh-settings'
+import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
+import { installSettings, SETTINGS_NAMESPACE } from '../../src/host/settings'
+import type { PluginSettings } from '../../src/host/settings'
+
+/** A provider implementing only the two storage primitives; the Service Definition owns the rest. */
+class MemorySettings extends SettingsProvider {
+  doc: Record
+
+  constructor(ctx: ConstructorParameters[0], options?: { doc?: Record }) {
+    super(ctx)
+    this.doc = structuredClone(options?.doc ?? {})
+  }
+
+  get writable(): boolean {
+    return true
+  }
+
+  protected load(): Promise> {
+    return Promise.resolve(structuredClone(this.doc))
+  }
+
+  protected persist(ns: SettingsNamespace, section: Record): Promise {
+    this.doc[String(ns)] = structuredClone(section)
+    return Promise.resolve()
+  }
+}
+
+const ns = settingsNamespace(SETTINGS_NAMESPACE)
+
+/** Poll until the inject callback inside installSettings has registered the namespace. */
+async function untilRegistered(ctx: Context): Promise {
+  for (let i = 0; i < 200; i++) {
+    if (ctx.settings.get(ns) !== undefined) return
+    await new Promise(resolve => setTimeout(resolve, 1))
+  }
+  assert.fail('the dsh-context settings namespace was never registered')
+}
+
+async function boot(doc?: Record) {
+  const ctx = new Context()
+  await ctx.plugin(MemorySettings, doc === undefined ? undefined : { doc })
+  installSettings(ctx)
+  await untilRegistered(ctx)
+  return { ctx, provider: ctx.get('settings') as MemorySettings }
+}
+
+describe('installSettings', () => {
+  test('the namespace is the plugin short name', () => {
+    assert.equal(SETTINGS_NAMESPACE, 'dsh-context')
+  })
+
+  test('registers the dsh-context namespace with schema defaults', async () => {
+    const { ctx } = await boot()
+    const descriptors = ctx.settings.describe()
+    assert.ok(descriptors.some(d => String(d.ns) === 'dsh-context'), 'the namespace is registered')
+    assert.deepEqual(ctx.settings.get(ns), {
+      defaultGranularity: 'step',
+      defaultTrendMode: 'total',
+    }, 'schema defaults resolve')
+  })
+
+  test('updates flow through the real scope; invalid values reject', async () => {
+    const { ctx, provider } = await boot()
+    await ctx.settings.update(ns, { defaultGranularity: 'turn' })
+    assert.deepEqual(ctx.settings.get(ns), {
+      defaultGranularity: 'turn',
+      defaultTrendMode: 'total',
+    }, 'the update resolves over the schema defaults')
+    assert.deepEqual(provider.doc['dsh-context'], { defaultGranularity: 'turn' }, 'the provider persisted the section')
+
+    await assert.rejects(
+      ctx.settings.update(ns, { defaultGranularity: 'week' }),
+      'an unknown granularity fails validation before anything persists',
+    )
+    assert.deepEqual(provider.doc['dsh-context'], { defaultGranularity: 'turn' }, 'a rejected write never reaches storage')
+  })
+
+  test('a stale persisted trend mode degrades to the default (loose)', async () => {
+    const { ctx } = await boot({ 'dsh-context': { defaultTrendMode: 'net' } })
+    const value = ctx.settings.get(ns) as PluginSettings
+    assert.equal(value.defaultTrendMode, 'total', 'the stale value falls back instead of breaking the section')
+    assert.equal(value.defaultGranularity, 'step')
+  })
+
+  test('without a settings provider the install is inert', () => {
+    const ctx = new Context()
+    assert.doesNotThrow(() => installSettings(ctx))
+    assert.equal(ctx.get('settings'), undefined, 'no provider composed, nothing registered')
+  })
+})
diff --git a/tests/host/timeline.spec.ts b/tests/host/timeline.spec.ts
new file mode 100644
index 0000000..0a0d85b
--- /dev/null
+++ b/tests/host/timeline.spec.ts
@@ -0,0 +1,132 @@
+// Unit tests for the contextTimeline projection unit (src/host/timeline.ts) —
+// the dual-contract definition shape, its wire/state schemas, and the
+// config-resolved retention bounds. Fold semantics themselves live in
+// fold.ts; here we pin the definition's contract surface over a real folded
+// log built with the shared envelope builders.
+
+import assert from 'node:assert/strict'
+import { describe, test } from 'vitest'
+import { createContextTimelineDefinition } from '../../src/host/timeline'
+import type { ContextTimeline, SurfaceNode } from '../../src/shared/types'
+import { driveTimeline } from './helpers/projection'
+import {
+  assistantMessage,
+  compaction,
+  header,
+  planMode,
+  requestContext,
+  toolCall,
+  toolResult,
+  userMessage,
+} from './helpers/events'
+import type { TimelineEvent } from '../../src/host/fold'
+
+type Def = ReturnType
+
+/** The dual-contract fields ride the definition past its declared 0.1.0 return type. */
+function compat(def: Def) {
+  return def as unknown as {
+    stateSchema: { parse: (value: unknown) => unknown }
+    wire: {
+      viewSchema: { safeParse: (value: unknown) => { success: boolean } }
+      view: Def['view']
+    }
+  }
+}
+
+/** A realistic session log touching every envelope family the fold serves. */
+function realLog(): TimelineEvent[] {
+  return [
+    header(1, {
+      system: 'You are an agent.',
+      tools: [{ name: 'bash', description: 'run a command' }],
+      model: 'deepseek-v4-flash',
+      provider: 'deepseek',
+    }),
+    requestContext(2, { contextWindow: 128000 }),
+    userMessage(3, [{ type: 'text', text: 'hello there' }], { kind: 'user' }),
+    assistantMessage(4, { turn: 1, step: 0, usage: { inputTokens: 10, outputTokens: 5, cacheReadTokens: 3 } }),
+    toolCall(5, { callId: 'c1', name: 'bash' }),
+    toolResult(6, { callId: 'c1', content: [{ type: 'text', text: 'ok' }] }),
+    assistantMessage(7, { turn: 1, step: 1, usage: { inputTokens: 20, outputTokens: 8 } }),
+    compaction(8, 'summary', { shadowedTokenCount: 12, shadowedSeqs: [3] }),
+    planMode(9, { active: true }),
+  ]
+}
+
+describe('createContextTimelineDefinition', () => {
+  test('carries both projection contracts on one unit', () => {
+    const def = createContextTimelineDefinition({})
+    assert.equal(def.key, 'contextTimeline')
+    assert.equal(def.stateVersion, 9)
+    assert.equal(typeof def.init, 'function')
+    assert.equal(typeof def.apply, 'function')
+    // 0.1.0 contract: schema + top-level view.
+    assert.equal(typeof def.schema.safeParse, 'function')
+    assert.equal(typeof def.view, 'function')
+    // 0.1.1-rc.1+ contract: stateSchema + wire block sharing the view.
+    const c = compat(def)
+    assert.equal(typeof c.stateSchema.parse, 'function')
+    assert.equal(typeof c.wire.viewSchema.safeParse, 'function')
+    assert.equal(c.wire.view, def.view, 'the wire block shares the view function')
+  })
+
+  test('the wire schema accepts a real folded view; the state schema accepts every intermediate state', () => {
+    const def = createContextTimelineDefinition({})
+    const c = compat(def)
+    const drive = driveTimeline(realLog())
+    const parsed = def.schema.safeParse(drive.view)
+    assert.equal(parsed.success, true, 'a real folded view validates')
+
+    const view = drive.view
+    assert.equal(view.ok, true)
+    assert.equal(view.model, 'deepseek-v4-flash')
+    assert.equal(view.provider, 'deepseek')
+    assert.equal(view.contextWindow, 128000)
+    assert.ok(view.nodes.length > 0)
+    assert.ok(view.requests.length > 0)
+    assert.ok(view.events.length > 0)
+
+    for (const [index, state] of drive.states.entries()) {
+      c.stateSchema.parse(structuredClone(state)) // throws on drift
+      assert.ok(true, `state ${index} validates`)
+    }
+    assert.equal(c.wire.viewSchema.safeParse(c.wire.view(drive.state)).success, true)
+  })
+
+  test('the wire schema rejects drift', () => {
+    const def = createContextTimelineDefinition({})
+    const drive = driveTimeline(realLog())
+
+    const extra = structuredClone(drive.view) as unknown as Record
+    extra.bogus = 1
+    assert.equal(def.schema.safeParse(extra).success, false, 'strict: unknown keys rejected')
+
+    const badCat = structuredClone(drive.view)
+    ;(badCat.nodes[0] as SurfaceNode).cat = 'weird' as SurfaceNode['cat']
+    assert.equal(def.schema.safeParse(badCat).success, false, 'the cat enum rejects unknown categories')
+
+    const badTokens = structuredClone(drive.view)
+    badTokens.nodes[0].tokens = -1
+    assert.equal(def.schema.safeParse(badTokens).success, false, 'negative token counts rejected')
+  })
+
+  test('maxNodes bounds the served surface nodes', () => {
+    const events: TimelineEvent[] = [
+      header(1, { system: 's', model: 'm', provider: 'p' }),
+      userMessage(2, [{ type: 'text', text: 'one' }], { kind: 'user' }),
+      userMessage(3, [{ type: 'text', text: 'two' }], { kind: 'user' }),
+      userMessage(4, [{ type: 'text', text: 'three' }], { kind: 'user' }),
+      userMessage(5, [{ type: 'text', text: 'four' }], { kind: 'user' }),
+    ]
+    const bounded = driveTimeline(events, { maxNodes: 2 })
+    assert.equal(bounded.view.nodes.length, 2, 'only the newest tail is served')
+    assert.deepEqual(bounded.view.nodes.map(n => n.seq), [4, 5])
+    assert.equal(bounded.view.droppedNodes, 2)
+    assert.equal(bounded.view.surfaceFloor, 3, 'the newest unserved live seq')
+
+    const unbounded: ContextTimeline = driveTimeline(events).view
+    assert.equal(unbounded.nodes.length, 4, 'default bounds serve every live node')
+    assert.equal(unbounded.droppedNodes, 0)
+  })
+})
diff --git a/tests/shared/imageTokens.spec.ts b/tests/shared/imageTokens.spec.ts
new file mode 100644
index 0000000..9691bef
--- /dev/null
+++ b/tests/shared/imageTokens.spec.ts
@@ -0,0 +1,68 @@
+// The official DeepSeek vision image-token calculator port
+// (src/shared/imageTokens.ts). Reference values verified against the docs
+// calculator itself (see the module header); the pixel sweep pins the
+// documented 117–384 band and the null contract for degenerate input.
+
+import assert from 'node:assert/strict'
+import { describe, test } from 'vitest'
+import { estimateImageTokens } from '../../src/shared/imageTokens'
+
+describe('estimateImageTokens', () => {
+  // Verified against https://api-docs.deepseek.com/zh-cn/quick_start/token_usage.
+  test.each([
+    [2048, 1365, 313],
+    [800, 600, 341],
+    [2048, 2048, 349],
+    [512, 512, 201],
+    [100, 100, 117],
+    [1920, 1080, 369],
+    [400, 900, 249],
+  ])('%i×%i → %i tokens (docs calculator reference)', (w, h, expected) => {
+    assert.equal(estimateImageTokens(w, h), expected)
+  })
+
+  test('every valid size lands inside the documented cap band', () => {
+    // The documented band is 117–384; aspect ratios at the 8:1 clamp price
+    // slightly under it (128×16 → 113) — same patch grid as the docs
+    // calculator, so pin the observed floor rather than the marketing one.
+    for (let w = 16; w <= 4096; w *= 2) {
+      for (let h = 16; h <= 4096; h *= 2) {
+        const tokens = estimateImageTokens(w, h)
+        assert.ok(tokens !== null && tokens >= 113 && tokens <= 384, `${w}×${h} → ${tokens}`)
+      }
+    }
+  })
+
+  test('extreme aspect ratios clamp to the 8:1 band before patching', () => {
+    assert.ok(estimateImageTokens(8000, 1000) !== null)
+    assert.ok(estimateImageTokens(1000, 8000) !== null)
+  })
+
+  test('a tall strip beyond the ~190:1 grid threshold solves on the collapsed-width arm', () => {
+    // The padded grid overflows the budget (860 tokens) and ratio 214 >
+    // 189.5, so the solve collapses width to one patch unit (gridW < 1 arm).
+    assert.equal(estimateImageTokens(50, 12000), 381)
+  })
+
+  test('a first-solve overflow walks the budget down until the grid fits', () => {
+    // Parity terms can push the solved grid a token or two over budget; the
+    // loop decrements until it fits (found by fuzz: 360×3243 exercises it).
+    assert.equal(estimateImageTokens(360, 3243), 281)
+  })
+
+  test('non-positive or non-finite dimensions return null', () => {
+    assert.equal(estimateImageTokens(0, 100), null)
+    assert.equal(estimateImageTokens(100, 0), null)
+    assert.equal(estimateImageTokens(-5, 100), null)
+    assert.equal(estimateImageTokens(100, -5), null)
+    assert.equal(estimateImageTokens(NaN, 100), null)
+    assert.equal(estimateImageTokens(100, Infinity), null)
+  })
+
+  test('astronomical (finite) dimensions fail to converge and return null instead of throwing', () => {
+    // w×h overflows to Infinity: the fixed-point iteration never stabilizes,
+    // so the 9-iteration cap narrows to null (untrusted-log defense).
+    assert.equal(estimateImageTokens(Number.MAX_VALUE, Number.MAX_VALUE), null)
+    assert.equal(estimateImageTokens(1e308, 1e308), null)
+  })
+})
diff --git a/tsconfig.tests.json b/tsconfig.tests.json
new file mode 100644
index 0000000..91c9bf1
--- /dev/null
+++ b/tsconfig.tests.json
@@ -0,0 +1,8 @@
+{
+  "extends": "./tsconfig.json",
+  "compilerOptions": {
+    // Tests import node:assert and vitest; the app code stays on types: [].
+    "types": ["node"]
+  },
+  "include": ["tests/host/**/*.ts", "tests/shared/**/*.ts", "tests/client/**/*.ts", "src/client/globals.d.ts", "vitest.config.ts"]
+}
diff --git a/vitest.config.ts b/vitest.config.ts
index bfe252f..d8d93d3 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,19 +1,50 @@
 import { defineConfig } from 'vitest/config'
 
+// Specs import src/ directly (never the built lib/ artifacts), so the suite
+// runs with no build step and coverage reflects exactly what ships. Two
+// projects split by runtime: host/shared run in plain node, client specs in
+// jsdom (React 18 + the real @deepseek-ai/dsh-client-ui-primitives, inlined
+// so vite resolves its CSS-module imports). Files are small, single-module,
+// and stateless — vitest forks them across workers in parallel.
 export default defineConfig({
   test: {
-    // Specs exercise the built lib/ artifacts — `pnpm run build` first; *.client.spec.ts is the jsdom browser lane.
-    include: ['tests/**/*.spec.ts'],
-    // Scope: src only — `lib/` is the tsdown build artifact and never carries its own executable surface to cover.
-    // Exclusions are pure type declarations with no runtime to instrument (v8 reports 0% and skews the summary).
+    projects: [
+      {
+        test: {
+          name: 'host',
+          include: ['tests/host/**/*.spec.ts', 'tests/shared/**/*.spec.ts'],
+          environment: 'node',
+        },
+      },
+      {
+        // The inlined primitives dist references a sourcemap it does not
+        // ship; vite's warning is noise, so the client lane logs errors only.
+        logLevel: 'error',
+        test: {
+          name: 'client',
+          include: ['tests/client/**/*.spec.ts'],
+          environment: 'jsdom',
+          setupFiles: ['tests/client/setup.ts'],
+          server: {
+            deps: {
+              // The primitives dist imports .module.css; inlining lets vite
+              // transform it (externalized Node ESM would reject the .css).
+              inline: ['@deepseek-ai/dsh-client-ui-primitives'],
+            },
+          },
+        },
+      },
+    ],
     coverage: {
       provider: 'v8',
       include: ['src/**/*.{ts,tsx}'],
       exclude: [
         'src/**/*.d.ts',
+        // Pure type declarations: no runtime surface to instrument.
         'src/shared/types.ts',
         'src/host/compat.ts',
       ],
+      thresholds: { perFile: true, statements: 100, branches: 100, functions: 100, lines: 100 },
       reporter: ['text', 'html'],
       reportsDirectory: './coverage',
     },