| ... | @@ -257,6 +257,157 @@ describe("projectActivityPayload", () => { | ... | @@ -257,6 +257,157 @@ describe("projectActivityPayload", () => { |
| 257 | }); | 257 | }); |
| 258 | }); | 258 | }); |
| 259 | | 259 | |
| | 260 | describe("superseded tool.updated snapshot dedup", () => { |
| | 261 | function makeToolLifecycleActivity( |
| | 262 | id: string, |
| | 263 | kind: "tool.updated" | "tool.completed", |
| | 264 | options: { |
| | 265 | readonly turn?: string; |
| | 266 | readonly title?: string; |
| | 267 | readonly detail?: string; |
| | 268 | readonly toolCallId?: string; |
| | 269 | } = {}, |
| | 270 | ): OrchestrationThreadActivity { |
| | 271 | const { turn = "turn-a", title = "File change", detail, toolCallId } = options; |
| | 272 | return { |
| | 273 | id: EventId.make(id), |
| | 274 | tone: "tool", |
| | 275 | kind, |
| | 276 | summary: title, |
| | 277 | payload: { |
| | 278 | itemType: "file_change", |
| | 279 | title, |
| | 280 | ...(detail ? { detail } : {}), |
| | 281 | data: { |
| | 282 | ...(toolCallId ? { toolCallId } : {}), |
| | 283 | toolName: "Edit", |
| | 284 | input: { file_path: "src/app.ts" }, |
| | 285 | }, |
| | 286 | }, |
| | 287 | turnId: TurnId.make(turn), |
| | 288 | createdAt: "2026-07-27T00:00:00.000Z", |
| | 289 | }; |
| | 290 | } |
| | 291 | |
| | 292 | function projectedIds(activities: ReadonlyArray<OrchestrationThreadActivity>) { |
| | 293 | return projectThreadDetailSnapshot({ |
| | 294 | snapshotSequence: 7, |
| | 295 | thread: makeThread(activities), |
| | 296 | }).thread.activities.map((activity) => activity.id); |
| | 297 | } |
| | 298 | |
| | 299 | it("drops updates a later completion supersedes in the same turn", () => { |
| | 300 | const update1 = makeToolLifecycleActivity("upd-1", "tool.updated"); |
| | 301 | const update2 = makeToolLifecycleActivity("upd-2", "tool.updated"); |
| | 302 | const completed = makeToolLifecycleActivity("done-1", "tool.completed"); |
| | 303 | |
| | 304 | expect(projectedIds([update1, update2, completed])).toEqual([completed.id]); |
| | 305 | }); |
| | 306 | |
| | 307 | it("matches on toolCallId when the adapter emits one", () => { |
| | 308 | const otherCall = makeToolLifecycleActivity("upd-other", "tool.updated", { |
| | 309 | toolCallId: "call-b", |
| | 310 | }); |
| | 311 | const update = makeToolLifecycleActivity("upd-a", "tool.updated", { toolCallId: "call-a" }); |
| | 312 | const completed = makeToolLifecycleActivity("done-a", "tool.completed", { |
| | 313 | toolCallId: "call-a", |
| | 314 | }); |
| | 315 | |
| | 316 | // Same itemType/title, different call: only call-a's update is superseded. |
| | 317 | expect(projectedIds([otherCall, update, completed])).toEqual([otherCall.id, completed.id]); |
| | 318 | }); |
| | 319 | |
| | 320 | it("keeps updates with no matching completion", () => { |
| | 321 | const inFlight = makeToolLifecycleActivity("upd-live", "tool.updated", { title: "Running" }); |
| | 322 | const other = makeToolLifecycleActivity("upd-other", "tool.updated", { title: "Reading" }); |
| | 323 | const completed = makeToolLifecycleActivity("done-other", "tool.completed", { |
| | 324 | title: "Reading", |
| | 325 | }); |
| | 326 | |
| | 327 | expect(projectedIds([inFlight, other, completed])).toEqual([inFlight.id, completed.id]); |
| | 328 | }); |
| | 329 | |
| | 330 | it("keeps an update whose completion lives in another turn", () => { |
| | 331 | // A live thread.reverted can discard the completing turn while keeping |
| | 332 | // the updating one, which would leave the call unrepresented. |
| | 333 | const update = makeToolLifecycleActivity("upd-kept", "tool.updated", { turn: "turn-kept" }); |
| | 334 | const completed = makeToolLifecycleActivity("done-later", "tool.completed", { |
| | 335 | turn: "turn-reverted", |
| | 336 | }); |
| | 337 | |
| | 338 | expect(projectedIds([update, completed])).toEqual([update.id, completed.id]); |
| | 339 | }); |
| | 340 | |
| | 341 | it("keeps an update that follows its completion", () => { |
| | 342 | // A later update under the same identity is the next call, still in flight. |
| | 343 | const completed = makeToolLifecycleActivity("done-first", "tool.completed"); |
| | 344 | const nextCall = makeToolLifecycleActivity("upd-next", "tool.updated"); |
| | 345 | |
| | 346 | expect(projectedIds([completed, nextCall])).toEqual([completed.id, nextCall.id]); |
| | 347 | }); |
| | 348 | |
| | 349 | it("keeps identity-less rows the clients never collapse", () => { |
| | 350 | const anonymous: OrchestrationThreadActivity = { |
| | 351 | id: EventId.make("upd-anon"), |
| | 352 | tone: "tool", |
| | 353 | kind: "tool.updated", |
| | 354 | summary: " ", |
| | 355 | payload: { data: { toolName: "Edit" } }, |
| | 356 | turnId: TurnId.make("turn-a"), |
| | 357 | createdAt: "2026-07-27T00:00:00.000Z", |
| | 358 | }; |
| | 359 | const completed: OrchestrationThreadActivity = { |
| | 360 | ...anonymous, |
| | 361 | id: EventId.make("done-anon"), |
| | 362 | kind: "tool.completed", |
| | 363 | }; |
| | 364 | |
| | 365 | expect(projectedIds([anonymous, completed])).toEqual([anonymous.id, completed.id]); |
| | 366 | }); |
| | 367 | |
| | 368 | it("does not filter live activity-appended events", () => { |
| | 369 | const update = makeToolLifecycleActivity("upd-live-event", "tool.updated"); |
| | 370 | const event = { |
| | 371 | sequence: 11, |
| | 372 | eventId: EventId.make("event-tool-updated"), |
| | 373 | aggregateKind: "thread", |
| | 374 | aggregateId: ThreadId.make("thread-projection"), |
| | 375 | occurredAt: "2026-07-27T00:00:03.000Z", |
| | 376 | commandId: null, |
| | 377 | causationEventId: null, |
| | 378 | correlationId: null, |
| | 379 | metadata: {}, |
| | 380 | type: "thread.activity-appended", |
| | 381 | payload: { |
| | 382 | threadId: ThreadId.make("thread-projection"), |
| | 383 | activity: update, |
| | 384 | }, |
| | 385 | } satisfies Extract<OrchestrationEvent, { type: "thread.activity-appended" }>; |
| | 386 | |
| | 387 | const projected = projectActivityEvent(event); |
| | 388 | expect( |
| | 389 | projected.type === "thread.activity-appended" ? projected.payload.activity.id : undefined, |
| | 390 | ).toEqual(update.id); |
| | 391 | }); |
| | 392 | |
| | 393 | it("leaves the collapsed work log identical to the full history", () => { |
| | 394 | const activities = [ |
| | 395 | makeToolLifecycleActivity("upd-1", "tool.updated", { detail: "writing" }), |
| | 396 | makeToolLifecycleActivity("upd-2", "tool.updated", { detail: "writing" }), |
| | 397 | makeToolLifecycleActivity("done-1", "tool.completed", { detail: "writing" }), |
| | 398 | ]; |
| | 399 | const projected = projectThreadDetailSnapshot({ |
| | 400 | snapshotSequence: 7, |
| | 401 | thread: makeThread(activities), |
| | 402 | }); |
| | 403 | |
| | 404 | const before = deriveWorkLogEntries(activities); |
| | 405 | const after = deriveWorkLogEntries(projected.thread.activities); |
| | 406 | expect(after).toHaveLength(before.length); |
| | 407 | expect(after.map((entry) => entry.label)).toEqual(before.map((entry) => entry.label)); |
| | 408 | }); |
| | 409 | }); |
| | 410 | |
| 260 | describe("context-window snapshot dedup", () => { | 411 | describe("context-window snapshot dedup", () => { |
| 261 | function makeContextWindowActivity( | 412 | function makeContextWindowActivity( |
| 262 | id: string, | 413 | id: string, |