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.
204 lines
7.7 KiB
JavaScript
204 lines
7.7 KiB
JavaScript
// Tests for task #157 additions to src/renderer/trace-aggregator.js:
|
|
// chunk-run collapsing, per-row event previews (tool-call/-result/
|
|
// request-header/hook/*), and the raw JSON payload viewer helper.
|
|
//
|
|
// Fixture discipline (memory/multi-agent-shared-repo-rules.md #4): the
|
|
// 115-chunk fixture comes from fixtures/trace-samples/1.1-trace-chunk-heavy.json
|
|
// which mirrors the real wire shape emitted by the daemon (data.chunk.text
|
|
// on text-delta, request/header carrying header.{model,tools,system,
|
|
// messagePrefix}, tool/result meta.card + meta.durationMs, hook payloads).
|
|
// If this fixture drifts from the daemon wire shape, the row previews
|
|
// become useless 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 loadHeavy() {
|
|
const p = path.join(__dirname, '..', 'fixtures', 'trace-samples', '1.1-trace-chunk-heavy.json')
|
|
return JSON.parse(fs.readFileSync(p, 'utf8'))
|
|
}
|
|
|
|
test('collapseChunkRuns folds a run of assistant/chunk into a single row', () => {
|
|
const events = loadHeavy()
|
|
const steps = TA.aggregateSteps(events)
|
|
assert.equal(steps.length, 1, 'fixture has one step')
|
|
const rows = TA.collapseChunkRuns(steps[0].events)
|
|
const runs = rows.filter((r) => r.kind === 'run')
|
|
assert.equal(runs.length, 1, 'the 115 chunks fold into one run')
|
|
assert.equal(runs[0].count, 115)
|
|
assert.equal(runs[0].events.length, 115)
|
|
assert.ok(runs[0].startSeq !== null && runs[0].endSeq !== null)
|
|
assert.ok(runs[0].endSeq > runs[0].startSeq)
|
|
})
|
|
|
|
test('collapseChunkRuns keeps non-chunk events as their own event rows in order', () => {
|
|
const events = loadHeavy()
|
|
const steps = TA.aggregateSteps(events)
|
|
const rows = TA.collapseChunkRuns(steps[0].events)
|
|
const kinds = rows.map((r) => r.kind === 'run' ? `run(${r.count})` : r.event.type)
|
|
// Expected order inside step 0 events: request/header, hook/before-tool-call,
|
|
// run of 115 chunks, tool/call, tool/result, assistant/message.
|
|
assert.deepEqual(
|
|
kinds,
|
|
['request/header', 'hook/before-tool-call', 'run(115)', 'tool/call', 'tool/result', 'assistant/message'],
|
|
)
|
|
})
|
|
|
|
test('collapseChunkRuns handles multiple runs separated by other events', () => {
|
|
const stream = [
|
|
{ type: 'assistant/chunk', seq: 1, data: { chunk: { type: 'text-delta', text: 'A' } } },
|
|
{ type: 'assistant/chunk', seq: 2, data: { chunk: { type: 'text-delta', text: 'B' } } },
|
|
{ type: 'request/header', seq: 3, data: { header: { model: 'x', messagePrefix: [] }, reason: 'mid' } },
|
|
{ type: 'assistant/chunk', seq: 4, data: { chunk: { type: 'text-delta', text: 'C' } } },
|
|
]
|
|
const rows = TA.collapseChunkRuns(stream)
|
|
assert.equal(rows.length, 3)
|
|
assert.equal(rows[0].kind, 'run'); assert.equal(rows[0].count, 2)
|
|
assert.equal(rows[1].kind, 'event'); assert.equal(rows[1].event.type, 'request/header')
|
|
assert.equal(rows[2].kind, 'run'); assert.equal(rows[2].count, 1)
|
|
})
|
|
|
|
test('collapseChunkRuns tolerates malformed input', () => {
|
|
assert.deepEqual(TA.collapseChunkRuns(null), [])
|
|
assert.deepEqual(TA.collapseChunkRuns(undefined), [])
|
|
assert.deepEqual(TA.collapseChunkRuns([null, undefined, { /* no type */ }]).length, 1)
|
|
})
|
|
|
|
test('chunkRunConcatText concatenates delta text and truncates with ellipsis', () => {
|
|
const chunks = [
|
|
{ data: { chunk: { text: 'Hello ' } } },
|
|
{ data: { chunk: { text: 'world' } } },
|
|
]
|
|
assert.equal(TA.chunkRunConcatText(chunks, 50), 'Hello world')
|
|
const many = Array.from({ length: 20 }, (_, i) => ({ data: { chunk: { text: 'abcde ' } } }))
|
|
const out = TA.chunkRunConcatText(many, 30)
|
|
assert.ok(out.endsWith('…'))
|
|
assert.equal(out.length, 30)
|
|
})
|
|
|
|
test('chunkRunConcatText reads legacy shapes: chunk.delta and data.text', () => {
|
|
const chunks = [
|
|
{ data: { chunk: { delta: 'foo ' } } },
|
|
{ data: { text: 'bar' } },
|
|
]
|
|
assert.equal(TA.chunkRunConcatText(chunks, 50), 'foo bar')
|
|
})
|
|
|
|
test('chunkRunConcatText returns empty on non-array or empty input', () => {
|
|
assert.equal(TA.chunkRunConcatText([], 10), '')
|
|
assert.equal(TA.chunkRunConcatText(null, 10), '')
|
|
})
|
|
|
|
test('previewForEvent narrates tool/call with argument gist', () => {
|
|
const ev = { type: 'tool/call', data: { name: 'read', arguments: '{"path":"src/foo.ts","offset":0}' } }
|
|
const out = TA.previewForEvent(ev)
|
|
assert.match(out, /^read\(/)
|
|
assert.match(out, /path="src\/foo\.ts"/)
|
|
})
|
|
|
|
test('previewForEvent narrates tool/result ok with size + duration', () => {
|
|
const ev = {
|
|
type: 'tool/result',
|
|
data: {
|
|
callId: 'c1',
|
|
content: [{ type: 'text', text: 'abcdefghij' }],
|
|
isError: false,
|
|
meta: { durationMs: 42 },
|
|
},
|
|
}
|
|
const out = TA.previewForEvent(ev)
|
|
assert.equal(out, '→ok c1 · 10b · 42ms')
|
|
})
|
|
|
|
test('previewForEvent narrates tool/result error with message', () => {
|
|
const ev = {
|
|
type: 'tool/result',
|
|
data: {
|
|
callId: 'c2',
|
|
isError: true,
|
|
content: [{ type: 'text', text: 'ENOENT: no such file' }],
|
|
},
|
|
}
|
|
const out = TA.previewForEvent(ev)
|
|
assert.match(out, /^→err c2/)
|
|
assert.match(out, /ENOENT/)
|
|
})
|
|
|
|
test('previewForEvent narrates request/header with model + counts + system-flag', () => {
|
|
const ev = {
|
|
type: 'request/header',
|
|
data: {
|
|
header: {
|
|
model: 'deepseek-chat',
|
|
system: 'you are…',
|
|
tools: [{ name: 'read' }, { name: 'edit' }, { name: 'bash' }],
|
|
messagePrefix: [1, 2, 3, 4],
|
|
},
|
|
reason: 'step-start',
|
|
},
|
|
}
|
|
const out = TA.previewForEvent(ev)
|
|
assert.equal(out, 'model=deepseek-chat · msgs=4 · tools=3 · sys · step-start')
|
|
})
|
|
|
|
test('previewForEvent narrates hook events with allowed/blocked', () => {
|
|
assert.equal(
|
|
TA.previewForEvent({ type: 'hook/before-tool-call', data: { hookName: 'guard', allowed: false } }),
|
|
'guard · BLOCKED',
|
|
)
|
|
assert.equal(
|
|
TA.previewForEvent({ type: 'hook/before-tool-call', data: { hookName: 'guard', allowed: true } }),
|
|
'guard · allowed',
|
|
)
|
|
})
|
|
|
|
test('previewForEvent falls back to compact JSON for unknown event types', () => {
|
|
const out = TA.previewForEvent({ type: 'foo/bar', data: { a: 1, b: 'two' } })
|
|
assert.match(out, /"a":1/)
|
|
})
|
|
|
|
test('previewForEvent handles null / bad input', () => {
|
|
assert.equal(TA.previewForEvent(null), '')
|
|
assert.equal(TA.previewForEvent({}), '')
|
|
assert.equal(TA.previewForEvent({ type: 'x' }), '')
|
|
})
|
|
|
|
test('previewForEvent shows single-chunk text-delta as its own text', () => {
|
|
const ev = { type: 'assistant/chunk', data: { chunk: { type: 'text-delta', text: 'streaming word' } } }
|
|
assert.equal(TA.previewForEvent(ev), 'streaming word')
|
|
})
|
|
|
|
test('payloadForEvent returns a pretty-printed JSON string of the whole event', () => {
|
|
const ev = { type: 'tool/call', seq: 12, data: { name: 'read', arguments: '{}' } }
|
|
const s = TA.payloadForEvent(ev)
|
|
assert.ok(s.includes('"type": "tool/call"'))
|
|
assert.ok(s.includes('"seq": 12'))
|
|
// multi-line
|
|
assert.ok(s.split('\n').length >= 4)
|
|
})
|
|
|
|
test('payloadForEvent falls back on circular refs', () => {
|
|
const ev = { type: 'x' }
|
|
ev.self = ev
|
|
const s = TA.payloadForEvent(ev)
|
|
assert.ok(typeof s === 'string' && s.length > 0)
|
|
})
|
|
|
|
test('115-chunk fixture concat preview reads like the answer body', () => {
|
|
const events = loadHeavy()
|
|
const chunks = events.filter((e) => e.type === 'assistant/chunk')
|
|
const preview = TA.chunkRunConcatText(chunks, 200)
|
|
// The first two words in the fixture are "Harness 是 …" — assert we
|
|
// got a substantive preview, not the empty string, and that it hit
|
|
// the 200-char cap (i.e. truncation actually triggered).
|
|
assert.match(preview, /^Harness /)
|
|
assert.equal(preview.length, 200)
|
|
assert.ok(preview.endsWith('…'))
|
|
})
|