diff --git a/packages/chat/src/index.ts b/packages/chat/src/index.ts index 431985753..765d3ac38 100644 --- a/packages/chat/src/index.ts +++ b/packages/chat/src/index.ts @@ -304,6 +304,7 @@ export { createDrizzleRoomMessageStore, createInMemoryRoomMessageStore, postRoomMessage, + previewOf, } from "./room-messages"; export type { RoomMessage, diff --git a/packages/chat/src/room-messages.test.ts b/packages/chat/src/room-messages.test.ts index a3b5da715..b80bad1cc 100644 --- a/packages/chat/src/room-messages.test.ts +++ b/packages/chat/src/room-messages.test.ts @@ -296,6 +296,84 @@ describe("listActivity", () => { expect(activity[WORKBENCH]?.preview).not.toMatch(/model key/i); expect(activity[WORKBENCH]?.preview).not.toMatch(/ref /i); }); + + // CL-6795: bench-list preview settles on the latest person-facing text — + // never a blank title-only row while readable messages exist, and never a + // stale greeting preferred over a newer human message. + test("a newer user message previews over a stale agent greeting", async () => { + const roomMessages = createInMemoryRoomMessageStore(); + const publisher = recordingPublisher(); + await postRoomMessage( + { roomMessages, publish: publisher.publish }, + { + tenantId: TENANT, + workbenchId: WORKBENCH, + sender: { name: "Myra", address: "run_myra@acme.example" }, + runId: "run_myra", + parts: [ + { kind: "text", text: "Hi — I'm Myra. What are we working on?" }, + ], + }, + ); + await Bun.sleep(2); + const user = await postRoomMessage( + { roomMessages, publish: publisher.publish }, + { + tenantId: TENANT, + workbenchId: WORKBENCH, + sender: { name: null, address: "prn_ada@acme.example" }, + parts: [{ kind: "text", text: "draft the agenda for Monday" }], + }, + ); + + const activity = await roomMessages.listActivity({ + tenantId: TENANT, + workbenches: [{ workbenchId: WORKBENCH }], + }); + + expect(activity[WORKBENCH]?.lastActivityAt).toBe(user.createdAt); + expect(activity[WORKBENCH]?.preview).toBe("draft the agenda for Monday"); + expect(activity[WORKBENCH]?.preview).not.toMatch(/I'm Myra/i); + }); + + test("a join notice after readable text keeps the prior preview, never blanks", async () => { + const roomMessages = createInMemoryRoomMessageStore(); + const publisher = recordingPublisher(); + await postRoomMessage( + { roomMessages, publish: publisher.publish }, + { + tenantId: TENANT, + workbenchId: WORKBENCH, + sender: { name: null, address: "prn_ada@acme.example" }, + parts: [{ kind: "text", text: "let's pull Scout in" }], + }, + ); + await Bun.sleep(2); + const joined = await postRoomMessage( + { roomMessages, publish: publisher.publish }, + { + tenantId: TENANT, + workbenchId: WORKBENCH, + sender: { name: null, address: "run_scout@acme.example" }, + runId: "run_scout", + parts: [ + { + kind: "event", + event: "workbench.agent-joined", + data: { address: "run_scout@acme.example" }, + }, + ], + }, + ); + + const activity = await roomMessages.listActivity({ + tenantId: TENANT, + workbenches: [{ workbenchId: WORKBENCH }], + }); + + expect(activity[WORKBENCH]?.lastActivityAt).toBe(joined.createdAt); + expect(activity[WORKBENCH]?.preview).toBe("let's pull Scout in"); + }); }); describe("previewOf", () => { diff --git a/packages/chat/src/room-messages.ts b/packages/chat/src/room-messages.ts index c67efdaa1..b1fe90eb1 100644 --- a/packages/chat/src/room-messages.ts +++ b/packages/chat/src/room-messages.ts @@ -181,8 +181,9 @@ function isFailurePreviewParts(parts: readonly Part[]): boolean { * Pick bench-list preview text from newest-first messages: skip failed * turns and classified failure paragraphs, keep the last good human/agent * text, and fall back to the short consumer notice when nothing else - * qualifies (CL-6735). Does not skip ordinary user/agent replies - * (CL-6795 is separate). + * qualifies (CL-6735). Event / attachment-only rows contribute no text and + * are walked past so a join notice never blanks a prior readable preview + * (CL-6795). Ordinary user/agent replies are never skipped. */ function activityPreviewFromNewestFirst( newestFirst: readonly RoomMessage[], @@ -199,6 +200,12 @@ function activityPreviewFromNewestFirst( return ""; } +/** True when listActivity must walk back past the newest row for a preview + * (CL-6735 failures; CL-6795 empty event/attachment-only newest). */ +function needsPreviewLookback(parts: readonly Part[]): boolean { + return isFailurePreviewParts(parts) || previewOf(parts).length === 0; +} + function summaryOf( newest: RoomMessage, unreadCount: number, @@ -430,7 +437,7 @@ export function createDrizzleRoomMessageStore( const newest = toRoomMessage(row as MessageRow); const unreadCount = unreadByWorkbenchId.get(newest.workbenchId) ?? 0; let newestFirstForPreview: readonly RoomMessage[] = [newest]; - if (isFailurePreviewParts(newest.parts)) { + if (needsPreviewLookback(newest.parts)) { const recentRows = await db .select() .from(workbenchMessages) diff --git a/packages/chat/test/room-activity.drizzle.test.ts b/packages/chat/test/room-activity.drizzle.test.ts index 2ecbf8dd1..8a8013aae 100644 --- a/packages/chat/test/room-activity.drizzle.test.ts +++ b/packages/chat/test/room-activity.drizzle.test.ts @@ -188,4 +188,44 @@ describeIfDb("createDrizzleRoomMessageStore: listActivity", () => { await sql.end(); } }); + + // CL-6795: a join/event newest row must not blank the list preview while + // earlier readable text still exists. + test("a join notice after readable text keeps the prior preview", async () => { + const sql = postgres(scratchUrl, { max: 5, onnotice: () => undefined }); + try { + const store = createDrizzleRoomMessageStore(drizzle(sql)); + await store.insertMessage({ + id: "msg_join_keep_1", + tenantId: TENANT, + workbenchId: "run_join_keep", + sender: { name: null, address: "prn_alice@acme.example" }, + parts: [{ kind: "text", text: "let's pull Scout in" }], + }); + const joined = await store.insertMessage({ + id: "msg_join_keep_2", + tenantId: TENANT, + workbenchId: "run_join_keep", + sender: { name: null, address: "run_scout@acme.example" }, + runId: "run_scout", + parts: [ + { + kind: "event", + event: "workbench.agent-joined", + data: { address: "run_scout@acme.example" }, + }, + ], + }); + + const activity = await store.listActivity({ + tenantId: TENANT, + workbenches: [{ workbenchId: "run_join_keep" }], + }); + + expect(activity["run_join_keep"]?.lastActivityAt).toBe(joined.createdAt); + expect(activity["run_join_keep"]?.preview).toBe("let's pull Scout in"); + } finally { + await sql.end(); + } + }); });