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:
1 parent
f45bd7e8d2
commit
f106b720ac
53 files changed
+9391
-14
No files matched your search
+122
-1
@@ -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
@@ -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",
|
||||
|
||||
Generated
+37
-8
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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}$/)
|
||||
})
|
||||
})
|
||||
@@ -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],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 = () => {}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }))
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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: {} }
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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',
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user