123 lines
5.6 KiB
JavaScript
123 lines
5.6 KiB
JavaScript
|
|
// Tests for P0-4: Fork button error-code classification + inflight-turn gating.
|
||
|
|
//
|
||
|
|
// The wire strips SessionForkError.code down to a JSON-RPC -32603 error whose
|
||
|
|
// `message` is preserved verbatim from packages/core/session/src/index.ts. The
|
||
|
|
// renderer classifies that message back into one of the four kernel codes so
|
||
|
|
// the system line can speak in replay-boundary language instead of raw error
|
||
|
|
// text.
|
||
|
|
//
|
||
|
|
// Intent red-line (2026-07-16 team-lead): fork wording NEVER says
|
||
|
|
// "copy" / "snapshot" / "duplicate current state" — a fork is a deterministic
|
||
|
|
// replay from a closed-turn boundary. The classifier's `humanMessage` field
|
||
|
|
// carries the user-facing phrasing and is asserted below.
|
||
|
|
|
||
|
|
'use strict'
|
||
|
|
|
||
|
|
const test = require('node:test')
|
||
|
|
const assert = require('node:assert/strict')
|
||
|
|
const { loadRenderer } = require('./renderer-harness.js')
|
||
|
|
|
||
|
|
test('classifyForkError → OPEN_TURN when message names an unclosed turn', async () => {
|
||
|
|
const { window } = await loadRenderer()
|
||
|
|
const { classifyForkError } = window.__dshRenderer
|
||
|
|
const raw = new Error('fork boundary 12 in session "abc" must be turn/end, got assistant/message')
|
||
|
|
const c = classifyForkError(raw)
|
||
|
|
assert.equal(c.code, 'OPEN_TURN')
|
||
|
|
// Human message stays in replay language — never "copy" / "snapshot".
|
||
|
|
assert.match(c.humanMessage, /closed[\s-]turn/i)
|
||
|
|
assert.doesNotMatch(c.humanMessage, /\b(copy|snapshot|duplicate)\b/i)
|
||
|
|
})
|
||
|
|
|
||
|
|
test('classifyForkError → INVALID_BOUNDARY for the three boundary-shape phrases', async () => {
|
||
|
|
const { window } = await loadRenderer()
|
||
|
|
const { classifyForkError } = window.__dshRenderer
|
||
|
|
const phrases = [
|
||
|
|
'fork boundary for session "x" must be a non-negative safe integer, got NaN',
|
||
|
|
'fork boundary 999 does not exist in session "x" (last seq: 42)',
|
||
|
|
'fork boundary 5 does not match a contiguous event seq in session "x"',
|
||
|
|
]
|
||
|
|
for (const p of phrases) {
|
||
|
|
const c = classifyForkError(new Error(p))
|
||
|
|
assert.equal(c.code, 'INVALID_BOUNDARY', p)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
test('classifyForkError → SESSION_NOT_LIVE / SESSION_NOT_FOUND / SESSION_ALREADY_EXISTS', async () => {
|
||
|
|
const { window } = await loadRenderer()
|
||
|
|
const { classifyForkError } = window.__dshRenderer
|
||
|
|
assert.equal(
|
||
|
|
classifyForkError(new Error('session "abc" is not the live store instance')).code,
|
||
|
|
'SESSION_NOT_LIVE',
|
||
|
|
)
|
||
|
|
assert.equal(
|
||
|
|
classifyForkError(new Error('session "abc" not found')).code,
|
||
|
|
'SESSION_NOT_FOUND',
|
||
|
|
)
|
||
|
|
assert.equal(
|
||
|
|
classifyForkError(new Error('session "abc-fork-1" already exists')).code,
|
||
|
|
'SESSION_ALREADY_EXISTS',
|
||
|
|
)
|
||
|
|
})
|
||
|
|
|
||
|
|
test('classifyForkError → UNKNOWN when the message is not recognized', async () => {
|
||
|
|
const { window } = await loadRenderer()
|
||
|
|
const { classifyForkError } = window.__dshRenderer
|
||
|
|
const c = classifyForkError(new Error('something completely different'))
|
||
|
|
assert.equal(c.code, 'UNKNOWN')
|
||
|
|
// Falls through to the raw message so we don't lose diagnostic content.
|
||
|
|
assert.match(c.humanMessage, /something completely different/)
|
||
|
|
})
|
||
|
|
|
||
|
|
test('classifyForkError prefers explicit code on the error object over message parsing', async () => {
|
||
|
|
const { window } = await loadRenderer()
|
||
|
|
const { classifyForkError } = window.__dshRenderer
|
||
|
|
// If main.js pre-classified and stuck the code on the object, honor it.
|
||
|
|
const err = new Error('opaque wrapped message')
|
||
|
|
err.code = 'OPEN_TURN'
|
||
|
|
const c = classifyForkError(err)
|
||
|
|
assert.equal(c.code, 'OPEN_TURN')
|
||
|
|
})
|
||
|
|
|
||
|
|
test('classifyForkError handles thrown strings and undefined', async () => {
|
||
|
|
const { window } = await loadRenderer()
|
||
|
|
const { classifyForkError } = window.__dshRenderer
|
||
|
|
assert.equal(classifyForkError(undefined).code, 'UNKNOWN')
|
||
|
|
assert.equal(classifyForkError(null).code, 'UNKNOWN')
|
||
|
|
assert.equal(classifyForkError('OPEN_TURN somehow').code, 'UNKNOWN')
|
||
|
|
})
|
||
|
|
|
||
|
|
test('fork button is disabled + tooltip explains replay boundary when a turn is in flight', async () => {
|
||
|
|
const { renderer, window } = await loadRenderer()
|
||
|
|
renderer.ensureSession('s1', { title: 'sess', header: {} })
|
||
|
|
await renderer.selectSession('s1')
|
||
|
|
// Start a turn so inflightTurn flips true, then append an assistant bubble
|
||
|
|
// so a fork button gets attached to it.
|
||
|
|
renderer.onSessionEvent('s1', { type: 'turn/start', seq: 1 })
|
||
|
|
renderer.onSessionEvent('s1', { type: 'assistant/message', seq: 2, data: { content: 'hi' } })
|
||
|
|
const bubbles = window.document.querySelectorAll('.msg.assistant')
|
||
|
|
assert.ok(bubbles.length >= 1, 'expected an assistant bubble in the stream')
|
||
|
|
const btn = bubbles[0].querySelector('.fork-here')
|
||
|
|
assert.ok(btn, 'expected a fork-here button on the assistant bubble')
|
||
|
|
renderer.updateForkButtons()
|
||
|
|
assert.equal(btn.disabled, true)
|
||
|
|
assert.match(btn.title, /replay/i)
|
||
|
|
assert.match(btn.title, /closed[\s-]turn|current turn to (?:end|finish)/i)
|
||
|
|
assert.doesNotMatch(btn.title, /\b(copy|snapshot|duplicate)\b/i)
|
||
|
|
})
|
||
|
|
|
||
|
|
test('fork button re-enables + swaps tooltip back to boundary preview when the turn ends', async () => {
|
||
|
|
const { renderer, window } = await loadRenderer()
|
||
|
|
renderer.ensureSession('s1', { title: 'sess', header: {} })
|
||
|
|
await renderer.selectSession('s1')
|
||
|
|
renderer.onSessionEvent('s1', { type: 'turn/start', seq: 1 })
|
||
|
|
renderer.onSessionEvent('s1', { type: 'assistant/message', seq: 2, data: { content: 'hi' } })
|
||
|
|
renderer.onSessionEvent('s1', { type: 'turn/end', seq: 3 })
|
||
|
|
renderer.updateForkButtons()
|
||
|
|
const btn = window.document.querySelectorAll('.msg.assistant')[0].querySelector('.fork-here')
|
||
|
|
assert.equal(btn.disabled, false)
|
||
|
|
// Boundary-preview tooltip mentions the seq (turn/end re-stamped
|
||
|
|
// data-fork-seq); still no "copy" language.
|
||
|
|
assert.match(btn.title, /seq 3|turn boundary|replay/i)
|
||
|
|
assert.doesNotMatch(btn.title, /\b(copy|snapshot|duplicate)\b/i)
|
||
|
|
})
|