| ... | ... | @@ -1230,4 +1230,153 @@ describe("OrchestrationEngine", () => { |
| 1230 | 1230 | |
| 1231 | 1231 | await system.dispose(); |
| 1232 | 1232 | }); |
| 1233 | |
| 1234 | it("replays the accepted receipt for a genuine retry of the same command", async () => { |
| 1235 | const createdAt = now(); |
| 1236 | const system = await createOrchestrationSystem(); |
| 1237 | const { engine } = system; |
| 1238 | |
| 1239 | await system.run( |
| 1240 | engine.dispatch({ |
| 1241 | type: "project.create", |
| 1242 | commandId: CommandId.make("cmd-retry-project-create"), |
| 1243 | projectId: asProjectId("project-retry"), |
| 1244 | title: "Retry Project", |
| 1245 | workspaceRoot: "/tmp/project-retry", |
| 1246 | defaultModelSelection: { |
| 1247 | instanceId: ProviderInstanceId.make("codex"), |
| 1248 | model: "gpt-5-codex", |
| 1249 | }, |
| 1250 | createdAt, |
| 1251 | }), |
| 1252 | ); |
| 1253 | await system.run( |
| 1254 | engine.dispatch({ |
| 1255 | type: "thread.create", |
| 1256 | commandId: CommandId.make("cmd-retry-thread-create"), |
| 1257 | threadId: ThreadId.make("thread-retry"), |
| 1258 | projectId: asProjectId("project-retry"), |
| 1259 | title: "retry", |
| 1260 | modelSelection: { |
| 1261 | instanceId: ProviderInstanceId.make("codex"), |
| 1262 | model: "gpt-5-codex", |
| 1263 | }, |
| 1264 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 1265 | runtimeMode: "approval-required", |
| 1266 | branch: null, |
| 1267 | worktreePath: null, |
| 1268 | createdAt, |
| 1269 | }), |
| 1270 | ); |
| 1271 | |
| 1272 | const turnStart = { |
| 1273 | type: "thread.turn.start", |
| 1274 | commandId: CommandId.make("cmd-retry-turn-start"), |
| 1275 | threadId: ThreadId.make("thread-retry"), |
| 1276 | message: { |
| 1277 | messageId: asMessageId("msg-retry"), |
| 1278 | role: "user", |
| 1279 | text: "hello", |
| 1280 | attachments: [], |
| 1281 | }, |
| 1282 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 1283 | runtimeMode: "approval-required", |
| 1284 | createdAt, |
| 1285 | } as const; |
| 1286 | |
| 1287 | const first = await system.run(engine.dispatch(turnStart)); |
| 1288 | const second = await system.run(engine.dispatch(turnStart)); |
| 1289 | expect(second.sequence).toBe(first.sequence); |
| 1290 | |
| 1291 | const readModel = await system.readModel(); |
| 1292 | const thread = readModel.threads.find((candidate) => candidate.id === "thread-retry"); |
| 1293 | expect(thread?.messages.filter((message) => message.role === "user")).toHaveLength(1); |
| 1294 | |
| 1295 | await system.dispose(); |
| 1296 | }); |
| 1297 | |
| 1298 | it("rejects reusing an accepted command id for a different aggregate", async () => { |
| 1299 | const createdAt = now(); |
| 1300 | const system = await createOrchestrationSystem(); |
| 1301 | const { engine } = system; |
| 1302 | |
| 1303 | await system.run( |
| 1304 | engine.dispatch({ |
| 1305 | type: "project.create", |
| 1306 | commandId: CommandId.make("cmd-conflict-project-create"), |
| 1307 | projectId: asProjectId("project-conflict"), |
| 1308 | title: "Conflict Project", |
| 1309 | workspaceRoot: "/tmp/project-conflict", |
| 1310 | defaultModelSelection: { |
| 1311 | instanceId: ProviderInstanceId.make("codex"), |
| 1312 | model: "gpt-5-codex", |
| 1313 | }, |
| 1314 | createdAt, |
| 1315 | }), |
| 1316 | ); |
| 1317 | for (const threadId of ["thread-conflict-a", "thread-conflict-b"]) { |
| 1318 | await system.run( |
| 1319 | engine.dispatch({ |
| 1320 | type: "thread.create", |
| 1321 | commandId: CommandId.make(`cmd-${threadId}-create`), |
| 1322 | threadId: ThreadId.make(threadId), |
| 1323 | projectId: asProjectId("project-conflict"), |
| 1324 | title: threadId, |
| 1325 | modelSelection: { |
| 1326 | instanceId: ProviderInstanceId.make("codex"), |
| 1327 | model: "gpt-5-codex", |
| 1328 | }, |
| 1329 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 1330 | runtimeMode: "approval-required", |
| 1331 | branch: null, |
| 1332 | worktreePath: null, |
| 1333 | createdAt, |
| 1334 | }), |
| 1335 | ); |
| 1336 | } |
| 1337 | |
| 1338 | await system.run( |
| 1339 | engine.dispatch({ |
| 1340 | type: "thread.turn.start", |
| 1341 | commandId: CommandId.make("cmd-conflict-turn-start"), |
| 1342 | threadId: ThreadId.make("thread-conflict-a"), |
| 1343 | message: { |
| 1344 | messageId: asMessageId("msg-conflict-a"), |
| 1345 | role: "user", |
| 1346 | text: "hello", |
| 1347 | attachments: [], |
| 1348 | }, |
| 1349 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 1350 | runtimeMode: "approval-required", |
| 1351 | createdAt, |
| 1352 | }), |
| 1353 | ); |
| 1354 | |
| 1355 | await expect( |
| 1356 | system.run( |
| 1357 | engine.dispatch({ |
| 1358 | type: "thread.turn.start", |
| 1359 | commandId: CommandId.make("cmd-conflict-turn-start"), |
| 1360 | threadId: ThreadId.make("thread-conflict-b"), |
| 1361 | message: { |
| 1362 | messageId: asMessageId("msg-conflict-b"), |
| 1363 | role: "user", |
| 1364 | text: "hello again", |
| 1365 | attachments: [], |
| 1366 | }, |
| 1367 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 1368 | runtimeMode: "approval-required", |
| 1369 | createdAt, |
| 1370 | }), |
| 1371 | ), |
| 1372 | ).rejects.toThrow("already used for thread 'thread-conflict-a'"); |
| 1373 | |
| 1374 | const readModel = await system.readModel(); |
| 1375 | const targetThread = readModel.threads.find( |
| 1376 | (candidate) => candidate.id === "thread-conflict-b", |
| 1377 | ); |
| 1378 | expect(targetThread?.messages.filter((message) => message.role === "user")).toHaveLength(0); |
| 1379 | |
| 1380 | await system.dispose(); |
| 1381 | }); |
| 1233 | 1382 | }); |