| ... | @@ -292,6 +292,7 @@ describe("CheckpointReactor", () => { | ... | @@ -292,6 +292,7 @@ describe("CheckpointReactor", () => { |
| 292 | async function createHarness(options?: { | 292 | async function createHarness(options?: { |
| 293 | readonly hasSession?: boolean; | 293 | readonly hasSession?: boolean; |
| 294 | readonly seedFilesystemCheckpoints?: boolean; | 294 | readonly seedFilesystemCheckpoints?: boolean; |
| | 295 | readonly initializeGit?: boolean; |
| 295 | readonly projectWorkspaceRoot?: string; | 296 | readonly projectWorkspaceRoot?: string; |
| 296 | readonly threadWorktreePath?: string | null; | 297 | readonly threadWorktreePath?: string | null; |
| 297 | readonly threadBranch?: string | null; | 298 | readonly threadBranch?: string | null; |
| ... | @@ -303,6 +304,9 @@ describe("CheckpointReactor", () => { | ... | @@ -303,6 +304,9 @@ describe("CheckpointReactor", () => { |
| 303 | readonly pullRequestRefreshCalls?: Array<string>; | 304 | readonly pullRequestRefreshCalls?: Array<string>; |
| 304 | }) { | 305 | }) { |
| 305 | const cwd = createGitRepository(); | 306 | const cwd = createGitRepository(); |
| | 307 | if (options?.initializeGit === false) { |
| | 308 | NodeFS.rmSync(NodePath.join(cwd, ".git"), { recursive: true }); |
| | 309 | } |
| 306 | tempDirs.push(cwd); | 310 | tempDirs.push(cwd); |
| 307 | const provider = createProviderServiceHarness( | 311 | const provider = createProviderServiceHarness( |
| 308 | cwd, | 312 | cwd, |
| ... | @@ -1158,52 +1162,148 @@ describe("CheckpointReactor", () => { | ... | @@ -1158,52 +1162,148 @@ describe("CheckpointReactor", () => { |
| 1158 | ).toBe(true); | 1162 | ).toBe(true); |
| 1159 | }); | 1163 | }); |
| 1160 | | 1164 | |
| 1161 | it("appends capture failure activity when turn diff summary cannot be derived", async () => { | 1165 | effectIt.effect("captures a checkpoint without a summary when the baseline is missing", () => |
| 1162 | const harness = await createHarness({ seedFilesystemCheckpoints: false }); | 1166 | Effect.gen(function* () { |
| 1163 | const createdAt = "2026-01-01T00:00:00.000Z"; | 1167 | const harness = yield* Effect.promise(() => |
| 1164 | | 1168 | createHarness({ seedFilesystemCheckpoints: false }), |
| 1165 | await Effect.runPromise( | 1169 | ); |
| 1166 | harness.engine.dispatch({ | 1170 | harness.provider.emit({ |
| 1167 | type: "thread.session.set", | 1171 | type: "turn.completed", |
| 1168 | commandId: CommandId.make("cmd-session-set-missing-baseline-diff"), | 1172 | eventId: EventId.make("evt-turn-completed-missing-baseline"), |
| | 1173 | provider: ProviderDriverKind.make("codex"), |
| | 1174 | createdAt: "2026-01-01T00:00:00.000Z", |
| 1169 | threadId: ThreadId.make("thread-1"), | 1175 | threadId: ThreadId.make("thread-1"), |
| 1170 | session: { | 1176 | turnId: asTurnId("turn-missing-baseline"), |
| 1171 | threadId: ThreadId.make("thread-1"), | 1177 | payload: { state: "completed" }, |
| 1172 | status: "ready", | 1178 | }); |
| 1173 | providerName: "codex", | 1179 | expect(yield* harness.nextReceipt).toMatchObject({ |
| 1174 | runtimeMode: "approval-required", | 1180 | type: "checkpoint.diff.finalized", |
| 1175 | activeTurnId: null, | 1181 | checkpointTurnCount: 1, |
| 1176 | lastError: null, | 1182 | }); |
| 1177 | updatedAt: createdAt, | 1183 | yield* Effect.promise(harness.drain); |
| 1178 | }, | 1184 | const thread = (yield* Effect.promise(harness.readModel)).threads[0]; |
| 1179 | createdAt, | 1185 | expect(thread?.checkpoints[0]).toMatchObject({ |
| 1180 | }), | 1186 | status: "ready", |
| 1181 | ); | 1187 | checkpointTurnCount: 1, |
| 1182 | | 1188 | files: [], |
| 1183 | harness.provider.emit({ | 1189 | }); |
| 1184 | type: "turn.completed", | 1190 | expect( |
| 1185 | eventId: EventId.make("evt-turn-completed-missing-baseline"), | 1191 | gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1)), |
| 1186 | provider: ProviderDriverKind.make("codex"), | 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", | 1199 | effectIt.effect.each([ |
| 1189 | threadId: ThreadId.make("thread-1"), | 1200 | { timing: "between turns", commit: false }, |
| 1190 | turnId: asTurnId("turn-missing-baseline"), | 1201 | { timing: "between turns", commit: true }, |
| 1191 | payload: { state: "completed" }, | 1202 | { timing: "during a turn", commit: false }, |
| 1192 | }); | 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"); | 1228 | if (timing === "during a turn") { |
| 1195 | const thread = await waitForThread( | 1229 | emit("turn.started", 2); |
| 1196 | harness.readModel, | 1230 | yield* Effect.promise(harness.drain); |
| 1197 | (entry) => | 1231 | } |
| 1198 | entry.checkpoints.length === 1 && | 1232 | runGit(harness.cwd, ["init", "--initial-branch=main"]); |
| 1199 | entry.activities.some((activity) => activity.kind === "checkpoint.capture.failed"), | 1233 | if (commit) { |
| 1200 | ); | 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); | 1289 | emit("turn.started", 3); |
| 1203 | expect( | 1290 | yield* Effect.promise(harness.drain); |
| 1204 | thread.activities.some((activity) => activity.kind === "checkpoint.capture.failed"), | 1291 | NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "next turn\n"); |
| 1205 | ).toBe(true); | 1292 | emit("turn.completed", 3); |
| 1206 | }); | 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 | it("captures pre-turn baseline from project workspace root when thread worktree is unset", async () => { | 1308 | it("captures pre-turn baseline from project workspace root when thread worktree is unset", async () => { |
| 1209 | const harness = await createHarness({ | 1309 | const harness = await createHarness({ |