Skip to content

Commit edbf408

Browse files
authored
feat(mothership): let an agent chat list its owner's other live chats for history recall (#9238)
* feat(mothership): let an agent chat list its owner's other live chats for history recall * test(mothership): mock organization chats through the central testing mock in the recall test * test(mothership): prove the recall chat list filters against real postgres rows * chore(mothership): sync the recall chat contract, which now requires an ISO timestamp * test(mothership): drop an unused import from the recall chat integration test
1 parent 8b7afed commit edbf408

10 files changed

Lines changed: 677 additions & 20 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { readRecallChatsContract } from '@/lib/api/contracts/mothership-chat-recall'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
} from '@/lib/api/server/routes'
7+
import { internalCopilotAuth } from '@/lib/mothership/auth/internal'
8+
import {
9+
RECALL_CHATS_AUDIENCE,
10+
readRecallChats,
11+
readRecallChatsOperation,
12+
} from '@/lib/mothership/chat/application/read-recall-chats'
13+
14+
export const POST = defineInternalJsonRoute({
15+
contract: readRecallChatsContract,
16+
auth: internalCopilotAuth(RECALL_CHATS_AUDIENCE, { organization: true }),
17+
operation: readRecallChatsOperation,
18+
rateLimit: internalRateLimits.none({
19+
reason: "Authenticated worker lists the chat owner's other live chats it may read back.",
20+
}),
21+
errorPolicy: internalOrchestrationErrorPolicy,
22+
mapInput: ({ body }) => body,
23+
useCase: readRecallChats,
24+
})
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { defineRouteContract } from '@/lib/api/contracts/types'
2+
import { RecallChatsRequest, RecallChatsResponse } from '@/lib/mothership/generated/chat-recall'
3+
4+
export const readRecallChatsContract = defineRouteContract({
5+
method: 'POST',
6+
path: '/api/mothership/chats/recall',
7+
body: RecallChatsRequest,
8+
response: { mode: 'json', schema: RecallChatsResponse },
9+
})
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
/** Runs the recall list query against real PostgreSQL rows, so each filter is proven by a row it drops. */
2+
import { db } from '@sim/db'
3+
import { copilotChats, organization, user, workspace } from '@sim/db/schema'
4+
import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
5+
import { generateId } from '@sim/utils/id'
6+
import { inArray, or } from 'drizzle-orm'
7+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
8+
import { listRecallChats } from '@/lib/mothership/chat/application/read-recall-chats'
9+
10+
const ownerId = generateId()
11+
const otherUserId = generateId()
12+
const workspaceId = generateId()
13+
const otherWorkspaceId = generateId()
14+
const organizationId = generateId()
15+
const otherOrganizationId = generateId()
16+
17+
const at = (minute: number) => new Date(Date.UTC(2026, 9, 19, 10, minute))
18+
const benchmark = { benchmark: { id: 'benchmark-1', operatorUserId: ownerId } }
19+
20+
async function chat(
21+
values: Partial<typeof copilotChats.$inferInsert> &
22+
({ workspaceId: string } | { organizationId: string })
23+
) {
24+
await db
25+
.insert(copilotChats)
26+
.values({ userId: ownerId, type: 'mothership', updatedAt: at(0), ...values })
27+
}
28+
29+
/** The tied pair in the order the query must return them: higher id first. */
30+
const tieOrder = (a: string, b: string) => (a > b ? [a, b] : [b, a])
31+
32+
const workspaceChats = {
33+
calling: generateId(),
34+
oldest: generateId(),
35+
tieA: generateId(),
36+
tieB: generateId(),
37+
newest: generateId(),
38+
plan: generateId(),
39+
}
40+
const organizationChats = {
41+
calling: generateId(),
42+
assistant: generateId(),
43+
agent: generateId(),
44+
plan: generateId(),
45+
}
46+
47+
beforeAll(async () => {
48+
const now = new Date()
49+
await db.insert(user).values(
50+
[ownerId, otherUserId].map((id) => ({
51+
id,
52+
name: 'Recall chats fixture',
53+
email: `${id}@recall-chats.test`,
54+
emailVerified: true,
55+
createdAt: now,
56+
updatedAt: now,
57+
}))
58+
)
59+
for (const id of [workspaceId, otherWorkspaceId])
60+
await insertWorkspaceFixture(db, {
61+
id,
62+
name: 'Recall chats fixture',
63+
ownerId,
64+
billedAccountUserId: ownerId,
65+
})
66+
await db.insert(organization).values(
67+
[organizationId, otherOrganizationId].map((id) => ({
68+
id,
69+
name: 'Recall chats fixture',
70+
slug: id,
71+
}))
72+
)
73+
74+
await chat({ id: workspaceChats.calling, workspaceId, updatedAt: at(50) })
75+
await chat({ id: workspaceChats.oldest, workspaceId, title: 'Oldest', updatedAt: at(1) })
76+
await chat({ id: workspaceChats.tieA, workspaceId, updatedAt: at(2) })
77+
await chat({ id: workspaceChats.tieB, workspaceId, updatedAt: at(2) })
78+
await chat({ id: workspaceChats.newest, workspaceId, title: 'Newest', updatedAt: at(3) })
79+
await chat({
80+
id: workspaceChats.plan,
81+
workspaceId,
82+
config: { conversationMode: 'plan' },
83+
updatedAt: at(4),
84+
})
85+
// Each row below differs from a listed one by exactly one excluded property.
86+
await chat({ workspaceId, deletedAt: now, updatedAt: at(40) })
87+
await chat({ workspaceId, config: benchmark, updatedAt: at(41) })
88+
await chat({ workspaceId, userId: otherUserId, updatedAt: at(42) })
89+
await chat({ workspaceId: otherWorkspaceId, updatedAt: at(43) })
90+
await chat({ workspaceId, type: 'copilot', updatedAt: at(44) })
91+
92+
await chat({ id: organizationChats.calling, organizationId, updatedAt: at(50) })
93+
await chat({ id: organizationChats.assistant, organizationId, updatedAt: at(1) })
94+
await chat({
95+
id: organizationChats.agent,
96+
organizationId,
97+
config: { conversationMode: 'agent' },
98+
updatedAt: at(2),
99+
})
100+
await chat({
101+
id: organizationChats.plan,
102+
organizationId,
103+
config: { conversationMode: 'plan' },
104+
updatedAt: at(3),
105+
})
106+
await chat({ organizationId, deletedAt: now, updatedAt: at(40) })
107+
await chat({ organizationId, config: benchmark, updatedAt: at(41) })
108+
await chat({ organizationId, userId: otherUserId, updatedAt: at(42) })
109+
await chat({ organizationId: otherOrganizationId, updatedAt: at(43) })
110+
await chat({ organizationId, type: 'copilot', updatedAt: at(44) })
111+
})
112+
113+
afterAll(async () => {
114+
await db
115+
.delete(copilotChats)
116+
.where(
117+
or(
118+
inArray(copilotChats.workspaceId, [workspaceId, otherWorkspaceId]),
119+
inArray(copilotChats.organizationId, [organizationId, otherOrganizationId])
120+
)
121+
)
122+
await db
123+
.delete(organization)
124+
.where(inArray(organization.id, [organizationId, otherOrganizationId]))
125+
await deleteWorkspaceFixture(db, inArray(workspace.id, [workspaceId, otherWorkspaceId]))
126+
await db.delete(user).where(inArray(user.id, [ownerId, otherUserId]))
127+
await db.$client.end()
128+
})
129+
130+
const ids = (rows: { id: string }[]) => rows.map(({ id }) => id)
131+
132+
describe("listing a chat owner's other live chats", () => {
133+
const inWorkspace = (includePlanChats: boolean, limit = 50) =>
134+
listRecallChats({
135+
userId: ownerId,
136+
owner: { workspaceId },
137+
excludeChatId: workspaceChats.calling,
138+
includePlanChats,
139+
limit,
140+
})
141+
const inOrganization = (includePlanChats: boolean) =>
142+
listRecallChats({
143+
userId: ownerId,
144+
owner: { organizationId },
145+
excludeChatId: organizationChats.calling,
146+
includePlanChats,
147+
limit: 50,
148+
})
149+
150+
it("lists only the owner's live home chats in the workspace, newest first and ties by id, without plan chats while Plan mode is off", async () => {
151+
const rows = await inWorkspace(false)
152+
expect(ids(rows)).toEqual([
153+
workspaceChats.newest,
154+
...tieOrder(workspaceChats.tieA, workspaceChats.tieB),
155+
workspaceChats.oldest,
156+
])
157+
expect(rows[0]).toEqual({ id: workspaceChats.newest, title: 'Newest', updatedAt: at(3) })
158+
})
159+
160+
it('lists the plan chats too while Plan mode is on', async () => {
161+
expect(ids(await inWorkspace(true))).toEqual([
162+
workspaceChats.plan,
163+
workspaceChats.newest,
164+
...tieOrder(workspaceChats.tieA, workspaceChats.tieB),
165+
workspaceChats.oldest,
166+
])
167+
})
168+
169+
it("lists only the owner's live home chats in the organization, plan chats only while Plan mode is on", async () => {
170+
expect(ids(await inOrganization(false))).toEqual([
171+
organizationChats.agent,
172+
organizationChats.assistant,
173+
])
174+
expect(ids(await inOrganization(true))).toEqual([
175+
organizationChats.plan,
176+
organizationChats.agent,
177+
organizationChats.assistant,
178+
])
179+
})
180+
181+
it('stops at the limit, keeping the most recently updated', async () => {
182+
expect(ids(await inWorkspace(true, 2))).toEqual([workspaceChats.plan, workspaceChats.newest])
183+
})
184+
})

0 commit comments

Comments
 (0)