refactor(app): fold timeline sequencing helpers into stream reducer (#664)
Three sibling modules (session-stream-lifecycle, session-timeline-seq-gate, and the bootstrap-tail policy from session-timeline-bootstrap-policy) only served the stream reducer. Inline them as private helpers in session-stream-reducers.ts; keep deriveInitialTimelineRequest exported for the hook caller. Removes shallow seams without changing behavior.
This commit is contained in:
@@ -1,30 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentStreamEventPayload } from "@server/shared/messages";
|
||||
import { deriveOptimisticLifecycleStatus } from "./session-stream-lifecycle";
|
||||
|
||||
const turnCompletedEvent: AgentStreamEventPayload = {
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
};
|
||||
|
||||
const turnFailedEvent: AgentStreamEventPayload = {
|
||||
type: "turn_failed",
|
||||
provider: "claude",
|
||||
error: "failed",
|
||||
};
|
||||
|
||||
const turnCanceledEvent: AgentStreamEventPayload = {
|
||||
type: "turn_canceled",
|
||||
provider: "codex",
|
||||
reason: "interrupted",
|
||||
};
|
||||
|
||||
describe("session stream lifecycle helpers", () => {
|
||||
it("derives optimistic terminal lifecycle only when current status is running", () => {
|
||||
expect(deriveOptimisticLifecycleStatus("running", turnCompletedEvent)).toBe("idle");
|
||||
expect(deriveOptimisticLifecycleStatus("running", turnFailedEvent)).toBe("error");
|
||||
expect(deriveOptimisticLifecycleStatus("running", turnCanceledEvent)).toBe(null);
|
||||
expect(deriveOptimisticLifecycleStatus("initializing", turnCompletedEvent)).toBe(null);
|
||||
expect(deriveOptimisticLifecycleStatus("idle", turnFailedEvent)).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
||||
import type { AgentStreamEventPayload } from "@server/shared/messages";
|
||||
|
||||
export function deriveOptimisticLifecycleStatus(
|
||||
currentStatus: AgentLifecycleStatus,
|
||||
event: AgentStreamEventPayload,
|
||||
): AgentLifecycleStatus | null {
|
||||
if (currentStatus !== "running") {
|
||||
return null;
|
||||
}
|
||||
switch (event.type) {
|
||||
case "turn_completed":
|
||||
return "idle";
|
||||
case "turn_failed":
|
||||
return "error";
|
||||
case "turn_canceled":
|
||||
// A canceled turn can be either a final user cancel or an interrupt before
|
||||
// a replacement turn starts. The daemon snapshot is authoritative here.
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,6 @@ import type { Agent } from "@/stores/session-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { applyStreamEvent, hydrateStreamState, reduceStreamUpdate } from "@/types/stream";
|
||||
import {
|
||||
classifySessionTimelineSeq,
|
||||
type SessionTimelineSeqDecision,
|
||||
} from "@/contexts/session-timeline-seq-gate";
|
||||
import {
|
||||
deriveBootstrapTailTimelinePolicy,
|
||||
shouldResolveTimelineInit,
|
||||
} from "@/contexts/session-timeline-bootstrap-policy";
|
||||
import { deriveOptimisticLifecycleStatus } from "@/contexts/session-stream-lifecycle";
|
||||
|
||||
const AGENT_STREAM_REDUCER_FLUSH_DELAY_MS = 16 * 3;
|
||||
|
||||
@@ -46,6 +37,16 @@ export interface AgentStreamReducerSideEffect {
|
||||
type TimelineDirection = "tail" | "before" | "after";
|
||||
type InitRequestDirection = "tail" | "after";
|
||||
|
||||
type SessionTimelineSeqCursor =
|
||||
| {
|
||||
epoch: string;
|
||||
endSeq: number;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
type SessionTimelineSeqDecision = "accept" | "drop_stale" | "drop_epoch" | "gap" | "init";
|
||||
|
||||
interface TimelineResponseEntry {
|
||||
seqStart: number;
|
||||
seqEnd: number;
|
||||
@@ -99,6 +100,106 @@ interface TimelinePathResult {
|
||||
sideEffects: TimelineReducerSideEffect[];
|
||||
}
|
||||
|
||||
function classifySessionTimelineSeq({
|
||||
cursor,
|
||||
epoch,
|
||||
seq,
|
||||
}: {
|
||||
cursor: SessionTimelineSeqCursor;
|
||||
epoch: string;
|
||||
seq: number;
|
||||
}): SessionTimelineSeqDecision {
|
||||
if (!cursor) {
|
||||
return "init";
|
||||
}
|
||||
if (cursor.epoch !== epoch) {
|
||||
return "drop_epoch";
|
||||
}
|
||||
if (seq <= cursor.endSeq) {
|
||||
return "drop_stale";
|
||||
}
|
||||
if (seq === cursor.endSeq + 1) {
|
||||
return "accept";
|
||||
}
|
||||
return "gap";
|
||||
}
|
||||
|
||||
function deriveBootstrapTailTimelinePolicy({
|
||||
direction,
|
||||
reset,
|
||||
epoch,
|
||||
endCursor,
|
||||
isInitializing,
|
||||
hasActiveInitDeferred,
|
||||
}: {
|
||||
direction: TimelineDirection;
|
||||
reset: boolean;
|
||||
epoch: string;
|
||||
endCursor: { seq: number } | null;
|
||||
isInitializing: boolean;
|
||||
hasActiveInitDeferred: boolean;
|
||||
}): {
|
||||
replace: boolean;
|
||||
catchUpCursor: { epoch: string; endSeq: number } | null;
|
||||
} {
|
||||
if (reset) {
|
||||
return { replace: true, catchUpCursor: null };
|
||||
}
|
||||
|
||||
const isBootstrapTailInit = direction === "tail" && isInitializing && hasActiveInitDeferred;
|
||||
if (!isBootstrapTailInit) {
|
||||
return { replace: false, catchUpCursor: null };
|
||||
}
|
||||
|
||||
return {
|
||||
replace: true,
|
||||
catchUpCursor: endCursor ? { epoch, endSeq: endCursor.seq } : null,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldResolveTimelineInit({
|
||||
hasActiveInitDeferred,
|
||||
isInitializing,
|
||||
initRequestDirection,
|
||||
responseDirection,
|
||||
reset,
|
||||
}: {
|
||||
hasActiveInitDeferred: boolean;
|
||||
isInitializing: boolean;
|
||||
initRequestDirection: InitRequestDirection;
|
||||
responseDirection: TimelineDirection;
|
||||
reset: boolean;
|
||||
}): boolean {
|
||||
if (!hasActiveInitDeferred || !isInitializing) {
|
||||
return false;
|
||||
}
|
||||
if (reset) {
|
||||
return true;
|
||||
}
|
||||
return responseDirection === initRequestDirection;
|
||||
}
|
||||
|
||||
function deriveOptimisticLifecycleStatus(
|
||||
currentStatus: AgentLifecycleStatus,
|
||||
event: AgentStreamEventPayload,
|
||||
): AgentLifecycleStatus | null {
|
||||
if (currentStatus !== "running") {
|
||||
return null;
|
||||
}
|
||||
switch (event.type) {
|
||||
case "turn_completed":
|
||||
return "idle";
|
||||
case "turn_failed":
|
||||
return "error";
|
||||
case "turn_canceled":
|
||||
// A canceled turn can be either a final user cancel or an interrupt before
|
||||
// a replacement turn starts. The daemon snapshot is authoritative here.
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function preserveReplacePathAssistantHead(params: {
|
||||
tail: StreamItem[];
|
||||
currentHead: StreamItem[];
|
||||
|
||||
@@ -1,108 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { classifySessionTimelineSeq } from "./session-timeline-seq-gate";
|
||||
import {
|
||||
deriveBootstrapTailTimelinePolicy,
|
||||
shouldResolveTimelineInit,
|
||||
} from "./session-timeline-bootstrap-policy";
|
||||
import { deriveInitialTimelineRequest } from "./session-timeline-bootstrap-policy";
|
||||
|
||||
describe("deriveBootstrapTailTimelinePolicy", () => {
|
||||
it("always replaces on explicit reset without catch-up cursor", () => {
|
||||
const policy = deriveBootstrapTailTimelinePolicy({
|
||||
describe("deriveInitialTimelineRequest", () => {
|
||||
it("requests tail history when authoritative history has not been applied", () => {
|
||||
expect(
|
||||
deriveInitialTimelineRequest({
|
||||
cursor: { epoch: "epoch-1", seq: 42 },
|
||||
hasAuthoritativeHistory: false,
|
||||
initialTimelineLimit: 100,
|
||||
}),
|
||||
).toEqual({
|
||||
direction: "tail",
|
||||
limit: 100,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
it("requests tail history when no cursor is available", () => {
|
||||
expect(
|
||||
deriveInitialTimelineRequest({
|
||||
cursor: null,
|
||||
hasAuthoritativeHistory: true,
|
||||
initialTimelineLimit: 100,
|
||||
}),
|
||||
).toEqual({
|
||||
direction: "tail",
|
||||
limit: 100,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
it("requests only entries after the known cursor when authoritative history exists", () => {
|
||||
expect(
|
||||
deriveInitialTimelineRequest({
|
||||
cursor: { epoch: "epoch-1", seq: 42 },
|
||||
hasAuthoritativeHistory: true,
|
||||
initialTimelineLimit: 100,
|
||||
}),
|
||||
).toEqual({
|
||||
direction: "after",
|
||||
reset: true,
|
||||
epoch: "epoch-1",
|
||||
endCursor: { seq: 200 },
|
||||
isInitializing: false,
|
||||
hasActiveInitDeferred: false,
|
||||
cursor: { epoch: "epoch-1", seq: 42 },
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
|
||||
expect(policy.replace).toBe(true);
|
||||
expect(policy.catchUpCursor).toBeNull();
|
||||
});
|
||||
|
||||
it("forces baseline replace and canonical catch-up for init tail race", () => {
|
||||
const advancedCursor = { epoch: "epoch-1", endSeq: 205 };
|
||||
const tailSeqStart = 101;
|
||||
const tailSeqEnd = 200;
|
||||
|
||||
let acceptedWithoutBootstrap = 0;
|
||||
for (let seq = tailSeqStart; seq <= tailSeqEnd; seq += 1) {
|
||||
const decision = classifySessionTimelineSeq({
|
||||
cursor: advancedCursor,
|
||||
epoch: "epoch-1",
|
||||
seq,
|
||||
});
|
||||
if (decision === "accept" || decision === "init") {
|
||||
acceptedWithoutBootstrap += 1;
|
||||
}
|
||||
}
|
||||
expect(acceptedWithoutBootstrap).toBe(0);
|
||||
|
||||
const policy = deriveBootstrapTailTimelinePolicy({
|
||||
direction: "tail",
|
||||
reset: false,
|
||||
epoch: "epoch-1",
|
||||
endCursor: { seq: 200 },
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: true,
|
||||
});
|
||||
|
||||
expect(policy.replace).toBe(true);
|
||||
expect(policy.catchUpCursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
endSeq: 200,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not replace non-bootstrap, non-reset responses", () => {
|
||||
const policy = deriveBootstrapTailTimelinePolicy({
|
||||
direction: "tail",
|
||||
reset: false,
|
||||
epoch: "epoch-1",
|
||||
endCursor: { seq: 200 },
|
||||
isInitializing: false,
|
||||
hasActiveInitDeferred: false,
|
||||
});
|
||||
|
||||
expect(policy.replace).toBe(false);
|
||||
expect(policy.catchUpCursor).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldResolveTimelineInit", () => {
|
||||
it("resolves tail init when the tail response arrives", () => {
|
||||
expect(
|
||||
shouldResolveTimelineInit({
|
||||
hasActiveInitDeferred: true,
|
||||
isInitializing: true,
|
||||
initRequestDirection: "tail",
|
||||
responseDirection: "tail",
|
||||
reset: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not resolve tail init when an after response arrives first", () => {
|
||||
expect(
|
||||
shouldResolveTimelineInit({
|
||||
hasActiveInitDeferred: true,
|
||||
isInitializing: true,
|
||||
initRequestDirection: "tail",
|
||||
responseDirection: "after",
|
||||
reset: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves after init when an after response arrives", () => {
|
||||
expect(
|
||||
shouldResolveTimelineInit({
|
||||
hasActiveInitDeferred: true,
|
||||
isInitializing: true,
|
||||
initRequestDirection: "after",
|
||||
responseDirection: "after",
|
||||
reset: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
type TimelineDirection = "tail" | "before" | "after";
|
||||
type InitRequestDirection = "tail" | "after";
|
||||
|
||||
type BootstrapTailCursor = {
|
||||
seq: number;
|
||||
} | null;
|
||||
|
||||
type InitialTimelineCursor = {
|
||||
epoch: string;
|
||||
seq: number;
|
||||
@@ -39,58 +32,3 @@ export function deriveInitialTimelineRequest({
|
||||
projection: "canonical",
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveBootstrapTailTimelinePolicy({
|
||||
direction,
|
||||
reset,
|
||||
epoch,
|
||||
endCursor,
|
||||
isInitializing,
|
||||
hasActiveInitDeferred,
|
||||
}: {
|
||||
direction: TimelineDirection;
|
||||
reset: boolean;
|
||||
epoch: string;
|
||||
endCursor: BootstrapTailCursor;
|
||||
isInitializing: boolean;
|
||||
hasActiveInitDeferred: boolean;
|
||||
}): {
|
||||
replace: boolean;
|
||||
catchUpCursor: { epoch: string; endSeq: number } | null;
|
||||
} {
|
||||
if (reset) {
|
||||
return { replace: true, catchUpCursor: null };
|
||||
}
|
||||
|
||||
const isBootstrapTailInit = direction === "tail" && isInitializing && hasActiveInitDeferred;
|
||||
if (!isBootstrapTailInit) {
|
||||
return { replace: false, catchUpCursor: null };
|
||||
}
|
||||
|
||||
return {
|
||||
replace: true,
|
||||
catchUpCursor: endCursor ? { epoch, endSeq: endCursor.seq } : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldResolveTimelineInit({
|
||||
hasActiveInitDeferred,
|
||||
isInitializing,
|
||||
initRequestDirection,
|
||||
responseDirection,
|
||||
reset,
|
||||
}: {
|
||||
hasActiveInitDeferred: boolean;
|
||||
isInitializing: boolean;
|
||||
initRequestDirection: InitRequestDirection;
|
||||
responseDirection: TimelineDirection;
|
||||
reset: boolean;
|
||||
}): boolean {
|
||||
if (!hasActiveInitDeferred || !isInitializing) {
|
||||
return false;
|
||||
}
|
||||
if (reset) {
|
||||
return true;
|
||||
}
|
||||
return responseDirection === initRequestDirection;
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { classifySessionTimelineSeq } from "./session-timeline-seq-gate";
|
||||
|
||||
describe("classifySessionTimelineSeq", () => {
|
||||
it("accepts contiguous forward seq", () => {
|
||||
expect(
|
||||
classifySessionTimelineSeq({
|
||||
cursor: { epoch: "epoch-1", endSeq: 4 },
|
||||
epoch: "epoch-1",
|
||||
seq: 5,
|
||||
}),
|
||||
).toBe("accept");
|
||||
});
|
||||
|
||||
it("drops stale seq older than the current end", () => {
|
||||
expect(
|
||||
classifySessionTimelineSeq({
|
||||
cursor: { epoch: "epoch-1", endSeq: 8 },
|
||||
epoch: "epoch-1",
|
||||
seq: 7,
|
||||
}),
|
||||
).toBe("drop_stale");
|
||||
});
|
||||
|
||||
it("drops duplicate replay seq equal to the current end", () => {
|
||||
expect(
|
||||
classifySessionTimelineSeq({
|
||||
cursor: { epoch: "epoch-1", endSeq: 8 },
|
||||
epoch: "epoch-1",
|
||||
seq: 8,
|
||||
}),
|
||||
).toBe("drop_stale");
|
||||
});
|
||||
|
||||
it("drops epoch mismatch", () => {
|
||||
expect(
|
||||
classifySessionTimelineSeq({
|
||||
cursor: { epoch: "epoch-1", endSeq: 4 },
|
||||
epoch: "epoch-2",
|
||||
seq: 5,
|
||||
}),
|
||||
).toBe("drop_epoch");
|
||||
});
|
||||
|
||||
it("initializes when cursor is null", () => {
|
||||
expect(
|
||||
classifySessionTimelineSeq({
|
||||
cursor: null,
|
||||
epoch: "epoch-1",
|
||||
seq: 1,
|
||||
}),
|
||||
).toBe("init");
|
||||
});
|
||||
|
||||
it("classifies forward gaps", () => {
|
||||
expect(
|
||||
classifySessionTimelineSeq({
|
||||
cursor: { epoch: "epoch-1", endSeq: 4 },
|
||||
epoch: "epoch-1",
|
||||
seq: 9,
|
||||
}),
|
||||
).toBe("gap");
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
export type SessionTimelineSeqCursor =
|
||||
| {
|
||||
epoch: string;
|
||||
endSeq: number;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
export type SessionTimelineSeqDecision = "accept" | "drop_stale" | "drop_epoch" | "gap" | "init";
|
||||
|
||||
export function classifySessionTimelineSeq({
|
||||
cursor,
|
||||
epoch,
|
||||
seq,
|
||||
}: {
|
||||
cursor: SessionTimelineSeqCursor;
|
||||
epoch: string;
|
||||
seq: number;
|
||||
}): SessionTimelineSeqDecision {
|
||||
if (!cursor) {
|
||||
return "init";
|
||||
}
|
||||
if (cursor.epoch !== epoch) {
|
||||
return "drop_epoch";
|
||||
}
|
||||
if (seq <= cursor.endSeq) {
|
||||
return "drop_stale";
|
||||
}
|
||||
if (seq === cursor.endSeq + 1) {
|
||||
return "accept";
|
||||
}
|
||||
return "gap";
|
||||
}
|
||||
Reference in New Issue
Block a user