Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
fc384cd
Fix steering acknowledgement and turn interruption
Quicksaver Jul 11, 2026
298f4b5
Preserve root turn interruption projection
Quicksaver Jul 11, 2026
cf6b0c8
Harden steering acknowledgement and turn interruption
Quicksaver Jul 11, 2026
3414977
Fix active Codex turn selection without timestamps
Quicksaver Jul 12, 2026
e2e05c8
Merge upstream Android support and preserve steering fixes
Quicksaver Jul 13, 2026
06e38dc
Align Expo updates with fork project
Quicksaver Jul 13, 2026
ef1f35e
Merge upstream/main into fix/repeated-steering-and-stop
Quicksaver Jul 15, 2026
dd45496
Merge remote-tracking branch 'upstream/main' into fix/repeated-steeri…
Quicksaver Jul 16, 2026
6c08e81
Remove unrelated Expo fork customization
Quicksaver Jul 16, 2026
726dd73
Document repeated steering and reliable stop behavior
Quicksaver Jul 17, 2026
0ac7499
Correct repeated steering branch documentation
Quicksaver Jul 17, 2026
195fbca
Merge remote-tracking branch 'upstream/main' into fix/repeated-steeri…
Quicksaver Jul 18, 2026
425371a
Document upstream merge impact on repeated steering
Quicksaver Jul 18, 2026
8a889a6
Isolate Codex interrupt resolution coverage
Quicksaver Jul 18, 2026
d1dca69
Keep steering acknowledgements and fallback turns current
Quicksaver Jul 18, 2026
0bf687f
Harden steering and interrupt review coverage
Quicksaver Jul 18, 2026
cb6190e
Refresh branch assessment details
Quicksaver Jul 18, 2026
4277c28
Refresh branch guidance for steering and stop fixes
Quicksaver Jul 19, 2026
e13518a
Merge remote-tracking branch 'upstream/main' into fix/repeated-steeri…
Quicksaver Jul 19, 2026
7f9ab76
Merge remote-tracking branch 'upstream/main' into fix/repeated-steeri…
Quicksaver Jul 19, 2026
e47c771
Merge remote-tracking branch 'upstream/main' into fix/repeated-steeri…
Quicksaver Jul 19, 2026
c1a0db3
Fix pnpm virtual store and Effect Vitest resolution
Quicksaver Jul 19, 2026
5384094
Merge remote-tracking branch 'upstream/main' into fix/repeated-steeri…
Quicksaver Jul 20, 2026
d193aba
Merge remote-tracking branch 'upstream/main' into fix/repeated-steeri…
Quicksaver Jul 20, 2026
4d21b97
Merge remote-tracking branch 'upstream/main' into fix/repeated-steeri…
Quicksaver Jul 20, 2026
31b31bc
Merge remote-tracking branch 'upstream/main' into fix/repeated-steeri…
Quicksaver Jul 21, 2026
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
29 changes: 29 additions & 0 deletions BRANCH_DETAILS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Repeated Steering And Reliable Stop

Running conversations allow users to send any number of steering prompts and stop the active agent at any time, including after one or more steers.

Expected behavior:

- A steering send remains locally busy only until the server projects that exact user-message id. That acknowledgement stays valid when another client later projects a newer user message, while an unrelated projected user message alone must not acknowledge the dispatch. Steering the existing running turn must not wait for a new turn or session transition before re-enabling the composer.
- Root interruption commands retain the projected active turn id in orchestration events, but the provider command reactor intentionally lets the root Codex adapter resolve the authoritative active provider turn. Subagent interruption continues to target the selected child turn explicitly and must not fall back to a root turn.
- Codex root interruption reads the live provider thread with `includeTurns: true`, selects the most recently started `inProgress` turn, and bounds that lookup with a timeout. When either candidate lacks `startedAt`, provider response order is authoritative and the later entry wins. A failed lookup is logged and may fall back to the session turn read after that lookup finishes; a successful lookup with no active turn returns without reviving a stale cached id.

Conflict guidance:

