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.
338 lines
12 KiB
JavaScript
338 lines
12 KiB
JavaScript
// Tests for src/renderer/context-page-model.js — task #185 (Context page,
|
|
// #179-A). Pure projections only; the DOM controller has its own smoke
|
|
// path (context-page.js is exercised in the CDP shots).
|
|
//
|
|
// Fixtures mirror the wire shapes in packages/core/session/src/types.ts
|
|
// (SessionEvent + turn/end + context/message + compact/summary + tool/call).
|
|
// The tests assert on the row+roster shape rather than on the tracker's
|
|
// internal token counts, because context-meter.js has its own dedicated
|
|
// coverage — this file's job is to prove projectTurnRows composes the
|
|
// pieces correctly, not to re-verify tracker arithmetic.
|
|
|
|
'use strict'
|
|
|
|
const test = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
|
|
const M = require('../src/renderer/context-page-model.js')
|
|
|
|
// Small helpers to build events at the ergonomic level tests read at.
|
|
let _seq = 0
|
|
function nextSeq() { _seq += 1; return _seq }
|
|
function reset() { _seq = 0 }
|
|
|
|
function inject(plugin, text = 'note', kind = 'plugin') {
|
|
return {
|
|
type: 'context/message',
|
|
seq: nextSeq(),
|
|
time: 1_700_000_000_000 + _seq * 1000,
|
|
data: {
|
|
content: [{ type: 'text', text }],
|
|
source: kind === 'user' ? { kind: 'user' } : { kind: 'plugin', plugin },
|
|
},
|
|
}
|
|
}
|
|
function compact({ range = [1, 20], model = 'deepseek-chat', maxTokens = 512, shadowedTokens = 15000 } = {}) {
|
|
return {
|
|
type: 'compact/summary',
|
|
seq: nextSeq(),
|
|
time: 1_700_000_000_000 + _seq * 1000,
|
|
data: {
|
|
summary: [{ type: 'text', text: 'summary' }],
|
|
shadowedRange: { start: range[0], end: range[1] },
|
|
shadowedSeqs: [],
|
|
shadowedTokenCount: shadowedTokens,
|
|
model,
|
|
maxTokens,
|
|
},
|
|
}
|
|
}
|
|
function recall(name = 'history_read', args = { seq: 42 }) {
|
|
return {
|
|
type: 'tool/call',
|
|
seq: nextSeq(),
|
|
time: 1_700_000_000_000 + _seq * 1000,
|
|
data: { name, arguments: JSON.stringify(args) },
|
|
}
|
|
}
|
|
function turnEnd(turn) {
|
|
return {
|
|
type: 'turn/end',
|
|
seq: nextSeq(),
|
|
time: 1_700_000_000_000 + _seq * 1000,
|
|
data: { turn, reason: { kind: 'completed' } },
|
|
}
|
|
}
|
|
function userMessageFromCompactPlugin() {
|
|
return {
|
|
type: 'user/message',
|
|
seq: nextSeq(),
|
|
time: 1_700_000_000_000 + _seq * 1000,
|
|
data: {
|
|
content: [{ type: 'text', text: '<manual compact trigger>' }],
|
|
source: { kind: 'plugin', plugin: 'compact' },
|
|
},
|
|
}
|
|
}
|
|
|
|
test('projectTurnRows: groups events by turn boundary', () => {
|
|
reset()
|
|
const events = [
|
|
inject('hooks-claude', 'CLAUDE.md loaded'),
|
|
inject('time-context', 'tick'),
|
|
turnEnd(1),
|
|
inject('hooks-claude', 'reloaded'),
|
|
recall('history_search'),
|
|
turnEnd(2),
|
|
]
|
|
const rows = M.projectTurnRows(events)
|
|
assert.equal(rows.length, 2)
|
|
assert.equal(rows[0].turn, 1)
|
|
assert.equal(rows[0].injectCount, 2)
|
|
assert.equal(rows[0].compactCount, 0)
|
|
assert.equal(rows[0].recallCount, 0)
|
|
assert.equal(rows[0].closed, true)
|
|
assert.equal(rows[1].turn, 2)
|
|
assert.equal(rows[1].injectCount, 1)
|
|
assert.equal(rows[1].recallCount, 1)
|
|
})
|
|
|
|
test('projectTurnRows: per-plugin injection slice preserves order and seq list', () => {
|
|
reset()
|
|
const events = [
|
|
inject('hooks-claude'),
|
|
inject('time-context'),
|
|
inject('hooks-claude'),
|
|
inject('acme-notifier'),
|
|
turnEnd(1),
|
|
]
|
|
const [row] = M.projectTurnRows(events)
|
|
const plugins = row.injects.map((s) => s.plugin)
|
|
// First-seen order — hooks-claude before time-context before acme-notifier.
|
|
assert.deepEqual(plugins, ['hooks-claude', 'time-context', 'acme-notifier'])
|
|
const claude = row.injects.find((s) => s.plugin === 'hooks-claude')
|
|
assert.equal(claude.count, 2)
|
|
assert.equal(claude.seqs.length, 2)
|
|
// Every inject seq is also on the flat list, in observation order.
|
|
assert.deepEqual(row.injectSeqs, [1, 2, 3, 4])
|
|
})
|
|
|
|
test('projectTurnRows: trailing in-flight bucket carries closed=false', () => {
|
|
reset()
|
|
const events = [
|
|
inject('hooks-claude'),
|
|
turnEnd(1),
|
|
inject('time-context'),
|
|
// no turn/end — turn 2 in progress
|
|
]
|
|
const rows = M.projectTurnRows(events)
|
|
assert.equal(rows.length, 2)
|
|
assert.equal(rows[0].closed, true)
|
|
assert.equal(rows[1].closed, false)
|
|
})
|
|
|
|
test('projectTurnRows: compact events counted in the turn they land in', () => {
|
|
reset()
|
|
const events = [
|
|
inject('hooks-claude'),
|
|
turnEnd(1),
|
|
compact({ range: [1, 30] }),
|
|
turnEnd(2),
|
|
]
|
|
const rows = M.projectTurnRows(events)
|
|
assert.equal(rows[1].compactCount, 1)
|
|
assert.equal(rows[1].compactSeqs.length, 1)
|
|
})
|
|
|
|
test('projectTurnRows: recall recognizes both history_read and history_search', () => {
|
|
reset()
|
|
const events = [recall('history_read'), recall('history_search'), turnEnd(1)]
|
|
const rows = M.projectTurnRows(events)
|
|
assert.equal(rows[0].recallCount, 2)
|
|
})
|
|
|
|
test('projectTurnRows: budget projection matches tracker (assumed source when no override)', () => {
|
|
reset()
|
|
const events = [inject('hooks-claude', 'x'.repeat(4000)), turnEnd(1)]
|
|
const rows = M.projectTurnRows(events)
|
|
assert.equal(rows[0].budgetSource, 'assumed')
|
|
assert.ok(rows[0].budget > 0, 'default 128k budget present')
|
|
assert.ok(rows[0].budgetPct >= 0 && rows[0].budgetPct <= 999)
|
|
})
|
|
|
|
test('projectTurnRows: explicit budget override marks source=server', () => {
|
|
reset()
|
|
const events = [inject('hooks-claude'), turnEnd(1)]
|
|
const rows = M.projectTurnRows(events, { budgetTokens: 200_000 })
|
|
assert.equal(rows[0].budgetSource, 'server')
|
|
assert.equal(rows[0].budget, 200_000)
|
|
})
|
|
|
|
test('projectTurnRows: non-context/message events do not inflate injectCount', () => {
|
|
reset()
|
|
const events = [
|
|
{ type: 'assistant/chunk', seq: nextSeq(), data: { text: 'hi' } },
|
|
{ type: 'tool/call', seq: nextSeq(), data: { name: 'bash', arguments: '{}' } },
|
|
turnEnd(1),
|
|
]
|
|
const rows = M.projectTurnRows(events)
|
|
assert.equal(rows[0].injectCount, 0)
|
|
assert.equal(rows[0].recallCount, 0)
|
|
assert.equal(rows[0].compactCount, 0)
|
|
})
|
|
|
|
test('projectTurnRows: robust to non-array + garbage input', () => {
|
|
assert.deepEqual(M.projectTurnRows(null), [])
|
|
assert.deepEqual(M.projectTurnRows(undefined), [])
|
|
const rows = M.projectTurnRows([null, 42, {}, { type: null }])
|
|
// Every input still contributes to eventCount when it's an object; skip when it's not.
|
|
assert.equal(rows.length, 0, 'no turn/end → no closed rows, no trailing since eventCount==0 for typeof!=object')
|
|
})
|
|
|
|
test('buildInjectionRoster: aggregates counts + first/last seq + family from inject-family', () => {
|
|
reset()
|
|
const events = [
|
|
inject('hooks-claude'),
|
|
inject('time-context'),
|
|
inject('hooks-claude'),
|
|
inject('acme-unknown'),
|
|
]
|
|
const roster = M.buildInjectionRoster(events, { isFirstTurn: true })
|
|
const byPlugin = Object.fromEntries(roster.map((r) => [r.plugin, r]))
|
|
assert.equal(byPlugin['hooks-claude'].count, 2)
|
|
assert.equal(byPlugin['hooks-claude'].family, 'A') // session-start on first turn
|
|
assert.equal(byPlugin['time-context'].family, 'C')
|
|
assert.equal(byPlugin['acme-unknown'].family, 'G') // unknown plugin
|
|
assert.ok(byPlugin['hooks-claude'].firstSeq < byPlugin['hooks-claude'].lastSeq)
|
|
})
|
|
|
|
test('summarizeCompactPolicy: reads the last compact/summary and detects manual source', () => {
|
|
reset()
|
|
const events = [
|
|
inject('hooks-claude'),
|
|
turnEnd(1),
|
|
userMessageFromCompactPlugin(),
|
|
compact({ model: 'deepseek-chat', maxTokens: 768, shadowedTokens: 12000 }),
|
|
turnEnd(2),
|
|
]
|
|
const policy = M.summarizeCompactPolicy(events)
|
|
assert.equal(policy.model, 'deepseek-chat')
|
|
assert.equal(policy.maxTokens, 768)
|
|
assert.equal(policy.source, 'manual')
|
|
assert.equal(policy.shadowedTokens, 12000)
|
|
})
|
|
|
|
test('summarizeCompactPolicy: returns null when no compact has landed', () => {
|
|
reset()
|
|
const policy = M.summarizeCompactPolicy([inject('hooks-claude'), turnEnd(1)])
|
|
assert.equal(policy, null)
|
|
})
|
|
|
|
test('summarizeCompactPolicy: unknown source when no adjacent user/message hint', () => {
|
|
reset()
|
|
const events = [compact({ model: 'x', maxTokens: 512 })]
|
|
const policy = M.summarizeCompactPolicy(events)
|
|
assert.equal(policy.source, 'unknown')
|
|
})
|
|
|
|
test('summarizeRecallConfig: buckets by tool name with sampleArgs preserved', () => {
|
|
reset()
|
|
const events = [
|
|
recall('history_read', { seq: 42 }),
|
|
recall('history_read', { seq: 88 }),
|
|
recall('history_search', { q: 'sessions' }),
|
|
]
|
|
const cfg = M.summarizeRecallConfig(events)
|
|
assert.equal(cfg.total, 3)
|
|
assert.equal(cfg.tools.length, 2)
|
|
const read = cfg.tools.find((t) => t.name === 'history_read')
|
|
assert.equal(read.count, 2)
|
|
assert.ok(read.sampleArgs && read.sampleArgs.includes('"seq":42'))
|
|
})
|
|
|
|
test('summarizeRecallConfig: empty when no recall events', () => {
|
|
const cfg = M.summarizeRecallConfig([inject('hooks-claude')])
|
|
assert.equal(cfg.total, 0)
|
|
assert.equal(cfg.tools.length, 0)
|
|
})
|
|
|
|
test('computeBudgetSparkline: normalises heights against series peak', () => {
|
|
const rows = [
|
|
{ turn: 1, budgetPct: 10 },
|
|
{ turn: 2, budgetPct: 30 },
|
|
{ turn: 3, budgetPct: 60 },
|
|
]
|
|
const line = M.computeBudgetSparkline(rows)
|
|
assert.equal(line.length, 3)
|
|
// Peak row's height == 1
|
|
assert.equal(line[2].height, 1)
|
|
// Non-peak scales linearly
|
|
assert.ok(line[1].height > 0.4 && line[1].height < 0.6)
|
|
// Floor is 0.05 so a tiny value is still visible
|
|
assert.ok(line[0].height >= 0.05)
|
|
})
|
|
|
|
test('computeBudgetSparkline: returns empty array when all pct are zero', () => {
|
|
const line = M.computeBudgetSparkline([{ turn: 1, budgetPct: 0 }, { turn: 2, budgetPct: 0 }])
|
|
assert.deepEqual(line, [])
|
|
})
|
|
|
|
test('serializeProfileYAML: emits the expected DSH profile shape', () => {
|
|
const yaml = M.serializeProfileYAML({
|
|
name: 'demo-profile',
|
|
shadowing: 'auto',
|
|
compactModel: 'deepseek-chat',
|
|
compactMaxTokens: 512,
|
|
compactSource: 'manual',
|
|
recall: { windowSeqs: 80, threshold: 0.4 },
|
|
injectionScopes: [
|
|
{ plugin: 'hooks-claude', allow: true },
|
|
{ plugin: 'time-context', allow: false },
|
|
],
|
|
})
|
|
assert.ok(yaml.includes('name: demo-profile'))
|
|
assert.ok(yaml.includes('mode: auto'))
|
|
assert.ok(yaml.includes('model: deepseek-chat'))
|
|
assert.ok(yaml.includes('maxTokens: 512'))
|
|
assert.ok(yaml.includes('source: manual'))
|
|
assert.ok(yaml.includes('windowSeqs: 80'))
|
|
assert.ok(yaml.includes('threshold: 0.4'))
|
|
assert.ok(yaml.includes('- plugin: hooks-claude'))
|
|
assert.ok(yaml.includes('allow: true'))
|
|
assert.ok(yaml.includes('- plugin: time-context'))
|
|
assert.ok(yaml.includes('allow: false'))
|
|
})
|
|
|
|
test('serializeProfileYAML: quotes strings with spaces + emits empty scopes as []', () => {
|
|
const yaml = M.serializeProfileYAML({ name: 'my profile', shadowing: 'off', injectionScopes: [] })
|
|
assert.ok(yaml.includes('name: "my profile"'))
|
|
assert.ok(yaml.includes('mode: off'))
|
|
assert.ok(yaml.includes('injectionScopes:\n []'))
|
|
})
|
|
|
|
test('capabilitiesLegend: every entry names its wire status + G* gap where applicable', () => {
|
|
const legend = M.capabilitiesLegend()
|
|
assert.ok(legend.length >= 4)
|
|
const statuses = new Set(legend.map((e) => e.status))
|
|
assert.ok(statuses.has('live'))
|
|
assert.ok(statuses.has('restart-required'))
|
|
assert.ok(statuses.has('upstream-pending'))
|
|
// Every non-live entry cites a G-number so the design pack's SDK-gap
|
|
// matrix and the page's legend stay in sync.
|
|
for (const e of legend) {
|
|
if (e.status !== 'live') assert.match(e.gap, /^G\d+$/)
|
|
if (e.status === 'live') assert.equal(e.gap, null)
|
|
assert.ok(typeof e.note === 'string' && e.note.length > 20, 'each note explains the reason')
|
|
}
|
|
})
|
|
|
|
test('pluginOf: handles all source shapes seen on the wire', () => {
|
|
const p = (s) => M.pluginOf({ data: { source: s } })
|
|
assert.equal(p({ kind: 'plugin', plugin: 'hooks-claude' }), 'hooks-claude')
|
|
assert.equal(p({ kind: 'user' }), 'user')
|
|
assert.equal(p({ kind: 'tool', tool: 'bash' }), 'tool')
|
|
assert.equal(p('user'), 'user')
|
|
assert.equal(p(null), 'other')
|
|
assert.equal(p({}), 'other')
|
|
})
|