Files
deepseek-harness/packages/client/ui-trajectory/tests/context-branches.spec.ts
T
creatixchu de96087923 feat(web): name context sources and mark steering in the transcript
Every logged non-user user/message collapsed into one identical
「上下文注入」 row, and mid-turn steering rendered in exactly the bubble a
turn-opening prompt uses, so the transcript could not say what had been
added or which message interrupted a running turn.

TranscriptAdapter and the history fold now attach a provenance view to
each context node, computed by contextProvenance() from the durable
source alone: a role (inject, or recall for a cross-session snapshot)
and a producer name read out of the log — instruction paths for
workspace-instructions, session titles for session-reference, the plugin
id for a plugin source, and the bare kind for any other. No client-side
table of producer names, so a renamed or newly mounted producer stays
identifiable without a client release and a foreign log projects like a
live one. ContextInjectionRow titles itself from the role and shows the
name beside it; MessageItem captions durable and pending steering
bubbles.

The caption reverses one clause of the no-interjection-chrome decision,
which removed it because the composer could not steer; composer steering
shipped afterwards without amending that note, so this change supplies
the product decision its reintroduction clause required and corrects the
stale facts left in it.

Fixes #1291
2026-08-04 17:44:18 +08:00

90 lines
2.2 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import type {
ConversationContext, ConversationNode, RequestView,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
deriveTrajectoryContextBranches,
trajectoryBranchContainsRequest,
} from '../src/client/context-branches.ts'
const checkpoint = {
kind: 'context',
seq: 100,
time: 100,
content: [],
source: { kind: 'plugin', plugin: 'compact' },
provenance: { role: 'inject', label: 'compact' },
} as ConversationNode
const abandoned = {
kind: 'assistant',
seq: 20,
time: 20,
turn: 1,
step: 1,
blocks: [{ kind: 'text', text: 'abandoned' }],
} as ConversationNode
const current = {
kind: 'user',
seq: 110,
time: 110,
content: [{ type: 'text', text: 'rewound' }],
source: { kind: 'plugin', plugin: 'rewind' },
} as ConversationNode
function request(
purpose: RequestView['purpose'],
startSeq: number,
resultSeq?: number,
replacementSeq?: number,
): RequestView {
const base = {
startSeq,
startedAt: startSeq,
completedAt: startSeq + 1,
status: 'complete' as const,
...(resultSeq === undefined ? {} : { resultSeq }),
}
return purpose === 'assistant'
? { ...base, purpose, turn: 1, step: 1 }
: {
...base,
purpose,
turn: 1,
step: 0,
...(replacementSeq === undefined ? {} : { replacementSeq }),
}
}
describe('trajectory context branches', () => {
it('inherits nodes and requests by retained surface position rather than seq cutoff', () => {
const contexts: ConversationContext[] = [
{ id: 0, nodes: [checkpoint, abandoned] },
{
id: 1,
parentId: 0,
origin: 'rewind',
originSeq: 110,
nodes: [checkpoint, current],
},
]
const branches = deriveTrajectoryContextBranches(contexts)
const successor = branches[1]!
expect(successor.nodes.map(node => node.seq)).toEqual([110])
expect(trajectoryBranchContainsRequest(
successor,
request('assistant', 10, 20),
)).toBe(false)
expect(trajectoryBranchContainsRequest(
successor,
request('compaction', 90, 95, 100),
)).toBe(true)
expect(trajectoryBranchContainsRequest(
successor,
request('assistant', 111),
)).toBe(true)
})
})