- In `apps/web/src/components/ChatView.logic.ts`, `apps/web/src/components/ChatView.logic.test.ts`, and `apps/web/src/components/ChatView.tsx`, preserve exact-id acknowledgement and consecutive-steer coverage. Keep the draft-hero dock transition and early in-flight guard, and call `beginLocalDispatch` only after `newMessageId()` provides the exact expected id.
- Keep live-turn lookup, ordering, timeout, and fallback coverage in `apps/server/src/provider/Layers/CodexInterruptResolution.test.ts`, separate from the runtime model/effort instruction coverage in `apps/server/src/provider/Layers/CodexSessionRuntime.test.ts`. `apps/server/src/provider/Layers/CodexSessionRuntime.ts` must retain both developer-instruction behavior and branch-owned interrupt resolution.

Primary files:

- `apps/web/src/components/ChatView.tsx`
- `apps/web/src/components/ChatView.logic.ts`
- `apps/server/src/provider/Layers/CodexSessionRuntime.ts`

Regression coverage lives in `apps/web/src/components/ChatView.logic.test.ts` and `apps/server/src/provider/Layers/CodexInterruptResolution.test.ts`. Keep coverage for consecutive in-turn steers, exact-message acknowledgement, timestamp-based live-turn selection, lookup timeout/failure fallback, and successful empty reads that suppress stale interrupts.

Use the existing `hasServerAcknowledgedLocalDispatch` helper for client dispatch correlation. Defer an explicit server receipt keyed by message id unless projected ids stop being authoritative.

## Development Ports

- Web: `5738`
- Server/WebSocket: `13778`
207 changes: 207 additions & 0 deletions apps/server/src/provider/Layers/CodexInterruptResolution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import * as NodeAssert from "node:assert/strict";

import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as TestClock from "effect/testing/TestClock";
import { describe, it } from "@effect/vitest";
import { TurnId } from "@t3tools/contracts";
import type * as CodexRpc from "effect-codex-app-server/rpc";
import type * as EffectCodexSchema from "effect-codex-app-server/schema";

import {
findActiveCodexTurnId,
resolveCodexInterruptTurnId,
shouldReplaceActiveCodexTurnCandidate,
} from "./CodexSessionRuntime.ts";

function makeThreadReadResponse(
turns: EffectCodexSchema.V2ThreadReadResponse["thread"]["turns"],
): EffectCodexSchema.V2ThreadReadResponse {
return {
thread: {
cliVersion: "0.0.0-test",
createdAt: 1,
cwd: "/tmp/project",
ephemeral: false,
id: "provider-thread-1",
modelProvider: "openai",
preview: "test thread",
sessionId: "session-1",
source: "appServer",
status: { type: "active", activeFlags: [] },
turns,
updatedAt: 2,
},
};
}

describe("findActiveCodexTurnId", () => {
it("selects the most recently started in-progress turn", () => {
const snapshot = makeThreadReadResponse([
{ id: "turn-active-new", status: "inProgress", startedAt: 30, items: [] },
{ id: "turn-completed", status: "completed", startedAt: 20, items: [] },
{ id: "turn-active-old", status: "inProgress", startedAt: 10, items: [] },
]);

NodeAssert.equal(findActiveCodexTurnId(snapshot), "turn-active-new");
});

it("selects a later in-progress turn without a start timestamp", () => {
const snapshot = makeThreadReadResponse([
{ id: "turn-active-old", status: "inProgress", startedAt: 10, items: [] },
{ id: "turn-active-new", status: "inProgress", items: [] },
]);

NodeAssert.equal(findActiveCodexTurnId(snapshot), "turn-active-new");
});

it("selects a later timestamped turn after one without a timestamp", () => {
const snapshot = makeThreadReadResponse([
{ id: "turn-active-old", status: "inProgress", items: [] },
{ id: "turn-active-new", status: "inProgress", startedAt: 10, items: [] },
]);

NodeAssert.equal(findActiveCodexTurnId(snapshot), "turn-active-new");
});

it("returns undefined when no turn is active", () => {
const response = makeThreadReadResponse([]);
NodeAssert.equal(findActiveCodexTurnId(response), undefined);
});
});

