diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index e20edf4c5e723..29931b04291ec 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -4064,6 +4064,58 @@ describe("CodexAdapterV2 post-settle continuation", () => { ], }); + it.effect("wakes with well-formed text when the cuts split an emoji", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ + scenario: "codex-bg-wake-surrogate", + entries: [ + ...backgroundExecTranscript.entries.slice(0, -1), + { + type: "emit_inbound", + label: "item/completed/command-late", + frame: { + method: "item/completed", + params: { + item: { + ...backgroundCommandItem("completed"), + command: `${"c".repeat(199)}🚀`, + aggregatedOutput: `🚀${"x".repeat(999)}\n`, + }, + threadId: BG_NATIVE_THREAD, + turnId: BG_NATIVE_TURN, + completedAtMs: 1782622465500, + }, + }, + }, + ], + }), + ); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-codex-bg-wake-surrogate"), + text: BG_PROMPT, + }), + ); + yield* harness.until(() => harness.continuationRequests.length > 0); + assert.isTrue(harness.continuationRequests[0]?.detail?.isWellFormed()); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ), + ); + it.effect.each([false, true])( "keeps commands interrupted after native thread closure (persistent tool: %s)", (retainContext) => diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 277232830676d..aceb3596ab1f6 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -430,17 +430,21 @@ function codexBackgroundCommandDetail(item: { readonly exitCode?: number | null | undefined; readonly aggregatedOutput?: string | null | undefined; }): string { + // Codex rejects a turn/start with a lone surrogate and never answers it, so cut on code points. + let commandEnd = BACKGROUND_COMMAND_DETAIL_COMMAND_MAX_LENGTH; + const finalCommandUnit = item.command.charCodeAt(commandEnd - 1); + if (finalCommandUnit >= 0xd800 && finalCommandUnit <= 0xdbff) commandEnd -= 1; const command = item.command.length > BACKGROUND_COMMAND_DETAIL_COMMAND_MAX_LENGTH - ? `${item.command.slice(0, BACKGROUND_COMMAND_DETAIL_COMMAND_MAX_LENGTH)}...` + ? `${item.command.slice(0, commandEnd)}...` : item.command; const exit = item.exitCode === null || item.exitCode === undefined ? "" : ` (exit ${item.exitCode})`; const output = (item.aggregatedOutput ?? "").trimEnd(); - const outputTail = - output.length > BACKGROUND_COMMAND_DETAIL_OUTPUT_TAIL_MAX_LENGTH - ? `...${output.slice(-BACKGROUND_COMMAND_DETAIL_OUTPUT_TAIL_MAX_LENGTH)}` - : output; + let outputStart = output.length - BACKGROUND_COMMAND_DETAIL_OUTPUT_TAIL_MAX_LENGTH; + const firstOutputUnit = output.charCodeAt(outputStart); + if (firstOutputUnit >= 0xdc00 && firstOutputUnit <= 0xdfff) outputStart += 1; + const outputTail = outputStart > 0 ? `...${output.slice(outputStart)}` : output; const header = `Background command completed${exit}: ${command}`; return outputTail.length === 0 ? header : `${header}\n\nOutput tail:\n${outputTail}`; }