fix(client): resolve trajectory review follow-ups
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user