describe("resolveCodexInterruptTurnId", () => {
it.effect("requests turns when resolving an interrupt without a projected turn id", () => {
let requestedParams: CodexRpc.ClientRequestParamsByMethod["thread/read"] | undefined;
let readThreadCallCount = 0;

return Effect.gen(function* () {
const turnId = yield* resolveCodexInterruptTurnId({
providerThreadId: "provider-thread-1",
requestedTurnId: undefined,
readSessionActiveTurnId: Effect.succeed(undefined),
readThread: (params) => {
readThreadCallCount += 1;
requestedParams = params;
return Effect.succeed(
makeThreadReadResponse([
{ id: "turn-active", status: "inProgress", startedAt: 10, items: [] },
]),
);
},
});

NodeAssert.deepStrictEqual(requestedParams, {
threadId: "provider-thread-1",
includeTurns: true,
});
NodeAssert.equal(readThreadCallCount, 1);
NodeAssert.equal(turnId, "turn-active");
});
});

it.effect("does not revive a stale projected turn after a successful empty read", () =>
Effect.gen(function* () {
const turnId = yield* resolveCodexInterruptTurnId({
providerThreadId: "provider-thread-1",
requestedTurnId: undefined,
readSessionActiveTurnId: Effect.succeed(TurnId.make("turn-stale")),
readThread: () => Effect.succeed(makeThreadReadResponse([])),
});

NodeAssert.equal(turnId, undefined);
}),
);

it.effect("falls back to the projected turn when the live lookup fails", () =>
Effect.gen(function* () {
const projectedTurnId = TurnId.make("turn-projected");
const turnId = yield* resolveCodexInterruptTurnId({
providerThreadId: "provider-thread-1",
requestedTurnId: undefined,
readSessionActiveTurnId: Effect.succeed(projectedTurnId),
readThread: () => Effect.fail("lookup failed"),
});

NodeAssert.equal(turnId, projectedTurnId);
}),
);

it.effect("bounds the live lookup and falls back to the projected turn on timeout", () =>
Effect.gen(function* () {
const projectedTurnId = TurnId.make("turn-projected");
const lookupStarted = yield* Deferred.make<void>();
const resolution = yield* resolveCodexInterruptTurnId({
providerThreadId: "provider-thread-1",
requestedTurnId: undefined,
readSessionActiveTurnId: Effect.succeed(projectedTurnId),
readThread: () =>
Effect.gen(function* () {
yield* Deferred.succeed(lookupStarted, undefined);
return yield* Effect.never;
}),
}).pipe(Effect.forkScoped);

yield* Deferred.await(lookupStarted);
yield* TestClock.adjust("2 seconds");
NodeAssert.equal(yield* Fiber.join(resolution), projectedTurnId);
}),
);

it.effect("reads the projected fallback after a live lookup times out", () =>
Effect.gen(function* () {
let projectedTurnId = TurnId.make("turn-old");
const lookupStarted = yield* Deferred.make<void>();
const resolution = yield* resolveCodexInterruptTurnId({
providerThreadId: "provider-thread-1",
requestedTurnId: undefined,
readSessionActiveTurnId: Effect.sync(() => projectedTurnId),
readThread: () =>
Effect.gen(function* () {
yield* Deferred.succeed(lookupStarted, undefined);
return yield* Effect.never;
}),
}).pipe(Effect.forkScoped);

yield* Deferred.await(lookupStarted);
// Mutate after the live lookup starts to verify that the fallback is
// evaluated lazily after the timeout instead of captured up front.
projectedTurnId = TurnId.make("turn-current");
yield* TestClock.adjust("2 seconds");
NodeAssert.equal(yield* Fiber.join(resolution), projectedTurnId);
}),
);
});

describe("shouldReplaceActiveCodexTurnCandidate", () => {
it("selects the first candidate", () => {
NodeAssert.equal(shouldReplaceActiveCodexTurnCandidate({ startedAt: 10 }, undefined), true);
});

it("orders timestamped turns by start time and lets a later equal entry win", () => {
NodeAssert.equal(
shouldReplaceActiveCodexTurnCandidate({ startedAt: 20 }, { startedAt: 10 }),
true,
);
NodeAssert.equal(
shouldReplaceActiveCodexTurnCandidate({ startedAt: 10 }, { startedAt: 20 }),
false,
);
NodeAssert.equal(
shouldReplaceActiveCodexTurnCandidate({ startedAt: 10 }, { startedAt: 10 }),
true,
);
});

it("lets the later provider entry win when either timestamp is absent", () => {
for (const [candidate, selected] of [
[{}, { startedAt: 10 }],
[{ startedAt: null }, { startedAt: 10 }],
[{ startedAt: 10 }, {}],
[{ startedAt: 10 }, { startedAt: null }],
] as const) {
NodeAssert.equal(shouldReplaceActiveCodexTurnCandidate(candidate, selected), true);
}
});
});
3 changes: 1 addition & 2 deletions apps/server/src/provider/Layers/CodexSessionRuntime.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import * as NodeAssert from "node:assert/strict";

