From fc384cd8b228063f6fa23c90b91083de0e070522 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sat, 11 Jul 2026 19:40:16 +0100 Subject: [PATCH 1/5] Fix steering acknowledgement and turn interruption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Resolve the provider runtime’s authoritative active Codex turn - Treat projected steer messages as server dispatch acknowledgement - Avoid stale root turn IDs when interrupting for steering - Cover active-turn lookup and running-thread steer behavior --- .../Layers/CodexSessionRuntime.test.ts | 30 ++++++++ .../provider/Layers/CodexSessionRuntime.ts | 26 ++++++- .../web/src/components/ChatView.logic.test.ts | 76 ++++++++++++++++++- apps/web/src/components/ChatView.logic.ts | 36 +++++++-- apps/web/src/components/ChatView.tsx | 3 + 5 files changed, 162 insertions(+), 9 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 8aeacd870cc..76cbe2b93ff 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -14,6 +14,7 @@ import { } from "../CodexDeveloperInstructions.ts"; import { buildTurnStartParams, + findActiveCodexTurnId, hasConfiguredMcpServer, isRecoverableThreadResumeError, openCodexThread, @@ -194,6 +195,35 @@ describe("buildTurnStartParams", () => { }); }); +describe("findActiveCodexTurnId", () => { + it("selects the newest in-progress turn from a thread snapshot", () => { + const response = makeThreadOpenResponse( + "provider-thread-1", + ) as unknown as CodexRpc.ClientRequestResponsesByMethod["thread/read"]; + const turns = [ + { id: "turn-completed", status: "completed", items: [] }, + { id: "turn-active-old", status: "inProgress", items: [] }, + { id: "turn-active-new", status: "inProgress", items: [] }, + ]; + const snapshot = { + ...response, + thread: { + ...response.thread, + turns, + }, + } as CodexRpc.ClientRequestResponsesByMethod["thread/read"]; + + NodeAssert.equal(findActiveCodexTurnId(snapshot), "turn-active-new"); + }); + + it("returns undefined when no turn is active", () => { + const response = makeThreadOpenResponse( + "provider-thread-1", + ) as unknown as CodexRpc.ClientRequestResponsesByMethod["thread/read"]; + NodeAssert.equal(findActiveCodexTurnId(response), undefined); + }); +}); + describe("T3 browser developer instructions", () => { it("prefers the product-native preview tools in both collaboration modes", () => { for (const instructions of [ diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 99ac498f0c3..b1a6fa1bb0b 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -692,6 +692,18 @@ function parseThreadSnapshot( }; } +export function findActiveCodexTurnId( + response: EffectCodexSchema.V2ThreadReadResponse, +): TurnId | undefined { + for (let index = response.thread.turns.length - 1; index >= 0; index -= 1) { + const turn = response.thread.turns[index]; + if (turn?.status === "inProgress") { + return TurnId.make(turn.id); + } + } + return undefined; +} + export const makeCodexSessionRuntime = ( options: CodexSessionRuntimeOptions, ): Effect.Effect< @@ -1316,7 +1328,19 @@ export const makeCodexSessionRuntime = ( Effect.gen(function* () { const providerThreadId = yield* readProviderThreadId; const session = yield* Ref.get(sessionRef); - const effectiveTurnId = turnId ?? session.activeTurnId; + const runtimeActiveTurnId = + turnId === undefined + ? yield* client + .request("thread/read", { + threadId: providerThreadId, + includeTurns: true, + }) + .pipe( + Effect.map(findActiveCodexTurnId), + Effect.orElseSucceed(() => undefined), + ) + : undefined; + const effectiveTurnId = turnId ?? runtimeActiveTurnId ?? session.activeTurnId; if (!effectiveTurnId) { return; } diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 0a0103df183..94b3e3aee94 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,4 +1,11 @@ -import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId, TurnId } from "@t3tools/contracts"; +import { + EnvironmentId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import type { Thread } from "../types"; @@ -71,7 +78,7 @@ const readySession = { }; describe("buildThreadTurnInterruptInput", () => { - it("targets the session's active running turn", () => { + it("lets the provider resolve the current running root turn", () => { const activeTurnId = TurnId.make("turn-running"); expect( @@ -84,7 +91,7 @@ describe("buildThreadTurnInterruptInput", () => { }, }), ), - ).toEqual({ threadId, turnId: activeTurnId }); + ).toEqual({ threadId }); }); it("omits a turn id when the session is not running", () => { @@ -360,6 +367,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { phase: "ready", latestTurn: completedTurn, session: readySession, + latestUserMessageId: null, hasPendingApproval: false, hasPendingUserInput: false, threadError: null, @@ -385,6 +393,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { phase: "ready", latestTurn: newerTurn, session: { ...readySession, updatedAt: newerTurn.completedAt }, + latestUserMessageId: null, hasPendingApproval: false, hasPendingUserInput: false, threadError: null, @@ -415,6 +424,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { status: "running", activeTurnId: TurnId.make("turn-other"), }, + latestUserMessageId: null, hasPendingApproval: false, hasPendingUserInput: false, threadError: null, @@ -430,6 +440,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { status: "running", activeTurnId: runningTurn.turnId, }, + latestUserMessageId: null, hasPendingApproval: false, hasPendingUserInput: false, threadError: null, @@ -444,6 +455,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { phase: "ready" as const, latestTurn: null, session: null, + latestUserMessageId: null, hasPendingApproval: false, hasPendingUserInput: false, threadError: null, @@ -453,4 +465,62 @@ describe("hasServerAcknowledgedLocalDispatch", () => { expect(hasServerAcknowledgedLocalDispatch({ ...common, hasPendingUserInput: true })).toBe(true); expect(hasServerAcknowledgedLocalDispatch({ ...common, threadError: "failed" })).toBe(true); }); + + it("acknowledges a steer when its user message is projected onto the running thread", () => { + const initialMessageId = MessageId.make("message-initial"); + const steerMessageId = MessageId.make("message-steer"); + const runningTurn = { + ...completedTurn, + state: "running" as const, + completedAt: null, + }; + const runningSession = { + ...readySession, + status: "running" as const, + activeTurnId: runningTurn.turnId, + }; + const localDispatch = createLocalDispatchSnapshot( + makeThread({ + messages: [ + { + id: initialMessageId, + role: "user", + text: "start", + turnId: runningTurn.turnId, + streaming: false, + createdAt: now, + updatedAt: now, + }, + ], + latestTurn: runningTurn, + session: runningSession, + }), + ); + + expect( + hasServerAcknowledgedLocalDispatch({ + localDispatch, + phase: "running", + latestTurn: runningTurn, + session: runningSession, + latestUserMessageId: initialMessageId, + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }), + ).toBe(false); + + expect( + hasServerAcknowledgedLocalDispatch({ + localDispatch, + phase: "running", + latestTurn: runningTurn, + session: runningSession, + latestUserMessageId: steerMessageId, + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }), + ).toBe(true); + }); }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 705793ec77e..53cac2346cb 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,6 +1,7 @@ import { type EnvironmentId, isProviderDriverKind, + type MessageId, ProjectId, type ModelSelection, type ProviderDriverKind, @@ -78,11 +79,10 @@ export function buildThreadTurnInterruptInput(thread: Pick>, +): MessageId | null { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message?.role === "user") { + return message.id; + } + } + return null; +} + export function createLocalDispatchSnapshot( activeThread: Thread | undefined, options?: { preparingWorktree?: boolean }, @@ -404,6 +417,7 @@ export function createLocalDispatchSnapshot( return { startedAt: new Date().toISOString(), preparingWorktree: Boolean(options?.preparingWorktree), + latestUserMessageId: getLatestUserMessageId(activeThread?.messages ?? []), latestTurnTurnId: latestTurn?.turnId ?? null, latestTurnRequestedAt: latestTurn?.requestedAt ?? null, latestTurnStartedAt: latestTurn?.startedAt ?? null, @@ -418,6 +432,7 @@ export function hasServerAcknowledgedLocalDispatch(input: { phase: SessionPhase; latestTurn: Thread["latestTurn"] | null; session: Thread["session"] | null; + latestUserMessageId: MessageId | null; hasPendingApproval: boolean; hasPendingUserInput: boolean; threadError: string | null | undefined; @@ -429,6 +444,17 @@ export function hasServerAcknowledgedLocalDispatch(input: { return true; } + // A prompt sent while a turn is already running is a steer. Providers can + // apply that prompt to the existing turn without opening a new one, so the + // latest turn/session fields may remain unchanged until the agent finishes. + // The projected user message is the server acknowledgement in that case. + if ( + input.localDispatch.sessionStatus === "running" && + input.localDispatch.latestUserMessageId !== input.latestUserMessageId + ) { + return true; + } + const latestTurn = input.latestTurn ?? null; const session = input.session ?? null; const latestTurnChanged = diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f5ea5bb1eba..526b25e27f4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -220,6 +220,7 @@ import { collectUserMessageBlobPreviewUrls, createLocalDispatchSnapshot, deriveComposerSendState, + getLatestUserMessageId, hasServerAcknowledgedLocalDispatch, getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, @@ -380,6 +381,7 @@ function useLocalDispatchState(input: { phase: input.phase, latestTurn: input.activeLatestTurn, session: input.activeThread?.session ?? null, + latestUserMessageId: getLatestUserMessageId(input.activeThread?.messages ?? []), hasPendingApproval: input.activePendingApproval !== null, hasPendingUserInput: input.activePendingUserInput !== null, threadError: input.threadError, @@ -389,6 +391,7 @@ function useLocalDispatchState(input: { input.activePendingApproval, input.activePendingUserInput, input.activeThread?.session, + input.activeThread?.messages, input.phase, input.threadError, localDispatch, From 298f4b5aecb4acfd4b8b28e97893bbe21633cc47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sat, 11 Jul 2026 19:54:15 +0100 Subject: [PATCH 2/5] Preserve root turn interruption projection --- apps/web/src/components/ChatView.logic.test.ts | 4 ++-- apps/web/src/components/ChatView.logic.ts | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 94b3e3aee94..2e0bef5a876 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -78,7 +78,7 @@ const readySession = { }; describe("buildThreadTurnInterruptInput", () => { - it("lets the provider resolve the current running root turn", () => { + it("targets the session's active running turn", () => { const activeTurnId = TurnId.make("turn-running"); expect( @@ -91,7 +91,7 @@ describe("buildThreadTurnInterruptInput", () => { }, }), ), - ).toEqual({ threadId }); + ).toEqual({ threadId, turnId: activeTurnId }); }); it("omits a turn id when the session is not running", () => { diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 53cac2346cb..b9b7927c00f 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -79,10 +79,11 @@ export function buildThreadTurnInterruptInput(thread: Pick Date: Sat, 11 Jul 2026 20:07:21 +0100 Subject: [PATCH 3/5] Harden steering acknowledgement and turn interruption - Resolve active Codex turns from timed, observable thread reads - Match projected steer acknowledgements to the dispatched message - Add realistic regression coverage for interrupt and steer paths --- .../Layers/CodexSessionRuntime.test.ts | 93 ++++++++++++++----- .../provider/Layers/CodexSessionRuntime.ts | 74 +++++++++++---- .../web/src/components/ChatView.logic.test.ts | 32 +++++++ apps/web/src/components/ChatView.logic.ts | 9 +- apps/web/src/components/ChatView.tsx | 22 +++-- 5 files changed, 178 insertions(+), 52 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 76cbe2b93ff..92ecb192f34 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -1,12 +1,12 @@ 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 { ThreadId } from "@t3tools/contracts"; +import { describe, it } from "@effect/vitest"; +import { ThreadId, TurnId } from "@t3tools/contracts"; import * as CodexErrors from "effect-codex-app-server/errors"; import * as CodexRpc from "effect-codex-app-server/rpc"; +import type * as EffectCodexSchema from "effect-codex-app-server/schema"; import { CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, @@ -18,6 +18,7 @@ import { hasConfiguredMcpServer, isRecoverableThreadResumeError, openCodexThread, + resolveCodexInterruptTurnId, } from "./CodexSessionRuntime.ts"; const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError); @@ -61,6 +62,27 @@ function makeThreadOpenResponse( } as unknown as CodexRpc.ClientRequestResponsesByMethod["thread/start"]; } +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("buildTurnStartParams", () => { it("keeps invalid turn values only in the schema cause", () => { const secret = "codex-turn-input-secret-sentinel"; @@ -196,32 +218,59 @@ describe("buildTurnStartParams", () => { }); describe("findActiveCodexTurnId", () => { - it("selects the newest in-progress turn from a thread snapshot", () => { - const response = makeThreadOpenResponse( - "provider-thread-1", - ) as unknown as CodexRpc.ClientRequestResponsesByMethod["thread/read"]; - const turns = [ - { id: "turn-completed", status: "completed", items: [] }, - { id: "turn-active-old", status: "inProgress", items: [] }, - { id: "turn-active-new", status: "inProgress", items: [] }, - ]; - const snapshot = { - ...response, - thread: { - ...response.thread, - turns, - }, - } as CodexRpc.ClientRequestResponsesByMethod["thread/read"]; + 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("returns undefined when no turn is active", () => { - const response = makeThreadOpenResponse( - "provider-thread-1", - ) as unknown as CodexRpc.ClientRequestResponsesByMethod["thread/read"]; + const response = makeThreadReadResponse([]); NodeAssert.equal(findActiveCodexTurnId(response), undefined); }); + + it.effect("requests turns when resolving an interrupt without a projected turn id", () => { + let requestedParams: CodexRpc.ClientRequestParamsByMethod["thread/read"] | undefined; + + return Effect.gen(function* () { + const turnId = yield* resolveCodexInterruptTurnId({ + providerThreadId: "provider-thread-1", + requestedTurnId: undefined, + sessionActiveTurnId: undefined, + readThread: (params) => { + 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(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, + sessionActiveTurnId: TurnId.make("turn-stale"), + readThread: () => Effect.succeed(makeThreadReadResponse([])), + }); + + NodeAssert.equal(turnId, undefined); + }), + ); }); describe("T3 browser developer instructions", () => { diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index b1a6fa1bb0b..2b5c3b2e2c6 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -54,6 +54,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", @@ -695,13 +696,57 @@ function parseThreadSnapshot( export function findActiveCodexTurnId( response: EffectCodexSchema.V2ThreadReadResponse, ): TurnId | undefined { - for (let index = response.thread.turns.length - 1; index >= 0; index -= 1) { - const turn = response.thread.turns[index]; - if (turn?.status === "inProgress") { - return TurnId.make(turn.id); + let activeTurn: EffectCodexSchema.V2ThreadReadResponse["thread"]["turns"][number] | undefined; + for (const turn of response.thread.turns) { + if (turn.status !== "inProgress") { + continue; } + if ( + activeTurn === undefined || + (turn.startedAt !== undefined && + turn.startedAt !== null && + (activeTurn.startedAt === undefined || + activeTurn.startedAt === null || + turn.startedAt >= activeTurn.startedAt)) || + ((turn.startedAt === undefined || turn.startedAt === null) && + (activeTurn.startedAt === undefined || activeTurn.startedAt === null)) + ) { + activeTurn = turn; + } + } + return activeTurn === undefined ? undefined : TurnId.make(activeTurn.id); +} + +export function resolveCodexInterruptTurnId(input: { + readonly providerThreadId: string; + readonly requestedTurnId: TurnId | undefined; + readonly sessionActiveTurnId: TurnId | undefined; + readonly readThread: ( + params: CodexRpc.ClientRequestParamsByMethod["thread/read"], + ) => Effect.Effect; +}): Effect.Effect { + if (input.requestedTurnId !== undefined) { + return Effect.succeed(input.requestedTurnId); } - return undefined; + + 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.orElseSucceed(() => input.sessionActiveTurnId), + ); } export const makeCodexSessionRuntime = ( @@ -1328,19 +1373,12 @@ export const makeCodexSessionRuntime = ( Effect.gen(function* () { const providerThreadId = yield* readProviderThreadId; const session = yield* Ref.get(sessionRef); - const runtimeActiveTurnId = - turnId === undefined - ? yield* client - .request("thread/read", { - threadId: providerThreadId, - includeTurns: true, - }) - .pipe( - Effect.map(findActiveCodexTurnId), - Effect.orElseSucceed(() => undefined), - ) - : undefined; - const effectiveTurnId = turnId ?? runtimeActiveTurnId ?? session.activeTurnId; + const effectiveTurnId = yield* resolveCodexInterruptTurnId({ + providerThreadId, + requestedTurnId: turnId, + sessionActiveTurnId: session.activeTurnId, + readThread: (params) => client.request("thread/read", params), + }); if (!effectiveTurnId) { return; } diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 2e0bef5a876..bd55403036a 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -495,6 +495,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { latestTurn: runningTurn, session: runningSession, }), + { expectedUserMessageId: steerMessageId }, ); expect( @@ -522,5 +523,36 @@ describe("hasServerAcknowledgedLocalDispatch", () => { threadError: null, }), ).toBe(true); + + const alreadyProjected = createLocalDispatchSnapshot( + makeThread({ + messages: [ + { + id: steerMessageId, + role: "user", + text: "steer", + turnId: runningTurn.turnId, + streaming: false, + createdAt: now, + updatedAt: now, + }, + ], + latestTurn: runningTurn, + session: runningSession, + }), + { expectedUserMessageId: MessageId.make("message-next-steer") }, + ); + expect( + hasServerAcknowledgedLocalDispatch({ + localDispatch: alreadyProjected, + phase: "running", + latestTurn: runningTurn, + session: runningSession, + latestUserMessageId: steerMessageId, + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }), + ).toBe(false); }); }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index b9b7927c00f..2a7c051ddb6 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -388,7 +388,7 @@ export async function waitForStartedServerThread( export interface LocalDispatchSnapshot { startedAt: string; preparingWorktree: boolean; - latestUserMessageId: MessageId | null; + expectedUserMessageId: MessageId | null; latestTurnTurnId: TurnId | null; latestTurnRequestedAt: string | null; latestTurnStartedAt: string | null; @@ -411,14 +411,14 @@ export function getLatestUserMessageId( export function createLocalDispatchSnapshot( activeThread: Thread | undefined, - options?: { preparingWorktree?: boolean }, + options?: { preparingWorktree?: boolean; expectedUserMessageId?: MessageId }, ): LocalDispatchSnapshot { const latestTurn = activeThread?.latestTurn ?? null; const session = activeThread?.session ?? null; return { startedAt: new Date().toISOString(), preparingWorktree: Boolean(options?.preparingWorktree), - latestUserMessageId: getLatestUserMessageId(activeThread?.messages ?? []), + expectedUserMessageId: options?.expectedUserMessageId ?? null, latestTurnTurnId: latestTurn?.turnId ?? null, latestTurnRequestedAt: latestTurn?.requestedAt ?? null, latestTurnStartedAt: latestTurn?.startedAt ?? null, @@ -451,7 +451,8 @@ export function hasServerAcknowledgedLocalDispatch(input: { // The projected user message is the server acknowledgement in that case. if ( input.localDispatch.sessionStatus === "running" && - input.localDispatch.latestUserMessageId !== input.latestUserMessageId + input.localDispatch.expectedUserMessageId !== null && + input.localDispatch.expectedUserMessageId === input.latestUserMessageId ) { return true; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 526b25e27f4..5eca7f50aa6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -369,6 +369,10 @@ function useLocalDispatchState(input: { threadError: string | null | undefined; }) { const [localDispatch, setLocalDispatch] = useState(null); + const latestUserMessageId = useMemo( + () => getLatestUserMessageId(input.activeThread?.messages ?? []), + [input.activeThread?.messages], + ); const resetLocalDispatch = useCallback(() => { setLocalDispatch(null); @@ -381,7 +385,7 @@ function useLocalDispatchState(input: { phase: input.phase, latestTurn: input.activeLatestTurn, session: input.activeThread?.session ?? null, - latestUserMessageId: getLatestUserMessageId(input.activeThread?.messages ?? []), + latestUserMessageId, hasPendingApproval: input.activePendingApproval !== null, hasPendingUserInput: input.activePendingUserInput !== null, threadError: input.threadError, @@ -391,15 +395,15 @@ function useLocalDispatchState(input: { input.activePendingApproval, input.activePendingUserInput, input.activeThread?.session, - input.activeThread?.messages, input.phase, input.threadError, + latestUserMessageId, localDispatch, ], ); const activeLocalDispatch = serverAcknowledgedLocalDispatch ? null : localDispatch; const beginLocalDispatch = useCallback( - (options?: { preparingWorktree?: boolean }) => { + (options?: { preparingWorktree?: boolean; expectedUserMessageId?: MessageId }) => { const preparingWorktree = Boolean(options?.preparingWorktree); setLocalDispatch((current) => { const active = serverAcknowledgedLocalDispatch ? null : current; @@ -3983,9 +3987,6 @@ function ChatViewContent(props: ChatViewProps) { return; } - sendInFlightRef.current = true; - beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); - const composerImagesSnapshot = [...composerImages]; const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; const composerElementContextsSnapshot = [...composerElementContexts]; @@ -4004,6 +4005,11 @@ function ChatViewContent(props: ChatViewProps) { composerReviewCommentsSnapshot, ); const messageIdForSend = newMessageId(); + sendInFlightRef.current = true; + beginLocalDispatch({ + preparingWorktree: Boolean(baseBranchForWorktree), + expectedUserMessageId: messageIdForSend, + }); const messageCreatedAt = new Date().toISOString(); const outgoingMessageText = formatOutgoingPrompt({ provider: ctxSelectedProvider, @@ -4165,7 +4171,7 @@ function ChatViewContent(props: ChatViewProps) { : {}), } : undefined; - beginLocalDispatch({ preparingWorktree: false }); + beginLocalDispatch({ preparingWorktree: false, expectedUserMessageId: messageIdForSend }); const startResult = await startThreadTurn({ environmentId, input: { @@ -4465,7 +4471,7 @@ function ChatViewContent(props: ChatViewProps) { }); sendInFlightRef.current = true; - beginLocalDispatch({ preparingWorktree: false }); + beginLocalDispatch({ preparingWorktree: false, expectedUserMessageId: messageIdForSend }); setThreadError(threadIdForSend, null); // Position this sent row once LegendList has measured the anchored tail. From 3414977d38898deea6576b6c97f10b57c52b6eab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sun, 12 Jul 2026 01:48:09 +0100 Subject: [PATCH 4/5] Fix active Codex turn selection without timestamps - Fall back to provider response order when start times are absent - Cover both mixed timestamp ordering cases --- .../Layers/CodexSessionRuntime.test.ts | 18 ++++++++++++++++++ .../src/provider/Layers/CodexSessionRuntime.ts | 12 +++++++----- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 92ecb192f34..3f30fb8237a 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -228,6 +228,24 @@ describe("findActiveCodexTurnId", () => { 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); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 2b5c3b2e2c6..f654fbd63bb 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -703,13 +703,15 @@ export function findActiveCodexTurnId( } if ( activeTurn === undefined || + turn.startedAt === undefined || + turn.startedAt === null || + activeTurn.startedAt === undefined || + activeTurn.startedAt === null || (turn.startedAt !== undefined && turn.startedAt !== null && - (activeTurn.startedAt === undefined || - activeTurn.startedAt === null || - turn.startedAt >= activeTurn.startedAt)) || - ((turn.startedAt === undefined || turn.startedAt === null) && - (activeTurn.startedAt === undefined || activeTurn.startedAt === null)) + activeTurn.startedAt !== undefined && + activeTurn.startedAt !== null && + turn.startedAt >= activeTurn.startedAt) ) { activeTurn = turn; } From 06e38dc21221a4c0b5b4b82ac1dccc9573a8aa97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 13 Jul 2026 19:37:25 +0100 Subject: [PATCH 5/5] Align Expo updates with fork project - Use the Quicksaver EAS project id for the OTA endpoint --- apps/mobile/app.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 141534b8348..79f46461f93 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -125,7 +125,7 @@ const config: ExpoConfig = { userInterfaceStyle: "automatic", updates: { enabled: true, - url: "https://u.expo.dev/d763fcb8-d37c-41ea-a773-b54a0ab4a454", + url: "https://u.expo.dev/c65ac46d-6488-49af-b61e-ab9bef78f96e", checkAutomatically: "ON_LOAD", fallbackToCacheTimeout: 0, },