test: rebuild the suite at source level with 100% per-file coverage gates

The old suite exercised the built lib/ bundle, so v8 remapped almost
nothing (8% src coverage) and every run needed a build first. The new
suite imports src/ directly under two vitest projects (node for
host/shared, jsdom for client), rendering real React 18 trees against
the real dictionaries and ui-primitives, with faithful in-memory
harness faces (cordis inject/effect, locale chain, slots, sessions
provide) instead of plugin mocks.

- 43 fine-grained, stateless, single-module spec files (606 tests)
  forked across workers: ~2.5s wall, no build step
- v8 thresholds at 100/100/100/100 per file over src/**
- host specs drive the real projection definitions (init/apply/view)
  plus one full cordis + SessionStore + SessionProjectionRegistry
  integration; the framework's snapshotJsonValue pins the
  projection-cache precondition on every intermediate fold state
- oxlint's src ruleset now covers tests/** as well
- typecheck covers the tests via tsconfig.tests.json
This commit is contained in:
Bowen Liang committed 2026-08-26 00:58:39 +08:00
1 parent f45bd7e8d2
commit f106b720ac
53 files changed
+9391 -14

No files matched your search

+122 -1
View File
@@ -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"
}
}
]
}
}
+4 -1
View File
@@ -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",
+37 -8
View File
@@ -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:
+138
View File
@@ -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> = {}): 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> = {}): 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)
})
})
+161
View File
@@ -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> = {}): SurfaceNode {
return { seq, cat: 'user', tokens: 1, ...over }
}
function req(seq: number, over: Partial<RequestRecord> = {}): 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)
})
})
+125
View File
@@ -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> = {}): 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'])
})
})
+122
View File
@@ -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']> = {}): Snapshot['current'] {
return { system: 0, tools: 0, user: 0, inject: 0, assistant: 0, tool: 0, total: 0, ...over }
}
function values(parts: PartsPart[]): Record<string, number> {
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])
})
})
+126
View File
@@ -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<InputTriggersFace['registerSource']>[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)
})
})
File diff suppressed because it is too large. Load diff
@@ -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<string, unknown> = {}): 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<string, unknown> = {
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()
})
})
+567
View File
@@ -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<string, unknown> = {}): 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<string, unknown> = {}): 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<string, unknown> = {}) {
const projections: Record<string, unknown> = { 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<HTMLSelectElement>(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<HTMLSelectElement>(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()
})
})
@@ -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> = {}): 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()
})
})
@@ -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<typeof vi.spyOn> | 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()
})
})
+162
View File
@@ -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<ContextEventRecord> & { 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()
})
})
+277
View File
@@ -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<void> {
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<HTMLImageElement>(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<string>(() => {})
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<HTMLImageElement>(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<string>((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<string>((_, 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<HTMLImageElement>(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 <body> 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<string>(() => {}) }))
assert.equal(queryAll(document.body, '.lc-att-lightbox').length, 0)
await m.unmount()
})
})
+75
View File
@@ -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()
})
})
+101
View File
@@ -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<ReturnType<typeof makePluginInfoFn>> {
vi.resetModules()
if (version === undefined) delete (globalThis as Record<string, unknown>).__DSH_CTX_VERSION__
else (globalThis as Record<string, unknown>).__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<string, unknown>).__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<FetchBody> }) => 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)
})
})
@@ -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>): 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<SurfaceNode> & { 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<HTMLElement>(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<HTMLElement>(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<number, ConversationNodeLike> = {
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<ReturnType<typeof render>>, 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()
})
})
+79
View File
@@ -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 <pre>; 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 <pre>', 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()
})
})
@@ -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 <T,>(sel: (s: SettingsState) => T): T => sel(state)
}
function stateOf(partial: Partial<SettingsState> = {}): 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<HTMLButtonElement>(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<HTMLButtonElement>(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()
})
})
+245
View File
@@ -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<string | null>(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()
})
})
+104
View File
@@ -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()
})
})
+663
View File
@@ -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<string, unknown>)[name]
else Object.defineProperty(HTMLElement.prototype, name, desc)
}
})
function req(seq: number, over: Partial<RequestRecord> = {}): 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<TrendChartProps, 'onSelect' | 'onHover' | 'onHoverTurn' | 'onPickTurn' | 'onFocusTurnHandled'> } {
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> = {}): 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<void> {
await act(async () => { el.scrollLeft = v })
await flush()
}
async function scrollEvent(el: Element): Promise<void> {
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<LayoutEl>(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<LayoutEl>(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<LayoutEl>(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)
})
})
+114
View File
@@ -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')
})
})
+69
View File
@@ -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}$/)
})
})
+134
View File
@@ -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> = {}): 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> = {}): 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],
])
})
})
+147
View File
@@ -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<string, unknown>) => 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<string, unknown>) => 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<string, Record<string, Record<string, string>>>()
constructor(readonly active = 'en') {}
register(ns: string, dicts: Record<string, Record<string, string>>): () => void {
this.namespaces.set(ns, dicts)
return () => {
this.namespaces.delete(ns)
}
}
bind(ns: string): (key: string, params?: Record<string, string | number>) => 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<string, unknown>
}
/**
* 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<string, unknown>()
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<string, unknown>, 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
},
}
}
}
+114
View File
@@ -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<string, Record<string, string>>): 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<void>
unmount(): Promise<void>
}
/** Mount a real React tree in jsdom, effects flushed through act. */
export async function mount(el: ReactElement): Promise<Mounted> {
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<void> {
await act(async () => {})
}
export function text(container: HTMLElement): string {
return container.textContent ?? ''
}
export function query<T extends Element = HTMLElement>(container: ParentNode, selector: string): T {
const el = container.querySelector<T>(selector)
if (el === null) throw new Error(`element not found: ${selector}`)
return el
}
export function queryAll<T extends Element = HTMLElement>(container: ParentNode, selector: string): T[] {
return [...container.querySelectorAll<T>(selector)]
}
/** Click through act so React processes the dispatch synchronously. */
export async function click(el: HTMLElement): Promise<void> {
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<void> {
await act(async () => {
el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }))
})
}
export async function unhover(el: Element): Promise<void> {
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<void> {
await act(async () => {
target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
})
}
export type { ReactElement, ReactNode }
+39
View File
@@ -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)
}
})
})
+327
View File
@@ -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<void>
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<typeof makeScope>
registration: { name: string; key?: string; locale?: string; inject?: () => unknown }
component: (props: Record<string, unknown>) => 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: <T,>(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)
})
})
+112
View File
@@ -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<typeof import('../../src/client/latestVersion')> {
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)
})
})
+50
View File
@@ -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<string, unknown>
afterEach(() => {
delete globals.__DSH_CTX_VERSION__
delete globals.__DSH_CTX_REPO__
vi.resetModules()
})
async function importMeta(): Promise<typeof import('../../src/client/meta')> {
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')
})
})
+72
View File
@@ -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)
})
})
+31
View File
@@ -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<string, unknown>
}
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')
})
})
+238
View File
@@ -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)
})
})
+215
View File
@@ -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<void> {
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')
})
})
+9
View File
@@ -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 = () => {}
}
+39
View File
@@ -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)
})
})
+42
View File
@@ -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 }))
})
})
+512
View File
@@ -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(`<skill_content name="${name}">instructions</skill_content>`)
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 })
})
})
+159
View File
@@ -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')
})
})
+298
View File
@@ -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))
})
})
+292
View File
@@ -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<SessionCostUsage>(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<SessionCostUsage>(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<SessionCostUsage | undefined>(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)
})
})
+158
View File
@@ -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<typeof createContextHeadersDefinition>
/** 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)
})
})
+104
View File
@@ -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<string, unknown> = { 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<string, unknown>): 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<string, unknown> = { 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<string, unknown> = {
content: [{ type: 'tool-result', toolCallId: opts.callId, content: opts.content }],
}
if (opts.noSource !== true) message.source = { kind: 'tool', callId: opts.callId }
const data: Record<string, unknown> = { 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<string, unknown> = { 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<string, unknown>): TimelineEvent {
return { type: `compaction/${kind}`, seq, time: at(), ...(data === undefined ? {} : { data }) }
}
export function planMode(seq: number, data?: Record<string, unknown>): 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: {} }
}
+84
View File
@@ -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<T>(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 }
+135
View File
@@ -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<T>(read: () => T | undefined, message: string): Promise<T> {
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
})
})
+248
View File
@@ -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)
})
})
+99
View File
@@ -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<string, unknown>
constructor(ctx: ConstructorParameters<typeof SettingsProvider>[0], options?: { doc?: Record<string, unknown> }) {
super(ctx)
this.doc = structuredClone(options?.doc ?? {})
}
get writable(): boolean {
return true
}
protected load(): Promise<Record<string, unknown>> {
return Promise.resolve(structuredClone(this.doc))
}
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
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<void> {
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<string, unknown>) {
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')
})
})
+132
View File
@@ -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<typeof createContextTimelineDefinition>
/** 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<string, unknown>
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)
})
})
+68
View File
@@ -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)
})
})
+8
View File
@@ -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"]
}
+35 -4
View File
@@ -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',
},