Skip to content
Open
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
2 changes: 2 additions & 0 deletions apps/server/src/persistence/Migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import Migration0048 from "./Migrations/048_ProjectionThreadBranchPullRequest.ts
import Migration0049 from "./Migrations/049_ProjectionThreadsActiveOrderKey.ts";
import Migration0050 from "./Migrations/050_ProjectionThreadPullRequests.ts";
import Migration0051 from "./Migrations/051_ProjectionThreadMessageContext.ts";
import Migration0052 from "./Migrations/052_CodexChildUsage.ts";

/**
* Migration loader with all migrations defined inline.
Expand Down Expand Up @@ -126,6 +127,7 @@ const migrationEntries = [
[49, "ProjectionThreadsActiveOrderKey", Migration0049],
[50, "ProjectionThreadPullRequests", Migration0050],
[51, "ProjectionThreadMessageContext", Migration0051],
[52, "CodexChildUsage", Migration0052],
] as const;

export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
Expand Down
28 changes: 28 additions & 0 deletions apps/server/src/persistence/Migrations/052_CodexChildUsage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import * as Effect from "effect/Effect";
import * as SqlClient from "effect/unstable/sql/SqlClient";

export default Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* sql`
CREATE TABLE codex_child_usage (
thread_id TEXT NOT NULL,
instance_id TEXT NOT NULL,
task_id TEXT NOT NULL,
usage_json TEXT NOT NULL DEFAULT '{"totalTokens":0}',
tool_uses INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (thread_id, instance_id, task_id)
)
`;
yield* sql`
CREATE TABLE codex_child_tool_calls (
thread_id TEXT NOT NULL,
instance_id TEXT NOT NULL,
task_id TEXT NOT NULL,
turn_id TEXT NOT NULL,
item_id TEXT NOT NULL,
PRIMARY KEY (thread_id, instance_id, task_id, turn_id, item_id),
FOREIGN KEY (thread_id, instance_id, task_id)
REFERENCES codex_child_usage (thread_id, instance_id, task_id) ON DELETE CASCADE
)
`;
});
2 changes: 2 additions & 0 deletions apps/server/src/provider/Drivers/CodexDriver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne

import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
import { ServerConfig } from "../../config.ts";
import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import { layerTest as codexResetCreditLayerTest } from "../Layers/codexResetCredit.ts";
import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
Expand All @@ -31,6 +32,7 @@ const testLayer = ServerConfig.layerTest(process.cwd(), {
prefix: "t3-codex-driver-maintenance-",
}).pipe(
Layer.provideMerge(NodeServices.layer),
Layer.provideMerge(SqlitePersistenceMemory),
Layer.provideMerge(ServerSettingsService.layerTest()),
Layer.provideMerge(ModelManifest.layerTest),
Layer.provideMerge(codexResetCreditLayerTest),
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/provider/Drivers/CodexDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import type * as SqlClient from "effect/unstable/sql/SqlClient";
import { HttpClient } from "effect/unstable/http";
import { ChildProcessSpawner } from "effect/unstable/process";

Expand Down Expand Up @@ -114,7 +115,8 @@ export type CodexDriverEnv =
| Path.Path
| ProviderEventLoggers
| ServerConfig
| ServerSettingsService;
| ServerSettingsService
| SqlClient.SqlClient;

export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
driverKind: DRIVER_KIND,
Expand Down
93 changes: 93 additions & 0 deletions apps/server/src/provider/Layers/CodexAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import * as TestClock from "effect/testing/TestClock";
import * as CodexErrors from "effect-codex-app-server/errors";

import { ServerConfig } from "../../config.ts";
import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import { ProviderAdapterValidationError } from "../Errors.ts";
import type { CodexAdapterShape } from "../Services/CodexAdapter.ts";
Expand Down Expand Up @@ -244,6 +245,7 @@ const validationLayer = it.layer(
Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
Layer.provideMerge(ServerSettingsService.layerTest()),
Layer.provideMerge(providerSessionDirectoryTestLayer),
Layer.provideMerge(SqlitePersistenceMemory),
Layer.provideMerge(NodeServices.layer),
),
);
Expand Down Expand Up @@ -314,6 +316,7 @@ const sessionErrorLayer = it.layer(
Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
Layer.provideMerge(ServerSettingsService.layerTest()),
Layer.provideMerge(providerSessionDirectoryTestLayer),
Layer.provideMerge(SqlitePersistenceMemory),
Layer.provideMerge(NodeServices.layer),
),
);
Expand Down Expand Up @@ -462,6 +465,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {
Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
Layer.provideMerge(ServerSettingsService.layerTest()),
Layer.provideMerge(providerSessionDirectoryTestLayer),
Layer.provideMerge(SqlitePersistenceMemory),
Layer.provideMerge(NodeServices.layer),
);

Expand Down Expand Up @@ -494,6 +498,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {
Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
Layer.provideMerge(ServerSettingsService.layerTest()),
Layer.provideMerge(providerSessionDirectoryTestLayer),
Layer.provideMerge(SqlitePersistenceMemory),
Layer.provideMerge(NodeServices.layer),
);

Expand Down Expand Up @@ -527,6 +532,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {
Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
Layer.provideMerge(ServerSettingsService.layerTest()),
Layer.provideMerge(providerSessionDirectoryTestLayer),
Layer.provideMerge(SqlitePersistenceMemory),
Layer.provideMerge(NodeServices.layer),
);

Expand Down Expand Up @@ -581,6 +587,7 @@ const lifecycleLayer = it.layer(
Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
Layer.provideMerge(ServerSettingsService.layerTest()),
Layer.provideMerge(providerSessionDirectoryTestLayer),
Layer.provideMerge(SqlitePersistenceMemory),
Layer.provideMerge(NodeServices.layer),
),
);
Expand Down Expand Up @@ -1046,6 +1053,88 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
}),
);

it.effect("preserves child answers and counts each tool once", () =>
Effect.gen(function* () {
const { adapter, runtime } = yield* startLifecycleRuntime();
const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 5)).pipe(
Effect.forkChild,
);
const items = [
{ type: "commandExecution", id: "tool-1", command: "cat README.md" },
{ type: "commandExecution", id: "tool-1", command: "cat README.md" },
{ type: "agentMessage", id: "answer-1", text: "" },
{ type: "agentMessage", id: "answer-1", text: "The project is T3 Code." },
];
for (const [index, item] of items.entries()) {
yield* runtime.emit({
id: asEventId(`evt-child-item-${index}`),
kind: "notification",
provider: ProviderDriverKind.make("codex"),
createdAt: "2026-01-01T00:00:00.000Z",
method: "collabAgent/item",
threadId: asThreadId("thread-1"),
payload: { agentThreadId: "child-answer", item },
});
}
for (const [index, extra] of [
{ method: "collabAgent/tokenUsage", tokenUsage: { total: { totalTokens: 42 } } },
{ method: "collabAgent/turnCompleted", turn: { status: "completed" } },
].entries()) {
yield* runtime.emit({
id: asEventId(`evt-child-state-${index}`),
kind: "notification",
provider: ProviderDriverKind.make("codex"),
createdAt: "2026-01-01T00:00:01.000Z",
method: extra.method,
threadId: asThreadId("thread-1"),
payload: { agentThreadId: "child-answer", ...extra },
});
}
const events = Array.from(yield* Fiber.join(eventsFiber));
const progress = events.filter((event) => event.type === "task.progress");
NodeAssert.deepStrictEqual(
{ summary: progress[2]?.payload.summary, usage: progress[3]?.payload.typedUsage },
{ summary: "The project is T3 Code.", usage: { totalTokens: 42, toolUses: 1 } },
);
NodeAssert.equal(events[4]?.type === "task.updated" && events[4].payload.status, "idle");
yield* adapter.stopSession(asThreadId("thread-1"));
const restarted = yield* startLifecycleRuntime();
const resumedFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 3)).pipe(
Effect.forkChild,
);
for (const [index, extra] of [
{ method: "collabAgent/item", item: items[0] },
{
method: "collabAgent/item",
item: { type: "mcpToolCall", id: "tool-2", server: "filesystem", tool: "read_file" },
},
{
method: "collabAgent/tokenUsage",
tokenUsage: { total: { totalTokens: 20, inputTokens: 10 } },
},
].entries()) {
yield* restarted.runtime.emit({
id: asEventId(`evt-child-resumed-${index}`),
kind: "notification",
provider: ProviderDriverKind.make("codex"),
createdAt: "2026-01-01T00:00:02.000Z",
method: extra.method,
threadId: asThreadId("thread-1"),
payload: { agentThreadId: "child-answer", ...extra },
});
}
const resumed = Array.from(yield* Fiber.join(resumedFiber));
NodeAssert.deepStrictEqual(
resumed.map((event) => (event.type === "task.progress" ? event.payload.typedUsage : null)),
[
{ totalTokens: 42, toolUses: 1 },
{ totalTokens: 42, toolUses: 2 },
{ totalTokens: 42, inputTokens: 10, toolUses: 2 },
],
);
}),
);

it.effect("carries child model metadata through every task event", () =>
Effect.gen(function* () {
const { adapter, runtime } = yield* startLifecycleRuntime();
Expand Down Expand Up @@ -2557,6 +2646,7 @@ const scopedLifecycleLayer = it.layer(
Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
Layer.provideMerge(ServerSettingsService.layerTest()),
Layer.provideMerge(providerSessionDirectoryTestLayer),
Layer.provideMerge(SqlitePersistenceMemory),
Layer.provideMerge(NodeServices.layer),
),
);
Expand Down Expand Up @@ -2601,6 +2691,7 @@ const scopedFailureLayer = it.layer(
Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
Layer.provideMerge(ServerSettingsService.layerTest()),
Layer.provideMerge(providerSessionDirectoryTestLayer),
Layer.provideMerge(SqlitePersistenceMemory),
Layer.provideMerge(NodeServices.layer),
),
);
Expand Down Expand Up @@ -2653,6 +2744,7 @@ it.effect("flushes managed native logs when the adapter layer shuts down", () =>
Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
Layer.provideMerge(ServerSettingsService.layerTest()),
Layer.provideMerge(providerSessionDirectoryTestLayer),
Layer.provideMerge(SqlitePersistenceMemory),
Layer.provideMerge(NodeServices.layer),
);
const context = yield* Layer.buildWithScope(layer, scope);
Expand Down Expand Up @@ -2709,6 +2801,7 @@ const usageLimitLayer = it.layer(
Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
Layer.provideMerge(ServerSettingsService.layerTest()),
Layer.provideMerge(providerSessionDirectoryTestLayer),
Layer.provideMerge(SqlitePersistenceMemory),
Layer.provideMerge(NodeServices.layer),
),
);
Expand Down
79 changes: 66 additions & 13 deletions apps/server/src/provider/Layers/CodexAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import * as Exit from "effect/Exit";
import * as Fiber from "effect/Fiber";
import * as FileSystem from "effect/FileSystem";
import * as Queue from "effect/Queue";
import * as SqlClient from "effect/unstable/sql/SqlClient";
import * as Schema from "effect/Schema";
import * as Scope from "effect/Scope";
import * as Stream from "effect/Stream";
Expand Down Expand Up @@ -71,6 +72,7 @@ import {
} from "./CodexSessionRuntime.ts";
import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
import { resolveCodexLaunchArgs } from "./codexLaunchArgs.ts";
import { recordCodexChildUsage } from "./codexChildUsage.ts";
import {
type CodexRateLimitSnapshot,
codexRateLimitsToUpdate,
Expand Down Expand Up @@ -768,8 +770,11 @@ function itemTitle(
}
}

function itemDetail(itemType: CanonicalItemType, item: CodexLifecycleItem): string | undefined {
const itemRecord = item as Record<string, unknown>;
function itemDetail(
itemType: CanonicalItemType,
item: Record<string, unknown>,
): string | undefined {
const itemRecord = item;
const action = itemRecord.action as Record<string, unknown> | undefined;
const actionQueries = Array.isArray(action?.queries) ? action.queries : [];
const candidates = [
Expand Down Expand Up @@ -1218,7 +1223,7 @@ function mapCollabAgentEvent(
? (tokenUsage.total as Record<string, unknown>)
: undefined;
const count = (value: unknown): number | undefined =>
typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
// Same validation as every other field: RuntimeTaskUsage.totalTokens
// is NonNegativeInt, so NaN/Infinity/negative wire values must miss.
const totalTokens = count(total?.totalTokens);
Expand Down Expand Up @@ -1259,18 +1264,13 @@ function mapCollabAgentEvent(
? (payload.item as Record<string, unknown>)
: undefined;
const itemTypeRaw = typeof item?.type === "string" ? item.type : undefined;
if (!itemTypeRaw) {
if (!item || !itemTypeRaw) {
return [];
}
// A loose summary from the raw item: the child stream is untyped at
// this boundary (synthetic event payload), so read best-effort fields
// rather than force a schema decode.
const looseSummary =
(typeof item?.command === "string" ? item.command : undefined) ??
(typeof item?.title === "string" ? item.title : undefined) ??
(typeof item?.query === "string" ? item.query : undefined);
const canonical = toCanonicalItemType(itemTypeRaw);
const summary = looseSummary ?? canonical.replaceAll("_", " ");
const detail = itemDetail(canonical, item);
if (canonical === "assistant_message" && !detail) return [];
const summary = detail ?? itemTitle(canonical) ?? canonical.replaceAll("_", " ");
return [
{
...base,
Expand Down Expand Up @@ -2218,6 +2218,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
options?: CodexAdapterLiveOptions,
) {
const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("codex");
const sql = yield* SqlClient.SqlClient;
const fileSystem = yield* FileSystem.FileSystem;
const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const crypto = yield* Crypto.Crypto;
Expand Down Expand Up @@ -2427,9 +2428,61 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
}
return runtimeEvent;
});
for (const [index, mapped] of mappedEvents.entries()) {
if (mapped.type !== "task.progress" || !event.method.startsWith("collabAgent/"))
continue;
const payload = asUnknownRecord(event.payload);
const item = asUnknownRecord(payload?.item);
const toolItemId =
typeof item?.id === "string" &&
[
"commandExecution",
"fileChange",
"mcpToolCall",
"dynamicToolCall",
"webSearch",
"imageView",
"imageGeneration",
"collabAgentToolCall",
].includes(String(item.type))
? item.id
: undefined;
if (!toolItemId && !mapped.payload.typedUsage) continue;
const usage = yield* recordCodexChildUsage(sql, {
threadId: event.threadId,
instanceId: boundInstanceId,
taskId: mapped.payload.taskId,
...(toolItemId
? {
toolItemId,
childTurnId:
typeof payload?.childTurnId === "string" ? payload.childTurnId : "",
}
: {}),
...(mapped.payload.typedUsage ? { usage: mapped.payload.typedUsage } : {}),
}).pipe(
Effect.catch((cause) =>
Effect.logWarning("Could not persist Codex child usage", { cause }).pipe(
Effect.as(undefined),
),
),
);
// Never replace the durable usage row with a partial snapshot on failure.
const progress = { ...mapped.payload };
delete progress.typedUsage;
mappedEvents[index] = {
...mapped,
payload: usage ? { ...progress, typedUsage: usage } : progress,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
}
const runtimeEvents = usageLimitError
? [usageLimitError, ...mappedEvents]
: mappedEvents;
: mappedEvents.filter(
(mapped) =>
event.method !== "collabAgent/tokenUsage" ||
mapped.type !== "task.progress" ||
mapped.payload.typedUsage !== undefined,
);
if (runtimeEvents.length === 0) {
yield* Effect.logDebug("ignoring unhandled Codex provider event", {
method: event.method,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/provider/Layers/CodexSessionRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1796,6 +1796,7 @@ export const makeCodexSessionRuntime = (
payload: {
...childIdentity,
item: notification.params.item,
childTurnId: notification.params.turnId,
},
});
return true;
Expand Down
Loading
Loading