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
5 changes: 3 additions & 2 deletions apps/server/src/orchestration/decider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,9 @@ function isStaleRequestFailureDetail(payload: Record<string, unknown> | null): b
}

// Scans the read model's activities, which the projector caps at the most
// recent 500 plus pending async questions. Async questions remain actionable
// while the agent works, so they must not expire with the activity window.
// recent 500 plus pending requests (async questions, native questions, and
// approvals). Open requests remain actionable while the agent works, so they
// must not expire with the activity window.
function openRequests(thread: Pick<OrchestrationThread, "activities">) {
const requests = new Map<string, OrchestrationThreadActivity>();
for (const activity of thread.activities) {
Expand Down
191 changes: 191 additions & 0 deletions apps/server/src/orchestration/projector.retainOpenRequests.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import {
CommandId,
DEFAULT_PROVIDER_INTERACTION_MODE,
EventId,
ProjectId,
ProviderInstanceId,
ThreadId,
} from "@t3tools/contracts";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";

import { decideOrchestrationCommand } from "./decider.ts";
import { createEmptyReadModel, projectEvent } from "./projector.ts";

const NOW = "2026-01-01T00:00:00.000Z";

const appendActivity = (
model: Effect.Success<ReturnType<typeof projectEvent>>,
sequence: number,
activity: {
readonly id: string;
readonly kind: string;
readonly summary: string;
readonly payload: unknown;
},
) =>
projectEvent(model, {
sequence,
eventId: EventId.make(`evt-activity-${sequence}`),
aggregateKind: "thread",
aggregateId: ThreadId.make("thread-1"),
type: "thread.activity-appended",
occurredAt: NOW,
commandId: CommandId.make(`cmd-activity-${sequence}`),
causationEventId: null,
correlationId: CommandId.make(`cmd-activity-${sequence}`),
metadata: {},
payload: {
threadId: ThreadId.make("thread-1"),
activity: {
id: EventId.make(activity.id),
tone: "info" as const,
kind: activity.kind,
summary: activity.summary,
payload: activity.payload,
turnId: null,
createdAt: NOW,
sequence,
},
},
});

const seedReadModel = Effect.gen(function* () {
const initial = createEmptyReadModel(NOW);
const withProject = yield* projectEvent(initial, {
sequence: 1,
eventId: EventId.make("evt-project-create"),
aggregateKind: "project",
aggregateId: ProjectId.make("project-1"),
type: "project.created",
occurredAt: NOW,
commandId: CommandId.make("cmd-project-create"),
causationEventId: null,
correlationId: CommandId.make("cmd-project-create"),
metadata: {},
payload: {
projectId: ProjectId.make("project-1"),
title: "Project",
workspaceRoot: "/tmp/project",
defaultModelSelection: null,
scripts: [],
createdAt: NOW,
updatedAt: NOW,
},
});
return yield* projectEvent(withProject, {
sequence: 2,
eventId: EventId.make("evt-thread-create"),
aggregateKind: "thread",
aggregateId: ThreadId.make("thread-1"),
type: "thread.created",
occurredAt: NOW,
commandId: CommandId.make("cmd-thread-create"),
causationEventId: null,
correlationId: CommandId.make("cmd-thread-create"),
metadata: {},
payload: {
threadId: ThreadId.make("thread-1"),
projectId: ProjectId.make("project-1"),
title: "Thread",
modelSelection: {
instanceId: ProviderInstanceId.make("codex"),
model: "gpt-5-codex",
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
branch: null,
worktreePath: null,
createdAt: NOW,
updatedAt: NOW,
},
});
});

it.layer(NodeServices.layer)("projector retains open approvals past the activity cap", (it) => {
it.effect("an unresolved approval.requested survives 500 later activities", () =>
Effect.gen(function* () {
let model = yield* seedReadModel;
model = yield* appendActivity(model, 3, {
id: "activity-approval-requested",
kind: "approval.requested",
summary: "App access requested",
payload: { requestId: "approval-1" },
});
for (let n = 0; n < 501; n++) {
model = yield* appendActivity(model, 4 + n, {
id: `activity-filler-${n}`,
kind: "message.appended",
summary: `filler ${n}`,
payload: {},
});
}
const thread = model.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
expect(thread).toBeDefined();
expect(thread?.activities.some((activity) => activity.kind === "approval.requested")).toBe(
true,
);
}),
);

it.effect("settle stays blocked while the retained approval is open", () =>
Effect.gen(function* () {
let model = yield* seedReadModel;
model = yield* appendActivity(model, 3, {
id: "activity-approval-requested",
kind: "approval.requested",
summary: "App access requested",
payload: { requestId: "approval-1" },
});
for (let n = 0; n < 501; n++) {
model = yield* appendActivity(model, 4 + n, {
id: `activity-filler-${n}`,
kind: "message.appended",
summary: `filler ${n}`,
payload: {},
});
}
const error = yield* decideOrchestrationCommand({
command: {
type: "thread.settle",
commandId: CommandId.make("cmd-settle"),
threadId: ThreadId.make("thread-1"),
},
readModel: model,
}).pipe(Effect.flip);
expect(error).toMatchObject({ _tag: "OrchestrationThreadSettleBlockedError" });
}),
);

it.effect("a resolved approval is still evicted past the activity cap", () =>
Effect.gen(function* () {
let model = yield* seedReadModel;
model = yield* appendActivity(model, 3, {
id: "activity-approval-requested",
kind: "approval.requested",
summary: "App access requested",
payload: { requestId: "approval-1" },
});
model = yield* appendActivity(model, 4, {
id: "activity-approval-resolved",
kind: "approval.resolved",
summary: "Approval resolved",
payload: { requestId: "approval-1" },
});
for (let n = 0; n < 501; n++) {
model = yield* appendActivity(model, 5 + n, {
id: `activity-filler-${n}`,
kind: "message.appended",
summary: `filler ${n}`,
payload: {},
});
}
const thread = model.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
expect(thread).toBeDefined();
expect(thread?.activities.some((activity) => activity.kind === "approval.requested")).toBe(
false,
);
}),
);
});
9 changes: 5 additions & 4 deletions apps/server/src/orchestration/projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,9 @@ type ThreadPatch = Partial<Omit<OrchestrationThread, "id" | "projectId">>;
const MAX_THREAD_MESSAGES = 2_000;
const MAX_THREAD_CHECKPOINTS = 500;

// Async questions can stay open while the agent produces more activity.
// Match the database snapshot's pending-question retention.
// Open requests stay actionable while the agent produces more activity:
// async questions, native questions, and approvals can all outlast the
// activity window. Match the database snapshot's pending-request retention.
function retainThreadActivities(activities: OrchestrationThread["activities"]) {
const recentStart = activities.length - 500;
if (recentStart <= 0) return activities;
Expand All @@ -70,9 +71,9 @@ function retainThreadActivities(activities: OrchestrationThread["activities"]) {
if (!Predicate.isObject(activity.payload)) continue;
const requestId = activity.payload.requestId;
if (typeof requestId !== "string") continue;
if (activity.kind === "user-input.requested" && activity.payload.responseMode === "message") {
if (activity.kind === "user-input.requested" || activity.kind === "approval.requested") {
pending.set(requestId, activity);
} else if (activity.kind === "user-input.resolved") {
} else if (activity.kind === "user-input.resolved" || activity.kind === "approval.resolved") {
pending.delete(requestId);
}
}
Expand Down
Loading