de96087923
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
90 lines
2.2 KiB
TypeScript
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)
|
|
})
|
|
})
|