authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-03 17:36:41-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 17:36:41-07:00
logd76b24dd15a219666941ab1b4967d8f738adcda0
tree9eddf7c38b4a6c31740a11ec210224df3d427acc
parente3723e06b5b154a3e0ede55c02e2cabc5301d3bf
signature Signed by PGP key B5690EEEBB952194

feat(codex): support async questions (#9512)


26 files changed, 962 insertions(+), 24 deletions(-)

apps/mobile/src/lib/threadActivity.test.ts+23
......@@ -88,6 +88,29 @@ const nativeQuestion = {
8888} as const;
8989
9090describe("pending user input answers", () => {
91 it("accepts free-text answers to async questions without options", () => {
92 const question = {
93 id: "0",
94 header: "Question",
95 question: "What should it be named?",
96 options: [],
97 allowCustomAnswer: true,
98 multiSelect: false,
99 };
100 const requested = makeActivity({
101 id: EventId.make("async-question"),
102 kind: "user-input.requested",
103 summary: "User input requested",
104 createdAt: "2026-09-03T00:00:00.000Z",
105 payload: { requestId: "async-1", responseMode: "message", questions: [question] },
106 });
107 const questions = derivePendingUserInputs([requested])[0]?.questions;
108 expect(questions).toEqual([question]);
109 expect(buildPendingUserInputAnswers(questions!, { "0": { customAnswer: "Example" } })).toEqual({
110 "0": "Example",
111 });
112 });
113
91114 it("preserves native choice values and custom-answer rules from activities", () => {
92115 const requested = makeActivity({
93116 id: EventId.make("native-question"),
apps/mobile/src/lib/threadActivity.ts+1-1
......@@ -245,7 +245,7 @@ function parseUserInputQuestions(
245245 };
246246 })
247247 .filter((option): option is UserInputQuestion["options"][number] => option !== null);
248 if (options.length === 0) {
248 if (options.length === 0 && question.allowCustomAnswer === false) {
249249 return null;
250250 }
251251 return {
apps/server/src/checkpointing/CheckpointDiffQuery.test.ts+5
......@@ -75,6 +75,7 @@ describe("CheckpointDiffQuery.layer", () => {
7575 Layer.provideMerge(Layer.succeed(CheckpointStore.CheckpointStore, checkpointStore)),
7676 Layer.provideMerge(
7777 Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, {
78 getUserInputActivity: () => Effect.die("unused"),
7879 getCommandReadModel: () =>
7980 Effect.die("CheckpointDiffQuery should not request the command read model"),
8081 getSnapshot: () =>
......@@ -185,6 +186,7 @@ describe("CheckpointDiffQuery.layer", () => {
185186 Layer.provideMerge(Layer.succeed(CheckpointStore.CheckpointStore, checkpointStore)),
186187 Layer.provideMerge(
187188 Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, {
189 getUserInputActivity: () => Effect.die("unused"),
188190 getCommandReadModel: () =>
189191 Effect.die("CheckpointDiffQuery should not request the command read model"),
190192 getSnapshot: () =>
......@@ -270,6 +272,7 @@ describe("CheckpointDiffQuery.layer", () => {
270272 Layer.provideMerge(Layer.succeed(CheckpointStore.CheckpointStore, checkpointStore)),
271273 Layer.provideMerge(
272274 Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, {
275 getUserInputActivity: () => Effect.die("unused"),
273276 getCommandReadModel: () =>
274277 Effect.die("CheckpointDiffQuery should not request the command read model"),
275278 getSnapshot: () =>
......@@ -340,6 +343,7 @@ describe("CheckpointDiffQuery.layer", () => {
340343 Layer.provideMerge(Layer.succeed(CheckpointStore.CheckpointStore, checkpointStore)),
341344 Layer.provideMerge(
342345 Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, {
346 getUserInputActivity: () => Effect.die("unused"),
343347 getCommandReadModel: () =>
344348 Effect.die("CheckpointDiffQuery should not request the command read model"),
345349 getSnapshot: () =>
......@@ -395,6 +399,7 @@ describe("CheckpointDiffQuery.layer", () => {
395399 Layer.provideMerge(Layer.succeed(CheckpointStore.CheckpointStore, checkpointStore)),
396400 Layer.provideMerge(
397401 Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, {
402 getUserInputActivity: () => Effect.die("unused"),
398403 getCommandReadModel: () =>
399404 Effect.die("CheckpointDiffQuery should not request the command read model"),
400405 getSnapshot: () =>
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+211-5
......@@ -1,4 +1,11 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeFSP from "node:fs/promises";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5
16import {
7 ApprovalRequestId,
8 EventId,
29 CheckpointRef,
310 CommandId,
411 DEFAULT_PROVIDER_INTERACTION_MODE,
......@@ -25,7 +32,10 @@ import { PersistenceSqlError } from "../../persistence/Errors.ts";
2532import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
2633import * as OrchestrationCommandReceipts from "../../persistence/Services/OrchestrationCommandReceipts.ts";
2734import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
28import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
35import {
36 makeSqlitePersistenceLive,
37 SqlitePersistenceMemory,
38} from "../../persistence/Layers/Sqlite.ts";
2939import {
3040 OrchestrationEventStore,
3141 type OrchestrationEventStoreShape,
......@@ -49,7 +59,10 @@ const asMessageId = (value: string): MessageId => MessageId.make(value);
4959const asTurnId = (value: string): TurnId => TurnId.make(value);
5060const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value);
5161
52function makeOrchestrationLayer() {
62function makeOrchestrationLayer(databasePath?: string) {
63 const persistence = databasePath
64 ? makeSqlitePersistenceLive(databasePath)
65 : SqlitePersistenceMemory;
5366 const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
5467 prefix: "t3-orchestration-engine-test-",
5568 });
......@@ -65,19 +78,21 @@ function makeOrchestrationLayer() {
6578 Layer.provide(OrchestrationEventStoreLive),
6679 Layer.provideMerge(OrchestrationCommandReceiptRepositoryLive),
6780 Layer.provide(RepositoryIdentityResolver.layer),
68 Layer.provide(SqlitePersistenceMemory),
81 Layer.provide(persistence),
6982 Layer.provideMerge(ServerConfigLayer),
7083 Layer.provideMerge(NodeServices.layer),
7184 );
7285}
7386
74async function createOrchestrationSystem() {
75 const runtime = ManagedRuntime.make(makeOrchestrationLayer());
87async function createOrchestrationSystem(databasePath?: string) {
88 const runtime = ManagedRuntime.make(makeOrchestrationLayer(databasePath));
7689 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
7790 const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery));
7891 return {
7992 engine,
8093 readModel: () => runtime.runPromise(snapshotQuery.getSnapshot()),
94 readThread: (threadId: ThreadId) =>
95 runtime.runPromise(snapshotQuery.getThreadDetailById(threadId)),
8196 run: <A, E>(effect: Effect.Effect<A, E>) => runtime.runPromise(effect),
8297 dispose: () => runtime.dispose(),
8398 };
......@@ -99,6 +114,196 @@ const hasMetricSnapshot = (
99114 );
100115
101116describe("OrchestrationEngine", () => {
117 it.each(["running", "stopped"] as const)(
118 "sends async answers with a %s session and rejects old duplicate replies",
119 async (status) => {
120 const directory = await NodeFSP.mkdtemp(
121 NodePath.join(NodeOS.tmpdir(), "t3-async-questions-"),
122 );
123 const databasePath = NodePath.join(directory, "state.sqlite");
124 let system = await createOrchestrationSystem(databasePath);
125 const threadId = ThreadId.make("async-thread");
126 const projectId = ProjectId.make("async-project");
127 const requestId = ApprovalRequestId.make("codex-async:question-1");
128 try {
129 await system.run(
130 system.engine.dispatch({
131 type: "project.create",
132 commandId: CommandId.make("async-project"),
133 projectId,
134 title: "Async questions",
135 workspaceRoot: "/tmp/async-questions",
136 createdAt: now(),
137 }),
138 );
139 await system.run(
140 system.engine.dispatch({
141 type: "thread.create",
142 commandId: CommandId.make("async-thread"),
143 threadId,
144 projectId,
145 title: "Async questions",
146 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
147 runtimeMode: "full-access",
148 interactionMode: "default",
149 branch: null,
150 worktreePath: null,
151 createdAt: now(),
152 }),
153 );
154 await system.run(
155 system.engine.dispatch({
156 type: "thread.session.set",
157 commandId: CommandId.make("async-session"),
158 threadId,
159 createdAt: now(),
160 session: {
161 threadId,
162 status,
163 providerName: "codex",
164 runtimeMode: "full-access",
165 activeTurnId: status === "running" ? TurnId.make("turn-1") : null,
166 lastError: null,
167 updatedAt: now(),
168 },
169 }),
170 );
171 await system.run(
172 system.engine.dispatch({
173 type: "thread.activity.append",
174 commandId: CommandId.make("async-question"),
175 threadId,
176 createdAt: now(),
177 activity: {
178 id: EventId.make("async-question"),
179 kind: "user-input.requested",
180 summary: "User input requested",
181 tone: "info",
182 turnId: TurnId.make("turn-1"),
183 createdAt: now(),
184 payload: {
185 requestId,
186 responseMode: "message",
187 questions: [
188 {
189 id: "0",
190 header: "Question",
191 question: "Which package manager?",
192 options: [{ label: "pnpm", description: "" }],
193 },
194 {
195 id: "1",
196 header: "Question",
197 question: "What should it be named?",
198 options: [],
199 },
200 ],
201 },
202 },
203 }),
204 );
205 const appendWork = async (prefix: string, createdAt: string) => {
206 for (let index = 0; index < 501; index += 1) {
207 await system.run(
208 system.engine.dispatch({
209 type: "thread.activity.append",
210 commandId: CommandId.make(`${prefix}-${index}`),
211 threadId,
212 createdAt,
213 activity: {
214 id: EventId.make(`${prefix}-${index}`),
215 kind: "tool.completed",
216 summary: "Work continued",
217 payload: {},
218 tone: "info",
219 turnId: TurnId.make("turn-1"),
220 createdAt,
221 },
222 }),
223 );
224 }
225 };
226 await appendWork("work", "2026-01-01T00:00:01.000Z");
227 const before = await system.readModel();
228 expect(
229 before.threads[0]?.activities.some((activity) => activity.id === "async-question"),
230 ).toBe(true);
231 if (status === "stopped") {
232 await system.dispose();
233 system = await createOrchestrationSystem(databasePath);
234 }
235 const response = {
236 type: "thread.user-input.respond" as const,
237 commandId: CommandId.make("async-response"),
238 threadId,
239 requestId,
240 answers: { "0": "pnpm", "1": "Example" },
241 createdAt: "2026-01-01T00:00:02.000Z",
242 };
243 await expect(
244 system.run(
245 system.engine.dispatch({
246 ...response,
247 commandId: CommandId.make("incomplete-answer"),
248 answers: { "0": "pnpm" },
249 }),
250 ),
251 ).rejects.toThrow("Answer each question before sending.");
252 await system.run(system.engine.dispatch(response));
253 const after = await system.readModel();
254 const userMessages = after.threads[0]?.messages.filter(
255 (message) => message.role === "user",
256 );
257 expect(userMessages).toHaveLength(1);
258 expect(userMessages?.[0]?.text).toBe(
259 "Which package manager?\npnpm\n\nWhat should it be named?\nExample",
260 );
261 expect(
262 after.threads[0]?.activities.find((activity) => activity.kind === "user-input.resolved")
263 ?.payload,
264 ).toMatchObject({ requestId, responseMode: "message", answers: response.answers });
265 const events = await system.run(Stream.runCollect(system.engine.readEvents(0)));
266 expect(
267 Array.from(events)
268 .filter((event) => event.commandId === response.commandId)
269 .map((event) => event.type),
270 ).toEqual([
271 "thread.activity-appended",
272 "thread.message-sent",
273 "thread.turn-start-requested",
274 ]);
275 await expect(
276 system.run(
277 system.engine.dispatch({
278 ...response,
279 commandId: CommandId.make("second-client-reply"),
280 }),
281 ),
282 ).rejects.toThrow("This question has already been answered.");
283 await appendWork("later-work", "2026-01-01T00:00:03.000Z");
284 const afterEviction = Option.getOrThrow(await system.readThread(threadId));
285 expect(
286 afterEviction.activities.some((activity) => activity.kind === "user-input.resolved"),
287 ).toBe(false);
288 if (status === "stopped") {
289 await system.dispose();
290 system = await createOrchestrationSystem(databasePath);
291 }
292 await expect(
293 system.run(
294 system.engine.dispatch({
295 ...response,
296 commandId: CommandId.make("reply-after-eviction"),
297 }),
298 ),
299 ).rejects.toThrow("This question has already been answered.");
300 } finally {
301 await system.dispose();
302 await NodeFSP.rm(directory, { recursive: true, force: true });
303 }
304 },
305 );
306
102307 it("bootstraps command handling from persisted projections without reading the full snapshot", async () => {
103308 let nextSequence = 8;
104309 const eventStore: OrchestrationEventStoreShape = {
......@@ -183,6 +388,7 @@ describe("OrchestrationEngine", () => {
183388 const layer = OrchestrationEngineLive.pipe(
184389 Layer.provide(
185390 Layer.succeed(ProjectionSnapshotQuery, {
391 getUserInputActivity: () => Effect.die("unused"),
186392 getCommandReadModel: () => Effect.succeed(commandReadModel),
187393 getSnapshot: () =>
188394 Effect.sync(() => {
apps/server/src/orchestration/Layers/OrchestrationEngine.ts+9
......@@ -195,9 +195,18 @@ const makeOrchestrationEngine = Effect.gen(function* () {
195195 });
196196 }
197197
198 // Command snapshots omit activities at startup and cap them while running.
199 // Read this request's durable state before deciding how to send the answer.
200 const userInputActivity =
201 envelope.command.type === "thread.user-input.respond"
202 ? yield* projectionSnapshotQuery.getUserInputActivity(envelope.command)
203 : Option.none();
198204 const eventBase = yield* decideOrchestrationCommand({
199205 command: envelope.command,
200206 readModel: commandReadModel,
207 ...(Option.isSome(userInputActivity)
208 ? { userInputActivity: userInputActivity.value }
209 : {}),
201210 }).pipe(
202211 Effect.provideService(Crypto.Crypto, crypto),
203212 Effect.mapError((cause) =>
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+36
......@@ -1,4 +1,5 @@
11import {
2 ApprovalRequestId,
23 ChatAttachment,
34 CheckpointRef,
45 IsoDateTime,
......@@ -1119,6 +1120,40 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
11191120 `,
11201121 });
11211122
1123 const getUserInputActivityRow = SqlSchema.findOneOption({
1124 Request: Schema.Struct({ threadId: ThreadId, requestId: ApprovalRequestId }),
1125 Result: ProjectionThreadActivityDbRowSchema,
1126 execute: ({ threadId, requestId }) => sql`
1127 SELECT
1128 activity_id AS "activityId",
1129 thread_id AS "threadId",
1130 turn_id AS "turnId",
1131 tone,
1132 kind,
1133 summary,
1134 payload_json AS "payload",
1135 sequence,
1136 created_at AS "createdAt"
1137 FROM projection_thread_activities
1138 WHERE thread_id = ${threadId}
1139 AND kind IN ('user-input.requested', 'user-input.resolved')
1140 AND json_extract(payload_json, '$.requestId') = ${requestId}
1141 ORDER BY sequence DESC, created_at DESC, activity_id DESC
1142 LIMIT 1
1143 `,
1144 });
1145
1146 const getUserInputActivity: ProjectionSnapshotQueryShape["getUserInputActivity"] = (input) =>
1147 getUserInputActivityRow(input).pipe(
1148 Effect.map(Option.map(mapThreadActivityRow)),
1149 Effect.mapError(
1150 toPersistenceSqlOrDecodeError(
1151 "ProjectionSnapshotQuery.getUserInputActivity:query",
1152 "ProjectionSnapshotQuery.getUserInputActivity:decodeRow",
1153 ),
1154 ),
1155 );
1156
11221157 const listThreadActivityIdsByThread = SqlSchema.findAll({
11231158 Request: ThreadIdLookupInput,
11241159 Result: ProjectionThreadActivityIdRowSchema,
......@@ -3151,6 +3186,7 @@ pending_approval_requests AS (
31513186
31523187 return {
31533188 getCommandReadModel,
3189 getUserInputActivity,
31543190 getSnapshot,
31553191 getShellSnapshot,
31563192 getArchivedShellSnapshot,
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+50
......@@ -2108,6 +2108,56 @@ describe("ProviderRuntimeIngestion", () => {
21082108 expect(message?.streaming).toBe(false);
21092109 });
21102110
2111 it("keeps streaming while an async question is pending", async () => {
2112 const harness = await createHarness({ serverSettings: { enableLegacyTokenStreaming: true } });
2113 const base = {
2114 provider: ProviderDriverKind.make("codex"),
2115 createdAt: "2026-01-01T00:00:00.000Z",
2116 threadId: asThreadId("thread-1"),
2117 turnId: asTurnId("turn-async"),
2118 };
2119 harness.emit({ ...base, type: "turn.started", eventId: asEventId("async-start") });
2120 harness.emit({
2121 ...base,
2122 type: "content.delta",
2123 eventId: asEventId("async-before"),
2124 itemId: asItemId("message-1"),
2125 payload: { streamKind: "assistant_text", delta: "Before. " },
2126 });
2127 harness.emit({
2128 ...base,
2129 type: "user-input.requested",
2130 eventId: asEventId("async-request"),
2131 requestId: ApprovalRequestId.make("codex-async:question-1"),
2132 payload: {
2133 responseMode: "message",
2134 questions: [
2135 {
2136 id: "0",
2137 header: "Question",
2138 question: "Which name?",
2139 options: [],
2140 allowCustomAnswer: true,
2141 },
2142 ],
2143 },
2144 });
2145 harness.emit({
2146 ...base,
2147 type: "content.delta",
2148 eventId: asEventId("async-after"),
2149 itemId: asItemId("message-1"),
2150 payload: { streamKind: "assistant_text", delta: "After." },
2151 });
2152 await harness.drain();
2153 const thread = (await harness.readModel()).threads[0];
2154 expect(thread?.session?.status).toBe("running");
2155 expect(thread?.messages).toMatchObject([{ text: "Before. After.", streaming: true }]);
2156 expect(
2157 thread?.activities.find((activity) => activity.kind === "user-input.requested")?.payload,
2158 ).toMatchObject({ responseMode: "message", requestId: "codex-async:question-1" });
2159 });
2160
21112161 it("does not create assistant segments for whitespace-only buffered text at approval boundaries", async () => {
21122162 const harness = await createHarness();
21132163 const startedAt = "2026-03-28T06:28:00.000Z";
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+3-1
......@@ -520,6 +520,7 @@ export function runtimeEventToActivities(
520520 payload: {
521521 ...(event.requestId ? { requestId: event.requestId } : {}),
522522 questions: event.payload.questions,
523 ...(event.payload.responseMode ? { responseMode: event.payload.responseMode } : {}),
523524 },
524525 turnId: toTurnId(event.turnId) ?? null,
525526 ...maybeSequence,
......@@ -1739,7 +1740,8 @@ const make = Effect.gen(function* () {
17391740 }
17401741
17411742 const pauseForUserTurnId =
1742 event.type === "request.opened" || event.type === "user-input.requested"
1743 event.type === "request.opened" ||
1744 (event.type === "user-input.requested" && event.payload.responseMode !== "message")
17431745 ? toTurnId(event.turnId)
17441746 : undefined;
17451747 if (pauseForUserTurnId) {
apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts+8
......@@ -7,6 +7,7 @@
77 * @module ProjectionSnapshotQuery
88 */
99import type {
10 ApprovalRequestId,
1011 CheckpointRef,
1112 OrchestrationCheckpointSummary,
1213 OrchestrationProject,
......@@ -16,6 +17,7 @@ import type {
1617 OrchestrationSearchThreadsResult,
1718 OrchestrationShellSnapshot,
1819 OrchestrationThread,
20 OrchestrationThreadActivity,
1921 OrchestrationThreadDetailSnapshot,
2022 OrchestrationThreadDetailWindow,
2123 OrchestrationThreadShell,
......@@ -72,6 +74,12 @@ export interface ProjectionThreadDetailQuery {
7274 * ProjectionSnapshotQueryShape - Service API for read-model snapshots.
7375 */
7476export interface ProjectionSnapshotQueryShape {
77 /** Read the latest request or resolution without loading the thread history. */
78 readonly getUserInputActivity: (input: {
79 readonly threadId: ThreadId;
80 readonly requestId: ApprovalRequestId;
81 }) => Effect.Effect<Option.Option<OrchestrationThreadActivity>, ProjectionRepositoryError>;
82
7583 /**
7684 * Read the lightweight command snapshot used to bootstrap the in-memory
7785 * orchestration engine without hydrating message/activity/checkpoint bodies.
apps/server/src/orchestration/decider.ts+77-7
......@@ -1,13 +1,19 @@
11import {
22 EventId,
3 MessageId,
4 UserInputRequestedPayload,
35 type OrchestrationCommand,
46 type OrchestrationEvent,
57 type OrchestrationReadModel,
68 type OrchestrationThread,
9 type OrchestrationThreadActivity,
710} from "@t3tools/contracts";
811import * as DateTime from "effect/DateTime";
912import * as Crypto from "effect/Crypto";
1013import * as Effect from "effect/Effect";
14import * as Schema from "effect/Schema";
15import * as Option from "effect/Option";
16import * as Predicate from "effect/Predicate";
1117import type * as PlatformError from "effect/PlatformError";
1218
1319import {
......@@ -29,6 +35,7 @@ import { projectEvent } from "./projector.ts";
2935import { threadHasQueuedTurnStart } from "./ThreadSettlementPolicy.ts";
3036
3137const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
38const decodeUserInputRequestedPayload = Schema.decodeUnknownOption(UserInputRequestedPayload);
3239
3340/**
3441 * Blocked-on-you work derived from the thread's retained activities: an
......@@ -55,12 +62,8 @@ function isStaleRequestFailureDetail(payload: Record<string, unknown> | null): b
5562}
5663
5764// Scans the read model's activities, which the projector caps at the most
58// recent 500. That bound is safe here: an OPEN approval/user-input request
59// blocks its turn, so the thread cannot accumulate hundreds of later
60// activities while one is outstanding — a request that has scrolled out of
61// the window is one whose turn kept running, i.e. it was resolved or went
62// stale. (The projection pipeline's pendingApprovalCount reads the same
63// capped stream and stays consistent with this view.)
65// recent 500 plus pending async questions. Async questions remain actionable
66// while the agent works, so they must not expire with the activity window.
6467function hasOpenBlockingRequest(thread: {
6568 readonly activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>;
6669}): boolean {
......@@ -185,9 +188,11 @@ const decideCommandSequence = Effect.fn("decideCommandSequence")(function* ({
185188export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand")(function* ({
186189 command,
187190 readModel,
191 userInputActivity,
188192}: {
189193 readonly command: OrchestrationCommand;
190194 readonly readModel: OrchestrationReadModel;
195 readonly userInputActivity?: OrchestrationThreadActivity;
191196}): Effect.fn.Return<
192197 DecideOrchestrationCommandResult,
193198 OrchestrationCommandRejection | PlatformError.PlatformError,
......@@ -1061,11 +1066,76 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
10611066 }
10621067
10631068 case "thread.user-input.respond": {
1064 yield* requireThread({
1069 const thread = yield* requireThread({
10651070 readModel,
10661071 command,
10671072 threadId: command.threadId,
10681073 });
1074 const request = userInputActivity;
1075 if (
1076 request &&
1077 Predicate.isObject(request.payload) &&
1078 request.payload.responseMode === "message"
1079 ) {
1080 const payload = decodeUserInputRequestedPayload(request.payload);
1081 if (request.kind !== "user-input.requested" || Option.isNone(payload)) {
1082 return yield* new OrchestrationCommandInvariantError({
1083 commandType: command.type,
1084 detail: "This question has already been answered.",
1085 });
1086 }
1087 const replies: string[] = [];
1088 for (const question of payload.value.questions) {
1089 const answer = command.answers[question.id];
1090 if (typeof answer !== "string" || answer.trim().length === 0) {
1091 return yield* new OrchestrationCommandInvariantError({
1092 commandType: command.type,
1093 detail: "Answer each question before sending.",
1094 });
1095 }
1096 replies.push(`${question.question}\n${answer.trim()}`);
1097 }
1098 // Commit the answer and its message together. The normal turn path
1099 // steers a running agent or resumes an idle session.
1100 return yield* decideCommandSequence({
1101 readModel,
1102 commands: [
1103 {
1104 type: "thread.activity.append",
1105 commandId: command.commandId,
1106 threadId: command.threadId,
1107 createdAt: command.createdAt,
1108 activity: {
1109 id: EventId.make(`async-answer:${command.requestId}`),
1110 kind: "user-input.resolved",
1111 summary: "User input submitted",
1112 tone: "info",
1113 turnId: request.turnId,
1114 createdAt: command.createdAt,
1115 payload: {
1116 requestId: command.requestId,
1117 responseMode: "message",
1118 answers: command.answers,
1119 },
1120 },
1121 },
1122 {
1123 type: "thread.turn.start",
1124 commandId: command.commandId,
1125 threadId: command.threadId,
1126 createdAt: command.createdAt,
1127 runtimeMode: thread.runtimeMode,
1128 interactionMode: thread.interactionMode,
1129 message: {
1130 messageId: MessageId.make(`async-answer:${command.requestId}`),
1131 role: "user",
1132 text: replies.join("\n\n"),
1133 attachments: [],
1134 },
1135 },
1136 ],
1137 });
1138 }
10691139 return {
10701140 ...(yield* withEventBase({
10711141 aggregateKind: "thread",
apps/server/src/orchestration/projector.ts+29-6
......@@ -7,6 +7,7 @@ import {
77} from "@t3tools/contracts";
88import * as Effect from "effect/Effect";
99import * as Schema from "effect/Schema";
10import * as Predicate from "effect/Predicate";
1011
1112import { toProjectorDecodeError, type OrchestrationProjectorDecodeError } from "./Errors.ts";
1213import {
......@@ -39,6 +40,28 @@ type ThreadPatch = Partial<Omit<OrchestrationThread, "id" | "projectId">>;
3940const MAX_THREAD_MESSAGES = 2_000;
4041const MAX_THREAD_CHECKPOINTS = 500;
4142
43// Async questions can stay open while the agent produces more activity.
44// Match the database snapshot's pending-question retention.
45function retainThreadActivities(activities: OrchestrationThread["activities"]) {
46 const recentStart = activities.length - 500;
47 if (recentStart <= 0) return activities;
48 const pending = new Map<string, OrchestrationThread["activities"][number]>();
49 for (const activity of activities) {
50 if (!Predicate.isObject(activity.payload)) continue;
51 const requestId = activity.payload.requestId;
52 if (typeof requestId !== "string") continue;
53 if (activity.kind === "user-input.requested" && activity.payload.responseMode === "message") {
54 pending.set(requestId, activity);
55 } else if (activity.kind === "user-input.resolved") {
56 pending.delete(requestId);
57 }
58 }
59 const pendingActivities = new Set(pending.values());
60 return activities.filter(
61 (activity, index) => index >= recentStart || pendingActivities.has(activity),
62 );
63}
64
4265function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") {
4366 if (status === "error") return "error" as const;
4467 if (status === "missing") return "interrupted" as const;
......@@ -804,12 +827,12 @@ export function projectEvent(
804827 return nextBase;
805828 }
806829
807 const activities = [
808 ...thread.activities.filter((entry) => entry.id !== payload.activity.id),
809 payload.activity,
810 ]
811 .toSorted(compareThreadActivities)
812 .slice(-500);
830 const activities = retainThreadActivities(
831 [
832 ...thread.activities.filter((entry) => entry.id !== payload.activity.id),
833 payload.activity,
834 ].toSorted(compareThreadActivities),
835 );
813836
814837 return {
815838 ...nextBase,
apps/server/src/project/ProjectSetupScriptRunner.test.ts+1
......@@ -26,6 +26,7 @@ const makeProject = (scripts: OrchestrationProject["scripts"]): OrchestrationPro
2626
2727const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) =>
2828 Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, {
29 getUserInputActivity: () => Effect.die("unused"),
2930 getCommandReadModel: () => Effect.die("unused"),
3031 getSnapshot: () => Effect.die("unused"),
3132 getShellSnapshot: () => Effect.die("unused"),
apps/server/src/provider/Layers/CodexAdapter.test.ts+75
......@@ -1648,6 +1648,81 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
16481648 }),
16491649 );
16501650
1651 it.effect("maps async agent questions without ending the turn", () =>
1652 Effect.gen(function* () {
1653 const { adapter, runtime } = yield* startLifecycleRuntime();
1654 const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 2)).pipe(
1655 Effect.forkChild,
1656 );
1657 yield* runtime.emit({
1658 id: asEventId("evt-async-question"),
1659 kind: "notification",
1660 provider: ProviderDriverKind.make("codex"),
1661 threadId: asThreadId("thread-1"),
1662 createdAt: "2026-01-01T00:00:00.000Z",
1663 method: "item/completed",
1664 payload: {
1665 completedAtMs: 0,
1666 threadId: "thread-1",
1667 turnId: "turn-1",
1668 item: {
1669 type: "agentMessage",
1670 id: "async-question-1",
1671 text: "Which package manager?\n- pnpm\n- npm\n\nWhat should it be named?",
1672 phase: "final_answer",
1673 delivery: "async",
1674 questions: [
1675 { title: "Which package manager?", options: ["pnpm", "npm"] },
1676 { title: "What should it be named?" },
1677 ],
1678 },
1679 },
1680 });
1681 yield* runtime.emit({
1682 id: asEventId("evt-async-continued"),
1683 kind: "notification",
1684 provider: ProviderDriverKind.make("codex"),
1685 threadId: asThreadId("thread-1"),
1686 createdAt: "2026-01-01T00:00:01.000Z",
1687 method: "item/agentMessage/delta",
1688 payload: {
1689 threadId: "thread-1",
1690 turnId: "turn-1",
1691 itemId: "message-2",
1692 delta: "I will keep working.",
1693 },
1694 });
1695 const events = Array.from(yield* Fiber.join(eventsFiber));
1696 NodeAssert.equal(events[0]?.type, "user-input.requested");
1697 NodeAssert.equal(events[0]?.requestId, "codex-async:thread-1:async-question-1");
1698 NodeAssert.deepEqual(events[0]?.payload, {
1699 responseMode: "message",
1700 questions: [
1701 {
1702 id: "0",
1703 header: "Question",
1704 question: "Which package manager?",
1705 options: [
1706 { label: "pnpm", description: "" },
1707 { label: "npm", description: "" },
1708 ],
1709 allowCustomAnswer: true,
1710 multiSelect: false,
1711 },
1712 {
1713 id: "1",
1714 header: "Question",
1715 question: "What should it be named?",
1716 options: [],
1717 allowCustomAnswer: true,
1718 multiSelect: false,
1719 },
1720 ],
1721 });
1722 NodeAssert.equal(events[1]?.type, "content.delta");
1723 }),
1724 );
1725
16511726 it.effect("unwraps Codex token usage payloads for context window events", () =>
16521727 Effect.gen(function* () {
16531728 const { adapter, runtime } = yield* startLifecycleRuntime();
apps/server/src/provider/Layers/CodexAdapter.ts+22
......@@ -8,6 +8,7 @@
88 * @module CodexAdapterLive
99 */
1010import {
11 EventId,
1112 type CanonicalItemType,
1213 type CanonicalRequestType,
1314 type CodexSettings,
......@@ -1445,6 +1446,27 @@ function mapToRuntimeEvents(
14451446 if (!item) {
14461447 return [];
14471448 }
1449 if (item.type === "agentMessage" && item.delivery === "async" && item.questions?.length) {
1450 return [
1451 {
1452 ...runtimeEventBase(event, canonicalThreadId),
1453 type: "user-input.requested",
1454 requestId: RuntimeRequestId.make(`codex-async:${canonicalThreadId}:${item.id}`),
1455 eventId: EventId.make(`codex-async:${canonicalThreadId}:${item.id}`),
1456 payload: {
1457 responseMode: "message",
1458 questions: item.questions.map((question, index) => ({
1459 id: String(index),
1460 header: "Question",
1461 question: question.title,
1462 options: (question.options ?? []).map((label) => ({ label, description: "" })),
1463 allowCustomAnswer: true,
1464 multiSelect: false,
1465 })),
1466 },
1467 },
1468 ];
1469 }
14481470 const itemType = toCanonicalItemType(item.type);
14491471 if (itemType === "plan") {
14501472 const detail = itemDetail(itemType, item);
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+1
......@@ -204,6 +204,7 @@ describe("ProviderSessionReaper", () => {
204204 Layer.provideMerge(Layer.succeed(ProviderService, providerService)),
205205 Layer.provideMerge(
206206 Layer.succeed(ProjectionSnapshotQuery, {
207 getUserInputActivity: () => Effect.die("unused"),
207208 getCommandReadModel: () => Effect.die("unused"),
208209 getSnapshot: () => Effect.die("unused"),
209210 getShellSnapshot: () => Effect.die("unused"),
apps/server/src/server.test.ts+1
......@@ -926,6 +926,7 @@ const buildAppUnderTest = (options?: {
926926 ),
927927 Layer.provide(
928928 Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({
929 getUserInputActivity: () => Effect.die("unused"),
929930 getCommandReadModel: () => Effect.succeed(makeDefaultOrchestrationReadModel()),
930931 getSnapshot: () => Effect.succeed(makeDefaultOrchestrationReadModel()),
931932 getShellSnapshot: () =>
apps/server/src/serverRuntimeStartup.reconcile.test.ts+2
......@@ -66,6 +66,7 @@ const makeProviderService = (liveThreadIds: ReadonlyArray<ThreadId> = []) =>
6666
6767const queryWithThreads = (threads: ReadonlyArray<ReturnType<typeof makeThread>>) =>
6868 ({
69 getUserInputActivity: () => Effect.die("unused"),
6970 getCommandReadModel: () => Effect.succeed({ threads } as never),
7071 }) as unknown as ProjectionSnapshotQuery.ProjectionSnapshotQuery["Service"];
7172
......@@ -631,6 +632,7 @@ it.effect("does not fail startup when the live provider session inventory cannot
631632 let queried = false;
632633 return ServerRuntimeStartup.reconcileProviderSessions.pipe(
633634 Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, {
635 getUserInputActivity: () => Effect.die("unused"),
634636 getCommandReadModel: () =>
635637 Effect.sync(() => {
636638 queried = true;
apps/server/src/serverRuntimeStartup.test.ts+4
......@@ -118,6 +118,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa
118118
119119 yield* ServerRuntimeStartup.launchStartupHeartbeat.pipe(
120120 Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, {
121 getUserInputActivity: () => Effect.die("unused"),
121122 getCommandReadModel: () => Effect.die("unused"),
122123 getSnapshot: () => Effect.die("unused"),
123124 getShellSnapshot: () => Effect.die("unused"),
......@@ -185,6 +186,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa
185186 autoBootstrapProjectFromCwd: true,
186187 } as never),
187188 Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, {
189 getUserInputActivity: () => Effect.die("unused"),
188190 getCommandReadModel: () => Effect.die("unused"),
189191 getSnapshot: () => Effect.die("unused"),
190192 getShellSnapshot: () => Effect.die("unused"),
......@@ -250,6 +252,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when
250252 autoBootstrapProjectFromCwd: true,
251253 } as never),
252254 Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, {
255 getUserInputActivity: () => Effect.die("unused"),
253256 getCommandReadModel: () => Effect.die("unused"),
254257 getSnapshot: () => Effect.die("unused"),
255258 getShellSnapshot: () => Effect.die("unused"),
......@@ -312,6 +315,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa
312315 autoBootstrapProjectFromCwd: true,
313316 } as never),
314317 Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, {
318 getUserInputActivity: () => Effect.die("unused"),
315319 getCommandReadModel: () => Effect.die("unused"),
316320 getSnapshot: () => Effect.die("unused"),
317321 getShellSnapshot: () => Effect.die("unused"),
apps/web/src/session-logic.test.ts+20
......@@ -250,6 +250,26 @@ describe("derivePendingApprovals", () => {
250250});
251251
252252describe("derivePendingUserInputs", () => {
253 it("keeps free-text questions without suggested answers", () => {
254 const question = {
255 id: "0",
256 header: "Question",
257 question: "What should it be named?",
258 options: [],
259 allowCustomAnswer: true,
260 multiSelect: false,
261 };
262 const activities = [
263 makeActivity({
264 id: "async-question",
265 kind: "user-input.requested",
266 summary: "User input requested",
267 payload: { requestId: "async-1", responseMode: "message", questions: [question] },
268 }),
269 ];
270 expect(derivePendingUserInputs(activities)[0]?.questions).toEqual([question]);
271 });
272
253273 it("preserves native choice values and the custom-answer restriction", () => {
254274 const question = {
255275 id: "interaction-result",
apps/web/src/session-logic.ts+1-1
......@@ -532,7 +532,7 @@ function parseUserInputQuestions(
532532 };
533533 })
534534 .filter((option): option is UserInputQuestion["options"][number] => option !== null);
535 if (options.length === 0) {
535 if (options.length === 0 && question.allowCustomAnswer === false) {
536536 return null;
537537 }
538538 return {
docs/internals/providers.md+21
......@@ -24,6 +24,27 @@ adapter in a child scope. Adapter implementations live beside them in
2424[`ProviderAdapter.ts`][adapter]. Read the driver plus its adapter to see how a specific agent's
2525transport, config, and event shapes are mapped.
2626
27## Codex async questions
28
29Codex 0.153 exposes `request_user_input_async` through `item/started` and `item/completed`
30notifications. The item has `type: "agentMessage"`, `delivery: "async"`, and a `questions` array.
31Each question has a `title` and an optional `options` array of strings. The tool returns `{"accepted":true}`
32without waiting. This is separate from the `item/tool/requestUserInput` server request.
33See the [Codex tool handler](https://github.com/openai/codex/blob/d979df154cf60e13eafb5453e75b6d84f21c67bf/codex-rs/core/src/tools/handlers/request_user_input_async.rs).
34
35The Codex adapter maps completed question items to `user-input.requested` with
36`responseMode: "message"` and stable request and event IDs. Questions use the existing web,
37desktop, and mobile panels. They stay pending while the turn runs and after it finishes.
38
39The engine reads the request's latest stored activity before deciding a reply. This works after
40startup, when the command snapshot has no activities, and after a resolution leaves the recent
41activity window. The query returns one activity, not the full thread history.
42
43For these requests, the decider saves the resolution and a user message in one transaction.
44The standard turn path delivers the message, including session resume and active-turn input.
45It does not send a JSON-RPC response to Codex. Other providers and blocking Codex questions
46keep their existing response paths.
47
2748## Registry and routing
2849
2950Two registries separate configuration from live processes:
docs/user/providers-codex.md+9
......@@ -34,6 +34,15 @@ In an existing Codex thread, send `/feedback` or `/feedback` followed by a descr
3434issue. T3 Code uploads the thread and Codex logs to OpenAI and shows a thread ID that you can copy
3535and share with OpenAI employees.
3636
37## Answer questions while Codex works
38
39Codex can ask questions without stopping its work. Choose a suggested answer or enter your own
40in the question panel. Questions without suggested answers accept text.
41
42Your answers are sent as a new message. They reach the current turn while Codex is working, or
43start a new turn if it has finished. Unanswered questions stay available after you reconnect.
44This works in the web, desktop, and mobile apps. Codex must support async questions.
45
3746## Sub-agent models
3847
3948The web and desktop Agents panel shows each sub-agent's model and reasoning effort when Codex
packages/contracts/src/providerRuntime.ts+3-2
......@@ -491,7 +491,7 @@ export type RequestResolvedPayload = typeof RequestResolvedPayload.Type;
491491
492492const UserInputQuestionOption = Schema.Struct({
493493 label: TrimmedNonEmptyStringSchema,
494 description: TrimmedNonEmptyStringSchema,
494 description: Schema.String,
495495 value: Schema.optional(Schema.String),
496496});
497497export type UserInputQuestionOption = typeof UserInputQuestionOption.Type;
......@@ -508,8 +508,9 @@ export const UserInputQuestion = Schema.Struct({
508508});
509509export type UserInputQuestion = typeof UserInputQuestion.Type;
510510
511const UserInputRequestedPayload = Schema.Struct({
511export const UserInputRequestedPayload = Schema.Struct({
512512 questions: Schema.Array(UserInputQuestion),
513 responseMode: Schema.optional(Schema.Literal("message")),
513514});
514515export type UserInputRequestedPayload = typeof UserInputRequestedPayload.Type;
515516
packages/effect-codex-app-server/scripts/generate.ts+55-1
......@@ -330,6 +330,60 @@ function stripNullDefaults(value: Schema.Json): Schema.Json {
330330 ) as Schema.Json;
331331}
332332
333// Codex 0.153 adds async questions to agent messages. Keep older protocol
334// fields until the next full refresh, including every thread history namespace.
335function addAsyncQuestionFields(value: Schema.Json): Schema.Json {
336 if (Array.isArray(value)) {
337 return value.map(addAsyncQuestionFields);
338 }
339 if (value === null || typeof value !== "object") {
340 return value;
341 }
342 const properties = "properties" in value ? value.properties : undefined;
343 const itemType =
344 properties && typeof properties === "object" && "type" in properties
345 ? properties.type
346 : undefined;
347 if (
348 properties &&
349 typeof properties === "object" &&
350 itemType &&
351 typeof itemType === "object" &&
352 "enum" in itemType &&
353 Array.isArray(itemType.enum) &&
354 itemType.enum.includes("agentMessage")
355 ) {
356 return {
357 ...value,
358 properties: {
359 ...properties,
360 delivery: { anyOf: [{ type: "string", enum: ["async"] }, { type: "null" }] },
361 questions: {
362 anyOf: [
363 {
364 type: "array",
365 items: {
366 type: "object",
367 properties: {
368 title: { type: "string" },
369 options: {
370 anyOf: [{ type: "array", items: { type: "string" } }, { type: "null" }],
371 },
372 },
373 required: ["title"],
374 },
375 },
376 { type: "null" },
377 ],
378 },
379 },
380 };
381 }
382 return Object.fromEntries(
383 Object.entries(value).map(([key, child]) => [key, addAsyncQuestionFields(child)]),
384 );
385}
386
333387function toPascalCaseMethod(method: string) {
334388 return method
335389 .split("/")
......@@ -648,7 +702,7 @@ const generateFiles = Effect.fn("generateFiles")(function* () {
648702 for (const [name, schema] of Object.entries(aggregateSchemas).toSorted(([left], [right]) =>
649703 left.localeCompare(right),
650704 )) {
651 generator.addSchema(name, schema as never);
705 generator.addSchema(name, addAsyncQuestionFields(schema) as never);
652706 }
653707
654708 const generatedEntries = new Map<string, string>();
packages/effect-codex-app-server/src/_generated/schema.gen.ts+272
......@@ -20405,6 +20405,11 @@ export type ServerNotification__ThreadItem =
2040520405 readonly memoryCitation?: ServerNotification__MemoryCitation | null;
2040620406 readonly phase?: ServerNotification__MessagePhase | null;
2040720407 readonly text: string;
20408 readonly delivery?: "async" | null;
20409 readonly questions?: ReadonlyArray<{
20410 readonly title: string;
20411 readonly options?: ReadonlyArray<string> | null;
20412 }> | null;
2040820413 readonly type: "agentMessage";
2040920414 }
2041020415 | { readonly id: string; readonly text: string; readonly type: "plan" }
......@@ -20530,6 +20535,18 @@ export const ServerNotification__ThreadItem = Schema.Union(
2053020535 ),
2053120536 phase: Schema.optionalKey(Schema.Union([ServerNotification__MessagePhase, Schema.Null])),
2053220537 text: Schema.String,
20538 delivery: Schema.optionalKey(Schema.Union([Schema.Literal("async"), Schema.Null])),
20539 questions: Schema.optionalKey(
20540 Schema.Union([
20541 Schema.Array(
20542 Schema.Struct({
20543 title: Schema.String,
20544 options: Schema.optionalKey(Schema.Union([Schema.Array(Schema.String), Schema.Null])),
20545 }),
20546 ),
20547 Schema.Null,
20548 ]),
20549 ),
2053320550 type: Schema.Literal("agentMessage").annotate({ title: "AgentMessageThreadItemType" }),
2053420551 }).annotate({ title: "AgentMessageThreadItem" }),
2053520552 Schema.Struct({
......@@ -21499,6 +21516,11 @@ export type V2ItemCompletedNotification__ThreadItem =
2149921516 readonly memoryCitation?: V2ItemCompletedNotification__MemoryCitation | null;
2150021517 readonly phase?: V2ItemCompletedNotification__MessagePhase | null;
2150121518 readonly text: string;
21519 readonly delivery?: "async" | null;
21520 readonly questions?: ReadonlyArray<{
21521 readonly title: string;
21522 readonly options?: ReadonlyArray<string> | null;
21523 }> | null;
2150221524 readonly type: "agentMessage";
2150321525 }
2150421526 | { readonly id: string; readonly text: string; readonly type: "plan" }
......@@ -21628,6 +21650,18 @@ export const V2ItemCompletedNotification__ThreadItem = Schema.Union(
2162821650 Schema.Union([V2ItemCompletedNotification__MessagePhase, Schema.Null]),
2162921651 ),
2163021652 text: Schema.String,
21653 delivery: Schema.optionalKey(Schema.Union([Schema.Literal("async"), Schema.Null])),
21654 questions: Schema.optionalKey(
21655 Schema.Union([
21656 Schema.Array(
21657 Schema.Struct({
21658 title: Schema.String,
21659 options: Schema.optionalKey(Schema.Union([Schema.Array(Schema.String), Schema.Null])),
21660 }),
21661 ),
21662 Schema.Null,
21663 ]),
21664 ),
2163121665 type: Schema.Literal("agentMessage").annotate({ title: "AgentMessageThreadItemType" }),
2163221666 }).annotate({ title: "AgentMessageThreadItem" }),
2163321667 Schema.Struct({
......@@ -21965,6 +21999,11 @@ export type V2ItemStartedNotification__ThreadItem =
2196521999 readonly memoryCitation?: V2ItemStartedNotification__MemoryCitation | null;
2196622000 readonly phase?: V2ItemStartedNotification__MessagePhase | null;
2196722001 readonly text: string;
22002 readonly delivery?: "async" | null;
22003 readonly questions?: ReadonlyArray<{
22004 readonly title: string;
22005 readonly options?: ReadonlyArray<string> | null;
22006 }> | null;
2196822007 readonly type: "agentMessage";
2196922008 }
2197022009 | { readonly id: string; readonly text: string; readonly type: "plan" }
......@@ -22092,6 +22131,18 @@ export const V2ItemStartedNotification__ThreadItem = Schema.Union(
2209222131 Schema.Union([V2ItemStartedNotification__MessagePhase, Schema.Null]),
2209322132 ),
2209422133 text: Schema.String,
22134 delivery: Schema.optionalKey(Schema.Union([Schema.Literal("async"), Schema.Null])),
22135 questions: Schema.optionalKey(
22136 Schema.Union([
22137 Schema.Array(
22138 Schema.Struct({
22139 title: Schema.String,
22140 options: Schema.optionalKey(Schema.Union([Schema.Array(Schema.String), Schema.Null])),
22141 }),
22142 ),
22143 Schema.Null,
22144 ]),
22145 ),
2209522146 type: Schema.Literal("agentMessage").annotate({ title: "AgentMessageThreadItemType" }),
2209622147 }).annotate({ title: "AgentMessageThreadItem" }),
2209722148 Schema.Struct({
......@@ -22601,6 +22652,11 @@ export type V2ReviewStartResponse__ThreadItem =
2260122652 readonly memoryCitation?: V2ReviewStartResponse__MemoryCitation | null;
2260222653 readonly phase?: V2ReviewStartResponse__MessagePhase | null;
2260322654 readonly text: string;
22655 readonly delivery?: "async" | null;
22656 readonly questions?: ReadonlyArray<{
22657 readonly title: string;
22658 readonly options?: ReadonlyArray<string> | null;
22659 }> | null;
2260422660 readonly type: "agentMessage";
2260522661 }
2260622662 | { readonly id: string; readonly text: string; readonly type: "plan" }
......@@ -22726,6 +22782,18 @@ export const V2ReviewStartResponse__ThreadItem = Schema.Union(
2272622782 ),
2272722783 phase: Schema.optionalKey(Schema.Union([V2ReviewStartResponse__MessagePhase, Schema.Null])),
2272822784 text: Schema.String,
22785 delivery: Schema.optionalKey(Schema.Union([Schema.Literal("async"), Schema.Null])),
22786 questions: Schema.optionalKey(
22787 Schema.Union([
22788 Schema.Array(
22789 Schema.Struct({
22790 title: Schema.String,
22791 options: Schema.optionalKey(Schema.Union([Schema.Array(Schema.String), Schema.Null])),
22792 }),
22793 ),
22794 Schema.Null,
22795 ]),
22796 ),
2272922797 type: Schema.Literal("agentMessage").annotate({ title: "AgentMessageThreadItemType" }),
2273022798 }).annotate({ title: "AgentMessageThreadItem" }),
2273122799 Schema.Struct({
......@@ -23050,6 +23118,11 @@ export type V2ThreadForkResponse__ThreadItem =
2305023118 readonly memoryCitation?: V2ThreadForkResponse__MemoryCitation | null;
2305123119 readonly phase?: V2ThreadForkResponse__MessagePhase | null;
2305223120 readonly text: string;
23121 readonly delivery?: "async" | null;
23122 readonly questions?: ReadonlyArray<{
23123 readonly title: string;
23124 readonly options?: ReadonlyArray<string> | null;
23125 }> | null;
2305323126 readonly type: "agentMessage";
2305423127 }
2305523128 | { readonly id: string; readonly text: string; readonly type: "plan" }
......@@ -23175,6 +23248,18 @@ export const V2ThreadForkResponse__ThreadItem = Schema.Union(
2317523248 ),
2317623249 phase: Schema.optionalKey(Schema.Union([V2ThreadForkResponse__MessagePhase, Schema.Null])),
2317723250 text: Schema.String,
23251 delivery: Schema.optionalKey(Schema.Union([Schema.Literal("async"), Schema.Null])),
23252 questions: Schema.optionalKey(
23253 Schema.Union([
23254 Schema.Array(
23255 Schema.Struct({
23256 title: Schema.String,
23257 options: Schema.optionalKey(Schema.Union([Schema.Array(Schema.String), Schema.Null])),
23258 }),
23259 ),
23260 Schema.Null,
23261 ]),
23262 ),
2317823263 type: Schema.Literal("agentMessage").annotate({ title: "AgentMessageThreadItemType" }),
2317923264 }).annotate({ title: "AgentMessageThreadItem" }),
2318023265 Schema.Struct({
......@@ -23468,6 +23553,11 @@ export type V2ThreadListResponse__ThreadItem =
2346823553 readonly memoryCitation?: V2ThreadListResponse__MemoryCitation | null;
2346923554 readonly phase?: V2ThreadListResponse__MessagePhase | null;
2347023555 readonly text: string;
23556 readonly delivery?: "async" | null;
23557 readonly questions?: ReadonlyArray<{
23558 readonly title: string;
23559 readonly options?: ReadonlyArray<string> | null;
23560 }> | null;
2347123561 readonly type: "agentMessage";
2347223562 }
2347323563 | { readonly id: string; readonly text: string; readonly type: "plan" }
......@@ -23593,6 +23683,18 @@ export const V2ThreadListResponse__ThreadItem = Schema.Union(
2359323683 ),
2359423684 phase: Schema.optionalKey(Schema.Union([V2ThreadListResponse__MessagePhase, Schema.Null])),
2359523685 text: Schema.String,
23686 delivery: Schema.optionalKey(Schema.Union([Schema.Literal("async"), Schema.Null])),
23687 questions: Schema.optionalKey(
23688 Schema.Union([
23689 Schema.Array(
23690 Schema.Struct({
23691 title: Schema.String,
23692 options: Schema.optionalKey(Schema.Union([Schema.Array(Schema.String), Schema.Null])),
23693 }),
23694 ),
23695 Schema.Null,
23696 ]),
23697 ),
2359623698 type: Schema.Literal("agentMessage").annotate({ title: "AgentMessageThreadItemType" }),
2359723699 }).annotate({ title: "AgentMessageThreadItem" }),
2359823700 Schema.Struct({
......@@ -23886,6 +23988,11 @@ export type V2ThreadMetadataUpdateResponse__ThreadItem =
2388623988 readonly memoryCitation?: V2ThreadMetadataUpdateResponse__MemoryCitation | null;
2388723989 readonly phase?: V2ThreadMetadataUpdateResponse__MessagePhase | null;
2388823990 readonly text: string;
23991 readonly delivery?: "async" | null;
23992 readonly questions?: ReadonlyArray<{
23993 readonly title: string;
23994 readonly options?: ReadonlyArray<string> | null;
23995 }> | null;
2388923996 readonly type: "agentMessage";
2389023997 }
2389123998 | { readonly id: string; readonly text: string; readonly type: "plan" }
......@@ -24015,6 +24122,18 @@ export const V2ThreadMetadataUpdateResponse__ThreadItem = Schema.Union(
2401524122 Schema.Union([V2ThreadMetadataUpdateResponse__MessagePhase, Schema.Null]),
2401624123 ),
2401724124 text: Schema.String,
24125 delivery: Schema.optionalKey(Schema.Union([Schema.Literal("async"), Schema.Null])),
24126 questions: Schema.optionalKey(
24127 Schema.Union([
24128 Schema.Array(
24129 Schema.Struct({
24130 title: Schema.String,
24131 options: Schema.optionalKey(Schema.Union([Schema.Array(Schema.String), Schema.Null])),
24132 }),
24133 ),
24134 Schema.Null,
24135 ]),
24136 ),
2401824137 type: Schema.Literal("agentMessage").annotate({ title: "AgentMessageThreadItemType" }),
2401924138 }).annotate({ title: "AgentMessageThreadItem" }),
2402024139 Schema.Struct({
......@@ -24309,6 +24428,11 @@ export type V2ThreadReadResponse__ThreadItem =
2430924428 readonly memoryCitation?: V2ThreadReadResponse__MemoryCitation | null;
2431024429 readonly phase?: V2ThreadReadResponse__MessagePhase | null;
2431124430 readonly text: string;
24431 readonly delivery?: "async" | null;
24432 readonly questions?: ReadonlyArray<{
24433 readonly title: string;
24434 readonly options?: ReadonlyArray<string> | null;
24435 }> | null;
2431224436 readonly type: "agentMessage";
2431324437 }
2431424438 | { readonly id: string; readonly text: string; readonly type: "plan" }
......@@ -24434,6 +24558,18 @@ export const V2ThreadReadResponse__ThreadItem = Schema.Union(
2443424558 ),
2443524559 phase: Schema.optionalKey(Schema.Union([V2ThreadReadResponse__MessagePhase, Schema.Null])),
2443624560 text: Schema.String,
24561 delivery: Schema.optionalKey(Schema.Union([Schema.Literal("async"), Schema.Null])),
24562 questions: Schema.optionalKey(
24563 Schema.Union([
24564 Schema.Array(
24565 Schema.Struct({
24566 title: Schema.String,
24567 options: Schema.optionalKey(Schema.Union([Schema.Array(Schema.String), Schema.Null])),
24568 }),
24569 ),
24570 Schema.Null,
24571 ]),
24572 ),
2443724573 type: Schema.Literal("agentMessage").annotate({ title: "AgentMessageThreadItemType" }),
2443824574 }).annotate({ title: "AgentMessageThreadItem" }),
2443924575 Schema.Struct({
......@@ -24735,6 +24871,11 @@ export type V2ThreadResumeResponse__ThreadItem =
2473524871 readonly memoryCitation?: V2ThreadResumeResponse__MemoryCitation | null;
2473624872 readonly phase?: V2ThreadResumeResponse__MessagePhase | null;
2473724873 readonly text: string;
24874 readonly delivery?: "async" | null;
24875 readonly questions?: ReadonlyArray<{
24876 readonly title: string;
24877 readonly options?: ReadonlyArray<string> | null;
24878 }> | null;
2473824879 readonly type: "agentMessage";
2473924880 }
2474024881 | { readonly id: string; readonly text: string; readonly type: "plan" }
......@@ -24860,6 +25001,18 @@ export const V2ThreadResumeResponse__ThreadItem = Schema.Union(
2486025001 ),
2486125002 phase: Schema.optionalKey(Schema.Union([V2ThreadResumeResponse__MessagePhase, Schema.Null])),
2486225003 text: Schema.String,
25004 delivery: Schema.optionalKey(Schema.Union([Schema.Literal("async"), Schema.Null])),
25005 questions: Schema.optionalKey(
25006 Schema.Union([
25007 Schema.Array(
25008 Schema.Struct({
25009 title: Schema.String,
25010 options: Schema.optionalKey(Schema.Union([Schema.Array(Schema.String), Schema.Null])),
25011 }),
25012 ),
25013 Schema.Null,
25014 ]),
25015 ),
2486325016 type: Schema.Literal("agentMessage").annotate({ title: "AgentMessageThreadItemType" }),
2486425017 }).annotate({ title: "AgentMessageThreadItem" }),
2486525018 Schema.Struct({
......@@ -25153,6 +25306,11 @@ export type V2ThreadRollbackResponse__ThreadItem =
2515325306 readonly memoryCitation?: V2ThreadRollbackResponse__MemoryCitation | null;
2515425307 readonly phase?: V2ThreadRollbackResponse__MessagePhase | null;
2515525308 readonly text: string;
25309 readonly delivery?: "async" | null;
25310 readonly questions?: ReadonlyArray<{
25311 readonly title: string;
25312 readonly options?: ReadonlyArray<string> | null;
25313 }> | null;
2515625314 readonly type: "agentMessage";
2515725315 }
2515825316 | { readonly id: string; readonly text: string; readonly type: "plan" }
......@@ -25280,6 +25438,18 @@ export const V2ThreadRollbackResponse__ThreadItem = Schema.Union(
2528025438 Schema.Union([V2ThreadRollbackResponse__MessagePhase, Schema.Null]),
2528125439 ),
2528225440 text: Schema.String,
25441 delivery: Schema.optionalKey(Schema.Union([Schema.Literal("async"), Schema.Null])),
25442 questions: Schema.optionalKey(
25443 Schema.Union([
25444 Schema.Array(
25445 Schema.Struct({
25446 title: Schema.String,
25447 options: Schema.optionalKey(Schema.Union([Schema.Array(Schema.String), Schema.Null])),
25448 }),
25449 ),
25450 Schema.Null,
25451 ]),
25452 ),
2528325453 type: Schema.Literal("agentMessage").annotate({ title: "AgentMessageThreadItemType" }),
2528425454 }).annotate({ title: "AgentMessageThreadItem" }),
2528525455 Schema.Struct({
......@@ -25583,6 +25753,11 @@ export type V2ThreadStartedNotification__ThreadItem =
2558325753 readonly memoryCitation?: V2ThreadStartedNotification__MemoryCitation | null;
2558425754 readonly phase?: V2ThreadStartedNotification__MessagePhase | null;
2558525755 readonly text: string;
25756 readonly delivery?: "async" | null;
25757 readonly questions?: ReadonlyArray<{
25758 readonly title: string;
25759 readonly options?: ReadonlyArray<string> | null;
25760 }> | null;
2558625761 readonly type: "agentMessage";
2558725762 }
2558825763 | { readonly id: string; readonly text: string; readonly type: "plan" }
......@@ -25712,6 +25887,18 @@ export const V2ThreadStartedNotification__ThreadItem = Schema.Union(
2571225887 Schema.Union([V2ThreadStartedNotification__MessagePhase, Schema.Null]),
2571325888 ),
2571425889 text: Schema.String,
25890 delivery: Schema.optionalKey(Schema.Union([Schema.Literal("async"), Schema.Null])),
25891 questions: Schema.optionalKey(
25892 Schema.Union([
25893 Schema.Array(
25894 Schema.Struct({
25895 title: Schema.String,
25896 options: Schema.optionalKey(Schema.Union([Schema.Array(Schema.String), Schema.Null])),
25897 }),
25898 ),
25899 Schema.Null,
25900 ]),
25901 ),
2571525902 type: Schema.Literal("agentMessage").annotate({ title: "AgentMessageThreadItemType" }),
2571625903 }).annotate({ title: "AgentMessageThreadItem" }),
2571725904 Schema.Struct({
......@@ -26006,6 +26193,11 @@ export type V2ThreadStartResponse__ThreadItem =
2600626193 readonly memoryCitation?: V2ThreadStartResponse__MemoryCitation | null;
2600726194 readonly phase?: V2ThreadStartResponse__MessagePhase | null;
2600826195 readonly text: string;
26196 readonly delivery?: "async" | null;
26197 readonly questions?: ReadonlyArray<{
26198 readonly title: string;
26199 readonly options?: ReadonlyArray<string> | null;
26200 }> | null;
2600926201 readonly type: "agentMessage";
2601026202 }
2601126203 | { readonly id: string; readonly text: string; readonly type: "plan" }
......@@ -26131,6 +26323,18 @@ export const V2ThreadStartResponse__ThreadItem = Schema.Union(
2613126323 ),
2613226324 phase: Schema.optionalKey(Schema.Union([V2ThreadStartResponse__MessagePhase, Schema.Null])),
2613326325 text: Schema.String,
26326 delivery: Schema.optionalKey(Schema.Union([Schema.Literal("async"), Schema.Null])),
26327 questions: Schema.optionalKey(
26328 Schema.Union([
26329 Schema.Array(
26330 Schema.Struct({
26331 title: Schema.String,
26332 options: Schema.optionalKey(Schema.Union([Schema.Array(Schema.String), Schema.Null])),
26333 }),
26334 ),
26335 Schema.Null,
26336 ]),
26337 ),
2613426338 type: Schema.Literal("agentMessage").annotate({ title: "AgentMessageThreadItemType" }),
2613526339 }).annotate({ title: "AgentMessageThreadItem" }),
2613626340 Schema.Struct({
......@@ -26424,6 +26628,11 @@ export type V2ThreadUnarchiveResponse__ThreadItem =
2642426628 readonly memoryCitation?: V2ThreadUnarchiveResponse__MemoryCitation | null;
2642526629 readonly phase?: V2ThreadUnarchiveResponse__MessagePhase | null;
2642626630 readonly text: string;
26631 readonly delivery?: "async" | null;
26632 readonly questions?: ReadonlyArray<{
26633 readonly title: string;
26634 readonly options?: ReadonlyArray<string> | null;
26635 }> | null;
2642726636 readonly type: "agentMessage";
2642826637 }
2642926638 | { readonly id: string; readonly text: string; readonly type: "plan" }
......@@ -26551,6 +26760,18 @@ export const V2ThreadUnarchiveResponse__ThreadItem = Schema.Union(
2655126760 Schema.Union([V2ThreadUnarchiveResponse__MessagePhase, Schema.Null]),
2655226761 ),
2655326762 text: Schema.String,
26763 delivery: Schema.optionalKey(Schema.Union([Schema.Literal("async"), Schema.Null])),
26764 questions: Schema.optionalKey(
26765 Schema.Union([
26766 Schema.Array(
26767 Schema.Struct({
26768 title: Schema.String,
26769 options: Schema.optionalKey(Schema.Union([Schema.Array(Schema.String), Schema.Null])),
26770 }),
26771 ),
26772 Schema.Null,
26773 ]),
26774 ),
2655426775 type: Schema.Literal("agentMessage").annotate({ title: "AgentMessageThreadItemType" }),
2655526776 }).annotate({ title: "AgentMessageThreadItem" }),
2655626777 Schema.Struct({
......@@ -26845,6 +27066,11 @@ export type V2TurnCompletedNotification__ThreadItem =
2684527066 readonly memoryCitation?: V2TurnCompletedNotification__MemoryCitation | null;
2684627067 readonly phase?: V2TurnCompletedNotification__MessagePhase | null;
2684727068 readonly text: string;
27069 readonly delivery?: "async" | null;
27070 readonly questions?: ReadonlyArray<{
27071 readonly title: string;
27072 readonly options?: ReadonlyArray<string> | null;
27073 }> | null;
2684827074 readonly type: "agentMessage";
2684927075 }
2685027076 | { readonly id: string; readonly text: string; readonly type: "plan" }
......@@ -26974,6 +27200,18 @@ export const V2TurnCompletedNotification__ThreadItem = Schema.Union(
2697427200 Schema.Union([V2TurnCompletedNotification__MessagePhase, Schema.Null]),
2697527201 ),
2697627202 text: Schema.String,
27203 delivery: Schema.optionalKey(Schema.Union([Schema.Literal("async"), Schema.Null])),
27204 questions: Schema.optionalKey(
27205 Schema.Union([
27206 Schema.Array(
27207 Schema.Struct({
27208 title: Schema.String,
27209 options: Schema.optionalKey(Schema.Union([Schema.Array(Schema.String), Schema.Null])),
27210 }),
27211 ),
27212 Schema.Null,
27213 ]),
27214 ),
2697727215 type: Schema.Literal("agentMessage").annotate({ title: "AgentMessageThreadItemType" }),
2697827216 }).annotate({ title: "AgentMessageThreadItem" }),
2697927217 Schema.Struct({
......@@ -27268,6 +27506,11 @@ export type V2TurnStartedNotification__ThreadItem =
2726827506 readonly memoryCitation?: V2TurnStartedNotification__MemoryCitation | null;
2726927507 readonly phase?: V2TurnStartedNotification__MessagePhase | null;
2727027508 readonly text: string;
27509 readonly delivery?: "async" | null;
27510 readonly questions?: ReadonlyArray<{
27511 readonly title: string;
27512 readonly options?: ReadonlyArray<string> | null;
27513 }> | null;
2727127514 readonly type: "agentMessage";
2727227515 }
2727327516 | { readonly id: string; readonly text: string; readonly type: "plan" }
......@@ -27395,6 +27638,18 @@ export const V2TurnStartedNotification__ThreadItem = Schema.Union(
2739527638 Schema.Union([V2TurnStartedNotification__MessagePhase, Schema.Null]),
2739627639 ),
2739727640 text: Schema.String,
27641 delivery: Schema.optionalKey(Schema.Union([Schema.Literal("async"), Schema.Null])),
27642 questions: Schema.optionalKey(
27643 Schema.Union([
27644 Schema.Array(
27645 Schema.Struct({
27646 title: Schema.String,
27647 options: Schema.optionalKey(Schema.Union([Schema.Array(Schema.String), Schema.Null])),
27648 }),
27649 ),
27650 Schema.Null,
27651 ]),
27652 ),
2739827653 type: Schema.Literal("agentMessage").annotate({ title: "AgentMessageThreadItemType" }),
2739927654 }).annotate({ title: "AgentMessageThreadItem" }),
2740027655 Schema.Struct({
......@@ -27689,6 +27944,11 @@ export type V2TurnStartResponse__ThreadItem =
2768927944 readonly memoryCitation?: V2TurnStartResponse__MemoryCitation | null;
2769027945 readonly phase?: V2TurnStartResponse__MessagePhase | null;
2769127946 readonly text: string;
27947 readonly delivery?: "async" | null;
27948 readonly questions?: ReadonlyArray<{
27949 readonly title: string;
27950 readonly options?: ReadonlyArray<string> | null;
27951 }> | null;
2769227952 readonly type: "agentMessage";
2769327953 }
2769427954 | { readonly id: string; readonly text: string; readonly type: "plan" }
......@@ -27814,6 +28074,18 @@ export const V2TurnStartResponse__ThreadItem = Schema.Union(
2781428074 ),
2781528075 phase: Schema.optionalKey(Schema.Union([V2TurnStartResponse__MessagePhase, Schema.Null])),
2781628076 text: Schema.String,
28077 delivery: Schema.optionalKey(Schema.Union([Schema.Literal("async"), Schema.Null])),
28078 questions: Schema.optionalKey(
28079 Schema.Union([
28080 Schema.Array(
28081 Schema.Struct({
28082 title: Schema.String,
28083 options: Schema.optionalKey(Schema.Union([Schema.Array(Schema.String), Schema.Null])),
28084 }),
28085 ),
28086 Schema.Null,
28087 ]),
28088 ),
2781728089 type: Schema.Literal("agentMessage").annotate({ title: "AgentMessageThreadItemType" }),
2781828090 }).annotate({ title: "AgentMessageThreadItem" }),
2781928091 Schema.Struct({
packages/effect-codex-app-server/src/schema.test.ts+23
......@@ -5,6 +5,29 @@ import * as CodexSchema from "./schema.ts";
55
66const isGetAccountResponse = Schema.is(CodexSchema.V2GetAccountResponse);
77
8it("keeps async questions in live notifications and thread history", () => {
9 const item = {
10 type: "agentMessage",
11 id: "question-1",
12 text: "Which package?\n- pnpm\n- npm\n\nWhat should it be named?",
13 phase: "final_answer",
14 delivery: "async",
15 questions: [
16 { title: "Which package manager?", options: ["pnpm", "npm"] },
17 { title: "What should it be named?" },
18 ],
19 } as const;
20 for (const schema of [
21 CodexSchema.ServerNotification__ThreadItem,
22 CodexSchema.V2ItemStartedNotification__ThreadItem,
23 CodexSchema.V2ItemCompletedNotification__ThreadItem,
24 CodexSchema.V2ThreadReadResponse__ThreadItem,
25 CodexSchema.V2ThreadResumeResponse__ThreadItem,
26 ]) {
27 assert.deepEqual(Schema.decodeUnknownSync(schema)(item), item);
28 }
29});
30
831it("accepts Codex 0.150 multi-agent values", () => {
932 const schemas = [
1033 CodexSchema.ServerNotification__SubAgentActivityKind,