fix(ui): keep streaming deltas through pipeline
This commit is contained in:
@@ -275,7 +275,7 @@ describe('createEventPipeline', () => {
|
|||||||
expect(received[0].payload.type).toBe('server.connected');
|
expect(received[0].payload.type).toBe('server.connected');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips stale message.part.delta events after a newer message.part.updated for the same field', async () => {
|
it('keeps message.part.delta events when a newer message.part.updated is queued for the same field', async () => {
|
||||||
installDomStubs();
|
installDomStubs();
|
||||||
|
|
||||||
let releaseStream;
|
let releaseStream;
|
||||||
@@ -285,8 +285,8 @@ describe('createEventPipeline', () => {
|
|||||||
|
|
||||||
const received = [];
|
const received = [];
|
||||||
|
|
||||||
// Simulate: part.updated arrives, then delta, then a newer part.updated for the
|
// The pipeline only routes/coalesces events. Whether this delta is already
|
||||||
// same part. The older queued delta becomes stale and must be skipped.
|
// represented by the newer snapshot is reducer state, not queue state.
|
||||||
const directory = '/test/dir';
|
const directory = '/test/dir';
|
||||||
const sdk = createSdkWithEvents([
|
const sdk = createSdkWithEvents([
|
||||||
// T0: message.part.updated for part-A
|
// T0: message.part.updated for part-A
|
||||||
@@ -299,7 +299,7 @@ describe('createEventPipeline', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// T1: message.part.delta for part-A (should be dropped as stale)
|
// T1: message.part.delta for part-A
|
||||||
{
|
{
|
||||||
payload: {
|
payload: {
|
||||||
type: 'message.part.delta',
|
type: 'message.part.delta',
|
||||||
@@ -329,7 +329,7 @@ describe('createEventPipeline', () => {
|
|||||||
sdk,
|
sdk,
|
||||||
onEvent: (dir, payload) => {
|
onEvent: (dir, payload) => {
|
||||||
received.push({ directory: dir, payload });
|
received.push({ directory: dir, payload });
|
||||||
if (received.length === 1) {
|
if (received.length === 2) {
|
||||||
cleanup();
|
cleanup();
|
||||||
releaseStream();
|
releaseStream();
|
||||||
resolve();
|
resolve();
|
||||||
@@ -340,8 +340,10 @@ describe('createEventPipeline', () => {
|
|||||||
|
|
||||||
await delivered;
|
await delivered;
|
||||||
|
|
||||||
expect(received.length).toBe(1);
|
expect(received.length).toBe(2);
|
||||||
expect(received[0].payload.type).toBe('message.part.updated');
|
expect(received[0].payload.type).toBe('message.part.updated');
|
||||||
|
expect(received[1].payload.type).toBe('message.part.delta');
|
||||||
|
expect(received[1].payload.properties.delta).toBe(' world');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps delta events for other fields on the same part', async () => {
|
it('keeps delta events for other fields on the same part', async () => {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Event Pipeline — transport connection, event coalescing, and batched flush.
|
* Event Pipeline — transport connection, event coalescing, and batched flush.
|
||||||
*
|
*
|
||||||
|
* This module must not make state-dependent decisions about event validity.
|
||||||
|
* For example, deciding whether a delta is already represented by a full part
|
||||||
|
* snapshot belongs in the reducer, which has access to the current state.
|
||||||
|
*
|
||||||
* Plain closure API:
|
* Plain closure API:
|
||||||
* const { cleanup } = createEventPipeline({ sdk, onEvent })
|
* const { cleanup } = createEventPipeline({ sdk, onEvent })
|
||||||
*
|
*
|
||||||
@@ -145,7 +149,6 @@ type DirectoryQueue = {
|
|||||||
queue: Event[]
|
queue: Event[]
|
||||||
buffer: Event[]
|
buffer: Event[]
|
||||||
coalesced: Map<string, number>
|
coalesced: Map<string, number>
|
||||||
staleDeltas: Set<string>
|
|
||||||
timer: ReturnType<typeof setTimeout> | undefined
|
timer: ReturnType<typeof setTimeout> | undefined
|
||||||
last: number
|
last: number
|
||||||
}
|
}
|
||||||
@@ -185,7 +188,6 @@ export function createEventPipeline(input: EventPipelineInput) {
|
|||||||
queue: [],
|
queue: [],
|
||||||
buffer: [],
|
buffer: [],
|
||||||
coalesced: new Map(),
|
coalesced: new Map(),
|
||||||
staleDeltas: new Set(),
|
|
||||||
timer: undefined,
|
timer: undefined,
|
||||||
last: 0,
|
last: 0,
|
||||||
}
|
}
|
||||||
@@ -212,8 +214,6 @@ export function createEventPipeline(input: EventPipelineInput) {
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const deltaKey = (messageID: string, partID: string, field: string) => `${messageID}:${partID}:${field}`
|
|
||||||
|
|
||||||
const flushDir = (directory: string) => {
|
const flushDir = (directory: string) => {
|
||||||
const d = directories.get(directory)
|
const d = directories.get(directory)
|
||||||
if (!d) return
|
if (!d) return
|
||||||
@@ -224,22 +224,14 @@ export function createEventPipeline(input: EventPipelineInput) {
|
|||||||
if (d.queue.length === 0) return
|
if (d.queue.length === 0) return
|
||||||
|
|
||||||
const events = d.queue
|
const events = d.queue
|
||||||
const staleDeltas = d.staleDeltas.size > 0 ? new Set(d.staleDeltas) : undefined
|
|
||||||
d.queue = d.buffer
|
d.queue = d.buffer
|
||||||
d.buffer = events
|
d.buffer = events
|
||||||
d.queue.length = 0
|
d.queue.length = 0
|
||||||
d.coalesced.clear()
|
d.coalesced.clear()
|
||||||
d.staleDeltas.clear()
|
|
||||||
|
|
||||||
d.last = Date.now()
|
d.last = Date.now()
|
||||||
syncDebug.pipeline.flush(events.length)
|
syncDebug.pipeline.flush(events.length)
|
||||||
for (const payload of events) {
|
for (const payload of events) {
|
||||||
if (staleDeltas && payload.type === "message.part.delta") {
|
|
||||||
const props = payload.properties as { messageID: string; partID: string; field: string }
|
|
||||||
if (staleDeltas.has(deltaKey(props.messageID, props.partID, props.field))) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onEvent(directory, payload)
|
onEvent(directory, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,15 +305,6 @@ export function createEventPipeline(input: EventPipelineInput) {
|
|||||||
} as unknown as Event
|
} as unknown as Event
|
||||||
} else {
|
} else {
|
||||||
d.queue[i] = normalizedPayload
|
d.queue[i] = normalizedPayload
|
||||||
if (normalizedPayload.type === "message.part.updated") {
|
|
||||||
const part = (normalizedPayload.properties as { part: Record<string, unknown> & { messageID: string; id: string } }).part
|
|
||||||
for (const field of ["text", "output"] as const) {
|
|
||||||
const value = part[field]
|
|
||||||
if (typeof value === "string" && value.length > 0) {
|
|
||||||
d.staleDeltas.add(deltaKey(part.messageID, part.id, field))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
syncDebug.pipeline.coalesced(normalizedPayload.type, k)
|
syncDebug.pipeline.coalesced(normalizedPayload.type, k)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -54,6 +54,17 @@ describe("getReconnectCandidateSessionIds", () => {
|
|||||||
}).sort()).toContain("active")
|
}).sort()).toContain("active")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("includes completed assistant sessions when the latest assistant parts are missing", () => {
|
||||||
|
expect(getReconnectCandidateSessionIds({
|
||||||
|
session: [createSession("blank")],
|
||||||
|
session_status: { blank: { type: "idle" } as SessionStatus },
|
||||||
|
message: {
|
||||||
|
blank: [createAssistantMessage("m-1", "blank", 1)],
|
||||||
|
},
|
||||||
|
part: {},
|
||||||
|
})).toEqual(["blank"])
|
||||||
|
})
|
||||||
|
|
||||||
test("does not include a viewed session from another directory", () => {
|
test("does not include a viewed session from another directory", () => {
|
||||||
expect(getReconnectCandidateSessionIds({
|
expect(getReconnectCandidateSessionIds({
|
||||||
session: [createSession("active")],
|
session: [createSession("active")],
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import type { SessionStatus, Message } from "@opencode-ai/sdk/v2/client"
|
import type { SessionStatus, Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||||
import type { Session } from "@opencode-ai/sdk/v2"
|
import type { Session } from "@opencode-ai/sdk/v2"
|
||||||
|
|
||||||
type ReconnectRecoveryState = {
|
type ReconnectRecoveryState = {
|
||||||
session: Session[]
|
session: Session[]
|
||||||
session_status?: Record<string, SessionStatus>
|
session_status?: Record<string, SessionStatus>
|
||||||
message?: Record<string, Message[]>
|
message?: Record<string, Message[]>
|
||||||
|
part?: Record<string, Part[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ViewedSessionRecoveryTarget = {
|
export type ViewedSessionRecoveryTarget = {
|
||||||
@@ -26,12 +27,17 @@ export function getReconnectCandidateSessionIds(state: ReconnectRecoveryState, o
|
|||||||
|
|
||||||
for (const [sessionId, messages] of Object.entries(state.message ?? {})) {
|
for (const [sessionId, messages] of Object.entries(state.message ?? {})) {
|
||||||
const lastMessage = messages[messages.length - 1]
|
const lastMessage = messages[messages.length - 1]
|
||||||
|
const lastAssistantComplete = lastMessage
|
||||||
|
&& lastMessage.role === "assistant"
|
||||||
|
&& typeof (lastMessage as { time?: { completed?: number } }).time?.completed === "number"
|
||||||
if (
|
if (
|
||||||
lastMessage
|
lastMessage
|
||||||
&& lastMessage.role === "assistant"
|
&& lastMessage.role === "assistant"
|
||||||
&& typeof (lastMessage as { time?: { completed?: number } }).time?.completed !== "number"
|
&& typeof (lastMessage as { time?: { completed?: number } }).time?.completed !== "number"
|
||||||
) {
|
) {
|
||||||
ids.add(sessionId)
|
ids.add(sessionId)
|
||||||
|
} else if (lastAssistantComplete && state.part && (state.part[lastMessage.id]?.length ?? 0) === 0) {
|
||||||
|
ids.add(sessionId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user