Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 211 additions & 1 deletion apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ const encodeStringJson = Schema.encodeEffect(Schema.fromJsonString(Schema.String
const isProviderAdapterEventStreamError = Schema.is(
ProviderAdapter.ProviderAdapterEventStreamError,
);
const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown));

describe("CodexAdapterV2 context usage", () => {
it("uses the current context rather than cumulative processed tokens", () => {
Expand Down Expand Up @@ -1086,6 +1087,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
Effect.void,
onRequest: (method: string, params: unknown) => Effect.Effect<void> = () => Effect.void,
readChildMetadata?: Parameters<typeof withCodexReplayChildMetadata>[2],
beforeEmitInbound?: CodexReplay.CodexAppServerReplayDriver["beforeEmitInbound"],
) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand All @@ -1096,9 +1098,13 @@ describe("CodexAdapterV2 post-settle continuation", () => {
const changes = yield* makeChangeSignal;
const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =
[];
const driver = yield* CodexReplay.makeReplayDriver(
transcript,
beforeEmitInbound === undefined ? {} : { beforeEmitInbound },
);
const clientFactory: CodexAdapterV2.CodexAppServerClientFactoryShape = {
open: (openInput) =>
Layer.build(CodexReplay.layerReplay(transcript)).pipe(
Layer.build(CodexReplay.layerReplayWithDriver(driver)).pipe(
Effect.mapError(
(cause) =>
new ProviderAdapter.ProviderAdapterOpenSessionError({
Expand Down Expand Up @@ -3237,6 +3243,210 @@ describe("CodexAdapterV2 post-settle continuation", () => {
});
};

it.effect.each([
{
name: "a text reply gains each saved image once",
reply: "Done.",
images: [
{ savedPath: "/tmp/a.png" },
{ savedPath: "/tmp/a.png" },
{ savedPath: "/tmp/failed.png", status: "failed" },
{
savedPath: "/tmp/limited.png",
failure: { type: "usageLimitExceeded", limitId: "image-generation" },
},
{ savedPath: null },
],
expected: "Done.\n\n![](</tmp/a.png>)",
},
{
name: "an image-only result becomes the reply",
reply: null,
images: [{ savedPath: "/tmp/a.png" }],
expected: "![](</tmp/a.png>)",
},
])("projects generated images: $name", ({ reply, images, expected }) =>
Effect.gen(function* () {
const scenario = "codex-generated-images";
const result = "aW1hZ2UtYnl0ZXMtbXVzdC1uZXZlci1yZWFjaC1jbGllbnRz";
const base = finalAnswerTranscript(
scenario,
reply === null ? [] : [{ id: "answer", text: reply }],
);
const harness = yield* makeCodexReplayHarness(
makeCodexReplayTranscript({
scenario,
entries: [
...base.entries.slice(0, -1),
...images.map((image, index) => ({
type: "emit_inbound" as const,
frame: {
method: "item/completed",
params: {
threadId: `native-${scenario}-thread`,
turnId: `native-${scenario}-turn`,
item: {
type: "imageGeneration",
id: `image-${index}`,
status: "completed",
result,
...image,
},
},
},
})),
...base.entries.slice(-1),
],
}),
);
yield* harness.runtime.startTurn(
makeCodexTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now: yield* DateTime.now,
attemptId: RunAttemptId.make("attempt-generated-images"),
text: "Reply with the requested recovery marker.",
}),
);
yield* harness.firstTerminal;

const messages = assistantMessages(harness.events);
assert.strictEqual(messages.at(-1)?.message.text, expected);
assert.strictEqual(messages.at(-1)?.message.streaming, false);
assert.strictEqual(new Set(messages.map(({ message }) => message.id)).size, 1);
// Image bytes stay with Codex; clients load the saved file.
assert.notInclude(encodeUnknownJson(harness.events), result);
}).pipe(
Effect.scoped,
Effect.provide(
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
),
),
);

it.effect.each(["complete", "stop"] as const)(
"retains generating images after settlement until %s",
(outcome) =>
Effect.gen(function* () {
const scenario = "codex-retained-image";
const base = finalAnswerTranscript(scenario, [{ id: "answer", text: "Done." }]);
const completions = {
first: yield* Deferred.make<void>(),
second: yield* Deferred.make<void>(),
};
const projected = yield* Deferred.make<void>();
const drained = yield* Deferred.make<void>();
const firstText = "Done.\n\n![](</tmp/first.png>)";
const imageEvent = (image: "first" | "second", phase: "started" | "completed") => ({
type: "emit_inbound" as const,
label: `${image}-${phase}`,
frame: {
method: `item/${phase}`,
params: {
threadId: `native-${scenario}-thread`,
turnId: `native-${scenario}-turn`,
item: {
type: "imageGeneration",
id: image,
status: phase === "started" ? "inProgress" : "completed",
result: "",
...(phase === "completed" ? { savedPath: `/tmp/${image}.png` } : {}),
},
},
},
});
const harness = yield* makeCodexReplayHarness(
makeCodexReplayTranscript({
scenario,
entries: [
...base.entries.slice(0, -2),
imageEvent("first", "started"),
imageEvent("second", "started"),
...base.entries.slice(-2),
imageEvent("first", "completed"),
imageEvent("second", "completed"),
],
}),
(event) =>
event.type === "message.updated" && event.message.text === firstText
? Deferred.succeed(projected, undefined)
: event.type === "background_work.updated" && !event.pending
? Deferred.succeed(drained, undefined)
: Effect.void,
undefined,
undefined,
(entry) =>
entry.label === "first-completed"
? Deferred.await(completions.first)
: entry.label === "second-completed"
? Deferred.await(completions.second)
: Effect.void,
);
yield* harness.runtime.startTurn(
makeCodexTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now: yield* DateTime.now,
attemptId: RunAttemptId.make("attempt-retained-image"),
text: "Reply with the requested recovery marker.",
}),
);
yield* harness.firstTerminal;
const pending = Effect.all([
harness.runtime.hasPendingBackgroundWorkForThread!(harness.providerThread),
harness.hasPendingBackgroundWork,
]);
assert.deepEqual(yield* pending, [true, true]);
assert.strictEqual(assistantMessages(harness.events).at(-1)?.message.text, "Done.");
const cleared = () =>
harness.events.some((e) => e.type === "background_work.updated" && !e.pending);

if (outcome === "complete") {
yield* Deferred.succeed(completions.first, undefined);
yield* Deferred.await(projected);
// The second image is still generating, so the first must not release the turn.
assert.deepEqual(yield* pending, [true, true]);
assert.isFalse(cleared());
yield* Deferred.succeed(completions.second, undefined);
} else {
yield* harness.runtime.interruptTurn({
providerThread: harness.providerThread,
providerTurnId: harness.terminalEvents()[0]!.providerTurnId,
requestRuntimeRestart: true,
});
}
yield* Deferred.await(drained);
assert.deepEqual(yield* pending, [false, false]);
assert.lengthOf(harness.terminalEvents(), 1);
// Run ingestion follows these updates, so they must bracket the terminal and the images.
const indexOf = (predicate: (event: ProviderAdapter.ProviderAdapterV2Event) => boolean) =>
harness.events.findIndex(predicate);
const started = indexOf((e) => e.type === "background_work.updated" && e.pending);
const terminal = indexOf((e) => e.type === "turn.terminal");
const finished = indexOf((e) => e.type === "background_work.updated" && !e.pending);
assert.isAtLeast(started, 0);
assert.isAbove(terminal, started);
assert.isAbove(finished, terminal);
if (outcome === "complete") {
const messages = assistantMessages(harness.events);
const projectedBoth = indexOf(
(e) =>
e.type === "message.updated" &&
e.message.text === `${firstText}\n\n![](</tmp/second.png>)`,
);
assert.isAbove(projectedBoth, terminal);
assert.isAbove(finished, projectedBoth);
assert.strictEqual(messages.at(-1)?.message.id, messages[0]?.message.id);
assert.strictEqual(messages.at(-1)?.message.streaming, false);
}
}).pipe(
Effect.scoped,
Effect.provide(
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
),
),
);

it.effect("suppresses a trailing empty final answer after a non-empty final answer", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
Loading
Loading