diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 522f81a894f6fcf431713a4a1231f5283ce3f184..0724c3c0a57f5088579d619957e4bde68de95d89 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -1049,6 +1049,19 @@ describe("AcpAdapterV2", () => { // Production thread 54aeb6d7 split after "(command". Metadata shapes // below were captured from live Devin sessions showy-mile/fragrant-chamomile. const updates = [ + { + sessionUpdate: "tool_call_update", + toolCallId: "child-a", + status: "in_progress", + _meta: { + "cognition.ai/subagent_started": { + agentId: "child-a", + title: "Map orchestration", + task: "Run pwd, then reply ONE.", + model: " \t ", + }, + }, + }, { sessionUpdate: "tool_call_update", toolCallId: "child-a", @@ -1235,6 +1248,18 @@ describe("AcpAdapterV2", () => { event.type === "subagent.updated" ? [event.subagent] : [], ); const task = tasks.at(-1); + assert.isNull(tasks[0]?.model); + const childThread = events.find( + (event) => + event.type === "app_thread.created" && event.appThread.id === task?.childThreadId, + ); + assert.equal( + childThread?.type === "app_thread.created" + ? childThread.appThread.modelSelection.model + : undefined, + modelSelection.model, + ); + assert.equal(task?.model, "SWE-1.7 Medium"); assert.equal(task?.status, "completed"); assert.equal(task?.result, "Final report: ONE"); const childMessages = new Map( diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index 6b5b1b91fb0122d27888cbfc3e95eebe2764bb20..179455cb64a8b8ed5f0a4bdd1834d4a9d1433a9f 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -2730,10 +2730,10 @@ export function makeAcpAdapterV2( nativeTaskRef: nativeItemRef, prompt: update.prompt, title: update.title, - model: update.model, result: null, startedAt: now, }), + model: update.model?.trim() || existing?.task.model || null, status: taskStatus, result: update.result ?? existing?.task.result ?? null, completedAt: acpSubagentStatusIsTerminal(taskStatus) ? now : null, @@ -2769,7 +2769,7 @@ export function makeAcpAdapterV2( providerInstanceId: context.input.modelSelection.instanceId, modelSelection: { ...context.input.modelSelection, - model: update.model ?? context.input.modelSelection.model, + model: task.model ?? context.input.modelSelection.model, }, title: subagentThreadTitle({ parentTitle: context.input.appThread.title, diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 245e76af29582ef1df1d5113eb86686a3ae7b7ca..63f0cbdae66bf80e2b337c2ad6458d2b6e46c6b4 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -32,6 +32,7 @@ import { assert, describe, it } from "@effect/vitest"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as CodexClient from "effect-codex-app-server/client"; +import * as CodexError from "effect-codex-app-server/errors"; import * as CodexReplay from "effect-codex-app-server/replay"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -1870,7 +1871,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { transcript: CodexReplay.CodexAppServerReplayTranscript, onEvent: (event: ProviderAdapterV2Event) => Effect.Effect = () => Effect.void, onRequest: (method: string, params: unknown) => Effect.Effect = () => Effect.void, - readChildMetadata?: (threadId: string) => Effect.Effect, + readChildMetadata?: Parameters[2], ) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -6708,6 +6709,8 @@ describe("CodexAdapterV2 post-settle continuation", () => { }); it.effect.each([ + { name: "current Codex Sol", model: "gpt-6-sol" }, + { name: "current Codex wrong child", model: null }, { name: "Sol", model: "gpt-5.6-sol" }, { name: "Fable", model: "gpt-5.6-fable" }, { name: "Astra", model: "gpt-6-astra" }, @@ -6719,6 +6722,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { Effect.gen(function* () { const metadataRead = yield* Deferred.make(); const modelReported = yield* Deferred.make(); + let metadataRequests = 0; const harness = yield* makeCodexReplayHarness( resumeSubagentTranscript, (event) => @@ -6727,14 +6731,22 @@ describe("CodexAdapterV2 post-settle continuation", () => { : Effect.void, undefined, (threadId) => { + metadataRequests++; assert.equal(threadId, RESUME_CHILD_THREAD); return Deferred.succeed(metadataRead, undefined).pipe( Effect.as( name === "invalid" ? {} : { - thread: { id: name === "wrong child" ? "other-child" : threadId }, - model: name === "wrong child" ? "gpt-5.6-sol" : model, + thread: { + id: name.includes("wrong child") ? "other-child" : threadId, + ...(name.startsWith("current Codex") ? { model: "gpt-6-sol" } : {}), + }, + model: name.startsWith("current Codex") + ? null + : name === "wrong child" + ? "gpt-5.6-sol" + : model, }, ), ); @@ -6754,10 +6766,68 @@ describe("CodexAdapterV2 post-settle continuation", () => { yield* TestClock.adjust("100 millis"); yield* harness.firstTerminal; assert.equal(harness.subagentUpdates().at(-1)?.subagent.model, model); + assert.equal(metadataRequests, name === "current Codex Sol" ? 1 : 2); }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), ), ); + it.effect.each(["failed", "malformed", "wrong child", "blank model"] as const)( + "resumes child metadata after a %s read", + (readResult) => + Effect.scoped( + Effect.gen(function* () { + const modelReported = yield* Deferred.make(); + const model = "gpt-6-sol"; + const metadataRequests: Array = []; + const harness = yield* makeCodexReplayHarness( + resumeSubagentTranscript, + (event) => + event.type === "subagent.updated" && event.subagent.model === model + ? Deferred.succeed(modelReported, undefined) + : Effect.void, + undefined, + (threadId, method) => { + assert.equal(threadId, RESUME_CHILD_THREAD); + metadataRequests.push(method); + if (method === "thread/resume") { + return Effect.succeed({ thread: { id: threadId }, model }); + } + switch (readResult) { + case "failed": + return Effect.fail( + new CodexError.CodexAppServerRequestError({ + code: -32000, + errorMessage: "Child metadata unavailable", + method, + }), + ); + case "malformed": + return Effect.succeed({}); + case "wrong child": + return Effect.succeed({ thread: { id: "other-child", model: "gpt-6-astra" } }); + case "blank model": + return Effect.succeed({ thread: { id: threadId, model: " \t " } }); + } + }, + ); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-child-model-fallback"), + text: RESUME_PROMPT, + }), + ); + yield* Deferred.await(modelReported); + yield* TestClock.adjust("100 millis"); + yield* harness.firstTerminal; + assert.equal(harness.subagentUpdates().at(-1)?.subagent.model, model); + assert.deepEqual(metadataRequests, ["thread/read", "thread/resume"]); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + it.effect.each(["thread/settings/updated", "model/rerouted"] as const)( "keeps %s child metadata when an older lookup finishes later", (method) => diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts index 3d8b4640045268a75b77e24dedc11f8b0e861138..403d85afae7da26485d315a653c88ffd95a6d003 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts @@ -3,6 +3,7 @@ import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { type ProviderReplayTranscript } from "@t3tools/contracts"; import * as CodexClient from "effect-codex-app-server/client"; +import type * as CodexError from "effect-codex-app-server/errors"; import * as CodexReplay from "effect-codex-app-server/replay"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -45,7 +46,10 @@ export type CodexOrchestratorReplayHarnessError = typeof CodexOrchestratorReplay export function withCodexReplayChildMetadata( client: CodexClient.CodexAppServerClient["Service"], transcript: CodexReplay.CodexAppServerReplayTranscript, - readMetadata: (threadId: string) => Effect.Effect = (threadId) => + readMetadata: ( + threadId: string, + method: "thread/read" | "thread/resume", + ) => Effect.Effect = (threadId) => Effect.succeed({ thread: { id: threadId }, model: null }), ): CodexClient.CodexAppServerClient["Service"] { const childThreadIds = new Set( @@ -67,12 +71,12 @@ export function withCodexReplayChildMetadata( raw: { ...client.raw, request: (method, params) => - method === "thread/resume" && + (method === "thread/read" || method === "thread/resume") && Predicate.isObject(params) && - params.excludeTurns === true && + (method === "thread/read" ? params.includeTurns === false : params.excludeTurns === true) && typeof params.threadId === "string" && childThreadIds.has(params.threadId) - ? readMetadata(params.threadId) + ? readMetadata(params.threadId, method) : client.raw.request(method, params), }, }; diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 4603731c3365904cc8fc5c5daf23a6a8fbf6e9b9..19517c3491161c849ef40636739570f8fb03ab27 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -1320,6 +1320,15 @@ const decodeCodexChildModel = Schema.decodeUnknownEffect( }), ); +const decodeCodexChildThread = Schema.decodeUnknownEffect( + Schema.Struct({ + thread: Schema.Struct({ + id: Schema.String, + model: Schema.optional(Schema.NullOr(Schema.String)), + }), + }), +); + export const makeCodexAppServerSpawnCommand = Effect.fn( "CodexAdapterV2.makeCodexAppServerSpawnCommand", )(function* (input: { @@ -2817,9 +2826,25 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi } if (task.model === null) { yield* client.raw - .request("thread/resume", { threadId: input.nativeThreadId, excludeTurns: true }) + .request("thread/read", { threadId: input.nativeThreadId, includeTurns: false }) .pipe( - Effect.flatMap(decodeCodexChildModel), + Effect.flatMap(decodeCodexChildThread), + Effect.map((response) => + response.thread.id === input.nativeThreadId && response.thread.model?.trim() + ? { thread: response.thread, model: response.thread.model } + : null, + ), + Effect.catch(() => Effect.succeed(null)), + Effect.flatMap((response) => + response === null + ? client.raw + .request("thread/resume", { + threadId: input.nativeThreadId, + excludeTurns: true, + }) + .pipe(Effect.flatMap(decodeCodexChildModel)) + : Effect.succeed(response), + ), Effect.timeout("5 seconds"), Effect.flatMap((response) => response.thread.id === input.nativeThreadId && diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts index 60ece7a42f3a147beb4fda094fd6586351c5effd..8b0bdfb98b97301f993261dddf2f7ed51822abeb 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts @@ -40,12 +40,14 @@ const decodeCursorSettings = Schema.decodeEffect(CursorSettings); describe("CursorAdapterV2", () => { it.effect.each([ - { status: "finished", model: undefined }, - { status: "cancelled", model: "claude-opus-4-6" }, - { status: "error", model: "custom-fable" }, + { status: "finished", model: undefined, lateModel: undefined }, + { status: "cancelled", model: "claude-opus-4-6", lateModel: undefined }, + { status: "error", model: "custom-fable", lateModel: undefined }, + { status: "finished", model: undefined, lateModel: "gpt-6-sol" }, + { status: "finished", model: "gpt-6-sol", lateModel: null }, ] as const)( - "settles missing task completions when the Cursor run is $status", - ({ status, model }) => + "projects Cursor tasks: $status, late model $lateModel", + ({ status, model, lateModel }) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -96,12 +98,24 @@ describe("CursorAdapterV2", () => { mode: "unspecified" as const, }, }; - for (const type of ["partial-tool-call", "tool-call-started"] as const) { + const updates = + lateModel !== undefined + ? (["tool-call-started", "tool-call-completed"] as const) + : (["partial-tool-call", "tool-call-started"] as const); + for (const type of updates) { yield* input.onDelta!({ type, modelCallId: "model-call", callId: "task-call", - toolCall: taskToolCall, + toolCall: { + ...taskToolCall, + args: { + ...taskToolCall.args, + ...(type === "tool-call-completed" + ? { model: lateModel ?? undefined } + : {}), + }, + }, }).pipe(Effect.orDie); } return { @@ -180,9 +194,16 @@ describe("CursorAdapterV2", () => { const rows = events.filter((event) => event.type === "subagent.updated"); assert.equal(rows[0]?.subagent.status, "running"); assert.equal(rows[0]?.subagent.model, model ?? null); + assert.equal(rows.at(-1)?.subagent.model, lateModel ?? model ?? null); assert.equal( rows.at(-1)?.subagent.status, - status === "finished" ? "idle" : status === "cancelled" ? "cancelled" : "failed", + lateModel !== undefined + ? "completed" + : status === "finished" + ? "idle" + : status === "cancelled" + ? "cancelled" + : "failed", ); assert.isNotNull(rows.at(-1)?.subagent.completedAt); }).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, IdAllocator.layer))), diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts index 948d74762492b7396852b752f41e5d6734678e5e..5ade7c13911b0080e9207f97fe03255af95380a1 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts @@ -1572,10 +1572,10 @@ export function makeCursorAdapterV2( }, prompt: args.prompt, title: args.description, - model: args.model?.trim() || null, result: null, startedAt: now, }), + model: args.model?.trim() || existing?.task.model || null, nativeTaskRef: { driver: CursorAgentSdk.CURSOR_PROVIDER, nativeId: input.callId,