import { it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
import { describe } from "vite-plus/test";
import { describe, it } from "@effect/vitest";
import { DEFAULT_MODEL, ThreadId } from "@t3tools/contracts";
import * as CodexErrors from "effect-codex-app-server/errors";
import * as CodexRpc from "effect-codex-app-server/rpc";
Expand Down
80 changes: 78 additions & 2 deletions apps/server/src/provider/Layers/CodexSessionRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const BENIGN_ERROR_LOG_SNIPPETS = [
"state db record_discrepancy: find_thread_path_by_id_str_in_subdir, falling_back",
];
const CODEX_APP_SERVER_FORCE_KILL_AFTER = "2 seconds" as const;
const CODEX_INTERRUPT_THREAD_READ_TIMEOUT = "2 seconds" as const;
const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [
"not found",
"missing thread",
Expand Down Expand Up @@ -692,6 +693,75 @@ function parseThreadSnapshot(
};
}

type CodexTurnOrderingCandidate = Pick<
EffectCodexSchema.V2ThreadReadResponse["thread"]["turns"][number],
"startedAt"
>;

export function shouldReplaceActiveCodexTurnCandidate(
candidate: CodexTurnOrderingCandidate,
selected: CodexTurnOrderingCandidate | undefined,
): boolean {
if (selected === undefined) {
return true;
}

// When either timestamp is absent, provider response order is authoritative.
// The caller scans in response order, so the later candidate replaces the selection.
if (candidate.startedAt == null || selected.startedAt == null) {
return true;
}

return candidate.startedAt >= selected.startedAt;
}

export function findActiveCodexTurnId(
response: EffectCodexSchema.V2ThreadReadResponse,
): TurnId | undefined {
let activeTurn: EffectCodexSchema.V2ThreadReadResponse["thread"]["turns"][number] | undefined;
for (const turn of response.thread.turns) {
if (turn.status !== "inProgress") {
continue;
}
if (shouldReplaceActiveCodexTurnCandidate(turn, activeTurn)) {
activeTurn = turn;
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
return activeTurn === undefined ? undefined : TurnId.make(activeTurn.id);
}

export function resolveCodexInterruptTurnId<E>(input: {
readonly providerThreadId: string;
readonly requestedTurnId: TurnId | undefined;
readonly readSessionActiveTurnId: Effect.Effect<TurnId | undefined>;
readonly readThread: (
params: CodexRpc.ClientRequestParamsByMethod["thread/read"],
) => Effect.Effect<CodexRpc.ClientRequestResponsesByMethod["thread/read"], E>;
}): Effect.Effect<TurnId | undefined> {
if (input.requestedTurnId !== undefined) {
return Effect.succeed(input.requestedTurnId);
}

return input
.readThread({
threadId: input.providerThreadId,
includeTurns: true,
})
.pipe(
Effect.timeout(CODEX_INTERRUPT_THREAD_READ_TIMEOUT),
Effect.map(findActiveCodexTurnId),
Effect.tapError((cause) =>
Effect.logWarning("Failed to resolve active Codex turn before interrupt.", {
providerThreadId: input.providerThreadId,
cause,
}),
),
// A failed lookup can still use the locally projected id. A successful
// lookup with no active turn must not revive a stale local id.
Effect.catch(() => input.readSessionActiveTurnId),
);
}

export const makeCodexSessionRuntime = (
options: CodexSessionRuntimeOptions,
): Effect.Effect<
Expand Down Expand Up @@ -1315,8 +1385,14 @@ export const makeCodexSessionRuntime = (
interruptTurn: (turnId) =>
Effect.gen(function* () {
const providerThreadId = yield* readProviderThreadId;
const session = yield* Ref.get(sessionRef);
const effectiveTurnId = turnId ?? session.activeTurnId;
const effectiveTurnId = yield* resolveCodexInterruptTurnId({
providerThreadId,
requestedTurnId: turnId,
readSessionActiveTurnId: Ref.get(sessionRef).pipe(
Effect.map((session) => session.activeTurnId),
),
readThread: (params) => client.request("thread/read", params),
});
if (!effectiveTurnId) {
return;
}
Expand Down
Loading
Loading