Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 55 additions & 1 deletion apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ function makeSnapshot(input: {
readonly worktreePath: string | null;
readonly checkpointTurnCount: number;
readonly checkpointRef: CheckpointRef;
readonly checkpointStatus?: "ready" | "missing" | "error";
}): OrchestrationReadModel {
return {
snapshotSequence: 0,
Expand Down Expand Up @@ -67,7 +68,7 @@ function makeSnapshot(input: {
turnId: TurnId.makeUnsafe("turn-1"),
checkpointTurnCount: input.checkpointTurnCount,
checkpointRef: input.checkpointRef,
status: "ready",
status: input.checkpointStatus ?? "ready",
files: [],
assistantMessageId: null,
completedAt: "2026-01-01T00:00:00.000Z",
Expand Down Expand Up @@ -194,4 +195,57 @@ describe("CheckpointDiffQueryLive", () => {
),
).rejects.toThrow("Thread 'thread-missing' not found.");
});

it("returns an empty diff when a non-ready checkpoint ref is absent on disk", async () => {
const projectId = ProjectId.makeUnsafe("project-1");
const threadId = ThreadId.makeUnsafe("thread-1");
const toCheckpointRef = CheckpointRef.makeUnsafe("provider-diff:evt-turn-diff-updated");

const snapshot = makeSnapshot({
projectId,
threadId,
workspaceRoot: "/tmp/workspace",
worktreePath: null,
checkpointTurnCount: 1,
checkpointRef: toCheckpointRef,
checkpointStatus: "missing",
});

const checkpointStore: CheckpointStoreShape = {
isGitRepository: () => Effect.succeed(true),
captureCheckpoint: () => Effect.void,
hasCheckpointRef: ({ checkpointRef }) =>
Effect.succeed(checkpointRef !== toCheckpointRef),
restoreCheckpoint: () => Effect.succeed(true),
diffCheckpoints: () => Effect.succeed("diff should not be read"),
deleteCheckpointRefs: () => Effect.void,
};

const layer = CheckpointDiffQueryLive.pipe(
Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)),
Layer.provideMerge(
Layer.succeed(ProjectionSnapshotQuery, {
getSnapshot: () => Effect.succeed(snapshot),
}),
),
);

const result = await Effect.runPromise(
Effect.gen(function* () {
const query = yield* CheckpointDiffQuery;
return yield* query.getTurnDiff({
threadId,
fromTurnCount: 0,
toTurnCount: 1,
});
}).pipe(Effect.provide(layer)),
);

expect(result).toEqual({
threadId,
fromTurnCount: 0,
toTurnCount: 1,
diff: "",
});
});
});
54 changes: 36 additions & 18 deletions apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,28 @@ const make = Effect.gen(function* () {
const getTurnDiff: CheckpointDiffQueryShape["getTurnDiff"] = (input) =>
Effect.gen(function* () {
const operation = "CheckpointDiffQuery.getTurnDiff";
const emptyTurnDiffResult = (
fromTurnCount: number,
toTurnCount: number,
): Effect.Effect<OrchestrationGetTurnDiffResultType, CheckpointInvariantError> =>
Effect.gen(function* () {
const emptyDiff: OrchestrationGetTurnDiffResultType = {
threadId: input.threadId,
fromTurnCount,
toTurnCount,
diff: "",
};
if (!isTurnDiffResult(emptyDiff)) {
return yield* new CheckpointInvariantError({
operation,
detail: "Computed turn diff result does not satisfy contract schema.",
});
}
return emptyDiff;
});

if (input.fromTurnCount === input.toTurnCount) {
const emptyDiff: OrchestrationGetTurnDiffResultType = {
threadId: input.threadId,
fromTurnCount: input.fromTurnCount,
toTurnCount: input.toTurnCount,
diff: "",
};
if (!isTurnDiffResult(emptyDiff)) {
return yield* new CheckpointInvariantError({
operation,
detail: "Computed turn diff result does not satisfy contract schema.",
});
}
return emptyDiff;
return yield* emptyTurnDiffResult(input.fromTurnCount, input.toTurnCount);
}

const snapshot = yield* projectionSnapshotQuery.getSnapshot();
Expand Down Expand Up @@ -73,12 +80,16 @@ const make = Effect.gen(function* () {
});
}

const fromCheckpointRef =
const fromCheckpoint =
input.fromTurnCount === 0
? checkpointRefForThreadTurn(input.threadId, 0)
? undefined
: thread.checkpoints.find(
(checkpoint) => checkpoint.checkpointTurnCount === input.fromTurnCount,
)?.checkpointRef;
);
const fromCheckpointRef =
input.fromTurnCount === 0
? checkpointRefForThreadTurn(input.threadId, 0)
: fromCheckpoint?.checkpointRef;
if (!fromCheckpointRef) {
return yield* new CheckpointUnavailableError({
threadId: input.threadId,
Expand All @@ -87,9 +98,10 @@ const make = Effect.gen(function* () {
});
}

const toCheckpointRef = thread.checkpoints.find(
const toCheckpoint = thread.checkpoints.find(
(checkpoint) => checkpoint.checkpointTurnCount === input.toTurnCount,
)?.checkpointRef;
);
const toCheckpointRef = toCheckpoint?.checkpointRef;
if (!toCheckpointRef) {
return yield* new CheckpointUnavailableError({
threadId: input.threadId,
Expand All @@ -113,6 +125,9 @@ const make = Effect.gen(function* () {
);

if (!fromExists) {
if (input.fromTurnCount > 0 && fromCheckpoint && fromCheckpoint.status !== "ready") {
return yield* emptyTurnDiffResult(input.fromTurnCount, input.toTurnCount);
Comment on lines +128 to +129

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep missing-base checkpoint as unavailable

When getTurnDiff sees a missing fromCheckpointRef and the source checkpoint is non-ready, it now returns diff: "" instead of an error. This turns an unavailable diff into a false “no changes” result for ranges like fromTurnCount=1,toTurnCount=2 after an interrupted/legacy turn where turn 1 has no filesystem ref but turn 2 does, so the UI can incorrectly report no net changes rather than surfacing that the base checkpoint is missing.

Useful? React with 👍 / 👎.

}
return yield* new CheckpointUnavailableError({
threadId: input.threadId,
turnCount: input.fromTurnCount,
Expand All @@ -121,6 +136,9 @@ const make = Effect.gen(function* () {
}

if (!toExists) {
if (toCheckpoint && toCheckpoint.status !== "ready") {
return yield* emptyTurnDiffResult(input.fromTurnCount, input.toTurnCount);
}
return yield* new CheckpointUnavailableError({
threadId: input.threadId,
turnCount: input.toTurnCount,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,6 @@ type ProviderRuntimeTestThread = ProviderRuntimeTestReadModel["threads"][number]
type ProviderRuntimeTestMessage = ProviderRuntimeTestThread["messages"][number];
type ProviderRuntimeTestProposedPlan = ProviderRuntimeTestThread["proposedPlans"][number];
type ProviderRuntimeTestActivity = ProviderRuntimeTestThread["activities"][number];
type ProviderRuntimeTestCheckpoint = ProviderRuntimeTestThread["checkpoints"][number];

describe("ProviderRuntimeIngestion", () => {
let runtime: ManagedRuntime.ManagedRuntime<
Expand Down Expand Up @@ -1214,8 +1213,8 @@ describe("ProviderRuntimeIngestion", () => {
entry.activities.some(
(activity: ProviderRuntimeTestActivity) => activity.kind === "runtime.warning",
) &&
entry.checkpoints.some(
(checkpoint: ProviderRuntimeTestCheckpoint) => checkpoint.turnId === "turn-p1",
entry.activities.some(
(activity: ProviderRuntimeTestActivity) => activity.kind === "turn.diff.updated",
),
);

Expand Down Expand Up @@ -1252,12 +1251,16 @@ describe("ProviderRuntimeIngestion", () => {
expect(warning?.kind).toBe("runtime.warning");
expect(warningPayload?.message).toBe("Provider got slow");

const checkpoint = thread.checkpoints.find(
(entry: ProviderRuntimeTestCheckpoint) => entry.turnId === "turn-p1",
const turnDiffActivity = thread.activities.find(
(activity: ProviderRuntimeTestActivity) => activity.id === "evt-turn-diff-updated",
);
expect(checkpoint?.status).toBe("missing");
expect(checkpoint?.assistantMessageId).toBe("assistant:item-p1-assistant");
expect(checkpoint?.checkpointRef).toBe("provider-diff:evt-turn-diff-updated");
const turnDiffPayload =
turnDiffActivity?.payload && typeof turnDiffActivity.payload === "object"
? (turnDiffActivity.payload as Record<string, unknown>)
: undefined;
expect(turnDiffActivity?.kind).toBe("turn.diff.updated");
expect(turnDiffPayload?.diffBytes).toBe(40);
expect(thread.checkpoints).toHaveLength(0);
});

it("projects Codex task lifecycle chunks into thread activities", async () => {
Expand Down
41 changes: 18 additions & 23 deletions apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
CommandId,
MessageId,
type OrchestrationEvent,
CheckpointRef,
ThreadId,
TurnId,
type OrchestrationThreadActivity,
Expand Down Expand Up @@ -304,6 +303,24 @@ function runtimeEventToActivities(
];
}

case "turn.diff.updated": {
const diffBytes = event.payload.unifiedDiff.length;
return [
{
id: event.eventId,
createdAt: event.createdAt,
tone: "info",
kind: "turn.diff.updated",
summary: diffBytes > 0 ? "Turn diff updated" : "Turn diff cleared",
payload: {
diffBytes,
},
turnId: toTurnId(event.turnId) ?? null,
...maybeSequence,
},
];
}

case "user-input.requested": {
return [
{
Expand Down Expand Up @@ -1039,28 +1056,6 @@ const make = Effect.gen(function* () {
});
}

if (event.type === "turn.diff.updated") {
const turnId = toTurnId(event.turnId);
if (turnId) {
const assistantMessageId = MessageId.makeUnsafe(
`assistant:${event.itemId ?? event.turnId ?? event.eventId}`,
);
yield* orchestrationEngine.dispatch({
type: "thread.turn.diff.complete",
commandId: providerCommandId(event, "thread-turn-diff-complete"),
threadId: thread.id,
turnId,
completedAt: now,
checkpointRef: CheckpointRef.makeUnsafe(`provider-diff:${event.eventId}`),
status: "missing",
files: [],
assistantMessageId,
checkpointTurnCount: thread.checkpoints.length + 1,
createdAt: now,
});
}
}

const activities = runtimeEventToActivities(event);
yield* Effect.forEach(activities, (activity) =>
orchestrationEngine.dispatch({
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3950,7 +3950,7 @@ const ChatHeader = memo(function ChatHeader({
</Badge>
)}
</div>
<div className="@container/header-actions flex min-w-0 flex-1 items-center justify-end gap-2 @sm/header-actions:gap-3">
<div className="@container/header-actions flex shrink-0 items-center justify-end gap-2 @sm/header-actions:gap-3">
{activeProjectScripts && (
<ProjectScriptsControl
scripts={activeProjectScripts}
Expand Down