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.
261 lines
11 KiB
JavaScript
261 lines
11 KiB
JavaScript
// Ticket B (task #124) — phantom-header pinning tests.
|
|
//
|
|
// Backstory (docs/tickets/ticket-B-phantom-header.md + docs/capability-frontend-audit.md §1.6):
|
|
// `SessionHeader` on the wire (packages/core/session/src/types.ts:29-49)
|
|
// actually ships only 6 fields — `version / id / createdAt / cwd? /
|
|
// parentSession? / seedLength?` — but the shell used to read 7 phantom
|
|
// fields that only exist in demo mocks. Real daemons return `undefined`
|
|
// for every phantom, and the audit's §1.6 concluded the tests never
|
|
// noticed because fixtures were hand-shaped to whatever the read site
|
|
// wanted (rule #4 of memory/multi-agent-shared-repo-rules.md — the very
|
|
// bug this file exists to prevent).
|
|
//
|
|
// This file's job is the wire-shape pinning gate: fixtures MUST match
|
|
// the real daemon's `Object.keys(header)` output exactly, so future code
|
|
// can't sneak a new phantom read back in by re-hand-shaping a fixture.
|
|
//
|
|
// If Ticket A (task #123) lands and adds `title / model / originKind`
|
|
// to the wire, extend WIRE_HEADER_KEYS below to match. Do NOT add
|
|
// mock-only keys.
|
|
//
|
|
// Runs under `node --test` — no DOM, no daemon.
|
|
|
|
'use strict'
|
|
|
|
const test = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
|
|
const {
|
|
buildSessionTree,
|
|
findChildForks,
|
|
classifySessionShape,
|
|
} = require('../src/renderer/session-tree.js')
|
|
|
|
// The canonical wire header shape as verified by CDP on port 9224
|
|
// (docs/capability-frontend-audit.md §1.6 CDP verification cell).
|
|
// Optional fields listed for completeness; presence is not required
|
|
// (e.g. a root session has no `parentSession`).
|
|
const WIRE_HEADER_REQUIRED = ['version', 'id', 'createdAt']
|
|
const WIRE_HEADER_OPTIONAL = ['cwd', 'parentSession', 'seedLength']
|
|
const WIRE_HEADER_ALLOWED = new Set([...WIRE_HEADER_REQUIRED, ...WIRE_HEADER_OPTIONAL])
|
|
|
|
// After Ticket A (#123) lands, add: 'title', 'model'. After Ticket B-1
|
|
// (S-class, this Ticket) lands: 'originKind'. Keep this comment in sync so
|
|
// the fixture reviewer knows what to expand.
|
|
|
|
// Real-shape fixture factory. Any test that asserts against the tree
|
|
// helpers should build entries with this so the pinning tests below stay
|
|
// meaningful — a fixture that adds keys via ad-hoc `header: { foo: 1 }`
|
|
// merges bypasses the gate.
|
|
function wireEntry(id, opts = {}) {
|
|
const header = {
|
|
version: 0,
|
|
id,
|
|
createdAt: opts.createdAt || 0,
|
|
}
|
|
if (opts.cwd !== undefined) header.cwd = opts.cwd
|
|
if (opts.parent !== undefined) {
|
|
// The wire shape for `parentSession` is `{ id, seq }`, NOT bare string
|
|
// and NOT `{ sessionId, seq }`. See types.ts:44.
|
|
header.parentSession = typeof opts.parent === 'string'
|
|
? { id: opts.parent, seq: opts.parentSeq || 0 }
|
|
: opts.parent
|
|
}
|
|
if (opts.seedLength !== undefined) header.seedLength = opts.seedLength
|
|
return {
|
|
sessionId: id,
|
|
header,
|
|
live: opts.live !== false,
|
|
persisted: opts.persisted === true,
|
|
running: opts.running === true,
|
|
lastEventTime: opts.lastEventTime || 0,
|
|
}
|
|
}
|
|
|
|
// -- B-7/B-8/B-9 pinning: every wireEntry must expose exactly the allowed keys.
|
|
|
|
test('B-7/8/9: wireEntry header contains only wire-truth keys (no phantoms)', () => {
|
|
const entry = wireEntry('sess-abc', {
|
|
cwd: '/tmp/proj',
|
|
parent: 'sess-root',
|
|
parentSeq: 42,
|
|
seedLength: 42,
|
|
})
|
|
const keys = Object.keys(entry.header)
|
|
for (const k of keys) {
|
|
assert.ok(
|
|
WIRE_HEADER_ALLOWED.has(k),
|
|
`phantom key on header: "${k}" is not one of ${[...WIRE_HEADER_ALLOWED].join('/')}`,
|
|
)
|
|
}
|
|
// Required keys always present.
|
|
for (const k of WIRE_HEADER_REQUIRED) {
|
|
assert.ok(keys.includes(k), `required wire key missing: ${k}`)
|
|
}
|
|
})
|
|
|
|
test('B-8 shape: parentSession is { id, seq }, not bare string, not { sessionId }', () => {
|
|
// Real bug we are guarding against: fixtures used to say
|
|
// header: { parentSession: 'parent-id' }
|
|
// which happened to work because buildSessionTree/findChildForks were also
|
|
// written to consume the string form. Wire truth (types.ts:44) is
|
|
// `{ id: SessionId, seq: number }`. If a fixture regresses to the bare
|
|
// string form, the mock will silently drift from the real daemon again.
|
|
const entry = wireEntry('child', { parent: 'root', parentSeq: 3 })
|
|
assert.equal(typeof entry.header.parentSession, 'object')
|
|
assert.equal(entry.header.parentSession.id, 'root')
|
|
assert.equal(entry.header.parentSession.seq, 3)
|
|
assert.equal(entry.header.parentSession.sessionId, undefined,
|
|
'parentSession must use `id` (not `sessionId`) per types.ts:44')
|
|
})
|
|
|
|
test('B-9 shape: seedLength is present on fork children, absent on user-created sessions', () => {
|
|
const forkChild = wireEntry('child', { parent: 'root', parentSeq: 41, seedLength: 42 })
|
|
const rootUser = wireEntry('root')
|
|
assert.equal(typeof forkChild.header.seedLength, 'number')
|
|
assert.equal(rootUser.header.seedLength, undefined,
|
|
'user-created sessions never have seedLength — see types.ts:49')
|
|
})
|
|
|
|
// -- session-tree.js consumers must handle the real wire shape correctly.
|
|
|
|
test('B-8 consumer: buildSessionTree links parent/child using { id, seq } shape', () => {
|
|
const list = [
|
|
wireEntry('a'),
|
|
wireEntry('b', { parent: 'a', parentSeq: 5, seedLength: 5 }),
|
|
wireEntry('c', { parent: 'a', parentSeq: 8, seedLength: 8 }),
|
|
]
|
|
// NOTE: as of Ticket B (#124), session-tree.js reads parentSession as if
|
|
// it were a bare string ("parent !== parentSessionId"). The real wire
|
|
// shape is `{ id, seq }`. This test asserts the fix: consumers unwrap
|
|
// `.id` before comparing.
|
|
const tree = buildSessionTree(list)
|
|
assert.equal(tree.length, 1, 'a is the only root; b & c hang off it')
|
|
const rootA = tree[0]
|
|
assert.equal(rootA.entry.sessionId, 'a')
|
|
const childIds = rootA.children.map((c) => c.entry.sessionId).sort()
|
|
assert.deepEqual(childIds, ['b', 'c'])
|
|
})
|
|
|
|
test('B-8 consumer: findChildForks matches parentSession.id against parentSessionId', () => {
|
|
const list = [
|
|
wireEntry('parent'),
|
|
wireEntry('child-a', { parent: 'parent', parentSeq: 3, seedLength: 4 }),
|
|
wireEntry('child-b', { parent: 'parent', parentSeq: 6, seedLength: 7 }),
|
|
wireEntry('other', { parent: 'someone-else', parentSeq: 0 }),
|
|
]
|
|
const forks = findChildForks('parent', list)
|
|
assert.equal(forks.length, 2)
|
|
assert.deepEqual(
|
|
forks.map((f) => ({ id: f.childSessionId, seq: f.forkSeq })).sort((a, b) => a.id.localeCompare(b.id)),
|
|
[
|
|
{ id: 'child-a', seq: 3 },
|
|
{ id: 'child-b', seq: 6 },
|
|
],
|
|
)
|
|
})
|
|
|
|
// -- B-4/B-5 (interrupted/lastError) consolidation on classifySessionShape.
|
|
|
|
test('B-4/5 classifySessionShape treats meta.lastError with kind !== "ok" as interrupted', () => {
|
|
// Wire truth: `SessionFinishedNotification.reason` is a `TurnEndReason`
|
|
// (types.ts:94-120) whose kind is one of `ok / cancelled / error / stopped`.
|
|
// Any non-ok reason should read as ✕ (interrupted) — B-5 (cancelled) and
|
|
// B-4 (error) share the same visual affordance.
|
|
const errored = { sessionId: 'e', meta: { lastError: { kind: 'error', message: 'oops' } } }
|
|
const cancelled = { sessionId: 'c', meta: { lastError: { kind: 'cancelled', reason: 'user' } } }
|
|
const ok = { sessionId: 'o', meta: { lastError: { kind: 'ok' } } }
|
|
assert.equal(classifySessionShape(errored).role, 'interrupted')
|
|
assert.equal(classifySessionShape(cancelled).role, 'interrupted')
|
|
assert.equal(classifySessionShape(ok).role, 'idle',
|
|
'kind:"ok" is a successful finish — should NOT trigger interrupted glyph')
|
|
})
|
|
|
|
test('B-4/5 classifySessionShape: clean session (no meta.lastError) stays idle', () => {
|
|
const clean = { sessionId: 'x' }
|
|
assert.equal(classifySessionShape(clean).role, 'idle')
|
|
})
|
|
|
|
// -- B-1 (originKind) fallback behaviour: fork glyph when originKind absent.
|
|
|
|
test('B-1 originKind: absent → falls back to fork classification (safe for old daemons)', () => {
|
|
// Ticket A + B-1 will add `originKind: 'user'|'subagent'|'fork'` to
|
|
// SessionHeader. Before that lands, `originKind` is undefined and the
|
|
// classifier must still work: if `parentSession` is set, it's a fork
|
|
// (subagent info is lost but the row still shows the right shape).
|
|
const child = { sessionId: 'c', header: { parentSession: { id: 'p', seq: 0 } } }
|
|
const shape = classifySessionShape(child)
|
|
assert.equal(shape.role, 'fork',
|
|
'no originKind → fork fallback per docs/tickets/ticket-B-phantom-header.md B-1')
|
|
})
|
|
|
|
test('B-1 originKind: subagent value → subagent classification', () => {
|
|
const subagent = {
|
|
sessionId: 'c',
|
|
header: { parentSession: { id: 'p', seq: 0 }, originKind: 'subagent' },
|
|
}
|
|
assert.equal(classifySessionShape(subagent).role, 'subagent')
|
|
})
|
|
|
|
// -- Documentation cross-check: the audit-corrected phantom count.
|
|
|
|
test('audit-correction: config.model is not a shell read site anymore', () => {
|
|
// This is a documentation pinning: docs/tickets/ticket-B-phantom-header.md
|
|
// §B-6 removed `header.config.model` from the phantom list because the
|
|
// shell has zero readers. If a new reader appears, this test flips and
|
|
// forces us to re-classify it (add to a bucket rather than let it slide
|
|
// into another phantom).
|
|
const fs = require('node:fs')
|
|
const path = require('node:path')
|
|
const rendererDir = path.join(__dirname, '..', 'src', 'renderer')
|
|
const files = fs.readdirSync(rendererDir).filter((f) => f.endsWith('.js'))
|
|
const offenders = []
|
|
for (const f of files) {
|
|
const src = fs.readFileSync(path.join(rendererDir, f), 'utf8')
|
|
// Match `header.config` or `entry.header.config` — this is a phantom
|
|
// path that has never existed on the wire. `entry.config` (without the
|
|
// `header.` prefix) is unrelated (part of setSessionConfig round-trip).
|
|
if (/header\.config\b/.test(src)) offenders.push(f)
|
|
}
|
|
assert.deepEqual(offenders, [],
|
|
`header.config is a phantom read path; found in: ${offenders.join(', ')}. ` +
|
|
'If you meant to read model config, use the wire path from Ticket A instead.')
|
|
})
|
|
|
|
// -- D: usageFraction has no phantom fallback anymore (B-3).
|
|
|
|
test('B-3 usageFraction: session-tree-page must not fall back to header.usageFraction', () => {
|
|
// Wire never shipped `header.usageFraction`. The tracker
|
|
// (context-meter.js) is the sole source; the previous fallback was dead
|
|
// code that only fired on hand-shaped mocks. Delete of that fallback is
|
|
// enforced here so a future PR doesn't reintroduce it.
|
|
const fs = require('node:fs')
|
|
const path = require('node:path')
|
|
const src = fs.readFileSync(
|
|
path.join(__dirname, '..', 'src', 'renderer', 'session-tree-page.js'),
|
|
'utf8',
|
|
)
|
|
assert.doesNotMatch(src, /header\.usageFraction/,
|
|
'session-tree-page.js must not read header.usageFraction — the tracker is the sole source')
|
|
})
|
|
|
|
// -- D: interrupted alias is retired in favor of meta.lastError (B-5).
|
|
|
|
test('B-5 interrupted alias retired: session-tree.js reads meta.lastError only', () => {
|
|
// Historical: `session-tree.js:202` checked `header.lastError ||
|
|
// header.interrupted || entry.interrupted`. `header.interrupted` was
|
|
// never a wire field; `entry.interrupted` was a mock convenience. Both
|
|
// are gone as of Ticket B-5, replaced by the derived `meta.lastError`
|
|
// (whose `kind !== 'ok'` covers both error and cancelled).
|
|
const fs = require('node:fs')
|
|
const path = require('node:path')
|
|
const src = fs.readFileSync(
|
|
path.join(__dirname, '..', 'src', 'renderer', 'session-tree.js'),
|
|
'utf8',
|
|
)
|
|
assert.doesNotMatch(src, /header\.interrupted/,
|
|
'session-tree.js must not read header.interrupted — see Ticket B §B-5')
|
|
assert.doesNotMatch(src, /entry\.interrupted\b/,
|
|
'session-tree.js must not read entry.interrupted — see Ticket B §B-5')
|
|
})
|