Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/La
import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts";
import { ProjectionCheckpointRepositoryLive } from "../src/persistence/Layers/ProjectionCheckpoints.ts";
import { ProjectionPendingApprovalRepositoryLive } from "../src/persistence/Layers/ProjectionPendingApprovals.ts";
import { ProjectionTurnRepositoryLive } from "../src/persistence/Layers/ProjectionTurns.ts";
import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts";
import { makeSqlitePersistenceLive } from "../src/persistence/Layers/Sqlite.ts";
import { ProjectionCheckpointRepository } from "../src/persistence/Services/ProjectionCheckpoints.ts";
Expand Down Expand Up @@ -297,6 +298,7 @@ export const makeOrchestrationIntegrationHarness = (
orchestrationLayer.pipe(Layer.provide(projectionSnapshotQueryLayer)),
ProjectionCheckpointRepositoryLive,
ProjectionPendingApprovalRepositoryLive,
ProjectionTurnRepositoryLive,
checkpointStoreLayer,
providerLayer,
RuntimeReceiptBusTest,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,9 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
'thread-1',
'turn-1',
'info',
'runtime.note',
'provider started',
'{"stage":"start"}',
'runtime.warning',
'Runtime warning',
'{"message":"provider started"}',
'2026-02-24T00:00:06.000Z'
)
`;
Expand Down Expand Up @@ -312,9 +312,9 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
{
id: asEventId("activity-1"),
tone: "info",
kind: "runtime.note",
summary: "provider started",
payload: { stage: "start" },
kind: "runtime.warning",
summary: "Runtime warning",
payload: { message: "provider started" },
turnId: asTurnId("turn-1"),
createdAt: "2026-02-24T00:00:06.000Z",
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -590,7 +590,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
turnId: row.turnId,
...(row.sequence !== null ? { sequence: row.sequence } : {}),
createdAt: row.createdAt,
});
} as OrchestrationThreadActivity);
activitiesByThread.set(row.threadId, threadActivities);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,8 +173,7 @@ const make = Effect.gen(function* () {
| "provider.turn.start.failed"
| "provider.turn.interrupt.failed"
| "provider.approval.respond.failed"
| "provider.user-input.respond.failed"
| "provider.session.stop.failed";
| "provider.user-input.respond.failed";
readonly summary: string;
readonly detail: string;
readonly turnId: TurnId | null;
Expand Down
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low

When event.payload.detail is null, the runtime warning payload calls toCanonicalJsonValue(null), which returns undefined via null ?? undefined. This causes detail: undefined to be stored, which is then dropped entirely during JSON serialization. Downstream consumers that distinguish between null and a missing detail will see the key disappear instead of receiving null. Consider preserving null values directly rather than normalizing them through toCanonicalJsonValue.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts around line 1693:

When `event.payload.detail` is `null`, the runtime warning payload calls `toCanonicalJsonValue(null)`, which returns `undefined` via `null ?? undefined`. This causes `detail: undefined` to be stored, which is then dropped entirely during JSON serialization. Downstream consumers that distinguish between `null` and a missing `detail` will see the key disappear instead of receiving `null`. Consider preserving `null` values directly rather than normalizing them through `toCanonicalJsonValue`.

Evidence trail:
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts lines 106-116 (toCanonicalJsonValue function), line 298 (usage with event.payload.detail), line 450 (similar usage). The `null ?? undefined` expression at line 115 returns `undefined` when normalized is `null`, and the spread at line 298 creates `{ detail: undefined }` which is dropped during JSON serialization.

Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { afterEach, describe, expect, it } from "vitest";

import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts";
import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
import {
ProviderService,
Expand Down Expand Up @@ -209,6 +210,7 @@ describe("ProviderRuntimeIngestion", () => {
Layer.provide(SqlitePersistenceMemory),
);
const layer = ProviderRuntimeIngestionLive.pipe(
Layer.provideMerge(ProjectionTurnRepositoryLive),
Layer.provideMerge(orchestrationLayer),
Layer.provideMerge(SqlitePersistenceMemory),
Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
Expand Down Expand Up @@ -1896,7 +1898,9 @@ describe("ProviderRuntimeIngestion", () => {
status: "in_progress",
title: "Run tests",
detail: "bun test",
data: { pid: 123 },
data: {
kind: "generic",
},
},
});

Expand Down
25 changes: 18 additions & 7 deletions apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
ApprovalRequestId,
type AssistantDeliveryMode,
type CanonicalJsonValue,
CommandId,
MessageId,
type OrchestrationEvent,
Expand All @@ -18,7 +19,6 @@ import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";

import { ProviderService } from "../../provider/Services/ProviderService.ts";
import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts";
import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts";
import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
import { isGitRepository } from "../../git/Utils.ts";
import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
Expand Down Expand Up @@ -98,6 +98,16 @@ function proposedPlanIdFromEvent(event: ProviderRuntimeEvent, threadId: ThreadId
return `plan:${threadId}:event:${event.eventId}`;
}

function toCanonicalJsonValue(value: unknown): CanonicalJsonValue | undefined {
if (value === undefined) {
return undefined;
}
const normalized = JSON.parse(
JSON.stringify(value, (_key, nestedValue) => (nestedValue === undefined ? null : nestedValue)),
) as CanonicalJsonValue | null;
return normalized ?? undefined;
}

function buildContextWindowActivityPayload(
event: ProviderRuntimeEvent,
): ThreadTokenUsageSnapshot | undefined {
Expand Down Expand Up @@ -250,7 +260,9 @@ function runtimeEventToActivities(
summary: "Runtime warning",
payload: {
message: truncateDetail(event.payload.message),
...(event.payload.detail !== undefined ? { detail: event.payload.detail } : {}),
...(event.payload.detail !== undefined
? { detail: toCanonicalJsonValue(event.payload.detail) }
: {}),
},
turnId: toTurnId(event.turnId) ?? null,
...maybeSequence,
Expand Down Expand Up @@ -400,7 +412,9 @@ function runtimeEventToActivities(
summary: "Context compacted",
payload: {
state: event.payload.state,
...(event.payload.detail !== undefined ? { detail: event.payload.detail } : {}),
...(event.payload.detail !== undefined
? { detail: toCanonicalJsonValue(event.payload.detail) }
: {}),
},
turnId: toTurnId(event.turnId) ?? null,
...maybeSequence,
Expand Down Expand Up @@ -1264,7 +1278,4 @@ const make = Effect.fn("make")(function* () {
} satisfies ProviderRuntimeIngestionShape;
});

export const ProviderRuntimeIngestionLive = Layer.effect(
ProviderRuntimeIngestionService,
make(),
).pipe(Layer.provide(ProjectionTurnRepositoryLive));
export const ProviderRuntimeIngestionLive = Layer.effect(ProviderRuntimeIngestionService, make());
4 changes: 2 additions & 2 deletions apps/server/src/orchestration/projector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -545,7 +545,7 @@ describe("orchestration projector", () => {
tone: "tool",
kind: "tool.started",
summary: "Edit file started",
payload: { toolKind: "command" },
payload: { itemType: "command_execution" },
turnId: "turn-1",
createdAt: "2026-02-23T10:00:02.750Z",
},
Expand Down Expand Up @@ -619,7 +619,7 @@ describe("orchestration projector", () => {
tone: "tool",
kind: "tool.completed",
summary: "Edit file complete",
payload: { toolKind: "command" },
payload: { itemType: "command_execution" },
turnId: "turn-2",
createdAt: "2026-02-23T10:00:04.750Z",
},
Expand Down
Loading
Loading