|
4 | 4 | buildPrompt, |
5 | 5 | adoptPersistedTask, |
6 | 6 | type Dispatcher, |
| 7 | + type GitSnapshot, |
7 | 8 | type Task, |
8 | 9 | } from "../src/server/dispatcher"; |
9 | 10 | import { createReportStore, type ReportStore } from "../src/server/report-store"; |
@@ -199,6 +200,148 @@ describe("dispatcher", () => { |
199 | 200 | }); |
200 | 201 | }); |
201 | 202 |
|
| 203 | +describe("dispatcher closes the report out", () => { |
| 204 | + let resultsDir: string; |
| 205 | + let tasksDir: string; |
| 206 | + let reportsDir: string; |
| 207 | + let store: ReportStore; |
| 208 | + |
| 209 | + /** A working tree where each named file carries a content stamp. */ |
| 210 | + function tree(...entries: [string, string][]): GitSnapshot { |
| 211 | + return Object.fromEntries(entries); |
| 212 | + } |
| 213 | + |
| 214 | + /** A dispatcher whose git snapshots are scripted, one call per invocation. */ |
| 215 | + function withGit(snapshots: (GitSnapshot | undefined)[], project = PROJECT): Dispatcher { |
| 216 | + let call = 0; |
| 217 | + return createDispatcher({ |
| 218 | + store, |
| 219 | + resultsDir, |
| 220 | + tasksDir, |
| 221 | + getProject: (slug) => (slug === "test-app" ? project : undefined), |
| 222 | + command: "echo", |
| 223 | + gitSnapshot: async () => snapshots[call++], |
| 224 | + }); |
| 225 | + } |
| 226 | + |
| 227 | + beforeEach(async () => { |
| 228 | + resultsDir = tmpDir(); |
| 229 | + tasksDir = tmpDir(); |
| 230 | + reportsDir = tmpDir(); |
| 231 | + await mkdir(resultsDir, { recursive: true }); |
| 232 | + await mkdir(tasksDir, { recursive: true }); |
| 233 | + await mkdir(reportsDir, { recursive: true }); |
| 234 | + store = createReportStore(reportsDir); |
| 235 | + }); |
| 236 | + |
| 237 | + afterEach(async () => { |
| 238 | + await Promise.all( |
| 239 | + [resultsDir, tasksDir, reportsDir].map((d) => rm(d, { recursive: true, force: true })), |
| 240 | + ); |
| 241 | + }); |
| 242 | + |
| 243 | + test("a run that changed files resolves it, with what changed", async () => { |
| 244 | + const dispatcher = withGit([tree(), tree(["src/hero.tsx", "12:100"])]); |
| 245 | + const reportId = (await store.save({ prompt: "fix it" }, "test-app")).id; |
| 246 | + |
| 247 | + dispatcher.enqueue(reportId, "test-app"); |
| 248 | + await dispatcher.process(); |
| 249 | + await dispatcher.drain(); |
| 250 | + |
| 251 | + expect((await store.get(reportId))?.status).toBe("resolved"); |
| 252 | + const resolution = JSON.parse( |
| 253 | + await readFile(join((await store.get(reportId))!.dir, "resolution.json"), "utf-8"), |
| 254 | + ); |
| 255 | + expect(resolution.summary).toContain("src/hero.tsx"); |
| 256 | + }); |
| 257 | + |
| 258 | + test("a plan-mode run that changed nothing reopens it and says why", async () => { |
| 259 | + // Identical snapshots: the agent proposed and waited for an answer. |
| 260 | + const dispatcher = withGit([ |
| 261 | + tree(["src/other.tsx", "40:100"]), |
| 262 | + tree(["src/other.tsx", "40:100"]), |
| 263 | + ]); |
| 264 | + const reportId = (await store.save({ prompt: "fix it" }, "test-app")).id; |
| 265 | + |
| 266 | + const taskId = dispatcher.enqueue(reportId, "test-app"); |
| 267 | + await dispatcher.process(); |
| 268 | + await dispatcher.drain(); |
| 269 | + |
| 270 | + expect(dispatcher.getTask(taskId)?.status).toBe("completed"); |
| 271 | + expect(dispatcher.getTask(taskId)?.result?.note).toContain('permission: "auto"'); |
| 272 | + // Back to "new", not left claiming someone dealt with it. |
| 273 | + expect((await store.get(reportId))?.status).toBe("new"); |
| 274 | + }); |
| 275 | + |
| 276 | + test("an auto-permission run that changed nothing says so without blaming plan mode", async () => { |
| 277 | + const dispatcher = withGit([tree(), tree()], { ...PROJECT, permission: "auto" }); |
| 278 | + const reportId = (await store.save({ prompt: "fix it" }, "test-app")).id; |
| 279 | + |
| 280 | + const taskId = dispatcher.enqueue(reportId, "test-app"); |
| 281 | + await dispatcher.process(); |
| 282 | + await dispatcher.drain(); |
| 283 | + |
| 284 | + expect(dispatcher.getTask(taskId)?.result?.note).toBe("the agent changed nothing."); |
| 285 | + expect((await store.get(reportId))?.status).toBe("new"); |
| 286 | + }); |
| 287 | + |
| 288 | + test("editing an already-dirty file counts as a change", async () => { |
| 289 | + // Porcelain names the same path before and after, so a path-list |
| 290 | + // comparison calls this a no-op and reopens a report that was handled. |
| 291 | + const dispatcher = withGit([ |
| 292 | + tree(["src/hero.tsx", "40:100"]), |
| 293 | + tree(["src/hero.tsx", "62:900"]), |
| 294 | + ]); |
| 295 | + const reportId = (await store.save({ prompt: "fix it" }, "test-app")).id; |
| 296 | + |
| 297 | + const taskId = dispatcher.enqueue(reportId, "test-app"); |
| 298 | + await dispatcher.process(); |
| 299 | + await dispatcher.drain(); |
| 300 | + |
| 301 | + expect(dispatcher.getTask(taskId)?.result?.note).toBeUndefined(); |
| 302 | + expect(dispatcher.getTask(taskId)?.result?.changedFiles).toEqual(["src/hero.tsx"]); |
| 303 | + expect((await store.get(reportId))?.status).toBe("resolved"); |
| 304 | + }); |
| 305 | + |
| 306 | + test("a file the agent reverted to clean counts as a change", async () => { |
| 307 | + const dispatcher = withGit([tree(["src/hero.tsx", "40:100"]), tree()]); |
| 308 | + const reportId = (await store.save({ prompt: "revert it" }, "test-app")).id; |
| 309 | + |
| 310 | + const taskId = dispatcher.enqueue(reportId, "test-app"); |
| 311 | + await dispatcher.process(); |
| 312 | + await dispatcher.drain(); |
| 313 | + |
| 314 | + expect(dispatcher.getTask(taskId)?.result?.changedFiles).toEqual(["src/hero.tsx"]); |
| 315 | + expect((await store.get(reportId))?.status).toBe("resolved"); |
| 316 | + }); |
| 317 | + |
| 318 | + test("without git it resolves rather than stranding the report", async () => { |
| 319 | + const dispatcher = withGit([undefined, undefined]); |
| 320 | + const reportId = (await store.save({ prompt: "fix it" }, "test-app")).id; |
| 321 | + |
| 322 | + const taskId = dispatcher.enqueue(reportId, "test-app"); |
| 323 | + await dispatcher.process(); |
| 324 | + await dispatcher.drain(); |
| 325 | + |
| 326 | + expect(dispatcher.getTask(taskId)?.result?.note).toBeUndefined(); |
| 327 | + expect((await store.get(reportId))?.status).toBe("resolved"); |
| 328 | + }); |
| 329 | + |
| 330 | + test("reopening does not re-run the report on its own", async () => { |
| 331 | + // Reopening is so the report is visibly still waiting, not a retry loop: |
| 332 | + // the completed task still guards it against another automatic dispatch. |
| 333 | + const dispatcher = withGit([tree(["a", "1:1"]), tree(["a", "1:1"])]); |
| 334 | + const reportId = (await store.save({ prompt: "fix it" }, "test-app")).id; |
| 335 | + |
| 336 | + dispatcher.enqueue(reportId, "test-app"); |
| 337 | + await dispatcher.process(); |
| 338 | + await dispatcher.drain(); |
| 339 | + |
| 340 | + expect((await store.get(reportId))?.status).toBe("new"); |
| 341 | + expect(await dispatcher.dispatchAll("test-app")).toHaveLength(0); |
| 342 | + }); |
| 343 | +}); |
| 344 | + |
202 | 345 | describe("buildPrompt", () => { |
203 | 346 | const report = { |
204 | 347 | id: "r1", |
|
0 commit comments