mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Minimal Electron shell over the DSH JSON-RPC runtime — a first-look at what a ChatGPT.app-style host on top of the DeepSeek Harness looks like, with the harness's normally-invisible internals (trace timeline, context surface, subagent tree, compaction, plugin registry, rubrics) brought forward as first-class UI surfaces so plugin authors and researchers can see what the agent is actually doing. Runs against three keyless-to-live profiles (stdio-echo works on master out of the box; daemon-echo / daemon-vibe-echo activate once the daemon-demo lands; stdio-deepseek and daemon-vibe hit the real DeepSeek API when you supply a key). HARNESS_DEV auto-resolves to the in-repo runtime when this shell ships under examples/desktop/, so a fresh clone launches without config; env DSH_DEV_ROOT overrides for custom layouts, and a sibling deepseek-harness-dev/ checkout is the original dev workflow. Cold-clone gate (P0 fixes for first-time-clone usability): - HARNESS_DEV: 3-candidate resolver (env → walk-up in-repo marker → sibling), unit-tested via mock fs so ordering is locked without needing either real layout on disk. - config yml leaves rewritten at assemble time so the sibling-clone paths (../../deepseek-harness-dev/examples/echo-agent/…) become the in-repo paths (../../echo-agent/…) in the released tree — source yml stays usable for local dev, released tree ships a working shape. - pnpm-workspace.yaml allowBuilds.electron = true (was placeholder). - missing-key card in stdio-deepseek offers a one-click switch to stdio-echo (the keyless profile that works on master) rather than daemon-echo (blocked on the not-yet-shipped daemon-demo). - assemble-oss-release.sh rewrites the source-side breadcrumb name 'dsh-desktop-demo' → 'dsh-desktop' for the released package.json. FOUC guard on the onboarding gate (41fc5df carried) keeps the first-launch splash from flashing before the runtime probe finishes. Test suite (1634 tests in source, 3990 in the runtime repo) covers resolver ordering, renderer classifiers, trace timeline shape, compaction diff rendering, rubric parity, and the missing-key onboarding paths.
272 lines
11 KiB
JavaScript
272 lines
11 KiB
JavaScript
// Tests for the task #157 L1 helpers on trace-aggregator.js:
|
|
// usage extraction / badge formatting / step + session sums, header
|
|
// config/tools/messagePrefix field surfaces, request/header-delta preview.
|
|
//
|
|
// Fixture discipline (memory/multi-agent-shared-repo-rules.md #4): the
|
|
// 1.1-trace-full.json fixture mirrors the real wire shape emitted by the
|
|
// daemon — assistant/message carries `data.usage` with the five llm/src/
|
|
// types.ts:90 keys (inputTokens/outputTokens/cacheReadTokens/cacheWrite
|
|
// Tokens/reasoningTokens); request/header carries `data.header` with
|
|
// `config` (model + sampling scalars), `system` (rendered system prompt
|
|
// text), `tools` (array of ToolDefinition {name,description,parameters}),
|
|
// and `messagePrefix` (rendered message history). If this fixture drifts
|
|
// from the daemon wire shape the L1 pane loses fidelity in the real app
|
|
// — same failure mode #157 is chasing.
|
|
|
|
'use strict'
|
|
|
|
const test = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
const fs = require('node:fs')
|
|
const path = require('node:path')
|
|
|
|
const TA = require('../src/renderer/trace-aggregator.js')
|
|
|
|
function loadFull() {
|
|
const p = path.join(__dirname, '..', 'fixtures', 'trace-samples', '1.1-trace-full.json')
|
|
return JSON.parse(fs.readFileSync(p, 'utf8'))
|
|
}
|
|
|
|
// --- usageFromMessage -------------------------------------------------------
|
|
|
|
test('usageFromMessage extracts every wire key, absent fields become null', () => {
|
|
const u = TA.usageFromMessage({
|
|
type: 'assistant/message',
|
|
data: { usage: { inputTokens: 100, outputTokens: 20 } },
|
|
})
|
|
assert.deepEqual(u, {
|
|
inputTokens: 100, outputTokens: 20,
|
|
cacheReadTokens: null, cacheWriteTokens: null, reasoningTokens: null,
|
|
})
|
|
})
|
|
|
|
test('usageFromMessage returns null when data.usage is missing', () => {
|
|
assert.equal(TA.usageFromMessage({ type: 'assistant/message', data: { content: [] } }), null)
|
|
assert.equal(TA.usageFromMessage({ type: 'assistant/message' }), null)
|
|
assert.equal(TA.usageFromMessage(null), null)
|
|
})
|
|
|
|
test('usageFromMessage keeps zero values (not treated as absent)', () => {
|
|
const u = TA.usageFromMessage({
|
|
type: 'assistant/message',
|
|
data: { usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 } },
|
|
})
|
|
assert.equal(u.inputTokens, 0)
|
|
assert.equal(u.outputTokens, 0)
|
|
assert.equal(u.cacheReadTokens, 0)
|
|
assert.equal(u.cacheWriteTokens, null)
|
|
})
|
|
|
|
test('USAGE_KEYS enumerates the five wire fields in the documented order', () => {
|
|
assert.deepEqual(TA.USAGE_KEYS, [
|
|
'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens',
|
|
])
|
|
})
|
|
|
|
// --- usageBadgeText ---------------------------------------------------------
|
|
|
|
test('usageBadgeText formats the five fields with typographic marks (no color emoji)', () => {
|
|
const s = TA.usageBadgeText({
|
|
inputTokens: 842, outputTokens: 126, cacheReadTokens: 3120,
|
|
cacheWriteTokens: 48, reasoningTokens: 15,
|
|
})
|
|
// 3120 → 3.1k, 842 → 842, 126 → 126, reasoning shows word not 🧠 (task #158 emoji-ban fix).
|
|
assert.match(s, /↑842/)
|
|
assert.match(s, /↓126/)
|
|
assert.match(s, /cache 3\.1k/)
|
|
assert.match(s, /reasoning 15/)
|
|
})
|
|
|
|
test('usageBadgeText skips null and zero cacheRead so bare wires stay compact', () => {
|
|
const s = TA.usageBadgeText({
|
|
inputTokens: 100, outputTokens: 20,
|
|
cacheReadTokens: null, cacheWriteTokens: null, reasoningTokens: null,
|
|
})
|
|
assert.equal(s, '↑100 ↓20')
|
|
const s2 = TA.usageBadgeText({
|
|
inputTokens: 100, outputTokens: 20,
|
|
cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0,
|
|
})
|
|
assert.equal(s2, '↑100 ↓20', 'zero cacheRead/reasoning are hidden from L0 badge')
|
|
})
|
|
|
|
test('usageBadgeText returns empty string when usage is null / empty', () => {
|
|
assert.equal(TA.usageBadgeText(null), '')
|
|
assert.equal(TA.usageBadgeText(undefined), '')
|
|
assert.equal(TA.usageBadgeText({}), '')
|
|
})
|
|
|
|
// --- formatK ----------------------------------------------------------------
|
|
|
|
test('formatK renders <1k as literal, 1-10k as N.Nk, >=10k as Nk, >=1M as N.NM', () => {
|
|
assert.equal(TA.formatK(0), '0')
|
|
assert.equal(TA.formatK(999), '999')
|
|
assert.equal(TA.formatK(1000), '1.0k')
|
|
assert.equal(TA.formatK(1234), '1.2k')
|
|
assert.equal(TA.formatK(9999), '10.0k')
|
|
assert.equal(TA.formatK(12345), '12k')
|
|
assert.equal(TA.formatK(1_500_000), '1.5M')
|
|
assert.equal(TA.formatK('nope'), '')
|
|
assert.equal(TA.formatK(NaN), '')
|
|
})
|
|
|
|
// --- addUsage / sumUsageForStep / sumUsageForSession -----------------------
|
|
|
|
test('addUsage sums fields, nulls stay null when both operands lack the key', () => {
|
|
const a = { inputTokens: 10, outputTokens: 5,
|
|
cacheReadTokens: null, cacheWriteTokens: null, reasoningTokens: null }
|
|
const b = { inputTokens: 20, outputTokens: 8,
|
|
cacheReadTokens: 30, cacheWriteTokens: null, reasoningTokens: null }
|
|
const r = TA.addUsage(a, b)
|
|
assert.equal(r.inputTokens, 30)
|
|
assert.equal(r.outputTokens, 13)
|
|
assert.equal(r.cacheReadTokens, 30) // one operand null, one 30 → 30
|
|
assert.equal(r.cacheWriteTokens, null) // both null → null
|
|
assert.equal(r.reasoningTokens, null)
|
|
})
|
|
|
|
test('sumUsageForStep sums usage across every assistant/message in the step outputs', () => {
|
|
const step = {
|
|
outputs: [
|
|
{ type: 'assistant/message', data: { usage: { inputTokens: 100, outputTokens: 20 } } },
|
|
{ type: 'tool/call', data: { name: 'read' } }, // ignored
|
|
{ type: 'assistant/message', data: { usage: { inputTokens: 50, outputTokens: 10, cacheReadTokens: 500 } } },
|
|
],
|
|
}
|
|
const u = TA.sumUsageForStep(step)
|
|
assert.equal(u.inputTokens, 150)
|
|
assert.equal(u.outputTokens, 30)
|
|
assert.equal(u.cacheReadTokens, 500)
|
|
assert.equal(u.cacheWriteTokens, null)
|
|
})
|
|
|
|
test('sumUsageForStep returns null when the step produced no assistant/message', () => {
|
|
assert.equal(TA.sumUsageForStep({ outputs: [{ type: 'tool/call' }] }), null)
|
|
assert.equal(TA.sumUsageForStep({ outputs: [] }), null)
|
|
assert.equal(TA.sumUsageForStep(null), null)
|
|
})
|
|
|
|
test('sumUsageForSession rolls up all steps in the trace', () => {
|
|
const events = loadFull()
|
|
const steps = TA.aggregateSteps(events)
|
|
assert.equal(steps.length, 2, 'fixture has two steps')
|
|
const total = TA.sumUsageForSession(steps)
|
|
// step 0: input=842, output=126, cacheRead=3120, cacheWrite=48
|
|
// step 1: input=112, output=22
|
|
assert.equal(total.inputTokens, 842 + 112)
|
|
assert.equal(total.outputTokens, 126 + 22)
|
|
assert.equal(total.cacheReadTokens, 3120)
|
|
assert.equal(total.cacheWriteTokens, 48)
|
|
})
|
|
|
|
// --- headerConfigFields -----------------------------------------------------
|
|
|
|
test('headerConfigFields lists model then every sampling scalar the wire ships', () => {
|
|
const events = loadFull()
|
|
const header = events.find((e) => e.type === 'request/header').data.header
|
|
const fields = TA.headerConfigFields(header)
|
|
const keys = fields.map((f) => f.key)
|
|
assert.equal(keys[0], 'model', 'model comes first')
|
|
// priority ordering: temperature/topP/topK/maxTokens/seed/... before stopSequences
|
|
const idxTemp = keys.indexOf('temperature')
|
|
const idxTopP = keys.indexOf('topP')
|
|
const idxStops = keys.indexOf('stopSequences')
|
|
assert.ok(idxTemp !== -1 && idxTopP !== -1 && idxStops !== -1)
|
|
assert.ok(idxTemp < idxTopP)
|
|
assert.ok(idxTopP < idxStops)
|
|
// zero-discard: seed=null is still emitted with value null (not filtered)
|
|
const seed = fields.find((f) => f.key === 'seed')
|
|
assert.ok(seed, 'seed field surfaces even when its value is null')
|
|
assert.equal(seed.value, null)
|
|
})
|
|
|
|
test('headerConfigFields tolerates missing config / non-object header', () => {
|
|
assert.deepEqual(TA.headerConfigFields(null), [])
|
|
assert.deepEqual(TA.headerConfigFields({}), [])
|
|
assert.deepEqual(TA.headerConfigFields({ model: 'foo' }), [{ key: 'model', value: 'foo' }])
|
|
})
|
|
|
|
// --- headerToolSummaries ----------------------------------------------------
|
|
|
|
test('headerToolSummaries returns {name, description, parameters, raw} per tool', () => {
|
|
const events = loadFull()
|
|
const header = events.find((e) => e.type === 'request/header').data.header
|
|
const tools = TA.headerToolSummaries(header)
|
|
assert.equal(tools.length, 3)
|
|
assert.equal(tools[0].name, 'read')
|
|
assert.match(tools[0].description, /^Read a file/)
|
|
assert.equal(typeof tools[0].parameters, 'object')
|
|
assert.equal(tools[0].parameters.type, 'object')
|
|
assert.equal(tools[0].raw, header.tools[0], 'raw preserves the full wire object')
|
|
})
|
|
|
|
test('headerToolSummaries gives a stable shape when a wire tool lacks fields', () => {
|
|
const tools = TA.headerToolSummaries({ tools: [{ name: 'x' }, { description: 'anon' }] })
|
|
assert.equal(tools[0].name, 'x')
|
|
assert.equal(tools[0].description, '')
|
|
assert.equal(tools[1].name, '(unnamed)')
|
|
assert.equal(tools[1].description, 'anon')
|
|
})
|
|
|
|
// --- headerDeltaPreview + previewForEvent ---------------------------------
|
|
|
|
test('headerDeltaPreview lists the delta keys with the reason', () => {
|
|
const events = loadFull()
|
|
const delta = events.find((e) => e.type === 'request/header-delta').data
|
|
const s = TA.headerDeltaPreview(delta)
|
|
assert.match(s, /delta\{messagePrefix, config\}/)
|
|
assert.match(s, /post-tool/)
|
|
})
|
|
|
|
test('previewForEvent recognises request/header-delta type', () => {
|
|
const events = loadFull()
|
|
const delta = events.find((e) => e.type === 'request/header-delta')
|
|
const s = TA.previewForEvent(delta)
|
|
assert.match(s, /delta\{/)
|
|
})
|
|
|
|
test('previewForEvent on assistant/message appends the usage badge', () => {
|
|
const events = loadFull()
|
|
const msg = events.find((e) => e.type === 'assistant/message' && e.data.usage.cacheReadTokens === 3120)
|
|
const s = TA.previewForEvent(msg)
|
|
// text body then usage badge, joined by ' · '
|
|
assert.match(s, /↑842/)
|
|
assert.match(s, /↓126/)
|
|
assert.match(s, /cache 3\.1k/)
|
|
})
|
|
|
|
test('previewForEvent falls back to badge-only when the message body is empty', () => {
|
|
const s = TA.previewForEvent({
|
|
type: 'assistant/message',
|
|
data: { content: [], usage: { inputTokens: 50, outputTokens: 10 } },
|
|
})
|
|
assert.equal(s, '↑50 ↓10')
|
|
})
|
|
|
|
// --- stepMetaFields --------------------------------------------------------
|
|
|
|
test('stepMetaFields emits all five keys even when values are null (zero-discard)', () => {
|
|
const rec = { turn: null, step: 0, startSeq: 201, endSeq: 212, durationMs: null }
|
|
const f = TA.stepMetaFields(rec)
|
|
assert.deepEqual(f.map((x) => x.key), ['turn', 'step', 'startSeq', 'endSeq', 'durationMs'])
|
|
assert.equal(f[0].value, null)
|
|
assert.equal(f[4].value, null)
|
|
})
|
|
|
|
// --- fixture end-to-end sanity ---------------------------------------------
|
|
|
|
test('1.1-trace-full.json has two steps producing assistant/messages with usage', () => {
|
|
const events = loadFull()
|
|
const steps = TA.aggregateSteps(events)
|
|
assert.equal(steps.length, 2)
|
|
const u0 = TA.sumUsageForStep(steps[0])
|
|
assert.equal(u0.inputTokens, 842)
|
|
assert.equal(u0.outputTokens, 126)
|
|
const u1 = TA.sumUsageForStep(steps[1])
|
|
assert.equal(u1.inputTokens, 112)
|
|
assert.equal(u1.outputTokens, 22)
|
|
// Step 1's usage lacks cache/reasoning, so those must be null on the sum.
|
|
assert.equal(u1.cacheReadTokens, null)
|
|
assert.equal(u1.reasoningTokens, null)
|
|
})
|