| author | |
| committer | |
| log | d76b24dd15a219666941ab1b4967d8f738adcda0 |
| tree | 9eddf7c38b4a6c31740a11ec210224df3d427acc |
| parent | e3723e06b5b154a3e0ede55c02e2cabc5301d3bf |
| signature | Signed by PGP key B5690EEEBB952194 |
26 files changed, 962 insertions(+), 24 deletions(-)
apps/mobile/src/lib/threadActivity.test.ts+23| ... | ... | @@ -88,6 +88,29 @@ const nativeQuestion = { |
| 88 | 88 | } as const; |
| 89 | 89 | |
| 90 | 90 | describe("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 | ||
| 91 | 114 | it("preserves native choice values and custom-answer rules from activities", () => { |
| 92 | 115 | const requested = makeActivity({ |
| 93 | 116 | id: EventId.make("native-question"), |
apps/mobile/src/lib/threadActivity.ts+1-1| ... | ... | @@ -245,7 +245,7 @@ function parseUserInputQuestions( |
| 245 | 245 | }; |
| 246 | 246 | }) |
| 247 | 247 | .filter((option): option is UserInputQuestion["options"][number] => option !== null); |
| 248 | if (options.length === 0) { | |
| 248 | if (options.length === 0 && question.allowCustomAnswer === false) { | |
| 249 | 249 | return null; |
| 250 | 250 | } |
| 251 | 251 | return { |
apps/server/src/checkpointing/CheckpointDiffQuery.test.ts+5| ... | ... | @@ -75,6 +75,7 @@ describe("CheckpointDiffQuery.layer", () => { |
| 75 | 75 | Layer.provideMerge(Layer.succeed(CheckpointStore.CheckpointStore, checkpointStore)), |
| 76 | 76 | Layer.provideMerge( |
| 77 | 77 | Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { |
| 78 | getUserInputActivity: () => Effect.die("unused"), | |
| 78 | 79 | getCommandReadModel: () => |
| 79 | 80 | Effect.die("CheckpointDiffQuery should not request the command read model"), |
| 80 | 81 | getSnapshot: () => |
| ... | ... | @@ -185,6 +186,7 @@ describe("CheckpointDiffQuery.layer", () => { |
| 185 | 186 | Layer.provideMerge(Layer.succeed(CheckpointStore.CheckpointStore, checkpointStore)), |
| 186 | 187 | Layer.provideMerge( |
| 187 | 188 | Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { |
| 189 | getUserInputActivity: () => Effect.die("unused"), | |
| 188 | 190 | getCommandReadModel: () => |
| 189 | 191 | Effect.die("CheckpointDiffQuery should not request the command read model"), |
| 190 | 192 | getSnapshot: () => |
| ... | ... | @@ -270,6 +272,7 @@ describe("CheckpointDiffQuery.layer", () => { |
| 270 | 272 | Layer.provideMerge(Layer.succeed(CheckpointStore.CheckpointStore, checkpointStore)), |
| 271 | 273 | Layer.provideMerge( |
| 272 | 274 | Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { |
| 275 | getUserInputActivity: () => Effect.die("unused"), | |
| 273 | 276 | getCommandReadModel: () => |
| 274 | 277 | Effect.die("CheckpointDiffQuery should not request the command read model"), |
| 275 | 278 | getSnapshot: () => |
| ... | ... | @@ -340,6 +343,7 @@ describe("CheckpointDiffQuery.layer", () => { |
| 340 | 343 | Layer.provideMerge(Layer.succeed(CheckpointStore.CheckpointStore, checkpointStore)), |
| 341 | 344 | Layer.provideMerge( |
| 342 | 345 | Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { |
| 346 | getUserInputActivity: () => Effect.die("unused"), | |
| 343 | 347 | getCommandReadModel: () => |
| 344 | 348 | Effect.die("CheckpointDiffQuery should not request the command read model"), |
| 345 | 349 | getSnapshot: () => |
| ... | ... | @@ -395,6 +399,7 @@ describe("CheckpointDiffQuery.layer", () => { |
| 395 | 399 | Layer.provideMerge(Layer.succeed(CheckpointStore.CheckpointStore, checkpointStore)), |
| 396 | 400 | Layer.provideMerge( |
| 397 | 401 | Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { |
| 402 | getUserInputActivity: () => Effect.die("unused"), | |
| 398 | 403 | getCommandReadModel: () => |
| 399 | 404 | Effect.die("CheckpointDiffQuery should not request the command read model"), |
| 400 | 405 | getSnapshot: () => |
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+211-5| ... | ... | @@ -1,4 +1,11 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | |
| 2 | import * as NodeFSP from "node:fs/promises"; | |
| 3 | import * as NodeOS from "node:os"; | |
| 4 | import * as NodePath from "node:path"; | |
| 5 | ||
| 1 | 6 | import { |
| 7 | ApprovalRequestId, | |
| 8 | EventId, | |
| 2 | 9 | CheckpointRef, |
| 3 | 10 | CommandId, |
| 4 | 11 | DEFAULT_PROVIDER_INTERACTION_MODE, |
| ... | ... | @@ -25,7 +32,10 @@ import { PersistenceSqlError } from "../../persistence/Errors.ts"; |
| 25 | 32 | import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; |
| 26 | 33 | import * as OrchestrationCommandReceipts from "../../persistence/Services/OrchestrationCommandReceipts.ts"; |
| 27 | 34 | import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; |
| 28 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | |
| 35 | import { | |
| 36 | makeSqlitePersistenceLive, | |
| 37 | SqlitePersistenceMemory, | |
| 38 | } from "../../persistence/Layers/Sqlite.ts"; | |
| 29 | 39 | import { |
| 30 | 40 | OrchestrationEventStore, |
| 31 | 41 | type OrchestrationEventStoreShape, |
| ... | ... | @@ -49,7 +59,10 @@ const asMessageId = (value: string): MessageId => MessageId.make(value); |
| 49 | 59 | const asTurnId = (value: string): TurnId => TurnId.make(value); |
| 50 | 60 | const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value); |
| 51 | 61 | |
| 52 | function makeOrchestrationLayer() { | |
| 62 | function makeOrchestrationLayer(databasePath?: string) { | |
| 63 | const persistence = databasePath | |
| 64 | ? makeSqlitePersistenceLive(databasePath) | |
| 65 | : SqlitePersistenceMemory; | |
| 53 | 66 | const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { |
| 54 | 67 | prefix: "t3-orchestration-engine-test-", |
| 55 | 68 | }); |
| ... | ... | @@ -65,19 +78,21 @@ function makeOrchestrationLayer() { |
| 65 | 78 | Layer.provide(OrchestrationEventStoreLive), |
| 66 | 79 | Layer.provideMerge(OrchestrationCommandReceiptRepositoryLive), |
| 67 | 80 | Layer.provide(RepositoryIdentityResolver.layer), |
| 68 | Layer.provide(SqlitePersistenceMemory), | |
| 81 | Layer.provide(persistence), | |
| 69 | 82 | Layer.provideMerge(ServerConfigLayer), |
| 70 | 83 | Layer.provideMerge(NodeServices.layer), |
| 71 | 84 | ); |
| 72 | 85 | } |
| 73 | 86 | |
| 74 | async function createOrchestrationSystem() { | |
| 75 | const runtime = ManagedRuntime.make(makeOrchestrationLayer()); | |
| 87 | async function createOrchestrationSystem(databasePath?: string) { | |
| 88 | const runtime = ManagedRuntime.make(makeOrchestrationLayer(databasePath)); | |
| 76 | 89 | const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); |
| 77 | 90 | const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery)); |
| 78 | 91 | return { |
| 79 | 92 | engine, |
| 80 | 93 | readModel: () => runtime.runPromise(snapshotQuery.getSnapshot()), |
| 94 | readThread: (threadId: ThreadId) => | |
| 95 | runtime.runPromise(snapshotQuery.getThreadDetailById(threadId)), | |
| 81 | 96 | run: <A, E>(effect: Effect.Effect<A, E>) => runtime.runPromise(effect), |
| 82 | 97 | dispose: () => runtime.dispose(), |
| 83 | 98 | }; |
| ... | ... | @@ -99,6 +114,196 @@ const hasMetricSnapshot = ( |
| 99 | 114 | ); |
| 100 | 115 | |
| 101 | 116 | describe("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 | ||
| 102 | 307 | it("bootstraps command handling from persisted projections without reading the full snapshot", async () => { |
| 103 | 308 | let nextSequence = 8; |
| 104 | 309 | const eventStore: OrchestrationEventStoreShape = { |
| ... | ... | @@ -183,6 +388,7 @@ describe("OrchestrationEngine", () => { |
| 183 | 388 | const layer = OrchestrationEngineLive.pipe( |
| 184 | 389 | Layer.provide( |
| 185 | 390 | Layer.succeed(ProjectionSnapshotQuery, { |
| 391 | getUserInputActivity: () => Effect.die("unused"), | |
| 186 | 392 | getCommandReadModel: () => Effect.succeed(commandReadModel), |
| 187 | 393 | getSnapshot: () => |
| 188 | 394 | Effect.sync(() => { |
apps/server/src/orchestration/Layers/OrchestrationEngine.ts+9| ... | ... | @@ -195,9 +195,18 @@ const makeOrchestrationEngine = Effect.gen(function* () { |
| 195 | 195 | }); |
| 196 | 196 | } |
| 197 | 197 | |
| 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(); | |
| 198 | 204 | const eventBase = yield* decideOrchestrationCommand({ |
| 199 | 205 | command: envelope.command, |
| 200 | 206 | readModel: commandReadModel, |
| 207 | ...(Option.isSome(userInputActivity) | |
| 208 | ? { userInputActivity: userInputActivity.value } | |
| 209 | : {}), | |
| 201 | 210 | }).pipe( |
| 202 | 211 | Effect.provideService(Crypto.Crypto, crypto), |
| 203 | 212 | Effect.mapError((cause) => |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+36| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { |
| 2 | ApprovalRequestId, | |
| 2 | 3 | ChatAttachment, |
| 3 | 4 | CheckpointRef, |
| 4 | 5 | IsoDateTime, |
| ... | ... | @@ -1119,6 +1120,40 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1119 | 1120 | `, |
| 1120 | 1121 | }); |
| 1121 | 1122 | |
| 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 | ||
| 1122 | 1157 | const listThreadActivityIdsByThread = SqlSchema.findAll({ |
| 1123 | 1158 | Request: ThreadIdLookupInput, |
| 1124 | 1159 | Result: ProjectionThreadActivityIdRowSchema, |
| ... | ... | @@ -3151,6 +3186,7 @@ pending_approval_requests AS ( |
| 3151 | 3186 | |
| 3152 | 3187 | return { |
| 3153 | 3188 | getCommandReadModel, |
| 3189 | getUserInputActivity, | |
| 3154 | 3190 | getSnapshot, |
| 3155 | 3191 | getShellSnapshot, |
| 3156 | 3192 | getArchivedShellSnapshot, |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+50| ... | ... | @@ -2108,6 +2108,56 @@ describe("ProviderRuntimeIngestion", () => { |
| 2108 | 2108 | expect(message?.streaming).toBe(false); |
| 2109 | 2109 | }); |
| 2110 | 2110 | |
| 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 | ||
| 2111 | 2161 | it("does not create assistant segments for whitespace-only buffered text at approval boundaries", async () => { |
| 2112 | 2162 | const harness = await createHarness(); |
| 2113 | 2163 | const startedAt = "2026-03-28T06:28:00.000Z"; |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+3-1| ... | ... | @@ -520,6 +520,7 @@ export function runtimeEventToActivities( |
| 520 | 520 | payload: { |
| 521 | 521 | ...(event.requestId ? { requestId: event.requestId } : {}), |
| 522 | 522 | questions: event.payload.questions, |
| 523 | ...(event.payload.responseMode ? { responseMode: event.payload.responseMode } : {}), | |
| 523 | 524 | }, |
| 524 | 525 | turnId: toTurnId(event.turnId) ?? null, |
| 525 | 526 | ...maybeSequence, |
| ... | ... | @@ -1739,7 +1740,8 @@ const make = Effect.gen(function* () { |
| 1739 | 1740 | } |
| 1740 | 1741 | |
| 1741 | 1742 | 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") | |
| 1743 | 1745 | ? toTurnId(event.turnId) |
| 1744 | 1746 | : undefined; |
| 1745 | 1747 | if (pauseForUserTurnId) { |
apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts+8| ... | ... | @@ -7,6 +7,7 @@ |
| 7 | 7 | * @module ProjectionSnapshotQuery |
| 8 | 8 | */ |
| 9 | 9 | import type { |
| 10 | ApprovalRequestId, | |
| 10 | 11 | CheckpointRef, |
| 11 | 12 | OrchestrationCheckpointSummary, |
| 12 | 13 | OrchestrationProject, |
| ... | ... | @@ -16,6 +17,7 @@ import type { |
| 16 | 17 | OrchestrationSearchThreadsResult, |
| 17 | 18 | OrchestrationShellSnapshot, |
| 18 | 19 | OrchestrationThread, |
| 20 | OrchestrationThreadActivity, | |
| 19 | 21 | OrchestrationThreadDetailSnapshot, |
| 20 | 22 | OrchestrationThreadDetailWindow, |
| 21 | 23 | OrchestrationThreadShell, |
| ... | ... | @@ -72,6 +74,12 @@ export interface ProjectionThreadDetailQuery { |
| 72 | 74 | * ProjectionSnapshotQueryShape - Service API for read-model snapshots. |
| 73 | 75 | */ |
| 74 | 76 | export 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 | ||
| 75 | 83 | /** |
| 76 | 84 | * Read the lightweight command snapshot used to bootstrap the in-memory |
| 77 | 85 | * orchestration engine without hydrating message/activity/checkpoint bodies. |
apps/server/src/orchestration/decider.ts+77-7| ... | ... | @@ -1,13 +1,19 @@ |
| 1 | 1 | import { |
| 2 | 2 | EventId, |
| 3 | MessageId, | |
| 4 | UserInputRequestedPayload, | |
| 3 | 5 | type OrchestrationCommand, |
| 4 | 6 | type OrchestrationEvent, |
| 5 | 7 | type OrchestrationReadModel, |
| 6 | 8 | type OrchestrationThread, |
| 9 | type OrchestrationThreadActivity, | |
| 7 | 10 | } from "@t3tools/contracts"; |
| 8 | 11 | import * as DateTime from "effect/DateTime"; |
| 9 | 12 | import * as Crypto from "effect/Crypto"; |
| 10 | 13 | import * as Effect from "effect/Effect"; |
| 14 | import * as Schema from "effect/Schema"; | |
| 15 | import * as Option from "effect/Option"; | |
| 16 | import * as Predicate from "effect/Predicate"; | |
| 11 | 17 | import type * as PlatformError from "effect/PlatformError"; |
| 12 | 18 | |
| 13 | 19 | import { |
| ... | ... | @@ -29,6 +35,7 @@ import { projectEvent } from "./projector.ts"; |
| 29 | 35 | import { threadHasQueuedTurnStart } from "./ThreadSettlementPolicy.ts"; |
| 30 | 36 | |
| 31 | 37 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); |
| 38 | const decodeUserInputRequestedPayload = Schema.decodeUnknownOption(UserInputRequestedPayload); | |
| 32 | 39 | |
| 33 | 40 | /** |
| 34 | 41 | * Blocked-on-you work derived from the thread's retained activities: an |
| ... | ... | @@ -55,12 +62,8 @@ function isStaleRequestFailureDetail(payload: Record<string, unknown> | null): b |
| 55 | 62 | } |
| 56 | 63 | |
| 57 | 64 | // 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. | |
| 64 | 67 | function hasOpenBlockingRequest(thread: { |
| 65 | 68 | readonly activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>; |
| 66 | 69 | }): boolean { |
| ... | ... | @@ -185,9 +188,11 @@ const decideCommandSequence = Effect.fn("decideCommandSequence")(function* ({ |
| 185 | 188 | export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand")(function* ({ |
| 186 | 189 | command, |
| 187 | 190 | readModel, |
| 191 | userInputActivity, | |
| 188 | 192 | }: { |
| 189 | 193 | readonly command: OrchestrationCommand; |
| 190 | 194 | readonly readModel: OrchestrationReadModel; |
| 195 | readonly userInputActivity?: OrchestrationThreadActivity; | |
| 191 | 196 | }): Effect.fn.Return< |
| 192 | 197 | DecideOrchestrationCommandResult, |
| 193 | 198 | OrchestrationCommandRejection | PlatformError.PlatformError, |
| ... | ... | @@ -1061,11 +1066,76 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 1061 | 1066 | } |
| 1062 | 1067 | |
| 1063 | 1068 | case "thread.user-input.respond": { |
| 1064 | yield* requireThread({ | |
| 1069 | const thread = yield* requireThread({ | |
| 1065 | 1070 | readModel, |
| 1066 | 1071 | command, |
| 1067 | 1072 | threadId: command.threadId, |
| 1068 | 1073 | }); |
| 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 | } | |
| 1069 | 1139 | return { |
| 1070 | 1140 | ...(yield* withEventBase({ |
| 1071 | 1141 | aggregateKind: "thread", |
apps/server/src/orchestration/projector.ts+29-6| ... | ... | @@ -7,6 +7,7 @@ import { |
| 7 | 7 | } from "@t3tools/contracts"; |
| 8 | 8 | import * as Effect from "effect/Effect"; |
| 9 | 9 | import * as Schema from "effect/Schema"; |
| 10 | import * as Predicate from "effect/Predicate"; | |
| 10 | 11 | |
| 11 | 12 | import { toProjectorDecodeError, type OrchestrationProjectorDecodeError } from "./Errors.ts"; |
| 12 | 13 | import { |
| ... | ... | @@ -39,6 +40,28 @@ type ThreadPatch = Partial<Omit<OrchestrationThread, "id" | "projectId">>; |
| 39 | 40 | const MAX_THREAD_MESSAGES = 2_000; |
| 40 | 41 | const MAX_THREAD_CHECKPOINTS = 500; |
| 41 | 42 | |
| 43 | // Async questions can stay open while the agent produces more activity. | |
| 44 | // Match the database snapshot's pending-question retention. | |
| 45 | function 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 | ||
| 42 | 65 | function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") { |
| 43 | 66 | if (status === "error") return "error" as const; |
| 44 | 67 | if (status === "missing") return "interrupted" as const; |
| ... | ... | @@ -804,12 +827,12 @@ export function projectEvent( |
| 804 | 827 | return nextBase; |
| 805 | 828 | } |
| 806 | 829 | |
| 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 | ); | |
| 813 | 836 | |
| 814 | 837 | return { |
| 815 | 838 | ...nextBase, |
apps/server/src/project/ProjectSetupScriptRunner.test.ts+1| ... | ... | @@ -26,6 +26,7 @@ const makeProject = (scripts: OrchestrationProject["scripts"]): OrchestrationPro |
| 26 | 26 | |
| 27 | 27 | const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => |
| 28 | 28 | Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { |
| 29 | getUserInputActivity: () => Effect.die("unused"), | |
| 29 | 30 | getCommandReadModel: () => Effect.die("unused"), |
| 30 | 31 | getSnapshot: () => Effect.die("unused"), |
| 31 | 32 | getShellSnapshot: () => Effect.die("unused"), |
apps/server/src/provider/Layers/CodexAdapter.test.ts+75| ... | ... | @@ -1648,6 +1648,81 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 1648 | 1648 | }), |
| 1649 | 1649 | ); |
| 1650 | 1650 | |
| 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 | ||
| 1651 | 1726 | it.effect("unwraps Codex token usage payloads for context window events", () => |
| 1652 | 1727 | Effect.gen(function* () { |
| 1653 | 1728 | const { adapter, runtime } = yield* startLifecycleRuntime(); |
apps/server/src/provider/Layers/CodexAdapter.ts+22| ... | ... | @@ -8,6 +8,7 @@ |
| 8 | 8 | * @module CodexAdapterLive |
| 9 | 9 | */ |
| 10 | 10 | import { |
| 11 | EventId, | |
| 11 | 12 | type CanonicalItemType, |
| 12 | 13 | type CanonicalRequestType, |
| 13 | 14 | type CodexSettings, |
| ... | ... | @@ -1445,6 +1446,27 @@ function mapToRuntimeEvents( |
| 1445 | 1446 | if (!item) { |
| 1446 | 1447 | return []; |
| 1447 | 1448 | } |
| 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 | } | |
| 1448 | 1470 | const itemType = toCanonicalItemType(item.type); |
| 1449 | 1471 | if (itemType === "plan") { |
| 1450 | 1472 | const detail = itemDetail(itemType, item); |
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+1| ... | ... | @@ -204,6 +204,7 @@ describe("ProviderSessionReaper", () => { |
| 204 | 204 | Layer.provideMerge(Layer.succeed(ProviderService, providerService)), |
| 205 | 205 | Layer.provideMerge( |
| 206 | 206 | Layer.succeed(ProjectionSnapshotQuery, { |
| 207 | getUserInputActivity: () => Effect.die("unused"), | |
| 207 | 208 | getCommandReadModel: () => Effect.die("unused"), |
| 208 | 209 | getSnapshot: () => Effect.die("unused"), |
| 209 | 210 | getShellSnapshot: () => Effect.die("unused"), |
apps/server/src/server.test.ts+1| ... | ... | @@ -926,6 +926,7 @@ const buildAppUnderTest = (options?: { |
| 926 | 926 | ), |
| 927 | 927 | Layer.provide( |
| 928 | 928 | Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ |
| 929 | getUserInputActivity: () => Effect.die("unused"), | |
| 929 | 930 | getCommandReadModel: () => Effect.succeed(makeDefaultOrchestrationReadModel()), |
| 930 | 931 | getSnapshot: () => Effect.succeed(makeDefaultOrchestrationReadModel()), |
| 931 | 932 | getShellSnapshot: () => |
apps/server/src/serverRuntimeStartup.reconcile.test.ts+2| ... | ... | @@ -66,6 +66,7 @@ const makeProviderService = (liveThreadIds: ReadonlyArray<ThreadId> = []) => |
| 66 | 66 | |
| 67 | 67 | const queryWithThreads = (threads: ReadonlyArray<ReturnType<typeof makeThread>>) => |
| 68 | 68 | ({ |
| 69 | getUserInputActivity: () => Effect.die("unused"), | |
| 69 | 70 | getCommandReadModel: () => Effect.succeed({ threads } as never), |
| 70 | 71 | }) as unknown as ProjectionSnapshotQuery.ProjectionSnapshotQuery["Service"]; |
| 71 | 72 | |
| ... | ... | @@ -631,6 +632,7 @@ it.effect("does not fail startup when the live provider session inventory cannot |
| 631 | 632 | let queried = false; |
| 632 | 633 | return ServerRuntimeStartup.reconcileProviderSessions.pipe( |
| 633 | 634 | Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { |
| 635 | getUserInputActivity: () => Effect.die("unused"), | |
| 634 | 636 | getCommandReadModel: () => |
| 635 | 637 | Effect.sync(() => { |
| 636 | 638 | 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 |
| 118 | 118 | |
| 119 | 119 | yield* ServerRuntimeStartup.launchStartupHeartbeat.pipe( |
| 120 | 120 | Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { |
| 121 | getUserInputActivity: () => Effect.die("unused"), | |
| 121 | 122 | getCommandReadModel: () => Effect.die("unused"), |
| 122 | 123 | getSnapshot: () => Effect.die("unused"), |
| 123 | 124 | getShellSnapshot: () => Effect.die("unused"), |
| ... | ... | @@ -185,6 +186,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa |
| 185 | 186 | autoBootstrapProjectFromCwd: true, |
| 186 | 187 | } as never), |
| 187 | 188 | Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { |
| 189 | getUserInputActivity: () => Effect.die("unused"), | |
| 188 | 190 | getCommandReadModel: () => Effect.die("unused"), |
| 189 | 191 | getSnapshot: () => Effect.die("unused"), |
| 190 | 192 | getShellSnapshot: () => Effect.die("unused"), |
| ... | ... | @@ -250,6 +252,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when |
| 250 | 252 | autoBootstrapProjectFromCwd: true, |
| 251 | 253 | } as never), |
| 252 | 254 | Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { |
| 255 | getUserInputActivity: () => Effect.die("unused"), | |
| 253 | 256 | getCommandReadModel: () => Effect.die("unused"), |
| 254 | 257 | getSnapshot: () => Effect.die("unused"), |
| 255 | 258 | getShellSnapshot: () => Effect.die("unused"), |
| ... | ... | @@ -312,6 +315,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa |
| 312 | 315 | autoBootstrapProjectFromCwd: true, |
| 313 | 316 | } as never), |
| 314 | 317 | Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { |
| 318 | getUserInputActivity: () => Effect.die("unused"), | |
| 315 | 319 | getCommandReadModel: () => Effect.die("unused"), |
| 316 | 320 | getSnapshot: () => Effect.die("unused"), |
| 317 | 321 | getShellSnapshot: () => Effect.die("unused"), |
apps/web/src/session-logic.test.ts+20| ... | ... | @@ -250,6 +250,26 @@ describe("derivePendingApprovals", () => { |
| 250 | 250 | }); |
| 251 | 251 | |
| 252 | 252 | describe("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 | ||
| 253 | 273 | it("preserves native choice values and the custom-answer restriction", () => { |
| 254 | 274 | const question = { |
| 255 | 275 | id: "interaction-result", |
apps/web/src/session-logic.ts+1-1| ... | ... | @@ -532,7 +532,7 @@ function parseUserInputQuestions( |
| 532 | 532 | }; |
| 533 | 533 | }) |
| 534 | 534 | .filter((option): option is UserInputQuestion["options"][number] => option !== null); |
| 535 | if (options.length === 0) { | |
| 535 | if (options.length === 0 && question.allowCustomAnswer === false) { | |
| 536 | 536 | return null; |
| 537 | 537 | } |
| 538 | 538 | return { |
docs/internals/providers.md+21| ... | ... | @@ -24,6 +24,27 @@ adapter in a child scope. Adapter implementations live beside them in |
| 24 | 24 | [`ProviderAdapter.ts`][adapter]. Read the driver plus its adapter to see how a specific agent's |
| 25 | 25 | transport, config, and event shapes are mapped. |
| 26 | 26 | |
| 27 | ## Codex async questions | |
| 28 | ||
| 29 | Codex 0.153 exposes `request_user_input_async` through `item/started` and `item/completed` | |
| 30 | notifications. The item has `type: "agentMessage"`, `delivery: "async"`, and a `questions` array. | |
| 31 | Each question has a `title` and an optional `options` array of strings. The tool returns `{"accepted":true}` | |
| 32 | without waiting. This is separate from the `item/tool/requestUserInput` server request. | |
| 33 | See the [Codex tool handler](https://github.com/openai/codex/blob/d979df154cf60e13eafb5453e75b6d84f21c67bf/codex-rs/core/src/tools/handlers/request_user_input_async.rs). | |
| 34 | ||
| 35 | The 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, | |
| 37 | desktop, and mobile panels. They stay pending while the turn runs and after it finishes. | |
| 38 | ||
| 39 | The engine reads the request's latest stored activity before deciding a reply. This works after | |
| 40 | startup, when the command snapshot has no activities, and after a resolution leaves the recent | |
| 41 | activity window. The query returns one activity, not the full thread history. | |
| 42 | ||
| 43 | For these requests, the decider saves the resolution and a user message in one transaction. | |
| 44 | The standard turn path delivers the message, including session resume and active-turn input. | |
| 45 | It does not send a JSON-RPC response to Codex. Other providers and blocking Codex questions | |
| 46 | keep their existing response paths. | |
| 47 | ||
| 27 | 48 | ## Registry and routing |
| 28 | 49 | |
| 29 | 50 | Two 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 |
| 34 | 34 | issue. T3 Code uploads the thread and Codex logs to OpenAI and shows a thread ID that you can copy |
| 35 | 35 | and share with OpenAI employees. |
| 36 | 36 | |
| 37 | ## Answer questions while Codex works | |
| 38 | ||
| 39 | Codex can ask questions without stopping its work. Choose a suggested answer or enter your own | |
| 40 | in the question panel. Questions without suggested answers accept text. | |
| 41 | ||
| 42 | Your answers are sent as a new message. They reach the current turn while Codex is working, or | |
| 43 | start a new turn if it has finished. Unanswered questions stay available after you reconnect. | |
| 44 | This works in the web, desktop, and mobile apps. Codex must support async questions. | |
| 45 | ||
| 37 | 46 | ## Sub-agent models |
| 38 | 47 | |
| 39 | 48 | The 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; |
| 491 | 491 | |
| 492 | 492 | const UserInputQuestionOption = Schema.Struct({ |
| 493 | 493 | label: TrimmedNonEmptyStringSchema, |
| 494 | description: TrimmedNonEmptyStringSchema, | |
| 494 | description: Schema.String, | |
| 495 | 495 | value: Schema.optional(Schema.String), |
| 496 | 496 | }); |
| 497 | 497 | export type UserInputQuestionOption = typeof UserInputQuestionOption.Type; |
| ... | ... | @@ -508,8 +508,9 @@ export const UserInputQuestion = Schema.Struct({ |
| 508 | 508 | }); |
| 509 | 509 | export type UserInputQuestion = typeof UserInputQuestion.Type; |
| 510 | 510 | |
| 511 | const UserInputRequestedPayload = Schema.Struct({ | |
| 511 | export const UserInputRequestedPayload = Schema.Struct({ | |
| 512 | 512 | questions: Schema.Array(UserInputQuestion), |
| 513 | responseMode: Schema.optional(Schema.Literal("message")), | |
| 513 | 514 | }); |
| 514 | 515 | export type UserInputRequestedPayload = typeof UserInputRequestedPayload.Type; |
| 515 | 516 |
packages/effect-codex-app-server/scripts/generate.ts+55-1| ... | ... | @@ -330,6 +330,60 @@ function stripNullDefaults(value: Schema.Json): Schema.Json { |
| 330 | 330 | ) as Schema.Json; |
| 331 | 331 | } |
| 332 | 332 | |
| 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. | |
| 335 | function 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 | ||
| 333 | 387 | function toPascalCaseMethod(method: string) { |
| 334 | 388 | return method |
| 335 | 389 | .split("/") |
| ... | ... | @@ -648,7 +702,7 @@ const generateFiles = Effect.fn("generateFiles")(function* () { |
| 648 | 702 | for (const [name, schema] of Object.entries(aggregateSchemas).toSorted(([left], [right]) => |
| 649 | 703 | left.localeCompare(right), |
| 650 | 704 | )) { |
| 651 | generator.addSchema(name, schema as never); | |
| 705 | generator.addSchema(name, addAsyncQuestionFields(schema) as never); | |
| 652 | 706 | } |
| 653 | 707 | |
| 654 | 708 | 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 = |
| 20405 | 20405 | readonly memoryCitation?: ServerNotification__MemoryCitation | null; |
| 20406 | 20406 | readonly phase?: ServerNotification__MessagePhase | null; |
| 20407 | 20407 | readonly text: string; |
| 20408 | readonly delivery?: "async" | null; | |
| 20409 | readonly questions?: ReadonlyArray<{ | |
| 20410 | readonly title: string; | |
| 20411 | readonly options?: ReadonlyArray<string> | null; | |
| 20412 | }> | null; | |
| 20408 | 20413 | readonly type: "agentMessage"; |
| 20409 | 20414 | } |
| 20410 | 20415 | | { readonly id: string; readonly text: string; readonly type: "plan" } |
| ... | ... | @@ -20530,6 +20535,18 @@ export const ServerNotification__ThreadItem = Schema.Union( |
| 20530 | 20535 | ), |
| 20531 | 20536 | phase: Schema.optionalKey(Schema.Union([ServerNotification__MessagePhase, Schema.Null])), |
| 20532 | 20537 | 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 | ), | |
| 20533 | 20550 | type: Schema.Literal("agentMessage").annotate({ title: "AgentMessageThreadItemType" }), |
| 20534 | 20551 | }).annotate({ title: "AgentMessageThreadItem" }), |
| 20535 | 20552 | Schema.Struct({ |
| ... | ... | @@ -21499,6 +21516,11 @@ export type V2ItemCompletedNotification__ThreadItem = |
| 21499 | 21516 | readonly memoryCitation?: V2ItemCompletedNotification__MemoryCitation | null; |
| 21500 | 21517 | readonly phase?: V2ItemCompletedNotification__MessagePhase | null; |
| 21501 | 21518 | readonly text: string; |
| 21519 | readonly delivery?: "async" | null; | |
| 21520 | readonly questions?: ReadonlyArray<{ | |
| 21521 | readonly title: string; | |
| 21522 | readonly options?: ReadonlyArray<string> | null; | |
| 21523 | }> | null; | |
| 21502 | 21524 | readonly type: "agentMessage"; |
| 21503 | 21525 | } |
| 21504 | 21526 | | { readonly id: string; readonly text: string; readonly type: "plan" } |
| ... | ... | @@ -21628,6 +21650,18 @@ export const V2ItemCompletedNotification__ThreadItem = Schema.Union( |
| 21628 | 21650 | Schema.Union([V2ItemCompletedNotification__MessagePhase, Schema.Null]), |
| 21629 | 21651 | ), |
| 21630 | 21652 | 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 | ), | |
| 21631 | 21665 | type: Schema.Literal("agentMessage").annotate({ title: "AgentMessageThreadItemType" }), |
| 21632 | 21666 | }).annotate({ title: "AgentMessageThreadItem" }), |
| 21633 | 21667 | Schema.Struct({ |
| ... | ... | @@ -21965,6 +21999,11 @@ export type V2ItemStartedNotification__ThreadItem = |
| 21965 | 21999 | readonly memoryCitation?: V2ItemStartedNotification__MemoryCitation | null; |
| 21966 | 22000 | readonly phase?: V2ItemStartedNotification__MessagePhase | null; |
| 21967 | 22001 | readonly text: string; |
| 22002 | readonly delivery?: "async" | null; | |
| 22003 | readonly questions?: ReadonlyArray<{ | |
| 22004 | readonly title: string; | |
| 22005 | readonly options?: ReadonlyArray<string> | null; | |
| 22006 | }> | null; | |
| 21968 | 22007 | readonly type: "agentMessage"; |
| 21969 | 22008 | } |
| 21970 | 22009 | | { readonly id: string; readonly text: string; readonly type: "plan" } |
| ... | ... | @@ -22092,6 +22131,18 @@ export const V2ItemStartedNotification__ThreadItem = Schema.Union( |
| 22092 | 22131 | Schema.Union([V2ItemStartedNotification__MessagePhase, Schema.Null]), |
| 22093 | 22132 | ), |
| 22094 | 22133 | 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 | ), | |
| 22095 | 22146 | type: Schema.Literal("agentMessage").annotate({ title: "AgentMessageThreadItemType" }), |
| 22096 | 22147 | }).annotate({ title: "AgentMessageThreadItem" }), |
| 22097 | 22148 | Schema.Struct({ |
| ... | ... | @@ -22601,6 +22652,11 @@ export type V2ReviewStartResponse__ThreadItem = |
| 22601 | 22652 | readonly memoryCitation?: V2ReviewStartResponse__MemoryCitation | null; |
| 22602 | 22653 | readonly phase?: V2ReviewStartResponse__MessagePhase | null; |
| 22603 | 22654 | readonly text: string; |
| 22655 | readonly delivery?: "async" | null; | |
| 22656 | readonly questions?: ReadonlyArray<{ | |
| 22657 | readonly title: string; | |
| 22658 | readonly options?: ReadonlyArray<string> | null; | |
| 22659 | }> | null; | |
| 22604 | 22660 | readonly type: "agentMessage"; |
| 22605 | 22661 | } |
| 22606 | 22662 | | { readonly id: string; readonly text: string; readonly type: "plan" } |
| ... | ... | @@ -22726,6 +22782,18 @@ export const V2ReviewStartResponse__ThreadItem = Schema.Union( |
| 22726 | 22782 | ), |
| 22727 | 22783 | phase: Schema.optionalKey(Schema.Union([V2ReviewStartResponse__MessagePhase, Schema.Null])), |
| 22728 | 22784 | 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 | ), | |
| 22729 | 22797 | type: Schema.Literal("agentMessage").annotate({ title: "AgentMessageThreadItemType" }), |
| 22730 | 22798 | }).annotate({ title: "AgentMessageThreadItem" }), |
| 22731 | 22799 | Schema.Struct({ |
| ... | ... | @@ -23050,6 +23118,11 @@ export type V2ThreadForkResponse__ThreadItem = |
| 23050 | 23118 | readonly memoryCitation?: V2ThreadForkResponse__MemoryCitation | null; |
| 23051 | 23119 | readonly phase?: V2ThreadForkResponse__MessagePhase | null; |
| 23052 | 23120 | readonly text: string; |
| 23121 | readonly delivery?: "async" | null; | |
| 23122 | readonly questions?: ReadonlyArray<{ | |
| 23123 | readonly title: string; | |
| 23124 | readonly options?: ReadonlyArray<string> | null; | |
| 23125 | }> | null; | |
| 23053 | 23126 | readonly type: "agentMessage"; |
| 23054 | 23127 | } |
| 23055 | 23128 | | { readonly id: string; readonly text: string; readonly type: "plan" } |
| ... | ... | @@ -23175,6 +23248,18 @@ export const V2ThreadForkResponse__ThreadItem = Schema.Union( |
| 23175 | 23248 | ), |
| 23176 | 23249 | phase: Schema.optionalKey(Schema.Union([V2ThreadForkResponse__MessagePhase, Schema.Null])), |
| 23177 | 23250 | 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 | ), | |
| 23178 | 23263 | type: Schema.Literal("agentMessage").annotate({ title: "AgentMessageThreadItemType" }), |
| 23179 | 23264 | }).annotate({ title: "AgentMessageThreadItem" }), |
| 23180 | 23265 | Schema.Struct({ |
| ... | ... | @@ -23468,6 +23553,11 @@ export type V2ThreadListResponse__ThreadItem = |
| 23468 | 23553 | readonly memoryCitation?: V2ThreadListResponse__MemoryCitation | null; |
| 23469 | 23554 | readonly phase?: V2ThreadListResponse__MessagePhase | null; |
| 23470 | 23555 | readonly text: string; |
| 23556 | readonly delivery?: "async" | null; | |
| 23557 | readonly questions?: ReadonlyArray<{ | |
| 23558 | readonly title: string; | |
| 23559 | readonly options?: ReadonlyArray<string> | null; | |
| 23560 | }> | null; | |
| 23471 | 23561 | readonly type: "agentMessage"; |
| 23472 | 23562 | } |
| 23473 | 23563 | | { readonly id: string; readonly text: string; readonly type: "plan" } |
| ... | ... | @@ -23593,6 +23683,18 @@ export const V2ThreadListResponse__ThreadItem = Schema.Union( |
| 23593 | 23683 | ), |
| 23594 | 23684 | phase: Schema.optionalKey(Schema.Union([V2ThreadListResponse__MessagePhase, Schema.Null])), |
| 23595 | 23685 | 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 | ), | |
| 23596 | 23698 | type: Schema.Literal("agentMessage").annotate({ title: "AgentMessageThreadItemType" }), |
| 23597 | 23699 | }).annotate({ title: "AgentMessageThreadItem" }), |
| 23598 | 23700 | Schema.Struct({ |
| ... | ... | @@ -23886,6 +23988,11 @@ export type V2ThreadMetadataUpdateResponse__ThreadItem = |
| 23886 | 23988 | readonly memoryCitation?: V2ThreadMetadataUpdateResponse__MemoryCitation | null; |
| 23887 | 23989 | readonly phase?: V2ThreadMetadataUpdateResponse__MessagePhase | null; |
| 23888 | 23990 | readonly text: string; |
| 23991 | readonly delivery?: "async" | null; | |
| 23992 | readonly questions?: ReadonlyArray<{ | |
| 23993 | readonly title: string; | |
| 23994 | readonly options?: ReadonlyArray<string> | null; | |
| 23995 | }> | null; | |
| 23889 | 23996 | readonly type: "agentMessage"; |
| 23890 | 23997 | } |
| 23891 | 23998 | | { readonly id: string; readonly text: string; readonly type: "plan" } |
| ... | ... | @@ -24015,6 +24122,18 @@ export const V2ThreadMetadataUpdateResponse__ThreadItem = Schema.Union( |
| 24015 | 24122 | Schema.Union([V2ThreadMetadataUpdateResponse__MessagePhase, Schema.Null]), |
| 24016 | 24123 | ), |
| 24017 | 24124 | 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 | ), | |
| 24018 | 24137 | type: Schema.Literal("agentMessage").annotate({ title: "AgentMessageThreadItemType" }), |
| 24019 | 24138 | }).annotate({ title: "AgentMessageThreadItem" }), |
| 24020 | 24139 | Schema.Struct({ |
| ... | ... | @@ -24309,6 +24428,11 @@ export type V2ThreadReadResponse__ThreadItem = |
| 24309 | 24428 | readonly memoryCitation?: V2ThreadReadResponse__MemoryCitation | null; |
| 24310 | 24429 | readonly phase?: V2ThreadReadResponse__MessagePhase | null; |
| 24311 | 24430 | readonly text: string; |
| 24431 | readonly delivery?: "async" | null; | |
| 24432 | readonly questions?: ReadonlyArray<{ | |
| 24433 | readonly title: string; | |
| 24434 | readonly options?: ReadonlyArray<string> | null; | |
| 24435 | }> | null; | |
| 24312 | 24436 | readonly type: "agentMessage"; |
| 24313 | 24437 | } |
| 24314 | 24438 | | { readonly id: string; readonly text: string; readonly type: "plan" } |
| ... | ... | @@ -24434,6 +24558,18 @@ export const V2ThreadReadResponse__ThreadItem = Schema.Union( |
| 24434 | 24558 | ), |
| 24435 | 24559 | phase: Schema.optionalKey(Schema.Union([V2ThreadReadResponse__MessagePhase, Schema.Null])), |
| 24436 | 24560 | 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 | ), | |
| 24437 | 24573 | type: Schema.Literal("agentMessage").annotate({ title: "AgentMessageThreadItemType" }), |
| 24438 | 24574 | }).annotate({ title: "AgentMessageThreadItem" }), |
| 24439 | 24575 | Schema.Struct({ |
| ... | ... | @@ -24735,6 +24871,11 @@ export type V2ThreadResumeResponse__ThreadItem = |
| 24735 | 24871 | readonly memoryCitation?: V2ThreadResumeResponse__MemoryCitation | null; |
| 24736 | 24872 | readonly phase?: V2ThreadResumeResponse__MessagePhase | null; |
| 24737 | 24873 | readonly text: string; |
| 24874 | readonly delivery?: "async" | null; | |
| 24875 | readonly questions?: ReadonlyArray<{ | |
| 24876 | readonly title: string; | |
| 24877 | readonly options?: ReadonlyArray<string> | null; | |
| 24878 | }> | null; | |
| 24738 | 24879 | readonly type: "agentMessage"; |
| 24739 | 24880 | } |
| 24740 | 24881 | | { readonly id: string; readonly text: string; readonly type: "plan" } |
| ... | ... | @@ -24860,6 +25001,18 @@ export const V2ThreadResumeResponse__ThreadItem = Schema.Union( |
| 24860 | 25001 | ), |
| 24861 | 25002 | phase: Schema.optionalKey(Schema.Union([V2ThreadResumeResponse__MessagePhase, Schema.Null])), |
| 24862 | 25003 | 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 | ), | |
| 24863 | 25016 | type: Schema.Literal("agentMessage").annotate({ title: "AgentMessageThreadItemType" }), |
| 24864 | 25017 | }).annotate({ title: "AgentMessageThreadItem" }), |
| 24865 | 25018 | Schema.Struct({ |
| ... | ... | @@ -25153,6 +25306,11 @@ export type V2ThreadRollbackResponse__ThreadItem = |
| 25153 | 25306 | readonly memoryCitation?: V2ThreadRollbackResponse__MemoryCitation | null; |
| 25154 | 25307 | readonly phase?: V2ThreadRollbackResponse__MessagePhase | null; |
| 25155 | 25308 | readonly text: string; |
| 25309 | readonly delivery?: "async" | null; | |
| 25310 | readonly questions?: ReadonlyArray<{ | |
| 25311 | readonly title: string; | |
| 25312 | readonly options?: ReadonlyArray<string> | null; | |
| 25313 | }> | null; | |
| 25156 | 25314 | readonly type: "agentMessage"; |
| 25157 | 25315 | } |
| 25158 | 25316 | | { readonly id: string; readonly text: string; readonly type: "plan" } |
| ... | ... | @@ -25280,6 +25438,18 @@ export const V2ThreadRollbackResponse__ThreadItem = Schema.Union( |
| 25280 | 25438 | Schema.Union([V2ThreadRollbackResponse__MessagePhase, Schema.Null]), |
| 25281 | 25439 | ), |
| 25282 | 25440 | 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 | ), | |
| 25283 | 25453 | type: Schema.Literal("agentMessage").annotate({ title: "AgentMessageThreadItemType" }), |
| 25284 | 25454 | }).annotate({ title: "AgentMessageThreadItem" }), |
| 25285 | 25455 | Schema.Struct({ |
| ... | ... | @@ -25583,6 +25753,11 @@ export type V2ThreadStartedNotification__ThreadItem = |
| 25583 | 25753 | readonly memoryCitation?: V2ThreadStartedNotification__MemoryCitation | null; |
| 25584 | 25754 | readonly phase?: V2ThreadStartedNotification__MessagePhase | null; |
| 25585 | 25755 | readonly text: string; |
| 25756 | readonly delivery?: "async" | null; | |
| 25757 | readonly questions?: ReadonlyArray<{ | |
| 25758 | readonly title: string; | |
| 25759 | readonly options?: ReadonlyArray<string> | null; | |
| 25760 | }> | null; | |
| 25586 | 25761 | readonly type: "agentMessage"; |
| 25587 | 25762 | } |
| 25588 | 25763 | | { readonly id: string; readonly text: string; readonly type: "plan" } |
| ... | ... | @@ -25712,6 +25887,18 @@ export const V2ThreadStartedNotification__ThreadItem = Schema.Union( |
| 25712 | 25887 | Schema.Union([V2ThreadStartedNotification__MessagePhase, Schema.Null]), |
| 25713 | 25888 | ), |
| 25714 | 25889 | 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 | ), | |
| 25715 | 25902 | type: Schema.Literal("agentMessage").annotate({ title: "AgentMessageThreadItemType" }), |
| 25716 | 25903 | }).annotate({ title: "AgentMessageThreadItem" }), |
| 25717 | 25904 | Schema.Struct({ |
| ... | ... | @@ -26006,6 +26193,11 @@ export type V2ThreadStartResponse__ThreadItem = |
| 26006 | 26193 | readonly memoryCitation?: V2ThreadStartResponse__MemoryCitation | null; |
| 26007 | 26194 | readonly phase?: V2ThreadStartResponse__MessagePhase | null; |
| 26008 | 26195 | readonly text: string; |
| 26196 | readonly delivery?: "async" | null; | |
| 26197 | readonly questions?: ReadonlyArray<{ | |
| 26198 | readonly title: string; | |
| 26199 | readonly options?: ReadonlyArray<string> | null; | |
| 26200 | }> | null; | |
| 26009 | 26201 | readonly type: "agentMessage"; |
| 26010 | 26202 | } |
| 26011 | 26203 | | { readonly id: string; readonly text: string; readonly type: "plan" } |
| ... | ... | @@ -26131,6 +26323,18 @@ export const V2ThreadStartResponse__ThreadItem = Schema.Union( |
| 26131 | 26323 | ), |
| 26132 | 26324 | phase: Schema.optionalKey(Schema.Union([V2ThreadStartResponse__MessagePhase, Schema.Null])), |
| 26133 | 26325 | 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 | ), | |
| 26134 | 26338 | type: Schema.Literal("agentMessage").annotate({ title: "AgentMessageThreadItemType" }), |
| 26135 | 26339 | }).annotate({ title: "AgentMessageThreadItem" }), |
| 26136 | 26340 | Schema.Struct({ |
| ... | ... | @@ -26424,6 +26628,11 @@ export type V2ThreadUnarchiveResponse__ThreadItem = |
| 26424 | 26628 | readonly memoryCitation?: V2ThreadUnarchiveResponse__MemoryCitation | null; |
| 26425 | 26629 | readonly phase?: V2ThreadUnarchiveResponse__MessagePhase | null; |
| 26426 | 26630 | readonly text: string; |
| 26631 | readonly delivery?: "async" | null; | |
| 26632 | readonly questions?: ReadonlyArray<{ | |
| 26633 | readonly title: string; | |
| 26634 | readonly options?: ReadonlyArray<string> | null; | |
| 26635 | }> | null; | |
| 26427 | 26636 | readonly type: "agentMessage"; |
| 26428 | 26637 | } |
| 26429 | 26638 | | { readonly id: string; readonly text: string; readonly type: "plan" } |
| ... | ... | @@ -26551,6 +26760,18 @@ export const V2ThreadUnarchiveResponse__ThreadItem = Schema.Union( |
| 26551 | 26760 | Schema.Union([V2ThreadUnarchiveResponse__MessagePhase, Schema.Null]), |
| 26552 | 26761 | ), |
| 26553 | 26762 | 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 | ), | |
| 26554 | 26775 | type: Schema.Literal("agentMessage").annotate({ title: "AgentMessageThreadItemType" }), |
| 26555 | 26776 | }).annotate({ title: "AgentMessageThreadItem" }), |
| 26556 | 26777 | Schema.Struct({ |
| ... | ... | @@ -26845,6 +27066,11 @@ export type V2TurnCompletedNotification__ThreadItem = |
| 26845 | 27066 | readonly memoryCitation?: V2TurnCompletedNotification__MemoryCitation | null; |
| 26846 | 27067 | readonly phase?: V2TurnCompletedNotification__MessagePhase | null; |
| 26847 | 27068 | readonly text: string; |
| 27069 | readonly delivery?: "async" | null; | |
| 27070 | readonly questions?: ReadonlyArray<{ | |
| 27071 | readonly title: string; | |
| 27072 | readonly options?: ReadonlyArray<string> | null; | |
| 27073 | }> | null; | |
| 26848 | 27074 | readonly type: "agentMessage"; |
| 26849 | 27075 | } |
| 26850 | 27076 | | { readonly id: string; readonly text: string; readonly type: "plan" } |
| ... | ... | @@ -26974,6 +27200,18 @@ export const V2TurnCompletedNotification__ThreadItem = Schema.Union( |
| 26974 | 27200 | Schema.Union([V2TurnCompletedNotification__MessagePhase, Schema.Null]), |
| 26975 | 27201 | ), |
| 26976 | 27202 | 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 | ), | |
| 26977 | 27215 | type: Schema.Literal("agentMessage").annotate({ title: "AgentMessageThreadItemType" }), |
| 26978 | 27216 | }).annotate({ title: "AgentMessageThreadItem" }), |
| 26979 | 27217 | Schema.Struct({ |
| ... | ... | @@ -27268,6 +27506,11 @@ export type V2TurnStartedNotification__ThreadItem = |
| 27268 | 27506 | readonly memoryCitation?: V2TurnStartedNotification__MemoryCitation | null; |
| 27269 | 27507 | readonly phase?: V2TurnStartedNotification__MessagePhase | null; |
| 27270 | 27508 | readonly text: string; |
| 27509 | readonly delivery?: "async" | null; | |
| 27510 | readonly questions?: ReadonlyArray<{ | |
| 27511 | readonly title: string; | |
| 27512 | readonly options?: ReadonlyArray<string> | null; | |
| 27513 | }> | null; | |
| 27271 | 27514 | readonly type: "agentMessage"; |
| 27272 | 27515 | } |
| 27273 | 27516 | | { readonly id: string; readonly text: string; readonly type: "plan" } |
| ... | ... | @@ -27395,6 +27638,18 @@ export const V2TurnStartedNotification__ThreadItem = Schema.Union( |
| 27395 | 27638 | Schema.Union([V2TurnStartedNotification__MessagePhase, Schema.Null]), |
| 27396 | 27639 | ), |
| 27397 | 27640 | 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 | ), | |
| 27398 | 27653 | type: Schema.Literal("agentMessage").annotate({ title: "AgentMessageThreadItemType" }), |
| 27399 | 27654 | }).annotate({ title: "AgentMessageThreadItem" }), |
| 27400 | 27655 | Schema.Struct({ |
| ... | ... | @@ -27689,6 +27944,11 @@ export type V2TurnStartResponse__ThreadItem = |
| 27689 | 27944 | readonly memoryCitation?: V2TurnStartResponse__MemoryCitation | null; |
| 27690 | 27945 | readonly phase?: V2TurnStartResponse__MessagePhase | null; |
| 27691 | 27946 | readonly text: string; |
| 27947 | readonly delivery?: "async" | null; | |
| 27948 | readonly questions?: ReadonlyArray<{ | |
| 27949 | readonly title: string; | |
| 27950 | readonly options?: ReadonlyArray<string> | null; | |
| 27951 | }> | null; | |
| 27692 | 27952 | readonly type: "agentMessage"; |
| 27693 | 27953 | } |
| 27694 | 27954 | | { readonly id: string; readonly text: string; readonly type: "plan" } |
| ... | ... | @@ -27814,6 +28074,18 @@ export const V2TurnStartResponse__ThreadItem = Schema.Union( |
| 27814 | 28074 | ), |
| 27815 | 28075 | phase: Schema.optionalKey(Schema.Union([V2TurnStartResponse__MessagePhase, Schema.Null])), |
| 27816 | 28076 | 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 | ), | |
| 27817 | 28089 | type: Schema.Literal("agentMessage").annotate({ title: "AgentMessageThreadItemType" }), |
| 27818 | 28090 | }).annotate({ title: "AgentMessageThreadItem" }), |
| 27819 | 28091 | Schema.Struct({ |
packages/effect-codex-app-server/src/schema.test.ts+23| ... | ... | @@ -5,6 +5,29 @@ import * as CodexSchema from "./schema.ts"; |
| 5 | 5 | |
| 6 | 6 | const isGetAccountResponse = Schema.is(CodexSchema.V2GetAccountResponse); |
| 7 | 7 | |
| 8 | it("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 | ||
| 8 | 31 | it("accepts Codex 0.150 multi-agent values", () => { |
| 9 | 32 | const schemas = [ |
| 10 | 33 | CodexSchema.ServerNotification__SubAgentActivityKind, |