Skip to content
Open
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
99 changes: 99 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
buildThreadTurnInterruptInput,
createLocalDispatchSnapshot,
deriveComposerSendState,
deriveTurnFailureRecoveryAction,
dismissBranchMismatchForSession,
ENVIRONMENT_RECONNECT_WARNING_GRACE_MS,
getStartedThreadModelChangeBlockReason,
Expand Down Expand Up @@ -563,6 +564,104 @@ describe("startNewThreadForProject", () => {
});
});

describe("deriveTurnFailureRecoveryAction", () => {
const base = {
hasPendingSnapshot: true,
preSendTurnId: TurnId.make("turn-1"),
preSendLatestTurnCompletedAt: null,
preSendSessionUpdatedAt: "2026-01-01T00:00:00.000Z",
sessionStatus: "running" as const,
sessionUpdatedAt: "2026-01-01T00:00:01.000Z",
latestTurnId: TurnId.make("turn-2"),
latestTurnCompletedAt: null,
composerHasContent: false,
};

it("waits while there is no pending snapshot", () => {
expect(deriveTurnFailureRecoveryAction({ ...base, hasPendingSnapshot: false })).toBe("wait");
});

it("restores when the turn fails asynchronously and the composer is empty", () => {
expect(
deriveTurnFailureRecoveryAction({
...base,
sessionStatus: "error",
sessionUpdatedAt: "2026-01-01T00:00:05.000Z",
}),
).toBe("restore");
});

it("drops without restoring when the user already retyped", () => {
expect(
deriveTurnFailureRecoveryAction({
...base,
sessionStatus: "error",
sessionUpdatedAt: "2026-01-01T00:00:05.000Z",
composerHasContent: true,
}),
).toBe("drop");
});

it("waits on a stale pre-send error that has not advanced since the send", () => {
// The session was already in "error" when the user sent (lastError is
// carried forward until "ready"); with neither the session timestamp nor
// the turn id advancing, this must not spuriously restore before the new
// turn has begun.
expect(
deriveTurnFailureRecoveryAction({
...base,
preSendTurnId: TurnId.make("turn-1"),
latestTurnId: TurnId.make("turn-1"),
sessionStatus: "error",
sessionUpdatedAt: base.preSendSessionUpdatedAt,
}),
).toBe("wait");
});

it("restores an accept-then-fail landing in the same millisecond via a new turn id", () => {
// The failing session.set can share the pre-send millisecond timestamp; a
// freshly advanced turn id is enough to know the failure is this send's.
expect(
deriveTurnFailureRecoveryAction({
...base,
preSendTurnId: TurnId.make("turn-1"),
latestTurnId: TurnId.make("turn-2"),
sessionStatus: "error",
sessionUpdatedAt: base.preSendSessionUpdatedAt,
}),
).toBe("restore");
});

it("drops the snapshot once a fresh turn completes cleanly", () => {
expect(
deriveTurnFailureRecoveryAction({
...base,
sessionStatus: "idle",
latestTurnCompletedAt: "2026-01-01T00:00:09.000Z",
}),
).toBe("drop");
});

it("drops the snapshot after a steered turn completes under the same turn id", () => {
// Steering folds the message into the running turn, so the turn id does not
// change; the moved completion marker is what signals a clean finish.
expect(
deriveTurnFailureRecoveryAction({
...base,
preSendTurnId: TurnId.make("turn-2"),
latestTurnId: TurnId.make("turn-2"),
preSendLatestTurnCompletedAt: null,
latestTurnCompletedAt: "2026-01-01T00:00:09.000Z",
sessionStatus: "idle",
}),
).toBe("drop");
});

it("keeps waiting while the accepted turn is still running", () => {
expect(deriveTurnFailureRecoveryAction(base)).toBe("wait");
});
});

describe("hasServerAcknowledgedLocalDispatch", () => {
it("does not acknowledge unchanged server state", () => {
const localDispatch = createLocalDispatchSnapshot(
Expand Down
55 changes: 55 additions & 0 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,61 @@ export function createLocalDispatchSnapshot(
};
}

// A message that was submitted and cleared from the composer must never be
// lost when its turn fails. The synchronous send-failure path (a rejected
// start-turn RPC) restores the composer inline, but a turn that the server
// ACCEPTS and then fails asynchronously — a runtime stream error, a stale
// pending provider callback, a `runtime.error` — surfaces only later via
// `session.lastError`/`session.status`, long after `onSend` returned. This
// decides, from the in-flight snapshot captured at send time, whether that
// later failure should push the text back into an (empty) composer.
//
// The failure signal is `session.status === "error"`, not `lastError`:
// `lastError` is carried forward across a fresh turn until the session next
// reaches "ready", so keying on it would restore a stale prior-turn error.
// `sessionUpdatedAt` must differ from the pre-send value so a session that was
// already in "error" when the user sent (and has not yet transitioned) cannot
// trigger a spurious restore before the new turn has begun.
export function deriveTurnFailureRecoveryAction(input: {
hasPendingSnapshot: boolean;
preSendTurnId: TurnId | null;
preSendLatestTurnCompletedAt: string | null;
preSendSessionUpdatedAt: string | null;
sessionStatus: NonNullable<Thread["session"]>["status"] | null;
sessionUpdatedAt: string | null;
latestTurnId: TurnId | null;
latestTurnCompletedAt: string | null;
composerHasContent: boolean;
}): "restore" | "drop" | "wait" {
if (!input.hasPendingSnapshot) {
return "wait";
}
// "Fresh" = the session moved past the pre-send snapshot, so an error now is
// this send's, not a prior one still showing. A changed `sessionUpdatedAt`
// is the usual signal; a changed `latestTurnId` also counts, covering an
// accept-then-fail that lands in the same millisecond as the pre-send state.
const sessionAdvanced =
(input.sessionUpdatedAt !== null && input.sessionUpdatedAt !== input.preSendSessionUpdatedAt) ||
(input.latestTurnId !== null && input.latestTurnId !== input.preSendTurnId);
if (input.sessionStatus === "error" && sessionAdvanced) {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
// Never clobber text the user has started typing since the send; the
// failed attempt is still in the transcript for them to copy from.
return input.composerHasContent ? "drop" : "restore";
}
// Our turn ran to completion without error, so the snapshot is spent. A fresh
// turn advances `latestTurnId`; a steered follow-up folds into the turn that
// was already running and keeps the same id but moves `completedAt` from its
// pre-send value, so both must count as completion.
const turnCompleted =
input.latestTurnCompletedAt !== null &&
(input.latestTurnId !== input.preSendTurnId ||
input.latestTurnCompletedAt !== input.preSendLatestTurnCompletedAt);
if (turnCompleted && input.sessionStatus !== "error") {
return "drop";
}
return "wait";
}

export function hasServerAcknowledgedLocalDispatch(input: {
localDispatch: LocalDispatchSnapshot | null;
phase: SessionPhase;
Expand Down
Loading
Loading