| ... | ... | @@ -292,6 +292,7 @@ describe("CheckpointReactor", () => { |
| 292 | 292 | async function createHarness(options?: { |
| 293 | 293 | readonly hasSession?: boolean; |
| 294 | 294 | readonly seedFilesystemCheckpoints?: boolean; |
| 295 | readonly initializeGit?: boolean; |
| 295 | 296 | readonly projectWorkspaceRoot?: string; |
| 296 | 297 | readonly threadWorktreePath?: string | null; |
| 297 | 298 | readonly threadBranch?: string | null; |
| ... | ... | @@ -303,6 +304,9 @@ describe("CheckpointReactor", () => { |
| 303 | 304 | readonly pullRequestRefreshCalls?: Array<string>; |
| 304 | 305 | }) { |
| 305 | 306 | const cwd = createGitRepository(); |
| 307 | if (options?.initializeGit === false) { |
| 308 | NodeFS.rmSync(NodePath.join(cwd, ".git"), { recursive: true }); |
| 309 | } |
| 306 | 310 | tempDirs.push(cwd); |
| 307 | 311 | const provider = createProviderServiceHarness( |
| 308 | 312 | cwd, |
| ... | ... | @@ -1158,52 +1162,148 @@ describe("CheckpointReactor", () => { |
| 1158 | 1162 | ).toBe(true); |
| 1159 | 1163 | }); |
| 1160 | 1164 | |
| 1161 | | it("appends capture failure activity when turn diff summary cannot be derived", async () => { |
| 1162 | | const harness = await createHarness({ seedFilesystemCheckpoints: false }); |
| 1163 | | const createdAt = "2026-01-01T00:00:00.000Z"; |
| 1164 | | |
| 1165 | | await Effect.runPromise( |
| 1166 | | harness.engine.dispatch({ |
| 1167 | | type: "thread.session.set", |
| 1168 | | commandId: CommandId.make("cmd-session-set-missing-baseline-diff"), |
| 1165 | effectIt.effect("captures a checkpoint without a summary when the baseline is missing", () => |
| 1166 | Effect.gen(function* () { |
| 1167 | const harness = yield* Effect.promise(() => |
| 1168 | createHarness({ seedFilesystemCheckpoints: false }), |
| 1169 | ); |
| 1170 | harness.provider.emit({ |
| 1171 | type: "turn.completed", |
| 1172 | eventId: EventId.make("evt-turn-completed-missing-baseline"), |
| 1173 | provider: ProviderDriverKind.make("codex"), |
| 1174 | createdAt: "2026-01-01T00:00:00.000Z", |
| 1169 | 1175 | threadId: ThreadId.make("thread-1"), |
| 1170 | | session: { |
| 1171 | | threadId: ThreadId.make("thread-1"), |
| 1172 | | status: "ready", |
| 1173 | | providerName: "codex", |
| 1174 | | runtimeMode: "approval-required", |
| 1175 | | activeTurnId: null, |
| 1176 | | lastError: null, |
| 1177 | | updatedAt: createdAt, |
| 1178 | | }, |
| 1179 | | createdAt, |
| 1180 | | }), |
| 1181 | | ); |
| 1182 | | |
| 1183 | | harness.provider.emit({ |
| 1184 | | type: "turn.completed", |
| 1185 | | eventId: EventId.make("evt-turn-completed-missing-baseline"), |
| 1186 | | provider: ProviderDriverKind.make("codex"), |
| 1176 | turnId: asTurnId("turn-missing-baseline"), |
| 1177 | payload: { state: "completed" }, |
| 1178 | }); |
| 1179 | expect(yield* harness.nextReceipt).toMatchObject({ |
| 1180 | type: "checkpoint.diff.finalized", |
| 1181 | checkpointTurnCount: 1, |
| 1182 | }); |
| 1183 | yield* Effect.promise(harness.drain); |
| 1184 | const thread = (yield* Effect.promise(harness.readModel)).threads[0]; |
| 1185 | expect(thread?.checkpoints[0]).toMatchObject({ |
| 1186 | status: "ready", |
| 1187 | checkpointTurnCount: 1, |
| 1188 | files: [], |
| 1189 | }); |
| 1190 | expect( |
| 1191 | gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1)), |
| 1192 | ).toBe(true); |
| 1193 | expect( |
| 1194 | thread?.activities.some((activity) => activity.kind === "checkpoint.capture.failed"), |
| 1195 | ).toBe(false); |
| 1196 | }), |
| 1197 | ); |
| 1187 | 1198 | |
| 1188 | | createdAt: "2026-01-01T00:00:00.000Z", |
| 1189 | | threadId: ThreadId.make("thread-1"), |
| 1190 | | turnId: asTurnId("turn-missing-baseline"), |
| 1191 | | payload: { state: "completed" }, |
| 1192 | | }); |
| 1199 | effectIt.effect.each([ |
| 1200 | { timing: "between turns", commit: false }, |
| 1201 | { timing: "between turns", commit: true }, |
| 1202 | { timing: "during a turn", commit: false }, |
| 1203 | { timing: "during a turn", commit: true }, |
| 1204 | ])("resumes checkpointing after git init $timing (commit: $commit)", ({ timing, commit }) => |
| 1205 | Effect.gen(function* () { |
| 1206 | const harness = yield* Effect.promise(() => |
| 1207 | createHarness({ initializeGit: false, seedFilesystemCheckpoints: false }), |
| 1208 | ); |
| 1209 | const threadId = ThreadId.make("thread-1"); |
| 1210 | const createdAt = "2026-01-01T00:00:00.000Z"; |
| 1211 | const emit = (type: "turn.started" | "turn.completed", turn: number) => |
| 1212 | harness.provider.emit({ |
| 1213 | type, |
| 1214 | eventId: EventId.make(`${type}-${turn}`), |
| 1215 | provider: ProviderDriverKind.make("codex"), |
| 1216 | createdAt, |
| 1217 | threadId, |
| 1218 | turnId: asTurnId(`turn-${turn}`), |
| 1219 | ...(type === "turn.completed" ? { payload: { state: "completed" } } : {}), |
| 1220 | }); |
| 1221 | emit("turn.started", 1); |
| 1222 | yield* Effect.promise(harness.drain); |
| 1223 | NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "before git\n"); |
| 1224 | emit("turn.completed", 1); |
| 1225 | yield* Effect.promise(harness.drain); |
| 1226 | expect((yield* Effect.promise(harness.readModel)).threads[0]?.checkpoints).toEqual([]); |
| 1193 | 1227 | |
| 1194 | | await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); |
| 1195 | | const thread = await waitForThread( |
| 1196 | | harness.readModel, |
| 1197 | | (entry) => |
| 1198 | | entry.checkpoints.length === 1 && |
| 1199 | | entry.activities.some((activity) => activity.kind === "checkpoint.capture.failed"), |
| 1200 | | ); |
| 1228 | if (timing === "during a turn") { |
| 1229 | emit("turn.started", 2); |
| 1230 | yield* Effect.promise(harness.drain); |
| 1231 | } |
| 1232 | runGit(harness.cwd, ["init", "--initial-branch=main"]); |
| 1233 | if (commit) { |
| 1234 | runGit(harness.cwd, ["add", "."]); |
| 1235 | runGit(harness.cwd, [ |
| 1236 | "-c", |
| 1237 | "user.name=Test", |
| 1238 | "-c", |
| 1239 | "user.email=test@example.com", |
| 1240 | "commit", |
| 1241 | "-m", |
| 1242 | "Initial", |
| 1243 | ]); |
| 1244 | } |
| 1245 | if (timing === "between turns") { |
| 1246 | // Exercise the domain entry point as well as the provider turn-start event. |
| 1247 | yield* harness.engine.dispatch({ |
| 1248 | type: "thread.turn.start", |
| 1249 | commandId: CommandId.make("cmd-after-git-init"), |
| 1250 | threadId, |
| 1251 | message: { |
| 1252 | messageId: MessageId.make("message-after-git-init"), |
| 1253 | role: "user", |
| 1254 | text: "continue", |
| 1255 | attachments: [], |
| 1256 | }, |
| 1257 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 1258 | runtimeMode: "approval-required", |
| 1259 | createdAt, |
| 1260 | }); |
| 1261 | expect(yield* harness.nextReceipt).toMatchObject({ |
| 1262 | type: "checkpoint.baseline.captured", |
| 1263 | checkpointTurnCount: 0, |
| 1264 | }); |
| 1265 | emit("turn.started", 2); |
| 1266 | yield* Effect.promise(harness.drain); |
| 1267 | } |
| 1268 | NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "after git\n"); |
| 1269 | emit("turn.completed", 2); |
| 1270 | expect(yield* harness.nextReceipt).toMatchObject({ |
| 1271 | type: "checkpoint.diff.finalized", |
| 1272 | checkpointTurnCount: 1, |
| 1273 | }); |
| 1274 | expect(yield* harness.nextReceipt).toMatchObject({ type: "turn.processing.quiesced" }); |
| 1275 | yield* Effect.promise(harness.drain); |
| 1276 | const firstCheckpoint = (yield* Effect.promise(harness.readModel)).threads[0]?.checkpoints[0]; |
| 1277 | expect(firstCheckpoint?.files).toEqual( |
| 1278 | timing === "between turns" |
| 1279 | ? [{ path: "README.md", kind: "modified", additions: 1, deletions: 1 }] |
| 1280 | : [], |
| 1281 | ); |
| 1282 | expect( |
| 1283 | gitShowFileAtRef(harness.cwd, checkpointRefForThreadTurn(threadId, 1), "README.md"), |
| 1284 | ).toBe("after git\n"); |
| 1285 | expect(gitRefExists(harness.cwd, checkpointRefForThreadTurn(threadId, 0))).toBe( |
| 1286 | timing === "between turns", |
| 1287 | ); |
| 1201 | 1288 | |
| 1202 | | expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); |
| 1203 | | expect( |
| 1204 | | thread.activities.some((activity) => activity.kind === "checkpoint.capture.failed"), |
| 1205 | | ).toBe(true); |
| 1206 | | }); |
| 1289 | emit("turn.started", 3); |
| 1290 | yield* Effect.promise(harness.drain); |
| 1291 | NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "next turn\n"); |
| 1292 | emit("turn.completed", 3); |
| 1293 | expect(yield* harness.nextReceipt).toMatchObject({ |
| 1294 | type: "checkpoint.diff.finalized", |
| 1295 | checkpointTurnCount: 2, |
| 1296 | }); |
| 1297 | yield* Effect.promise(harness.drain); |
| 1298 | const thread = (yield* Effect.promise(harness.readModel)).threads[0]; |
| 1299 | expect(thread?.checkpoints[1]?.files).toEqual([ |
| 1300 | { path: "README.md", kind: "modified", additions: 1, deletions: 1 }, |
| 1301 | ]); |
| 1302 | expect( |
| 1303 | thread?.activities.some((activity) => activity.kind === "checkpoint.capture.failed"), |
| 1304 | ).toBe(false); |
| 1305 | }), |
| 1306 | ); |
| 1207 | 1307 | |
| 1208 | 1308 | it("captures pre-turn baseline from project workspace root when thread worktree is unset", async () => { |
| 1209 | 1309 | const harness = await createHarness({ |