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 01/15] 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 02/15] 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 03/15] 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 04/15] 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 05/15] 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, }, From 6c08e813d7f5b18e339c0494bcd1a035f6d1ff70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Thu, 16 Jul 2026 22:39:39 +0100 Subject: [PATCH 06/15] Remove unrelated Expo fork customization --- apps/mobile/app.config.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index e06c92dbcf0..c27be2e357f 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -160,7 +160,7 @@ const config: ExpoConfig = { userInterfaceStyle: "automatic", updates: { enabled: true, - url: "https://u.expo.dev/c65ac46d-6488-49af-b61e-ab9bef78f96e", + url: "https://u.expo.dev/d763fcb8-d37c-41ea-a773-b54a0ab4a454", checkAutomatically: "ON_LOAD", fallbackToCacheTimeout: 0, }, @@ -331,10 +331,10 @@ const config: ExpoConfig = { tracesToken: repoEnv.EXPO_PUBLIC_OTLP_TRACES_TOKEN ?? null, }, eas: { - projectId: "c65ac46d-6488-49af-b61e-ab9bef78f96e", + projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454", }, }, - owner: "quicksaver", + owner: "pingdotgg", }; export default config; From 726dd7319295bfdf8556a649dd8d190b4a028d1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Fri, 17 Jul 2026 19:32:10 +0100 Subject: [PATCH 07/15] Document repeated steering and reliable stop behavior - Capture exact message acknowledgement and interrupt routing contracts - Record Codex live-turn selection and fallback safeguards - Reserve branch-specific web and server development ports --- BRANCH_DETAILS.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 BRANCH_DETAILS.md diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md new file mode 100644 index 00000000000..88fa0ebc86a --- /dev/null +++ b/BRANCH_DETAILS.md @@ -0,0 +1,24 @@ +# 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. Unrelated projected user messages must not acknowledge the dispatch, and 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. +- Provider-control failure activities are best-effort when interrupt or session-stop recovery must clear projected state. A failure to record the diagnostic activity must not leave a subagent relation or provider session projected as running. +- 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 cached session turn; a successful lookup with no active turn returns without reviving a stale cached id. + +Primary files: + +- `apps/web/src/components/ChatView.tsx` +- `apps/web/src/components/ChatView.logic.ts` +- `apps/server/src/orchestration/Layers/ProviderCommandReactor.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/CodexSessionRuntime.test.ts`. Keep coverage for consecutive in-turn steers, exact-message acknowledgement, root projection turn ids, explicit child-turn targeting, timestamp-based live-turn selection, lookup timeout/failure fallback, and successful empty reads that suppress stale interrupts. + +## Development Ports + +- Web: `5738` +- Server/WebSocket: `13778` From 0ac74996288ca44b1d400012235fa406c0ec51d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Fri, 17 Jul 2026 19:52:01 +0100 Subject: [PATCH 08/15] Correct repeated steering branch documentation --- BRANCH_DETAILS.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index 88fa0ebc86a..4a881a4ac56 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -6,17 +6,15 @@ Expected behavior: - A steering send remains locally busy only until the server projects that exact user-message id. Unrelated projected user messages must not acknowledge the dispatch, and 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. -- Provider-control failure activities are best-effort when interrupt or session-stop recovery must clear projected state. A failure to record the diagnostic activity must not leave a subagent relation or provider session projected as running. - 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 cached session turn; a successful lookup with no active turn returns without reviving a stale cached id. Primary files: - `apps/web/src/components/ChatView.tsx` - `apps/web/src/components/ChatView.logic.ts` -- `apps/server/src/orchestration/Layers/ProviderCommandReactor.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/CodexSessionRuntime.test.ts`. Keep coverage for consecutive in-turn steers, exact-message acknowledgement, root projection turn ids, explicit child-turn targeting, timestamp-based live-turn selection, lookup timeout/failure fallback, and successful empty reads that suppress stale interrupts. +Regression coverage lives in `apps/web/src/components/ChatView.logic.test.ts` and `apps/server/src/provider/Layers/CodexSessionRuntime.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. ## Development Ports From 425371ac1b1f4cbc3967a79c15180128095f7ab9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sat, 18 Jul 2026 23:40:38 +0100 Subject: [PATCH 09/15] Document upstream merge impact on repeated steering --- BRANCH_DETAILS.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index 4a881a4ac56..cd936de8792 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -1,5 +1,19 @@ # Repeated Steering And Reliable Stop +## Current Status + +Branch maintenance snapshot for the 2026-07-18 upstream merge: + +- Generated from `upstream/main` `1735e27d9e5106bbb35d5b1dd10363604a54b69e` with starting branch HEAD `0ac74996288ca44b1d400012235fa406c0ec51d4`. The merge commit is `195fbca66e408e03fc6f6ac8220e5b5c7430277d`; the previous upstream merge is `dd4549645d8545e2c566560470f00be69bb98f42`, whose upstream parent is `fdca15471d92e95e4ec5501f45dbf3ce81f8d991`. +- Shared refs at merge time: `origin/main` `2fe83d19ea2548dfaa7770aea29e5047d0b17b6e`, local `main` `cfbd1261caa08707987d2dfc2edc93caa0790d54`, and branch tracking ref `origin/fix/repeated-steering-and-stop` `6c08e813d7f5b18e339c0494bcd1a035f6d1ff70`. +- After the merge and this documentation refresh, the resulting branch is `13 ahead / 0 behind upstream/main`, `63 ahead / 505 behind origin/main`, `63 ahead / 517 behind local main`, and `54 ahead / 0 behind origin/fix/repeated-steering-and-stop`. The fork diff is `6 files changed, 394 insertions(+), 54 deletions(-)` against the `upstream/main` tree. +- The 50 incoming commits from `8a02c718619a9d4c6588d3ab7a89204023cfab62` through `1735e27d9e5106bbb35d5b1dd10363604a54b69e` add the draft landing hero, terminal selection copying, file-explorer mention actions, desktop release-note UI, mobile screenshot automation, refreshed branding, and Codex runtime model/effort instructions. They also improve active-turn sending, initial thread snapshot replay, deferred active-thread cache writes, timestamp validation/canonicalization, remote-environment cleanup, diff and terminal behavior, provider reliability, and source-control path handling. +- Customized-behavior impact: upstream's active-turn send fix acknowledged any changed latest user message. This branch still requires the projected id to equal the exact outbound steering id, so an unrelated user projection cannot release local busy state. Upstream's initial-snapshot replay, deferred active-thread cache writes, and timestamp hardening can change when that exact message becomes visible but complement rather than replace the correlation rule. Upstream did not add equivalent root/subagent interrupt routing or live Codex active-turn resolution, so no branch customization is retired. +- Conflict note for `apps/web/src/components/ChatView.logic.ts`, `apps/web/src/components/ChatView.logic.test.ts`, and `apps/web/src/components/ChatView.tsx`: the upstream latest-message-change acknowledgement and its weaker regression assertion were replaced by the existing exact-id correlation and consecutive-steer coverage. The new draft-hero dock transition and early in-flight guard were preserved, while `beginLocalDispatch` now waits until `newMessageId()` provides the exact expected id. +- Conflict note for `apps/server/src/provider/Layers/CodexSessionRuntime.test.ts`: upstream's runtime model/effort instruction tests and imports were combined with the branch's live-turn lookup, ordering, timeout, and fallback tests. `apps/server/src/provider/Layers/CodexSessionRuntime.ts` merged additively, retaining both the incoming developer-instruction behavior and branch-owned interrupt resolution. +- Validation note: the 50 focused steering/Codex interruption tests, `vp check`, repository typecheck, and mobile native static check pass; Playwright reached the authenticated draft hero and composer on ports `5738`/`13778`. The full repository run passed 4,782 tests and failed six tests in three incoming upstream test files; an isolated rerun cleared `server.test.ts` but retained three GitHub-selector timeouts in `GitManager.test.ts` and the ACP replay-idle timeout in `AcpJsonRpcConnection.test.ts`. None exercises a branch-primary customization file. +- Follow-up maintenance note: upstream now owns a generic active-turn acknowledgement path in the same broad `ChatView` modules, making the branch's stricter message correlation a recurring merge hotspot. A later branch refactor should isolate dispatch correlation into one small tested state helper (or, preferably, converge on an explicit server receipt keyed by message id) so draft/composer UI evolution does not repeatedly conflict with the exact-id guarantee. The branch's Codex interrupt selection is similarly isolated enough to extract from the growing runtime test file, but this merge does not require either structural change. + 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: From 8a889a67f247984b76d678f59617b0c9f92b8bda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sat, 18 Jul 2026 23:48:20 +0100 Subject: [PATCH 10/15] Isolate Codex interrupt resolution coverage --- BRANCH_DETAILS.md | 10 +- .../Layers/CodexInterruptResolution.test.ts | 172 ++++++++++++++++++ .../Layers/CodexSessionRuntime.test.ts | 165 +---------------- 3 files changed, 178 insertions(+), 169 deletions(-) create mode 100644 apps/server/src/provider/Layers/CodexInterruptResolution.test.ts diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index cd936de8792..14c8f59f79a 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -6,13 +6,13 @@ Branch maintenance snapshot for the 2026-07-18 upstream merge: - Generated from `upstream/main` `1735e27d9e5106bbb35d5b1dd10363604a54b69e` with starting branch HEAD `0ac74996288ca44b1d400012235fa406c0ec51d4`. The merge commit is `195fbca66e408e03fc6f6ac8220e5b5c7430277d`; the previous upstream merge is `dd4549645d8545e2c566560470f00be69bb98f42`, whose upstream parent is `fdca15471d92e95e4ec5501f45dbf3ce81f8d991`. - Shared refs at merge time: `origin/main` `2fe83d19ea2548dfaa7770aea29e5047d0b17b6e`, local `main` `cfbd1261caa08707987d2dfc2edc93caa0790d54`, and branch tracking ref `origin/fix/repeated-steering-and-stop` `6c08e813d7f5b18e339c0494bcd1a035f6d1ff70`. -- After the merge and this documentation refresh, the resulting branch is `13 ahead / 0 behind upstream/main`, `63 ahead / 505 behind origin/main`, `63 ahead / 517 behind local main`, and `54 ahead / 0 behind origin/fix/repeated-steering-and-stop`. The fork diff is `6 files changed, 394 insertions(+), 54 deletions(-)` against the `upstream/main` tree. +- After the merge, conflict-resolution hardening, and this documentation refresh, the resulting branch is `14 ahead / 0 behind upstream/main`, `64 ahead / 505 behind origin/main`, `64 ahead / 517 behind local main`, and `55 ahead / 0 behind origin/fix/repeated-steering-and-stop`. The fork diff is `7 files changed, 402 insertions(+), 53 deletions(-)` against the `upstream/main` tree. - The 50 incoming commits from `8a02c718619a9d4c6588d3ab7a89204023cfab62` through `1735e27d9e5106bbb35d5b1dd10363604a54b69e` add the draft landing hero, terminal selection copying, file-explorer mention actions, desktop release-note UI, mobile screenshot automation, refreshed branding, and Codex runtime model/effort instructions. They also improve active-turn sending, initial thread snapshot replay, deferred active-thread cache writes, timestamp validation/canonicalization, remote-environment cleanup, diff and terminal behavior, provider reliability, and source-control path handling. - Customized-behavior impact: upstream's active-turn send fix acknowledged any changed latest user message. This branch still requires the projected id to equal the exact outbound steering id, so an unrelated user projection cannot release local busy state. Upstream's initial-snapshot replay, deferred active-thread cache writes, and timestamp hardening can change when that exact message becomes visible but complement rather than replace the correlation rule. Upstream did not add equivalent root/subagent interrupt routing or live Codex active-turn resolution, so no branch customization is retired. - Conflict note for `apps/web/src/components/ChatView.logic.ts`, `apps/web/src/components/ChatView.logic.test.ts`, and `apps/web/src/components/ChatView.tsx`: the upstream latest-message-change acknowledgement and its weaker regression assertion were replaced by the existing exact-id correlation and consecutive-steer coverage. The new draft-hero dock transition and early in-flight guard were preserved, while `beginLocalDispatch` now waits until `newMessageId()` provides the exact expected id. -- Conflict note for `apps/server/src/provider/Layers/CodexSessionRuntime.test.ts`: upstream's runtime model/effort instruction tests and imports were combined with the branch's live-turn lookup, ordering, timeout, and fallback tests. `apps/server/src/provider/Layers/CodexSessionRuntime.ts` merged additively, retaining both the incoming developer-instruction behavior and branch-owned interrupt resolution. -- Validation note: the 50 focused steering/Codex interruption tests, `vp check`, repository typecheck, and mobile native static check pass; Playwright reached the authenticated draft hero and composer on ports `5738`/`13778`. The full repository run passed 4,782 tests and failed six tests in three incoming upstream test files; an isolated rerun cleared `server.test.ts` but retained three GitHub-selector timeouts in `GitManager.test.ts` and the ACP replay-idle timeout in `AcpJsonRpcConnection.test.ts`. None exercises a branch-primary customization file. -- Follow-up maintenance note: upstream now owns a generic active-turn acknowledgement path in the same broad `ChatView` modules, making the branch's stricter message correlation a recurring merge hotspot. A later branch refactor should isolate dispatch correlation into one small tested state helper (or, preferably, converge on an explicit server receipt keyed by message id) so draft/composer UI evolution does not repeatedly conflict with the exact-id guarantee. The branch's Codex interrupt selection is similarly isolated enough to extract from the growing runtime test file, but this merge does not require either structural change. +- Conflict note for `apps/server/src/provider/Layers/CodexSessionRuntime.test.ts`: upstream's runtime model/effort instruction tests and imports were initially combined with the branch's live-turn lookup, ordering, timeout, and fallback tests. The branch-owned suite now lives in `apps/server/src/provider/Layers/CodexInterruptResolution.test.ts`, removing the concrete collision with continued upstream growth in the general runtime test file. `apps/server/src/provider/Layers/CodexSessionRuntime.ts` merged additively, retaining both the incoming developer-instruction behavior and branch-owned interrupt resolution. +- Validation note: the 50 focused steering/Codex interruption tests, `vp check`, repository typecheck, and mobile native static check pass; the extracted server pair specifically passes 28 tests. Playwright reached the authenticated draft hero and composer on ports `5738`/`13778`. The full repository run passed 4,782 tests and failed six tests in three incoming upstream test files; an isolated rerun cleared `server.test.ts` but retained three GitHub-selector timeouts in `GitManager.test.ts` and the ACP replay-idle timeout in `AcpJsonRpcConnection.test.ts`. None exercises a branch-primary customization file. +- Follow-up maintenance note: `hasServerAcknowledgedLocalDispatch` is already the branch's small, tested dispatch-correlation helper, so another client helper layer would not reduce the send-site conflict introduced by the upstream draft hero. An explicit server receipt keyed by message id would broaden contracts and runtime behavior without closing a demonstrated correctness gap; defer that protocol change unless projected ids stop being authoritative. 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. @@ -28,7 +28,7 @@ Primary files: - `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/CodexSessionRuntime.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. +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. ## Development Ports diff --git a/apps/server/src/provider/Layers/CodexInterruptResolution.test.ts b/apps/server/src/provider/Layers/CodexInterruptResolution.test.ts new file mode 100644 index 00000000000..14138e85a44 --- /dev/null +++ b/apps/server/src/provider/Layers/CodexInterruptResolution.test.ts @@ -0,0 +1,172 @@ +import * as NodeAssert from "node:assert/strict"; + +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, + shouldPreferActiveCodexTurnCandidate, +} 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); + }); + + 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); + }), + ); + + 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, + sessionActiveTurnId: 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 resolution = yield* resolveCodexInterruptTurnId({ + providerThreadId: "provider-thread-1", + requestedTurnId: undefined, + sessionActiveTurnId: projectedTurnId, + readThread: () => Effect.never, + }).pipe(Effect.forkScoped); + + yield* Effect.yieldNow; + yield* TestClock.adjust("2 seconds"); + NodeAssert.equal(yield* Fiber.join(resolution), projectedTurnId); + }), + ); +}); + +describe("shouldPreferActiveCodexTurnCandidate", () => { + it("selects the first candidate", () => { + NodeAssert.equal(shouldPreferActiveCodexTurnCandidate({ startedAt: 10 }, undefined), true); + }); + + it("orders timestamped turns by start time and lets a later equal entry win", () => { + NodeAssert.equal( + shouldPreferActiveCodexTurnCandidate({ startedAt: 20 }, { startedAt: 10 }), + true, + ); + NodeAssert.equal( + shouldPreferActiveCodexTurnCandidate({ startedAt: 10 }, { startedAt: 20 }), + false, + ); + NodeAssert.equal( + shouldPreferActiveCodexTurnCandidate({ 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(shouldPreferActiveCodexTurnCandidate(candidate, selected), true); + } + }); +}); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index a116219d1b0..db24b780dc0 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -1,14 +1,11 @@ import * as NodeAssert from "node:assert/strict"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; import * as Schema from "effect/Schema"; -import * as TestClock from "effect/testing/TestClock"; import { describe, it } from "@effect/vitest"; -import { DEFAULT_MODEL, ThreadId, TurnId } from "@t3tools/contracts"; +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"; -import type * as EffectCodexSchema from "effect-codex-app-server/schema"; import { buildCodexDeveloperInstructions, @@ -17,12 +14,9 @@ import { } from "../CodexDeveloperInstructions.ts"; import { buildTurnStartParams, - findActiveCodexTurnId, hasConfiguredMcpServer, isRecoverableThreadResumeError, openCodexThread, - resolveCodexInterruptTurnId, - shouldPreferActiveCodexTurnCandidate, } from "./CodexSessionRuntime.ts"; const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError); @@ -66,27 +60,6 @@ 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"; @@ -243,142 +216,6 @@ describe("buildTurnStartParams", () => { }); }); -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); - }); - - 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); - }), - ); - - 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, - sessionActiveTurnId: 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 resolution = yield* resolveCodexInterruptTurnId({ - providerThreadId: "provider-thread-1", - requestedTurnId: undefined, - sessionActiveTurnId: projectedTurnId, - readThread: () => Effect.never, - }).pipe(Effect.forkScoped); - - yield* Effect.yieldNow; - yield* TestClock.adjust("2 seconds"); - NodeAssert.equal(yield* Fiber.join(resolution), projectedTurnId); - }), - ); -}); - -describe("shouldPreferActiveCodexTurnCandidate", () => { - it("selects the first candidate", () => { - NodeAssert.equal(shouldPreferActiveCodexTurnCandidate({ startedAt: 10 }, undefined), true); - }); - - it("orders timestamped turns by start time and lets a later equal entry win", () => { - NodeAssert.equal( - shouldPreferActiveCodexTurnCandidate({ startedAt: 20 }, { startedAt: 10 }), - true, - ); - NodeAssert.equal( - shouldPreferActiveCodexTurnCandidate({ startedAt: 10 }, { startedAt: 20 }), - false, - ); - NodeAssert.equal( - shouldPreferActiveCodexTurnCandidate({ 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(shouldPreferActiveCodexTurnCandidate(candidate, selected), true); - } - }); -}); - describe("buildCodexDeveloperInstructions", () => { it("appends runtime info after the mode instructions", () => { const instructions = buildCodexDeveloperInstructions("default", { From d1dca69f781aa03ea77bee39b81d5641ed5d7edc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sun, 19 Jul 2026 00:12:13 +0100 Subject: [PATCH 11/15] Keep steering acknowledgements and fallback turns current - Match dispatched steering IDs across all projected user messages - Read the session fallback only after live Codex lookup failures - Cover multi-client steering and timeout races with regression tests --- BRANCH_DETAILS.md | 6 +-- .../Layers/CodexInterruptResolution.test.ts | 25 +++++++-- .../provider/Layers/CodexSessionRuntime.ts | 9 ++-- .../web/src/components/ChatView.logic.test.ts | 53 ++++++++++++++++--- apps/web/src/components/ChatView.logic.ts | 21 +++----- apps/web/src/components/ChatView.tsx | 9 +--- 6 files changed, 82 insertions(+), 41 deletions(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index 14c8f59f79a..8d98ace46ad 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -11,16 +11,16 @@ Branch maintenance snapshot for the 2026-07-18 upstream merge: - Customized-behavior impact: upstream's active-turn send fix acknowledged any changed latest user message. This branch still requires the projected id to equal the exact outbound steering id, so an unrelated user projection cannot release local busy state. Upstream's initial-snapshot replay, deferred active-thread cache writes, and timestamp hardening can change when that exact message becomes visible but complement rather than replace the correlation rule. Upstream did not add equivalent root/subagent interrupt routing or live Codex active-turn resolution, so no branch customization is retired. - Conflict note for `apps/web/src/components/ChatView.logic.ts`, `apps/web/src/components/ChatView.logic.test.ts`, and `apps/web/src/components/ChatView.tsx`: the upstream latest-message-change acknowledgement and its weaker regression assertion were replaced by the existing exact-id correlation and consecutive-steer coverage. The new draft-hero dock transition and early in-flight guard were preserved, while `beginLocalDispatch` now waits until `newMessageId()` provides the exact expected id. - Conflict note for `apps/server/src/provider/Layers/CodexSessionRuntime.test.ts`: upstream's runtime model/effort instruction tests and imports were initially combined with the branch's live-turn lookup, ordering, timeout, and fallback tests. The branch-owned suite now lives in `apps/server/src/provider/Layers/CodexInterruptResolution.test.ts`, removing the concrete collision with continued upstream growth in the general runtime test file. `apps/server/src/provider/Layers/CodexSessionRuntime.ts` merged additively, retaining both the incoming developer-instruction behavior and branch-owned interrupt resolution. -- Validation note: the 50 focused steering/Codex interruption tests, `vp check`, repository typecheck, and mobile native static check pass; the extracted server pair specifically passes 28 tests. Playwright reached the authenticated draft hero and composer on ports `5738`/`13778`. The full repository run passed 4,782 tests and failed six tests in three incoming upstream test files; an isolated rerun cleared `server.test.ts` but retained three GitHub-selector timeouts in `GitManager.test.ts` and the ACP replay-idle timeout in `AcpJsonRpcConnection.test.ts`. None exercises a branch-primary customization file. +- Validation note: the 51 focused steering/Codex interruption tests, `vp check`, repository typecheck, and mobile native static check pass; the extracted server pair specifically passes 29 tests. Playwright reached the authenticated draft hero and composer on ports `5738`/`13778`. The full repository run passed 4,782 tests and failed six tests in three incoming upstream test files; an isolated rerun cleared `server.test.ts` but retained three GitHub-selector timeouts in `GitManager.test.ts` and the ACP replay-idle timeout in `AcpJsonRpcConnection.test.ts`. None exercises a branch-primary customization file. - Follow-up maintenance note: `hasServerAcknowledgedLocalDispatch` is already the branch's small, tested dispatch-correlation helper, so another client helper layer would not reduce the send-site conflict introduced by the upstream draft hero. An explicit server receipt keyed by message id would broaden contracts and runtime behavior without closing a demonstrated correctness gap; defer that protocol change unless projected ids stop being authoritative. 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. Unrelated projected user messages must not acknowledge the dispatch, and steering the existing running turn must not wait for a new turn or session transition before re-enabling the composer. +- 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 cached session turn; a successful lookup with no active turn returns without reviving a stale cached id. +- 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. Primary files: diff --git a/apps/server/src/provider/Layers/CodexInterruptResolution.test.ts b/apps/server/src/provider/Layers/CodexInterruptResolution.test.ts index 14138e85a44..46febe02054 100644 --- a/apps/server/src/provider/Layers/CodexInterruptResolution.test.ts +++ b/apps/server/src/provider/Layers/CodexInterruptResolution.test.ts @@ -76,7 +76,7 @@ describe("findActiveCodexTurnId", () => { const turnId = yield* resolveCodexInterruptTurnId({ providerThreadId: "provider-thread-1", requestedTurnId: undefined, - sessionActiveTurnId: undefined, + readSessionActiveTurnId: Effect.succeed(undefined), readThread: (params) => { requestedParams = params; return Effect.succeed( @@ -100,7 +100,7 @@ describe("findActiveCodexTurnId", () => { const turnId = yield* resolveCodexInterruptTurnId({ providerThreadId: "provider-thread-1", requestedTurnId: undefined, - sessionActiveTurnId: TurnId.make("turn-stale"), + readSessionActiveTurnId: Effect.succeed(TurnId.make("turn-stale")), readThread: () => Effect.succeed(makeThreadReadResponse([])), }); @@ -114,7 +114,7 @@ describe("findActiveCodexTurnId", () => { const turnId = yield* resolveCodexInterruptTurnId({ providerThreadId: "provider-thread-1", requestedTurnId: undefined, - sessionActiveTurnId: projectedTurnId, + readSessionActiveTurnId: Effect.succeed(projectedTurnId), readThread: () => Effect.fail("lookup failed"), }); @@ -128,7 +128,7 @@ describe("findActiveCodexTurnId", () => { const resolution = yield* resolveCodexInterruptTurnId({ providerThreadId: "provider-thread-1", requestedTurnId: undefined, - sessionActiveTurnId: projectedTurnId, + readSessionActiveTurnId: Effect.succeed(projectedTurnId), readThread: () => Effect.never, }).pipe(Effect.forkScoped); @@ -137,6 +137,23 @@ describe("findActiveCodexTurnId", () => { 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 resolution = yield* resolveCodexInterruptTurnId({ + providerThreadId: "provider-thread-1", + requestedTurnId: undefined, + readSessionActiveTurnId: Effect.sync(() => projectedTurnId), + readThread: () => Effect.never, + }).pipe(Effect.forkScoped); + + yield* Effect.yieldNow; + projectedTurnId = TurnId.make("turn-current"); + yield* TestClock.adjust("2 seconds"); + NodeAssert.equal(yield* Fiber.join(resolution), projectedTurnId); + }), + ); }); describe("shouldPreferActiveCodexTurnCandidate", () => { diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index eed0916c17c..bbd89f9660b 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -731,7 +731,7 @@ export function findActiveCodexTurnId( export function resolveCodexInterruptTurnId(input: { readonly providerThreadId: string; readonly requestedTurnId: TurnId | undefined; - readonly sessionActiveTurnId: TurnId | undefined; + readonly readSessionActiveTurnId: Effect.Effect; readonly readThread: ( params: CodexRpc.ClientRequestParamsByMethod["thread/read"], ) => Effect.Effect; @@ -756,7 +756,7 @@ export function resolveCodexInterruptTurnId(input: { ), // 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), + Effect.catch(() => input.readSessionActiveTurnId), ); } @@ -1383,11 +1383,12 @@ export const makeCodexSessionRuntime = ( interruptTurn: (turnId) => Effect.gen(function* () { const providerThreadId = yield* readProviderThreadId; - const session = yield* Ref.get(sessionRef); const effectiveTurnId = yield* resolveCodexInterruptTurnId({ providerThreadId, requestedTurnId: turnId, - sessionActiveTurnId: session.activeTurnId, + readSessionActiveTurnId: Ref.get(sessionRef).pipe( + Effect.map((session) => session.activeTurnId), + ), readThread: (params) => client.request("thread/read", params), }); if (!effectiveTurnId) { diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index bd55403036a..5c3e36465cc 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -367,7 +367,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { phase: "ready", latestTurn: completedTurn, session: readySession, - latestUserMessageId: null, + projectedMessages: [], hasPendingApproval: false, hasPendingUserInput: false, threadError: null, @@ -393,7 +393,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { phase: "ready", latestTurn: newerTurn, session: { ...readySession, updatedAt: newerTurn.completedAt }, - latestUserMessageId: null, + projectedMessages: [], hasPendingApproval: false, hasPendingUserInput: false, threadError: null, @@ -424,7 +424,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { status: "running", activeTurnId: TurnId.make("turn-other"), }, - latestUserMessageId: null, + projectedMessages: [], hasPendingApproval: false, hasPendingUserInput: false, threadError: null, @@ -440,7 +440,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { status: "running", activeTurnId: runningTurn.turnId, }, - latestUserMessageId: null, + projectedMessages: [], hasPendingApproval: false, hasPendingUserInput: false, threadError: null, @@ -455,7 +455,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { phase: "ready" as const, latestTurn: null, session: null, - latestUserMessageId: null, + projectedMessages: [], hasPendingApproval: false, hasPendingUserInput: false, threadError: null, @@ -504,7 +504,12 @@ describe("hasServerAcknowledgedLocalDispatch", () => { phase: "running", latestTurn: runningTurn, session: runningSession, - latestUserMessageId: initialMessageId, + projectedMessages: [ + { + id: initialMessageId, + role: "user", + }, + ], hasPendingApproval: false, hasPendingUserInput: false, threadError: null, @@ -517,7 +522,34 @@ describe("hasServerAcknowledgedLocalDispatch", () => { phase: "running", latestTurn: runningTurn, session: runningSession, - latestUserMessageId: steerMessageId, + projectedMessages: [ + { + id: steerMessageId, + role: "user", + }, + ], + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }), + ).toBe(true); + + expect( + hasServerAcknowledgedLocalDispatch({ + localDispatch, + phase: "running", + latestTurn: runningTurn, + session: runningSession, + projectedMessages: [ + { + id: steerMessageId, + role: "user", + }, + { + id: MessageId.make("message-other-client"), + role: "user", + }, + ], hasPendingApproval: false, hasPendingUserInput: false, threadError: null, @@ -548,7 +580,12 @@ describe("hasServerAcknowledgedLocalDispatch", () => { phase: "running", latestTurn: runningTurn, session: runningSession, - latestUserMessageId: steerMessageId, + projectedMessages: [ + { + id: steerMessageId, + role: "user", + }, + ], hasPendingApproval: false, hasPendingUserInput: false, threadError: null, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 2a7c051ddb6..82fb93f22f7 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -397,18 +397,6 @@ export interface LocalDispatchSnapshot { sessionUpdatedAt: string | null; } -export function getLatestUserMessageId( - messages: ReadonlyArray>, -): 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; expectedUserMessageId?: MessageId }, @@ -433,7 +421,7 @@ export function hasServerAcknowledgedLocalDispatch(input: { phase: SessionPhase; latestTurn: Thread["latestTurn"] | null; session: Thread["session"] | null; - latestUserMessageId: MessageId | null; + projectedMessages: ReadonlyArray>; hasPendingApproval: boolean; hasPendingUserInput: boolean; threadError: string | null | undefined; @@ -449,10 +437,13 @@ export function hasServerAcknowledgedLocalDispatch(input: { // 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. + const expectedUserMessageId = input.localDispatch.expectedUserMessageId; if ( input.localDispatch.sessionStatus === "running" && - input.localDispatch.expectedUserMessageId !== null && - input.localDispatch.expectedUserMessageId === input.latestUserMessageId + expectedUserMessageId !== null && + input.projectedMessages.some( + (message) => message.role === "user" && message.id === expectedUserMessageId, + ) ) { return true; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d3d8dc889a6..80f7149f98f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -230,7 +230,6 @@ import { collectUserMessageBlobPreviewUrls, createLocalDispatchSnapshot, deriveComposerSendState, - getLatestUserMessageId, hasServerAcknowledgedLocalDispatch, getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, @@ -453,10 +452,6 @@ 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); @@ -468,8 +463,8 @@ function useLocalDispatchState(input: { localDispatch, phase: input.phase, latestTurn: input.activeLatestTurn, - latestUserMessageId, session: input.activeThread?.session ?? null, + projectedMessages: input.activeThread?.messages ?? [], hasPendingApproval: input.activePendingApproval !== null, hasPendingUserInput: input.activePendingUserInput !== null, threadError: input.threadError, @@ -478,10 +473,10 @@ function useLocalDispatchState(input: { input.activeLatestTurn, input.activePendingApproval, input.activePendingUserInput, + input.activeThread?.messages, input.activeThread?.session, input.phase, input.threadError, - latestUserMessageId, localDispatch, ], ); From 0bf687f2abdf5904de4e3d684e7b8fa082d1f792 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sun, 19 Jul 2026 00:25:27 +0100 Subject: [PATCH 12/15] Harden steering and interrupt review coverage - Make timeout tests wait for the live lookup explicitly - Clarify replacement and dispatch snapshot naming - Scan projected messages from the latest entry first --- .../Layers/CodexInterruptResolution.test.ts | 40 ++++++++++++----- .../provider/Layers/CodexSessionRuntime.ts | 4 +- .../web/src/components/ChatView.logic.test.ts | 43 ++++++++++++++++--- apps/web/src/components/ChatView.logic.ts | 21 +++++---- 4 files changed, 79 insertions(+), 29 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexInterruptResolution.test.ts b/apps/server/src/provider/Layers/CodexInterruptResolution.test.ts index 46febe02054..96a45a3558d 100644 --- a/apps/server/src/provider/Layers/CodexInterruptResolution.test.ts +++ b/apps/server/src/provider/Layers/CodexInterruptResolution.test.ts @@ -1,5 +1,6 @@ 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"; @@ -11,7 +12,7 @@ import type * as EffectCodexSchema from "effect-codex-app-server/schema"; import { findActiveCodexTurnId, resolveCodexInterruptTurnId, - shouldPreferActiveCodexTurnCandidate, + shouldReplaceActiveCodexTurnCandidate, } from "./CodexSessionRuntime.ts"; function makeThreadReadResponse( @@ -68,9 +69,12 @@ describe("findActiveCodexTurnId", () => { 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({ @@ -78,6 +82,7 @@ describe("findActiveCodexTurnId", () => { requestedTurnId: undefined, readSessionActiveTurnId: Effect.succeed(undefined), readThread: (params) => { + readThreadCallCount += 1; requestedParams = params; return Effect.succeed( makeThreadReadResponse([ @@ -91,6 +96,7 @@ describe("findActiveCodexTurnId", () => { threadId: "provider-thread-1", includeTurns: true, }); + NodeAssert.equal(readThreadCallCount, 1); NodeAssert.equal(turnId, "turn-active"); }); }); @@ -125,14 +131,19 @@ describe("findActiveCodexTurnId", () => { 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(); const resolution = yield* resolveCodexInterruptTurnId({ providerThreadId: "provider-thread-1", requestedTurnId: undefined, readSessionActiveTurnId: Effect.succeed(projectedTurnId), - readThread: () => Effect.never, + readThread: () => + Effect.gen(function* () { + yield* Deferred.succeed(lookupStarted, undefined); + return yield* Effect.never; + }), }).pipe(Effect.forkScoped); - yield* Effect.yieldNow; + yield* Deferred.await(lookupStarted); yield* TestClock.adjust("2 seconds"); NodeAssert.equal(yield* Fiber.join(resolution), projectedTurnId); }), @@ -141,14 +152,21 @@ describe("findActiveCodexTurnId", () => { 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(); const resolution = yield* resolveCodexInterruptTurnId({ providerThreadId: "provider-thread-1", requestedTurnId: undefined, readSessionActiveTurnId: Effect.sync(() => projectedTurnId), - readThread: () => Effect.never, + readThread: () => + Effect.gen(function* () { + yield* Deferred.succeed(lookupStarted, undefined); + return yield* Effect.never; + }), }).pipe(Effect.forkScoped); - yield* Effect.yieldNow; + 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); @@ -156,22 +174,22 @@ describe("findActiveCodexTurnId", () => { ); }); -describe("shouldPreferActiveCodexTurnCandidate", () => { +describe("shouldReplaceActiveCodexTurnCandidate", () => { it("selects the first candidate", () => { - NodeAssert.equal(shouldPreferActiveCodexTurnCandidate({ startedAt: 10 }, undefined), true); + NodeAssert.equal(shouldReplaceActiveCodexTurnCandidate({ startedAt: 10 }, undefined), true); }); it("orders timestamped turns by start time and lets a later equal entry win", () => { NodeAssert.equal( - shouldPreferActiveCodexTurnCandidate({ startedAt: 20 }, { startedAt: 10 }), + shouldReplaceActiveCodexTurnCandidate({ startedAt: 20 }, { startedAt: 10 }), true, ); NodeAssert.equal( - shouldPreferActiveCodexTurnCandidate({ startedAt: 10 }, { startedAt: 20 }), + shouldReplaceActiveCodexTurnCandidate({ startedAt: 10 }, { startedAt: 20 }), false, ); NodeAssert.equal( - shouldPreferActiveCodexTurnCandidate({ startedAt: 10 }, { startedAt: 10 }), + shouldReplaceActiveCodexTurnCandidate({ startedAt: 10 }, { startedAt: 10 }), true, ); }); @@ -183,7 +201,7 @@ describe("shouldPreferActiveCodexTurnCandidate", () => { [{ startedAt: 10 }, {}], [{ startedAt: 10 }, { startedAt: null }], ] as const) { - NodeAssert.equal(shouldPreferActiveCodexTurnCandidate(candidate, selected), true); + NodeAssert.equal(shouldReplaceActiveCodexTurnCandidate(candidate, selected), true); } }); }); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index bbd89f9660b..59572dcc6b3 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -696,7 +696,7 @@ type CodexTurnOrderingCandidate = Pick< "startedAt" >; -export function shouldPreferActiveCodexTurnCandidate( +export function shouldReplaceActiveCodexTurnCandidate( candidate: CodexTurnOrderingCandidate, selected: CodexTurnOrderingCandidate | undefined, ): boolean { @@ -721,7 +721,7 @@ export function findActiveCodexTurnId( if (turn.status !== "inProgress") { continue; } - if (shouldPreferActiveCodexTurnCandidate(turn, activeTurn)) { + if (shouldReplaceActiveCodexTurnCandidate(turn, activeTurn)) { activeTurn = turn; } } diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 5c3e36465cc..a61ffe31284 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -555,12 +555,26 @@ describe("hasServerAcknowledgedLocalDispatch", () => { threadError: null, }), ).toBe(true); + }); - const alreadyProjected = createLocalDispatchSnapshot( + it("requires the next steer id after the previous steer was projected", () => { + const previousSteerMessageId = MessageId.make("message-steer"); + const nextSteerMessageId = MessageId.make("message-next-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: steerMessageId, + id: previousSteerMessageId, role: "user", text: "steer", turnId: runningTurn.turnId, @@ -572,17 +586,18 @@ describe("hasServerAcknowledgedLocalDispatch", () => { latestTurn: runningTurn, session: runningSession, }), - { expectedUserMessageId: MessageId.make("message-next-steer") }, + { expectedUserMessageId: nextSteerMessageId }, ); + expect( hasServerAcknowledgedLocalDispatch({ - localDispatch: alreadyProjected, + localDispatch, phase: "running", latestTurn: runningTurn, session: runningSession, projectedMessages: [ { - id: steerMessageId, + id: previousSteerMessageId, role: "user", }, ], @@ -591,5 +606,23 @@ describe("hasServerAcknowledgedLocalDispatch", () => { threadError: null, }), ).toBe(false); + + expect( + hasServerAcknowledgedLocalDispatch({ + localDispatch, + phase: "running", + latestTurn: runningTurn, + session: runningSession, + projectedMessages: [ + { + id: nextSteerMessageId, + role: "user", + }, + ], + 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 82fb93f22f7..804ccdfe59d 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -389,7 +389,7 @@ export interface LocalDispatchSnapshot { startedAt: string; preparingWorktree: boolean; expectedUserMessageId: MessageId | null; - latestTurnTurnId: TurnId | null; + latestTurnId: TurnId | null; latestTurnRequestedAt: string | null; latestTurnStartedAt: string | null; latestTurnCompletedAt: string | null; @@ -407,7 +407,7 @@ export function createLocalDispatchSnapshot( startedAt: new Date().toISOString(), preparingWorktree: Boolean(options?.preparingWorktree), expectedUserMessageId: options?.expectedUserMessageId ?? null, - latestTurnTurnId: latestTurn?.turnId ?? null, + latestTurnId: latestTurn?.turnId ?? null, latestTurnRequestedAt: latestTurn?.requestedAt ?? null, latestTurnStartedAt: latestTurn?.startedAt ?? null, latestTurnCompletedAt: latestTurn?.completedAt ?? null, @@ -438,20 +438,19 @@ export function hasServerAcknowledgedLocalDispatch(input: { // latest turn/session fields may remain unchanged until the agent finishes. // The projected user message is the server acknowledgement in that case. const expectedUserMessageId = input.localDispatch.expectedUserMessageId; - if ( - input.localDispatch.sessionStatus === "running" && - expectedUserMessageId !== null && - input.projectedMessages.some( - (message) => message.role === "user" && message.id === expectedUserMessageId, - ) - ) { - return true; + if (input.localDispatch.sessionStatus === "running" && expectedUserMessageId !== null) { + for (let index = input.projectedMessages.length - 1; index >= 0; index -= 1) { + const message = input.projectedMessages[index]; + if (message?.role === "user" && message.id === expectedUserMessageId) { + return true; + } + } } const latestTurn = input.latestTurn ?? null; const session = input.session ?? null; const latestTurnChanged = - input.localDispatch.latestTurnTurnId !== (latestTurn?.turnId ?? null) || + input.localDispatch.latestTurnId !== (latestTurn?.turnId ?? null) || input.localDispatch.latestTurnRequestedAt !== (latestTurn?.requestedAt ?? null) || input.localDispatch.latestTurnStartedAt !== (latestTurn?.startedAt ?? null) || input.localDispatch.latestTurnCompletedAt !== (latestTurn?.completedAt ?? null); From cb6190ec8de6f137a7b009dcfb24638e4603de30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sun, 19 Jul 2026 00:35:38 +0100 Subject: [PATCH 13/15] Refresh branch assessment details - Record assessment fixes and review outcomes - Update validation counts and branch divergence --- BRANCH_DETAILS.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index 8d98ace46ad..29b852b3fd8 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -6,12 +6,13 @@ Branch maintenance snapshot for the 2026-07-18 upstream merge: - Generated from `upstream/main` `1735e27d9e5106bbb35d5b1dd10363604a54b69e` with starting branch HEAD `0ac74996288ca44b1d400012235fa406c0ec51d4`. The merge commit is `195fbca66e408e03fc6f6ac8220e5b5c7430277d`; the previous upstream merge is `dd4549645d8545e2c566560470f00be69bb98f42`, whose upstream parent is `fdca15471d92e95e4ec5501f45dbf3ce81f8d991`. - Shared refs at merge time: `origin/main` `2fe83d19ea2548dfaa7770aea29e5047d0b17b6e`, local `main` `cfbd1261caa08707987d2dfc2edc93caa0790d54`, and branch tracking ref `origin/fix/repeated-steering-and-stop` `6c08e813d7f5b18e339c0494bcd1a035f6d1ff70`. -- After the merge, conflict-resolution hardening, and this documentation refresh, the resulting branch is `14 ahead / 0 behind upstream/main`, `64 ahead / 505 behind origin/main`, `64 ahead / 517 behind local main`, and `55 ahead / 0 behind origin/fix/repeated-steering-and-stop`. The fork diff is `7 files changed, 402 insertions(+), 53 deletions(-)` against the `upstream/main` tree. +- After the merge, assessment fixes, and this documentation refresh, the resulting branch is `17 ahead / 0 behind upstream/main`, `67 ahead / 505 behind origin/main`, `67 ahead / 517 behind local main`, and `58 ahead / 0 behind origin/fix/repeated-steering-and-stop`. The fork diff is `7 files changed, 497 insertions(+), 56 deletions(-)` against the `upstream/main` tree. - The 50 incoming commits from `8a02c718619a9d4c6588d3ab7a89204023cfab62` through `1735e27d9e5106bbb35d5b1dd10363604a54b69e` add the draft landing hero, terminal selection copying, file-explorer mention actions, desktop release-note UI, mobile screenshot automation, refreshed branding, and Codex runtime model/effort instructions. They also improve active-turn sending, initial thread snapshot replay, deferred active-thread cache writes, timestamp validation/canonicalization, remote-environment cleanup, diff and terminal behavior, provider reliability, and source-control path handling. - Customized-behavior impact: upstream's active-turn send fix acknowledged any changed latest user message. This branch still requires the projected id to equal the exact outbound steering id, so an unrelated user projection cannot release local busy state. Upstream's initial-snapshot replay, deferred active-thread cache writes, and timestamp hardening can change when that exact message becomes visible but complement rather than replace the correlation rule. Upstream did not add equivalent root/subagent interrupt routing or live Codex active-turn resolution, so no branch customization is retired. - Conflict note for `apps/web/src/components/ChatView.logic.ts`, `apps/web/src/components/ChatView.logic.test.ts`, and `apps/web/src/components/ChatView.tsx`: the upstream latest-message-change acknowledgement and its weaker regression assertion were replaced by the existing exact-id correlation and consecutive-steer coverage. The new draft-hero dock transition and early in-flight guard were preserved, while `beginLocalDispatch` now waits until `newMessageId()` provides the exact expected id. - Conflict note for `apps/server/src/provider/Layers/CodexSessionRuntime.test.ts`: upstream's runtime model/effort instruction tests and imports were initially combined with the branch's live-turn lookup, ordering, timeout, and fallback tests. The branch-owned suite now lives in `apps/server/src/provider/Layers/CodexInterruptResolution.test.ts`, removing the concrete collision with continued upstream growth in the general runtime test file. `apps/server/src/provider/Layers/CodexSessionRuntime.ts` merged additively, retaining both the incoming developer-instruction behavior and branch-owned interrupt resolution. -- Validation note: the 51 focused steering/Codex interruption tests, `vp check`, repository typecheck, and mobile native static check pass; the extracted server pair specifically passes 29 tests. Playwright reached the authenticated draft hero and composer on ports `5738`/`13778`. The full repository run passed 4,782 tests and failed six tests in three incoming upstream test files; an isolated rerun cleared `server.test.ts` but retained three GitHub-selector timeouts in `GitManager.test.ts` and the ACP replay-idle timeout in `AcpJsonRpcConnection.test.ts`. None exercises a branch-primary customization file. +- Assessment note: Codex review found that latest-only acknowledgement could become busy again after another client's later steer and that interrupt fallback captured the session turn before the live lookup completed. Commit `d1dca69f7` now searches all projected user ids and reads fallback state only after lookup failure/timeout; Blast follow-up `0bf687f2a` added explicit timeout barriers/call-count coverage, split consecutive-steer cases, clarified names, and scans projected messages newest-first. Initial CodeRabbit and final Codex reviews reported no findings; final CodeRabbit was rate-limited and skipped as required. +- Validation note: the 52 focused steering/Codex interruption tests, `vp check`, repository typecheck, and mobile native static check pass; the extracted server pair specifically passes 30 tests. Playwright reached the authenticated draft hero and composer on ports `5738`/`13778`. The full repository run passed 4,782 tests and failed six tests in three incoming upstream test files; an isolated rerun cleared `server.test.ts` but retained three GitHub-selector timeouts in `GitManager.test.ts` and the ACP replay-idle timeout in `AcpJsonRpcConnection.test.ts`. None exercises a branch-primary customization file. - Follow-up maintenance note: `hasServerAcknowledgedLocalDispatch` is already the branch's small, tested dispatch-correlation helper, so another client helper layer would not reduce the send-site conflict introduced by the upstream draft hero. An explicit server receipt keyed by message id would broaden contracts and runtime behavior without closing a demonstrated correctness gap; defer that protocol change unless projected ids stop being authoritative. 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. From 4277c28e5eb4f3c5f7749edf003e15b71c1a1ea2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sun, 19 Jul 2026 10:38:56 +0100 Subject: [PATCH 14/15] Refresh branch guidance for steering and stop fixes - Document conflict-resolution guidance for web and Codex runtime changes - Preserve dispatch-correlation and interruption-resolution coverage --- BRANCH_DETAILS.md | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index 29b852b3fd8..00938a5a9c8 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -1,20 +1,5 @@ # Repeated Steering And Reliable Stop -## Current Status - -Branch maintenance snapshot for the 2026-07-18 upstream merge: - -- Generated from `upstream/main` `1735e27d9e5106bbb35d5b1dd10363604a54b69e` with starting branch HEAD `0ac74996288ca44b1d400012235fa406c0ec51d4`. The merge commit is `195fbca66e408e03fc6f6ac8220e5b5c7430277d`; the previous upstream merge is `dd4549645d8545e2c566560470f00be69bb98f42`, whose upstream parent is `fdca15471d92e95e4ec5501f45dbf3ce81f8d991`. -- Shared refs at merge time: `origin/main` `2fe83d19ea2548dfaa7770aea29e5047d0b17b6e`, local `main` `cfbd1261caa08707987d2dfc2edc93caa0790d54`, and branch tracking ref `origin/fix/repeated-steering-and-stop` `6c08e813d7f5b18e339c0494bcd1a035f6d1ff70`. -- After the merge, assessment fixes, and this documentation refresh, the resulting branch is `17 ahead / 0 behind upstream/main`, `67 ahead / 505 behind origin/main`, `67 ahead / 517 behind local main`, and `58 ahead / 0 behind origin/fix/repeated-steering-and-stop`. The fork diff is `7 files changed, 497 insertions(+), 56 deletions(-)` against the `upstream/main` tree. -- The 50 incoming commits from `8a02c718619a9d4c6588d3ab7a89204023cfab62` through `1735e27d9e5106bbb35d5b1dd10363604a54b69e` add the draft landing hero, terminal selection copying, file-explorer mention actions, desktop release-note UI, mobile screenshot automation, refreshed branding, and Codex runtime model/effort instructions. They also improve active-turn sending, initial thread snapshot replay, deferred active-thread cache writes, timestamp validation/canonicalization, remote-environment cleanup, diff and terminal behavior, provider reliability, and source-control path handling. -- Customized-behavior impact: upstream's active-turn send fix acknowledged any changed latest user message. This branch still requires the projected id to equal the exact outbound steering id, so an unrelated user projection cannot release local busy state. Upstream's initial-snapshot replay, deferred active-thread cache writes, and timestamp hardening can change when that exact message becomes visible but complement rather than replace the correlation rule. Upstream did not add equivalent root/subagent interrupt routing or live Codex active-turn resolution, so no branch customization is retired. -- Conflict note for `apps/web/src/components/ChatView.logic.ts`, `apps/web/src/components/ChatView.logic.test.ts`, and `apps/web/src/components/ChatView.tsx`: the upstream latest-message-change acknowledgement and its weaker regression assertion were replaced by the existing exact-id correlation and consecutive-steer coverage. The new draft-hero dock transition and early in-flight guard were preserved, while `beginLocalDispatch` now waits until `newMessageId()` provides the exact expected id. -- Conflict note for `apps/server/src/provider/Layers/CodexSessionRuntime.test.ts`: upstream's runtime model/effort instruction tests and imports were initially combined with the branch's live-turn lookup, ordering, timeout, and fallback tests. The branch-owned suite now lives in `apps/server/src/provider/Layers/CodexInterruptResolution.test.ts`, removing the concrete collision with continued upstream growth in the general runtime test file. `apps/server/src/provider/Layers/CodexSessionRuntime.ts` merged additively, retaining both the incoming developer-instruction behavior and branch-owned interrupt resolution. -- Assessment note: Codex review found that latest-only acknowledgement could become busy again after another client's later steer and that interrupt fallback captured the session turn before the live lookup completed. Commit `d1dca69f7` now searches all projected user ids and reads fallback state only after lookup failure/timeout; Blast follow-up `0bf687f2a` added explicit timeout barriers/call-count coverage, split consecutive-steer cases, clarified names, and scans projected messages newest-first. Initial CodeRabbit and final Codex reviews reported no findings; final CodeRabbit was rate-limited and skipped as required. -- Validation note: the 52 focused steering/Codex interruption tests, `vp check`, repository typecheck, and mobile native static check pass; the extracted server pair specifically passes 30 tests. Playwright reached the authenticated draft hero and composer on ports `5738`/`13778`. The full repository run passed 4,782 tests and failed six tests in three incoming upstream test files; an isolated rerun cleared `server.test.ts` but retained three GitHub-selector timeouts in `GitManager.test.ts` and the ACP replay-idle timeout in `AcpJsonRpcConnection.test.ts`. None exercises a branch-primary customization file. -- Follow-up maintenance note: `hasServerAcknowledgedLocalDispatch` is already the branch's small, tested dispatch-correlation helper, so another client helper layer would not reduce the send-site conflict introduced by the upstream draft hero. An explicit server receipt keyed by message id would broaden contracts and runtime behavior without closing a demonstrated correctness gap; defer that protocol change unless projected ids stop being authoritative. - 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: @@ -23,6 +8,11 @@ Expected behavior: - 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` @@ -31,6 +21,8 @@ Primary files: 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` From c1a0db3f4749ed68852891d5532776a805cc9c98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 20 Jul 2026 00:17:38 +0100 Subject: [PATCH 15/15] Fix pnpm virtual store and Effect Vitest resolution - Enable the global virtual store - Correct prerelease package extensions and refresh the lockfile --- pnpm-lock.yaml | 103 ++++++++++++++++++++++++++++++++++++-------- pnpm-workspace.yaml | 8 +++- 2 files changed, 90 insertions(+), 21 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c58ccffc420..0cac51a9eb8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,7 +66,7 @@ overrides: vite: npm:@voidzero-dev/vite-plus-core@0.2.2 yaml: ^2.9.0 -packageExtensionsChecksum: sha256-CUzzeefpj3gNFrCKNBhV9FOaniNbrLdKyIhWQyXuaiE= +packageExtensionsChecksum: sha256-dL4kgB3oS88+usby/QZU7EK4kxNoz9EGcSH5yDZBXZM= patchedDependencies: '@effect/vitest@4.0.0-beta.78': 42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f @@ -153,7 +153,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -419,7 +419,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@pierre/trees': specifier: 1.0.0-beta.4 version: 1.0.0-beta.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -471,7 +471,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@t3tools/contracts': specifier: workspace:* version: link:../../packages/contracts @@ -616,7 +616,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@rolldown/plugin-babel': specifier: ^0.2.0 version: 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.3) @@ -682,7 +682,7 @@ importers: version: link:../../packages/shared alchemy: specifier: https://pkg.ing/alchemy/078ff00 - version: https://pkg.ing/alchemy/078ff00(2403b7b35608124e7556b92b6489da39) + version: https://pkg.ing/alchemy/078ff00(23ff62b65d6c8c4c39b8fd62a02be58c) drizzle-orm: specifier: 1.0.0-rc.3 version: 1.0.0-rc.3(@cloudflare/workers-types@4.20260604.1)(@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) @@ -698,7 +698,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -726,7 +726,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -745,7 +745,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -758,7 +758,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -777,7 +777,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -799,7 +799,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -833,7 +833,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -858,7 +858,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -880,7 +880,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -911,7 +911,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/pngjs': specifier: 6.0.5 version: 6.0.5 @@ -2012,6 +2012,10 @@ packages: resolution: {integrity: sha512-5KQsQYrQ/o7mfOVAxRtNnfD9M0W4OI6yQd0n/m2N7OOLxTdX4FwN4s/X4obykBC7ZEwH+bzMrFJiB4pq9lrQKQ==} peerDependencies: effect: 4.0.0-beta.78 + vitest: '*' + peerDependenciesMeta: + vitest: + optional: true '@egjs/hammerjs@2.0.17': resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} @@ -11761,9 +11765,43 @@ snapshots: '@effect/tsgo-win32-arm64': 0.13.2 '@effect/tsgo-win32-x64': 0.13.2 - '@effect/vitest@4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': + '@effect/vitest@4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0)': dependencies: effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + vite-plus: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + optionalDependencies: + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@edge-runtime/vm' + - '@opentelemetry/api' + - '@types/node' + - '@vitejs/devtools' + - '@vitest/browser-playwright' + - '@vitest/browser-webdriverio' + - '@vitest/coverage-istanbul' + - '@vitest/coverage-v8' + - '@vitest/ui' + - bufferutil + - esbuild + - happy-dom + - jiti + - jsdom + - less + - msw + - publint + - sass + - sass-embedded + - stylus + - sugarss + - svelte + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - yaml '@egjs/hammerjs@2.0.17': dependencies: @@ -15302,7 +15340,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@https://pkg.ing/alchemy/078ff00(2403b7b35608124e7556b92b6489da39): + alchemy@https://pkg.ing/alchemy/078ff00(23ff62b65d6c8c4c39b8fd62a02be58c): dependencies: '@alchemy.run/node-utils': 0.0.4 '@aws-sdk/credential-providers': 3.1062.0 @@ -15316,7 +15354,7 @@ snapshots: '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@distilled.cloud/neon': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@distilled.cloud/planetscale': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) - '@effect/vitest': 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + '@effect/vitest': 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@octokit/rest': 22.0.1 '@smithy/node-config-provider': 4.4.6 @@ -15349,12 +15387,39 @@ snapshots: vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@edge-runtime/vm' + - '@opentelemetry/api' - '@types/node' - '@types/react' + - '@vitejs/devtools' + - '@vitest/browser-playwright' + - '@vitest/browser-webdriverio' + - '@vitest/coverage-istanbul' + - '@vitest/coverage-v8' + - '@vitest/ui' - bufferutil + - esbuild + - happy-dom + - jiti + - jsdom + - less + - msw - pg-native + - publint - react-devtools-core + - sass + - sass-embedded + - stylus + - sugarss + - svelte + - terser + - tsx + - typescript + - unplugin-unused + - unrun - utf-8-validate + - vitest - workerd alien-signals@2.0.6: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 22757702fc8..632fcd45202 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,6 +5,8 @@ packages: - packages/* - scripts +enableGlobalVirtualStore: true + # Successor to onlyBuiltDependencies/ignoredBuiltDependencies (pnpm 11). # true = allowed to run build scripts; false mirrors the pnpm 10 behavior # where anything outside onlyBuiltDependencies was silently not built. @@ -95,9 +97,11 @@ packageExtensions: "@clerk/expo@*": dependencies: "@expo/config-plugins": 56.0.9 - "@effect/vitest@*": + # Wildcard semver selectors exclude prereleases. + "@effect/vitest@4.0.0-beta.78": dependencies: - vite-plus: "catalog:" + # The patched package imports vite-plus inside the shared graph. + vite-plus: 0.2.2 peerDependenciesMeta: vitest: optional: true