fix(client): resolve trajectory review follow-ups

This commit is contained in:
_Kerman
2026-07-28 22:54:46 +08:00
parent a245c8a011
commit 943ef7403e
11 changed files with 392 additions and 96 deletions
@@ -6,7 +6,7 @@ import type {
AssistantMessageNode, ConversationContext, ConversationNode, RequestView,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
deriveTrajectoryContextBranches, trajectoryBranchContainsSeq,
deriveTrajectoryContextBranches, trajectoryBranchContainsRequest,
} from './context-branches.ts'
import {
TrajectoryTable,
@@ -28,7 +28,7 @@ const EMPTY_REQUESTS: readonly RequestView[] = []
/** Session-history paging needed by the event-complete trajectory view. */
export interface TrajectoryViewInjected {
loadAllHistory: () => Promise<void>
loadAllHistory: (signal: AbortSignal) => Promise<void>
}
interface UsageLike {
@@ -160,7 +160,11 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
const loadAllHistoryRef = useRef(loadAllHistory)
loadAllHistoryRef.current = loadAllHistory
useEffect(() => {
if (openState === 'open' && hasMore) void loadAllHistoryRef.current()
const controller = new AbortController()
if (openState === 'open' && hasMore) {
void loadAllHistoryRef.current(controller.signal)
}
return () => { controller.abort() }
}, [hasMore, openState])
const requests = inspection?.requests ?? EMPTY_REQUESTS
const callSchemas = inspection?.callSchemas
@@ -185,7 +189,7 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
}, [currentBranch, nodes])
const selectedRequests = useMemo(
() => requests.filter(request =>
trajectoryBranchContainsSeq(currentBranch, request.startSeq),
trajectoryBranchContainsRequest(currentBranch, request),
),
[currentBranch, requests],
)
@@ -1,7 +1,7 @@
/** Rewind-delimited trajectory branches assembled across surface rewrites. */
import type {
ConversationContext, ConversationNode,
ConversationContext, ConversationNode, RequestView,
} from '@deepseek-ai/dsh-client-runtime/client'
/** One continuous context branch; compactions stay inline while rewinds start a successor branch. */
@@ -10,13 +10,10 @@ export interface TrajectoryContextBranch {
contexts: readonly ConversationContext[]
latest: ConversationContext
nodes: readonly ConversationNode[]
ranges: readonly TrajectoryBranchRange[]
}
/** One half-open session-event range carried by a rewind branch. */
export interface TrajectoryBranchRange {
start: number
end: number
/** Seq that opened this branch; earlier requests require retained surface provenance. */
startSeq: number
/** Exact pre-rewind surface records inherited by this branch. */
retainedSurfaceSeqs: ReadonlySet<number>
}
interface MutableBranch {
@@ -24,7 +21,8 @@ interface MutableBranch {
contexts: ConversationContext[]
latest: ConversationContext
nodes: Map<number, ConversationNode>
ranges: TrajectoryBranchRange[]
startSeq: number
retainedSurfaceSeqs: Set<number>
}
function isCompactionCheckpoint(node: ConversationNode): boolean {
@@ -51,29 +49,18 @@ export function deriveTrajectoryContextBranches(
const startsBranch = mutable.length === 0 || context.origin === 'rewind'
if (startsBranch) {
const previous = mutable.at(-1)
const originSeq = context.originSeq ?? Number.POSITIVE_INFINITY
if (previous !== undefined) {
const openRange = previous.ranges.at(-1)
if (openRange === undefined) {
throw new Error('trajectory branch must contain an open event range')
}
openRange.end = originSeq
}
const retainedCutoff = Math.max(
Number.NEGATIVE_INFINITY,
...context.nodes
.filter(node => node.seq < originSeq)
const retainedSurfaceSeqs = new Set(
context.nodes
.filter(node =>
context.originSeq !== undefined && node.seq < context.originSeq,
)
.map(node => node.seq),
)
const inheritedNodes = previous === undefined
? []
: [...previous.nodes.values()].filter(node => node.seq <= retainedCutoff)
const inheritedRanges = previous === undefined
? []
: previous.ranges.flatMap((range) => {
const end = Math.min(range.end, retainedCutoff + 1)
return end <= range.start ? [] : [{ start: range.start, end }]
})
: [...previous.nodes.values()].filter(node =>
retainedSurfaceSeqs.has(node.seq),
)
mutable.push({
id: context.id,
contexts: [context],
@@ -82,13 +69,8 @@ export function deriveTrajectoryContextBranches(
[...inheritedNodes, ...context.nodes.filter(node => !isCompactionCheckpoint(node))]
.map(node => [node.seq, node]),
),
ranges: [
...inheritedRanges,
{
start: context.originSeq ?? Number.NEGATIVE_INFINITY,
end: Number.POSITIVE_INFINITY,
},
],
startSeq: context.originSeq ?? Number.NEGATIVE_INFINITY,
retainedSurfaceSeqs,
})
continue
}
@@ -105,19 +87,27 @@ export function deriveTrajectoryContextBranches(
contexts: branch.contexts,
latest: branch.latest,
nodes: [...branch.nodes.values()].sort((left, right) => left.seq - right.seq),
ranges: branch.ranges,
startSeq: branch.startSeq,
retainedSurfaceSeqs: branch.retainedSurfaceSeqs,
}))
}
/**
* Test whether a session event belongs to one rewind branch's continuous history.
* @param branch - Branch carrying inherited and post-rewind log ranges.
* @param seq - Session event sequence.
* @returns Whether the event belongs to the branch.
* Test whether a provider request belongs to one rewind branch.
* @param branch - Branch carrying exact inherited surface provenance.
* @param request - Provider request to classify.
* @returns Whether the request began on this branch or produced a retained surface record.
*/
export function trajectoryBranchContainsSeq(
export function trajectoryBranchContainsRequest(
branch: TrajectoryContextBranch,
seq: number,
request: RequestView,
): boolean {
return branch.ranges.some(range => seq >= range.start && seq < range.end)
if (request.startSeq >= branch.startSeq) return true
return (
request.resultSeq !== undefined
&& branch.retainedSurfaceSeqs.has(request.resultSeq)
) || (
request.replacementSeq !== undefined
&& branch.retainedSurfaceSeqs.has(request.replacementSeq)
)
}
@@ -34,7 +34,9 @@ export function apply(ctx: Context): void {
if (session === undefined) {
throw new Error(`ui-trajectory: session "${sessionId}" resolved no binding`)
}
return { loadAllHistory: () => session.loadAllHistory() }
return {
loadAllHistory: signal => session.loadAllHistory(signal),
}
},
}, TrajectoryView)
}
@@ -0,0 +1,83 @@
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' },
} 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 {
return {
purpose,
startSeq,
turn: 1,
step: purpose === 'assistant' ? 1 : 0,
startedAt: startSeq,
completedAt: startSeq + 1,
status: 'complete',
...(resultSeq === undefined ? {} : { resultSeq }),
...(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)
})
})
@@ -92,7 +92,7 @@ function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
async function bench() {
const ctx = new Context()
const slots = new SlotsService(ctx)
const loadAllHistory = vi.fn(() => Promise.resolve())
const loadAllHistory = vi.fn((_signal: AbortSignal) => Promise.resolve())
// The conversation entry's role: declare the ring, then seed the chat entry.
slots.register({
name: 'root',
@@ -211,6 +211,10 @@ describe('tab switching in ConversationRoot', () => {
await vi.waitFor(() => {
expect(b.loadAllHistory).toHaveBeenCalledOnce()
})
const signal = b.loadAllHistory.mock.calls[0]?.[0]
expect(signal?.aborted).toBe(false)
fireEvent.click(screen.getByRole('tab', { name: 'Chat' }))
expect(signal?.aborted).toBe(true)
})
it('opens a local record inspector and switches payload tabs without opening chat details', async () => {