fix(ui-trajectory): place steering before request boundary
This commit is contained in:
@@ -492,6 +492,21 @@ function requestKey(turn: number | null, group: string): string {
|
||||
return `${turn}\u0000${group}`
|
||||
}
|
||||
|
||||
function indexRequestBoundaries(records: readonly TableRecord[]): ReadonlyMap<string, number> {
|
||||
const boundaries = new Map<string, number>()
|
||||
for (const record of records) {
|
||||
const key = requestKey(record.turn, record.group)
|
||||
if (boundaries.has(key)) continue
|
||||
if (requestStep(record.group) === undefined) {
|
||||
if (record.groupStart) boundaries.set(key, record.cell.index)
|
||||
continue
|
||||
}
|
||||
if (record.cell.kind === 'user' || record.cell.kind === 'context') continue
|
||||
boundaries.set(key, record.cell.index)
|
||||
}
|
||||
return boundaries
|
||||
}
|
||||
|
||||
function sectionLabel(turn: number | null): string {
|
||||
return turn === null ? 'Between turns' : `Turn ${turn}`
|
||||
}
|
||||
@@ -499,16 +514,18 @@ function sectionLabel(turn: number | null): string {
|
||||
function indexRequestNumbers(
|
||||
records: readonly TableRecord[],
|
||||
sessionNumbers: readonly TrajectoryRequestNumber[] | undefined,
|
||||
boundaries: ReadonlyMap<string, number>,
|
||||
): ReadonlyMap<string, number> {
|
||||
const numbers = new Map<string, number>()
|
||||
for (const request of sessionNumbers ?? []) {
|
||||
numbers.set(requestKey(request.turn, request.group), request.number)
|
||||
}
|
||||
let next = Math.max(0, ...numbers.values()) + 1
|
||||
const boundaries = records
|
||||
.filter(record => record.groupStart && requestStep(record.group) !== undefined)
|
||||
const boundaryRecords = records
|
||||
.filter(record => boundaries.get(requestKey(record.turn, record.group)) === record.cell.index
|
||||
&& requestStep(record.group) !== undefined)
|
||||
.sort((left, right) => left.cell.index - right.cell.index)
|
||||
for (const record of boundaries) {
|
||||
for (const record of boundaryRecords) {
|
||||
const key = requestKey(record.turn, record.group)
|
||||
if (!numbers.has(key)) numbers.set(key, next++)
|
||||
}
|
||||
@@ -1731,9 +1748,10 @@ export function TrajectoryTable({
|
||||
useEffect(() => {
|
||||
onSelectedIndexChange?.(selectedIndex)
|
||||
}, [onSelectedIndexChange, selectedIndex])
|
||||
const requestBoundaries = useMemo(() => indexRequestBoundaries(allRecords), [allRecords])
|
||||
const requestNumbers = useMemo(
|
||||
() => indexRequestNumbers(allRecords, sessionRequestNumbers),
|
||||
[allRecords, sessionRequestNumbers],
|
||||
() => indexRequestNumbers(allRecords, sessionRequestNumbers, requestBoundaries),
|
||||
[allRecords, requestBoundaries, sessionRequestNumbers],
|
||||
)
|
||||
const records = useMemo(() => {
|
||||
if (searchMatchIndexes !== null) return filterRecords(allRecords, searchMatchIndexes)
|
||||
@@ -2218,10 +2236,11 @@ export function TrajectoryTable({
|
||||
const isRequestOnly = record.cell.requestOnly === true
|
||||
const isInitialSystem = record.cell.kind === 'system'
|
||||
&& record.cell.index === allRecords[0]?.cell.index
|
||||
const request = record.groupStart
|
||||
const key = requestKey(record.turn, record.group)
|
||||
const request = requestBoundaries.get(key) === record.cell.index
|
||||
&& !isCollapsedSummary
|
||||
&& (record.turn === null || !collapsedTurns.has(record.turn))
|
||||
? requestNumbers.get(requestKey(record.turn, record.group))
|
||||
? requestNumbers.get(key)
|
||||
: undefined
|
||||
const requestInfo = request === undefined
|
||||
? undefined
|
||||
|
||||
@@ -145,6 +145,7 @@ export function TrajectoryView({
|
||||
snapshot.openState === 'loading' || snapshot.loadingOlder)
|
||||
const hasOlderHistory = useSession(snapshot => snapshot.hasMore)
|
||||
const nodes = inspection.eventNodes
|
||||
const eventLocations = inspection.eventLocations
|
||||
const historyBaseSeq = nodes[0]?.seq ?? 0
|
||||
const partial = inspection.partial
|
||||
const runningCalls = inspection.runningCalls
|
||||
@@ -254,6 +255,7 @@ export function TrajectoryView({
|
||||
const finalized = useMemo(() => {
|
||||
const turns = deriveTrajectoryLayout({
|
||||
nodes,
|
||||
eventLocations,
|
||||
partial: partialTurn === null || partialStep === null
|
||||
? null
|
||||
: { turn: partialTurn, step: partialStep, blocks: [] },
|
||||
@@ -263,7 +265,7 @@ export function TrajectoryView({
|
||||
})
|
||||
return { turns, lastIndex: lastCellIndex(turns) }
|
||||
}, [
|
||||
nodes, partialTurn, partialStep,
|
||||
nodes, eventLocations, partialTurn, partialStep,
|
||||
runningCalls, requests, callSchemas,
|
||||
])
|
||||
const timelinePartialSignature = partialStructureSignature(partial)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import type {
|
||||
AssistantBlock,
|
||||
AssistantMessageNode,
|
||||
ConversationLocation,
|
||||
ConversationSnapshot,
|
||||
RequestInspectionSnapshot,
|
||||
RequestPromptChange,
|
||||
@@ -34,6 +35,7 @@ export interface TrajectoryTurnModel {
|
||||
/** Snapshot slice the trajectory view folds. */
|
||||
export interface TrajectoryLayoutInput {
|
||||
nodes: ConversationSnapshot['nodes']
|
||||
eventLocations?: ReadonlyMap<number, ConversationLocation>
|
||||
partial: ConversationSnapshot['partial']
|
||||
runningCalls: ConversationSnapshot['runningCalls']
|
||||
requests?: readonly RequestView[]
|
||||
@@ -71,7 +73,7 @@ type CompactionRequestView = Extract<RequestView, { purpose: 'compaction' }>
|
||||
|
||||
type InputNode = Extract<
|
||||
ConversationSnapshot['nodes'][number],
|
||||
{ kind: 'user' | 'context' }
|
||||
{ kind: 'user' | 'steering' | 'context' }
|
||||
>
|
||||
|
||||
type OrderedLayoutEntry =
|
||||
@@ -135,12 +137,13 @@ function inputCellDetail(node: InputNode): Pick<
|
||||
*/
|
||||
export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] {
|
||||
const {
|
||||
nodes, partial, runningCalls, requests = [], callSchemas,
|
||||
nodes, eventLocations, partial, runningCalls, requests = [], callSchemas,
|
||||
} = input
|
||||
const resultByCall = indexResults(nodes)
|
||||
const callById = new Map<string, ToolCallBlock>(resultByCall)
|
||||
for (const call of runningCalls) callById.set(call.callId, call)
|
||||
const emittedCallIds = indexAssistantCallIds(nodes)
|
||||
const followingAssistants = indexFollowingAssistants(nodes)
|
||||
const callStartById = new Map<string, number>()
|
||||
for (const result of resultByCall.values()) {
|
||||
const startedAt = finiteTime(result.callTime)
|
||||
@@ -185,6 +188,19 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
}
|
||||
groups.push({ title, laid: [...laid] })
|
||||
}
|
||||
const pushStepInput = (turn: number, step: number, laid: readonly LaidCell[]) => {
|
||||
if (laid.length === 0) return
|
||||
const groups = bucket(turn).groups
|
||||
const title = `Step ${step}`
|
||||
const existing = groups.find(group => group.title === title)
|
||||
if (existing === undefined) {
|
||||
groups.push({ title, laid: [...laid] })
|
||||
return
|
||||
}
|
||||
const request = existing.laid.findIndex(entry => entry.cell.requestOnly === true)
|
||||
if (request === -1) existing.laid.push(...laid)
|
||||
else existing.laid.splice(request, 0, ...laid)
|
||||
}
|
||||
|
||||
const representedRequests = new Set<string>()
|
||||
for (const node of nodes) {
|
||||
@@ -338,7 +354,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
if (node.kind === 'user') {
|
||||
// user/message has no turn on the wire; enclose it in the next assistant
|
||||
// (or partial) turn, else open the turn after the last assistant.
|
||||
const turn = enclosingUserTurn(nodes, i, partial, lastAssistantTurn)
|
||||
const turn = enclosingUserTurn(followingAssistants[i], partial, lastAssistantTurn)
|
||||
pushMessage(turn, {
|
||||
absTime: finiteTime(node.time),
|
||||
cell: {
|
||||
@@ -351,6 +367,26 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
|
||||
continue
|
||||
}
|
||||
if (node.kind === 'steering') {
|
||||
const placement = steeringPlacement(
|
||||
followingAssistants[i],
|
||||
partial,
|
||||
lastAssistantTurn,
|
||||
eventLocations?.get(node.seq),
|
||||
)
|
||||
const laid = {
|
||||
absTime: finiteTime(node.time),
|
||||
cell: {
|
||||
index: ++index,
|
||||
kind: 'user' as const,
|
||||
...inputCellDetail(node),
|
||||
},
|
||||
}
|
||||
if (placement.step === undefined) pushMessage(placement.turn, laid)
|
||||
else pushStepInput(placement.turn, placement.step, [laid])
|
||||
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
|
||||
continue
|
||||
}
|
||||
if (node.kind === 'assistant') {
|
||||
const laidList = withSubCalls(
|
||||
expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById, callById),
|
||||
@@ -364,7 +400,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
continue
|
||||
}
|
||||
if (node.kind === 'context') {
|
||||
const turn = enclosingUserTurn(nodes, i, partial, lastAssistantTurn)
|
||||
const turn = enclosingUserTurn(followingAssistants[i], partial, lastAssistantTurn)
|
||||
pushMessage(turn, {
|
||||
absTime: finiteTime(node.time),
|
||||
cell: {
|
||||
@@ -821,22 +857,53 @@ function stringifySourceValue(value: unknown): string {
|
||||
* in-flight partial, else the turn after the last finalized assistant (or 1).
|
||||
*/
|
||||
function enclosingUserTurn(
|
||||
nodes: ConversationSnapshot['nodes'],
|
||||
userIndex: number,
|
||||
followingAssistant: AssistantMessageNode | undefined,
|
||||
partial: ConversationSnapshot['partial'],
|
||||
lastAssistantTurn: number | null,
|
||||
): number {
|
||||
for (let i = userIndex + 1; i < nodes.length; i++) {
|
||||
const n = nodes[i]
|
||||
/* v8 ignore next -- dense-array guard: i stays within nodes.length, so the undefined arm needs a sparse array no caller builds. */
|
||||
if (n === undefined) continue
|
||||
if (n.kind === 'assistant') return n.turn
|
||||
}
|
||||
if (followingAssistant !== undefined) return followingAssistant.turn
|
||||
if (partial !== null) return partial.turn
|
||||
if (lastAssistantTurn !== null) return lastAssistantTurn + 1
|
||||
return 1
|
||||
}
|
||||
|
||||
function steeringPlacement(
|
||||
followingAssistant: AssistantMessageNode | undefined,
|
||||
partial: ConversationSnapshot['partial'],
|
||||
lastAssistantTurn: number | null,
|
||||
location: ConversationLocation | undefined,
|
||||
): { turn: number; step?: number } {
|
||||
if (location?.kind === 'step') {
|
||||
return { turn: location.turn.turn, step: location.step.step }
|
||||
}
|
||||
const locatedTurn = location?.kind === 'turn' ? location.turn.turn : undefined
|
||||
if (followingAssistant !== undefined
|
||||
&& (locatedTurn === undefined || followingAssistant.turn === locatedTurn)) {
|
||||
return {
|
||||
turn: followingAssistant.turn,
|
||||
...(followingAssistant.step > 0 ? { step: followingAssistant.step } : {}),
|
||||
}
|
||||
}
|
||||
if (partial !== null && (locatedTurn === undefined || partial.turn === locatedTurn)) {
|
||||
return { turn: partial.turn, ...(partial.step > 0 ? { step: partial.step } : {}) }
|
||||
}
|
||||
if (locatedTurn !== undefined) return { turn: locatedTurn }
|
||||
return { turn: lastAssistantTurn ?? 1 }
|
||||
}
|
||||
|
||||
function indexFollowingAssistants(
|
||||
nodes: ConversationSnapshot['nodes'],
|
||||
): readonly (AssistantMessageNode | undefined)[] {
|
||||
const following = new Array<AssistantMessageNode | undefined>(nodes.length)
|
||||
let assistant: AssistantMessageNode | undefined
|
||||
for (let index = nodes.length - 1; index >= 0; index--) {
|
||||
following[index] = assistant
|
||||
const node = nodes[index]
|
||||
if (node?.kind === 'assistant') assistant = node
|
||||
}
|
||||
return following
|
||||
}
|
||||
|
||||
function enclosingPromptTurn(
|
||||
nodes: ConversationSnapshot['nodes'],
|
||||
seq: number,
|
||||
|
||||
@@ -53,12 +53,14 @@ export type TrajectoryContribution =
|
||||
export interface TrajectoryConversationViewNode extends ConversationViewNode {
|
||||
readonly target: 'trajectory'
|
||||
readonly anchorSeq: number
|
||||
readonly location: ConversationLocation
|
||||
readonly data: TrajectoryContribution
|
||||
}
|
||||
|
||||
/** Stage-oriented Trajectory data assembled from registered business Contexts. */
|
||||
export interface TrajectorySnapshot {
|
||||
readonly eventNodes: readonly ConversationNode[]
|
||||
readonly eventLocations: ReadonlyMap<number, ConversationLocation>
|
||||
readonly requests: readonly RequestView[]
|
||||
readonly callSchemas: ReadonlyMap<string, ConversationPromptSnapshot['tools'][number]>
|
||||
readonly partial: PartialAssistant | null
|
||||
|
||||
@@ -22,6 +22,7 @@ export function trajectoryNode(
|
||||
id: context.id,
|
||||
target: 'trajectory',
|
||||
anchorSeq,
|
||||
location: context.start?.location ?? { kind: 'unresolved' },
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ type ToolSchema = ConversationPromptSnapshot['tools'][number]
|
||||
/** Stable empty target used until a Session has assembled Trajectory records. */
|
||||
export const EMPTY_TRAJECTORY_SNAPSHOT: TrajectorySnapshot = {
|
||||
eventNodes: EMPTY_LIST,
|
||||
eventLocations: new Map(),
|
||||
requests: EMPTY_LIST,
|
||||
callSchemas: new Map(),
|
||||
partial: null,
|
||||
@@ -179,6 +180,7 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder<
|
||||
if (key !== undefined) headersByStep.set(key, contribution.data.header)
|
||||
}
|
||||
const finalized: ConversationNode[] = []
|
||||
const eventLocations = new Map<number, TrajectoryConversationViewNode['location']>()
|
||||
const requests: RequestView[] = []
|
||||
const boundaries: { seq: number; time: number }[] = []
|
||||
const turnEndings: { turn: number; time: number; error?: string }[] = []
|
||||
@@ -198,6 +200,7 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder<
|
||||
}
|
||||
if (data.kind === 'node') {
|
||||
finalized.push(data.node)
|
||||
eventLocations.set(data.node.seq, contribution.location)
|
||||
continue
|
||||
}
|
||||
if (data.kind === 'assistant') {
|
||||
@@ -244,6 +247,7 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder<
|
||||
const eventNodes = finalized
|
||||
return {
|
||||
eventNodes,
|
||||
eventLocations,
|
||||
requests,
|
||||
callSchemas,
|
||||
partial,
|
||||
|
||||
Reference in New Issue
Block a user