| ... | ... | @@ -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 | 411 | describe("context-window snapshot dedup", () => { |
| 261 | 412 | function makeContextWindowActivity( |
| 262 | 413 | id: string, |