fix(client): address conversation assembly review findings

This commit is contained in:
imccyu
2026-08-09 17:02:20 +08:00
parent 522acc7432
commit 8723a0e15f
7 changed files with 128 additions and 20 deletions

View File

@@ -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(

View File

@@ -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]

View File

@@ -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((