fix(client): address conversation assembly review findings
This commit is contained in:
@@ -238,7 +238,7 @@ export class ConversationNodeAssembler {
|
||||
}
|
||||
this.applyPendingMatches(pending, affected)
|
||||
this.replayContexts(affected)
|
||||
if ((fresh.length > 0 || previousHasMore !== hasMore) && this.replayDependencies()) {
|
||||
if ((this.revised.size > 0 || previousHasMore !== hasMore) && this.replayDependencies()) {
|
||||
publication = 'immediate'
|
||||
}
|
||||
if (changedLocations.size > 0) publication = 'immediate'
|
||||
@@ -563,18 +563,18 @@ export class ConversationNodeAssembler {
|
||||
|
||||
private replayRevisedDependents(): boolean {
|
||||
const pending = [...this.revised]
|
||||
const replayed = new Set<InternalContext>()
|
||||
const affected = new Set<InternalContext>()
|
||||
for (let index = 0; index < pending.length; index++) {
|
||||
const dependency = pending[index]
|
||||
if (dependency === undefined) continue
|
||||
for (const dependent of this.dependents.get(dependency.key) ?? []) {
|
||||
if (replayed.has(dependent)) continue
|
||||
replayed.add(dependent)
|
||||
this.replayContext(dependent)
|
||||
if (affected.has(dependent)) continue
|
||||
affected.add(dependent)
|
||||
pending.push(dependent)
|
||||
}
|
||||
}
|
||||
return replayed.size > 0
|
||||
this.replayContexts(affected)
|
||||
return affected.size > 0
|
||||
}
|
||||
|
||||
private readerFor(
|
||||
|
||||
@@ -344,6 +344,7 @@ export class Session implements SessionFace {
|
||||
// §D.2 continuity assertion: on violation drop the page fail-soft rather than render an out-of-order stream.
|
||||
console.error(`[web-runtime] history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${this.baseSeq}`)
|
||||
this.hasMore = false
|
||||
this.conversation.prepend([], false)
|
||||
return
|
||||
}
|
||||
this.events = [...older.map(e => e.event), ...this.events]
|
||||
|
||||
@@ -458,6 +458,73 @@ describe('ConversationNodeAssembler', () => {
|
||||
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(2)
|
||||
})
|
||||
|
||||
it('replays a transitive dependency closure in start order', () => {
|
||||
const sourceA: ConversationNodeDefinition<number> = {
|
||||
kind: 'diamond-a',
|
||||
match: (event) => {
|
||||
if (event.type === 'user/message') return { id: 'one', role: 'start' }
|
||||
if ((event.type as string) === 'diamond/a') return { id: 'one', role: 'update' }
|
||||
return null
|
||||
},
|
||||
start: () => 1,
|
||||
update: (_context, match) => (match.event.data as unknown as { value: number }).value,
|
||||
buildViewNode: () => null,
|
||||
}
|
||||
const sourceX: ConversationNodeDefinition<number> = {
|
||||
kind: 'diamond-x',
|
||||
match: (event) => {
|
||||
if (event.type === 'turn/start') return { id: 'one', role: 'start' }
|
||||
if ((event.type as string) === 'diamond/x') return { id: 'one', role: 'update' }
|
||||
return null
|
||||
},
|
||||
start: () => 10,
|
||||
update: (_context, match) => (match.event.data as unknown as { value: number }).value,
|
||||
buildViewNode: () => null,
|
||||
}
|
||||
const middle: ConversationNodeDefinition<number> = {
|
||||
kind: 'diamond-b',
|
||||
match: event => event.type === 'assistant/message'
|
||||
? { id: 'one', role: 'start' }
|
||||
: null,
|
||||
start: (_context, _match, reader) => (
|
||||
(reader.previous<number>('diamond-a')?.state ?? 0)
|
||||
+ (reader.previous<number>('diamond-x')?.state ?? 0)
|
||||
),
|
||||
update: context => context.state,
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const consumer: ConversationNodeDefinition<number> = {
|
||||
kind: 'diamond-c',
|
||||
match: event => event.type === 'tool/call'
|
||||
? { id: 'one', role: 'start' }
|
||||
: null,
|
||||
start: (_context, _match, reader) => (
|
||||
(reader.previous<number>('diamond-a')?.state ?? 0) * 100
|
||||
+ (reader.previous<number>('diamond-b')?.state ?? 0)
|
||||
),
|
||||
update: context => context.state,
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
new TestEventDefinitions([sourceA, sourceX, middle, consumer]),
|
||||
new TestViewDefinitions([testView()]),
|
||||
)
|
||||
assembler.replaceWindow([
|
||||
input(at(1, 'user/message', { id: 'source', content: [], source: { kind: 'user' } })),
|
||||
input(at(2, 'turn/start', { turn: 1 })),
|
||||
input(at(3, 'assistant/message', { turn: 1, step: 1, message: { role: 'assistant', content: [] } })),
|
||||
input(at(4, 'tool/call', { turn: 1, step: 1, callId: 'call', name: 'x', arguments: '{}' })),
|
||||
], false)
|
||||
|
||||
assembler.append(input(at(5, 'diamond/x', { value: 20 })))
|
||||
assembler.append(input(at(6, 'diamond/a', { value: 2 })))
|
||||
assembler.flush()
|
||||
|
||||
const value = [...chatSnapshot(assembler)?.nodes.values() ?? []]
|
||||
.find(candidate => candidate.kind === 'diamond-c')
|
||||
expect(value?.data).toBe(222)
|
||||
})
|
||||
|
||||
it('replays Location-derived State and rebuilds only owned Nodes when a step closes', () => {
|
||||
const apply = vi.fn()
|
||||
const starts = vi.fn((
|
||||
|
||||
Reference in New Issue
Block a user