fix(api,stage-ui): keep the character of synchronized chats (#2874)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
RainbowBird
2026-10-21 01:56:16 +08:00
committed by GitHub
co-authored by Claude Opus 5.5
parent bc41885f0b
commit 7a0f5bc391
9 changed files with 521 additions and 18 deletions
@@ -0,0 +1,112 @@
# A character is a member of a chat
Status: proposed
## Context
The product goal is a contact list, where each character is a contact, as in Discord.
A later goal is a group chat with one or more users and many characters.
The client is local-first. A user without an account must get every function in this document.
The code calls the entity a "card". A card holds the persona, the voice, the chat model, and a set of display models.
A chat session holds one `characterId`. The local index groups sessions by that id.
These parts are already on `main`:
- A character holds a set of Avatar Model references and one default (#2458).
- Display model files synchronize to the account, and a downloaded model keeps its id (#2869).
- Text turns, voice turns, and each model step read the settings of the session character through `getModules(characterId)` (#2725).
The current code has these gaps:
- Chat synchronization loses the character. A second device puts every received chat under the `default` character (`session-store.ts`, the `adopt` step).
- The activation of a card writes its modules into the global runtime stores (`writeRuntimeModules`).
- Code reads `meta.characterId` directly. No function tells which characters are members of a session.
## Decision
### Names
- A **character** is the entity. It is a member of a chat.
- A **character card** is the import and export format of a character.
- This change renames the concept and the text that users see. It does not rename code identifiers.
### Relations
```mermaid
erDiagram
CHAT ||--|{ MEMBER : has
CHAT ||--o{ MESSAGE : has
USER ||--o{ MEMBER : "is"
CHARACTER ||--o{ MEMBER : "is"
CHARACTER }o--o{ DISPLAY_MODEL : "can show"
CHARACTER ||--|| VOICE : has
CHARACTER ||--|| PERSONA : has
CHARACTER ||--|| CHAT_MODEL : has
CHARACTER ||--o{ MEMORY : "has (later)"
```
- Today, a chat has one user and one character.
- A group chat uses the same relations with more members.
- A character does not change after its chat exists. A chat of one character has no member conflict.
### Chat members
- Chat synchronization sends and receives the members of each chat. See `server/docs/ai/adr/2026-10-09-chat-list-members.md`.
- One function answers "which characters are members of this session". New code calls this function. Step 2 adds it.
- New code does not read `meta.characterId` or the character groups of the local index.
- The local storage shape stays. A later change for group chats replaces the body of that one function.
### Character settings
- Turns read the settings of the session character through `getModules(characterId)`. This is on `main`.
- The activation of a card stops the writes into the global runtime stores. Those stores hold only the global defaults.
- The order of the sources is: character value, then global default. A later change can put a value of the chat member first.
### Display models
- A character holds `avatarModels` and `defaultAvatarModelId` (#2458).
- Many characters can refer to one display model.
### Local-first rules
- The local data is the source. The server is a mirror that carries the data to other devices.
- If a device does not have a display model that a character refers to, the device keeps the reference.
- The device then shows a placeholder and asks the user to import the model. It does not write the default model into the character.
- If a received chat refers to a character that the device does not have, the device keeps the character id.
- A provider that a device did not configure follows the same rule. The device uses the next source and keeps the stored value.
### Repair of old sessions
- Earlier versions put received chats under `default`.
- During a reconcile, a device compares the local character of a session with the character member on the server.
- If the two differ, the device applies the server value and moves the session to that character group.
- The rule is safe because a character does not change after its chat exists.
- The move keeps the stored messages and the time of the session.
- The move does not change the session that a window shows.
## Non-goals
- The UI of a group chat, and the order in which many characters speak.
- Chats with many users. They need permissions and an order that the server decides.
- Character memory. `stores/character/notebook.ts` stays global. New memory data must use a character id as its key.
- A new name for `airi-card` identifiers in code.
- A change to the chat model of a character.
## Steps
| Step | Content | Workspaces | State |
| --- | --- | --- | --- |
| 1 | Synchronize chat members. Repair old sessions. | `server/apps/api`, `packages/stage-ui` | This change |
| 2 | Add the function for the members of a session. | `packages/stage-ui` | To do |
| 3 | Stop the writes into the global runtime stores when a card becomes active. | `packages/stage-ui`, settings pages | To do |
| 4 | Build the contact list for the mobile layout. | `packages/stage-ui`, `packages/stage-layouts` | To do |
Turns that read the session character (#2725) and a set of display models (#2458) are on `main`.
## Consequences
- A contact list can group sessions by character on every device of an account.
- Two windows can show different characters, because a turn no longer reads a global selection.
- A character on a second device can show a placeholder until the user imports its display model.
- Step 3 changes what the settings pages edit. A page must tell whether it edits the global default or the character.
@@ -1,9 +1,9 @@
import type { ChatSessionMeta } from '../../types/chat-session'
import type { CloudChatMapper, RemoteChat } from './cloud-mapper'
import type { CloudChatMapper, ListedRemoteChat } from './cloud-mapper'
import { describe, expect, it, vi } from 'vitest'
import { applyCreateActions, createCloudChatMapper, reconcileLocalAndRemote } from './cloud-mapper'
import { applyCreateActions, characterIdOfRemoteChat, createCloudChatMapper, reconcileLocalAndRemote } from './cloud-mapper'
function makeMeta(partial: Partial<ChatSessionMeta>): ChatSessionMeta {
return {
@@ -16,16 +16,38 @@ function makeMeta(partial: Partial<ChatSessionMeta>): ChatSessionMeta {
}
}
function makeRemote(partial: Partial<RemoteChat>): RemoteChat {
function makeRemote(partial: Partial<ListedRemoteChat>): ListedRemoteChat {
return {
id: partial.id ?? 'chat-1',
type: partial.type ?? 'bot',
title: partial.title ?? null,
createdAt: partial.createdAt ?? '2026-01-01T00:00:00.000Z',
updatedAt: partial.updatedAt ?? '2026-01-01T00:00:00.000Z',
members: partial.members ?? membersOf('char-1'),
}
}
function membersOf(...characterIds: string[]): ListedRemoteChat['members'] {
return [
{ memberType: 'user', characterId: null },
...characterIds.map(characterId => ({ memberType: 'character' as const, characterId })),
]
}
describe('characterIdOfRemoteChat', () => {
it('returns the character member of a chat', () => {
expect(characterIdOfRemoteChat(makeRemote({ members: membersOf('luna') }))).toBe('luna')
})
it('returns undefined for a chat without a character member', () => {
expect(characterIdOfRemoteChat(makeRemote({ members: membersOf() }))).toBeUndefined()
})
it('returns undefined for a chat with many character members', () => {
expect(characterIdOfRemoteChat(makeRemote({ members: membersOf('luna', 'sol') }))).toBeUndefined()
})
})
describe('reconcileLocalAndRemote', () => {
/**
* @example
@@ -152,6 +174,52 @@ function emptyResponse(init: { status?: number, statusText?: string } = {}): Res
})
}
describe('reconcileLocalAndRemote · reassign', () => {
/**
* @example
* An earlier version put the received chat "abc" under `default`.
* The remote chat has the character member "luna".
* Expected: the session moves to "luna".
*/
it('reassigns a mapped session whose remote chat has another character', () => {
const plan = reconcileLocalAndRemote(
[makeMeta({ sessionId: 'abc', cloudChatId: 'abc', characterId: 'default' })],
[makeRemote({ id: 'abc', members: membersOf('luna') })],
)
expect(plan.reassign).toEqual([{ sessionId: 'abc', characterId: 'luna' }])
})
it('leaves a session whose remote chat has the same character', () => {
const plan = reconcileLocalAndRemote(
[makeMeta({ sessionId: 'abc', cloudChatId: 'abc', characterId: 'luna' })],
[makeRemote({ id: 'abc', members: membersOf('luna') })],
)
expect(plan.reassign).toEqual([])
})
it('leaves a session whose remote chat has no single character member', () => {
const plan = reconcileLocalAndRemote(
[
makeMeta({ sessionId: 'none', cloudChatId: 'none', characterId: 'default' }),
makeMeta({ sessionId: 'many', cloudChatId: 'many', characterId: 'default' }),
],
[
makeRemote({ id: 'none', members: membersOf() }),
makeRemote({ id: 'many', members: membersOf('luna', 'sol') }),
],
)
expect(plan.reassign).toEqual([])
})
it('leaves a session that has no remote chat', () => {
const plan = reconcileLocalAndRemote(
[makeMeta({ sessionId: 'abc', cloudChatId: 'gone', characterId: 'default' })],
[],
)
expect(plan.reassign).toEqual([])
})
})
describe('createCloudChatMapper.listChats', () => {
/**
* @example
@@ -159,11 +227,15 @@ describe('createCloudChatMapper.listChats', () => {
*/
it('returns the chats array on 2xx', async () => {
const fetchMock = vi.fn(async () => jsonResponse({
chats: [{ id: 'a', type: 'bot', title: null, createdAt: '2026-01-01', updatedAt: '2026-01-01' }],
chats: [{ id: 'a', type: 'bot', title: null, createdAt: '2026-01-01', updatedAt: '2026-01-01', members: [
{ id: 'm1', chatId: 'a', memberType: 'user', userId: 'u1', characterId: null },
{ id: 'm2', chatId: 'a', memberType: 'character', userId: null, characterId: 'luna' },
] }],
}))
const mapper = createCloudChatMapper({ serverUrl: 'https://api.example.com', fetch: fetchMock as unknown as typeof fetch })
const chats = await mapper.listChats()
expect(chats.map(c => c.id)).toEqual(['a'])
expect(chats.map(characterIdOfRemoteChat)).toEqual(['luna'])
expect(fetchMock).toHaveBeenCalledWith(
'https://api.example.com/api/v1/chats',
expect.objectContaining({ method: 'GET' }),
@@ -175,6 +247,19 @@ describe('createCloudChatMapper.listChats', () => {
* Server schema drift: `chats` is `null` instead of an array. Without
* boundary validation this would feed `null.length` down into reconcile.
*/
/**
* @example
* A server older than this client returns chats without `members`.
* A parse that accepted them would put every received chat under `default`.
*/
it('rejects a chat without members', async () => {
const fetchMock = vi.fn(async () => jsonResponse({
chats: [{ id: 'a', type: 'bot', title: null, createdAt: '2026-01-01', updatedAt: '2026-01-01' }],
}))
const mapper = createCloudChatMapper({ serverUrl: 'https://api.example.com', fetch: fetchMock as unknown as typeof fetch })
await expect(mapper.listChats()).rejects.toThrow()
})
it('rejects malformed responses on 2xx via schema validation', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ chats: null }))
const mapper = createCloudChatMapper({ serverUrl: 'https://api.example.com', fetch: fetchMock as unknown as typeof fetch })
@@ -237,7 +322,7 @@ describe('createCloudChatMapper.createChat', () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(jsonResponse({ message: 'already exists' }, { status: 409, statusText: 'Conflict' }))
.mockResolvedValueOnce(jsonResponse({
chats: [{ id: 'minted', type: 'bot', title: null, createdAt: '2026-01-01', updatedAt: '2026-01-01' }],
chats: [{ id: 'minted', type: 'bot', title: null, createdAt: '2026-01-01', updatedAt: '2026-01-01', members: [] }],
}))
const mapper = createCloudChatMapper({ serverUrl: 'https://api.example.com', fetch: fetchMock as unknown as typeof fetch })
const chat = await mapper.createChat({ id: 'minted', type: 'bot' })
@@ -14,13 +14,24 @@ const RemoteChatSchema = v.object({
updatedAt: v.string(),
})
const ListChatsResponseSchema = v.object({
chats: v.array(RemoteChatSchema),
const ListedRemoteChatSchema = v.object({
...RemoteChatSchema.entries,
members: v.array(v.object({
memberType: v.picklist(['user', 'character', 'bot']),
characterId: v.nullable(v.string()),
})),
})
/** Minimal shape of a chat returned by `GET /api/v1/chats`. */
const ListChatsResponseSchema = v.object({
chats: v.array(ListedRemoteChatSchema),
})
/** A chat as `POST /api/v1/chats` returns it. The response does not include the members. */
export type RemoteChat = v.InferOutput<typeof RemoteChatSchema>
/** A chat as `GET /api/v1/chats` returns it, with the members that tell which character the chat belongs to. */
export type ListedRemoteChat = v.InferOutput<typeof ListedRemoteChatSchema>
export interface CreateRemoteChatInput {
id?: string
type?: 'private' | 'bot' | 'group' | 'channel'
@@ -57,7 +68,7 @@ export interface CreateCloudChatMapperOptions {
export interface CloudChatMapper {
/** GET /api/v1/chats — returns the full list for the current user. */
listChats: () => Promise<RemoteChat[]>
listChats: () => Promise<ListedRemoteChat[]>
/**
* POST /api/v1/chats — server may auto-generate id if not provided. A
* 409 Conflict (id already exists) is treated as an idempotent claim and
@@ -199,11 +210,26 @@ export function createCloudChatMapper(options: CreateCloudChatMapperOptions): Cl
* POST `/api/v1/chats` to mint a chat for it.
* - `adopt`: remote chat exists with no local session at all; need to create
* a local session shell so future `pullMessages` can populate it.
* - `reassign`: local session and remote chat are the same chat, but the
* local session is under another character than the remote chat.
*/
export interface ReconcilePlan {
claim: Array<{ sessionId: string, cloudChatId: string }>
create: Array<{ sessionId: string, characterId: string }>
adopt: RemoteChat[]
adopt: ListedRemoteChat[]
reassign: Array<{ sessionId: string, characterId: string }>
}
/**
* Returns the character that a remote chat belongs to.
*
* Every chat that this client creates has one character member. The result is
* `undefined` for a chat with no character member or with many, because a
* local session holds one character.
*/
export function characterIdOfRemoteChat(chat: ListedRemoteChat): string | undefined {
const characterIds = chat.members.flatMap(member => member.memberType === 'character' && member.characterId ? [member.characterId] : [])
return characterIds.length === 1 ? characterIds[0] : undefined
}
/**
@@ -219,14 +245,14 @@ export interface ReconcilePlan {
* out by the caller before reconcile — they are not cloud-eligible.
*
* Returns:
* - A plan of three lists. The caller applies them in any order; `create`
* actions need the network, `claim` / `adopt` are pure store mutations.
* - A plan of four lists. The caller applies them in any order; `create`
* actions need the network, the other lists are pure store mutations.
*/
export function reconcileLocalAndRemote(
localSessions: ChatSessionMeta[],
remoteChats: RemoteChat[],
remoteChats: ListedRemoteChat[],
): ReconcilePlan {
const remoteById = new Map<string, RemoteChat>()
const remoteById = new Map<string, ListedRemoteChat>()
for (const chat of remoteChats)
remoteById.set(chat.id, chat)
@@ -261,7 +287,7 @@ export function reconcileLocalAndRemote(
create.push({ sessionId: meta.sessionId, characterId: meta.characterId })
}
const adopt: RemoteChat[] = []
const adopt: ListedRemoteChat[] = []
for (const chat of remoteChats) {
if (localByCloudId.has(chat.id))
continue
@@ -270,7 +296,18 @@ export function reconcileLocalAndRemote(
adopt.push(chat)
}
return { claim, create, adopt }
// The character of a chat does not change after the chat exists, so a
// difference means that an earlier version put a received chat under
// `default`. The server holds the character that the creating device sent.
const reassign: ReconcilePlan['reassign'] = []
for (const meta of localSessions) {
const remote = remoteById.get(meta.cloudChatId ?? meta.sessionId)
const characterId = remote && characterIdOfRemoteChat(remote)
if (characterId && characterId !== meta.characterId)
reassign.push({ sessionId: meta.sessionId, characterId })
}
return { claim, create, adopt, reassign }
}
/**
@@ -2,11 +2,13 @@ export type {
CloudChatMapper,
CreateCloudChatMapperOptions,
CreateRemoteChatInput,
ListedRemoteChat,
ReconcilePlan,
RemoteChat,
} from './cloud-mapper'
export {
applyCreateActions,
characterIdOfRemoteChat,
createCloudChatMapper,
reconcileLocalAndRemote,
} from './cloud-mapper'
@@ -496,6 +496,7 @@ describe('chat-session-store · cloud placeholder hydration', () => {
title: null,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
members: [],
}
userIdRef.value = 'cloud-user'
getIndexMock.mockResolvedValue({
@@ -549,6 +550,103 @@ describe('chat-session-store · cloud placeholder hydration', () => {
})
})
describe('chat-session-store · cloud character membership', () => {
function remoteChatOf(id: string, characterId: string) {
return {
id,
type: 'bot' as const,
title: null,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
members: [
{ memberType: 'user' as const, characterId: null },
{ memberType: 'character' as const, characterId },
],
}
}
async function reconcile() {
const store = useChatSessionStore()
await store.initialize()
cloudWsStatus = 'open'
cloudStatusListener?.('open')
await vi.waitFor(() => {
expect(store.cloudSyncReady).toBe(true)
})
return store
}
it('puts a received chat under the character of its member, also when the device does not have that character', async () => {
const localMeta: ChatSessionMeta = { sessionId: 'local-session', userId: 'cloud-user', characterId: 'default', createdAt: 1, updatedAt: 1, cloudChatId: 'local-session' }
userIdRef.value = 'cloud-user'
getIndexMock.mockResolvedValue({
userId: 'cloud-user',
characters: { default: { activeSessionId: localMeta.sessionId, sessions: { [localMeta.sessionId]: localMeta } } },
})
getSessionMock.mockResolvedValue({ meta: localMeta, messages: [] })
listChatsMock.mockResolvedValue([remoteChatOf('local-session', 'default'), remoteChatOf('luna-session', 'luna')])
const store = await reconcile()
expect(store.sessionMetas['luna-session'].characterId).toBe('luna')
const savedIndex = saveIndexMock.mock.calls.at(-1)![0]
expect(Object.keys(savedIndex.characters.luna.sessions)).toEqual(['luna-session'])
expect(Object.keys(savedIndex.characters.default.sessions)).toEqual(['local-session'])
})
it('moves a session from default to the character of its remote chat and keeps its messages', async () => {
// ROOT CAUSE:
//
// The chat list did not include the members, so the adopt step put every
// received chat under `default`. Those sessions stay under `default`.
//
// We fixed this by a comparison with the character member of the remote
// chat during each reconcile.
const misfiled: ChatSessionMeta = { sessionId: 'misfiled', userId: 'cloud-user', characterId: 'default', createdAt: 1, updatedAt: 1, cloudChatId: 'misfiled' }
const kept: ChatSessionMeta = { sessionId: 'kept', userId: 'cloud-user', characterId: 'default', createdAt: 2, updatedAt: 2, cloudChatId: 'kept' }
const misfiledMessages: ChatSessionRecord['messages'] = [{ id: 'message-1', role: 'user', content: 'Hello Luna' }]
userIdRef.value = 'cloud-user'
getIndexMock.mockResolvedValue({
userId: 'cloud-user',
characters: { default: { activeSessionId: kept.sessionId, sessions: { misfiled, kept } } },
})
getSessionMock.mockImplementation(sessionId => Promise.resolve(sessionId === 'misfiled'
? { meta: misfiled, messages: misfiledMessages }
: { meta: kept, messages: [] }))
listChatsMock.mockResolvedValue([remoteChatOf('misfiled', 'luna'), remoteChatOf('kept', 'default')])
const store = await reconcile()
expect(store.sessionMetas.misfiled.characterId).toBe('luna')
expect(store.sessionMetas.kept.characterId).toBe('default')
expect(saveSessionMock).toHaveBeenCalledWith('misfiled', {
meta: { ...misfiled, characterId: 'luna' },
messages: misfiledMessages,
})
const savedIndex = saveIndexMock.mock.calls.at(-1)![0]
expect(Object.keys(savedIndex.characters.luna.sessions)).toEqual(['misfiled'])
expect(Object.keys(savedIndex.characters.default.sessions)).toEqual(['kept'])
expect(savedIndex.characters.default.activeSessionId).toBe('kept')
})
it('selects another session for the previous character when its active session moves', async () => {
const misfiled: ChatSessionMeta = { sessionId: 'misfiled', userId: 'cloud-user', characterId: 'default', createdAt: 1, updatedAt: 1, cloudChatId: 'misfiled' }
const kept: ChatSessionMeta = { sessionId: 'kept', userId: 'cloud-user', characterId: 'default', createdAt: 2, updatedAt: 2, cloudChatId: 'kept' }
userIdRef.value = 'cloud-user'
getIndexMock.mockResolvedValue({
userId: 'cloud-user',
characters: { default: { activeSessionId: misfiled.sessionId, sessions: { misfiled, kept } } },
})
getSessionMock.mockImplementation(sessionId => Promise.resolve({ meta: sessionId === 'misfiled' ? misfiled : kept, messages: [] }))
listChatsMock.mockResolvedValue([remoteChatOf('misfiled', 'luna'), remoteChatOf('kept', 'default')])
await reconcile()
const savedIndex = saveIndexMock.mock.calls.at(-1)![0]
expect(savedIndex.characters.default.activeSessionId).toBe('kept')
})
})
describe('chat-session-store · cloud deletion', () => {
it('tombstones an unmapped cloud session before an in-flight create can finish', async () => {
// ROOT CAUSE:
@@ -17,6 +17,7 @@ import { authedFetch } from '../../libs/auth-fetch'
import { chatAssetIdsOf, inlineChatAssets } from '../../libs/chat-assets'
import {
applyCreateActions,
characterIdOfRemoteChat,
createChatWsClient,
createCloudChatMapper,
extractMessageText,
@@ -864,6 +865,46 @@ export const useChatSessionStore = defineStore('chat-session', () => {
void persistSession(action.sessionId)
}
// reassign: move a session to the character of its remote chat.
for (const action of plan.reassign) {
const meta = sessionMetas.value[action.sessionId]
if (!meta || !index.value)
continue
const previousCharacterIndex = index.value.characters[meta.characterId]
if (previousCharacterIndex) {
delete previousCharacterIndex.sessions[action.sessionId]
// The previous character opens another of its sessions next time. An
// empty id makes it create a session, as after a deletion.
if (previousCharacterIndex.activeSessionId === action.sessionId)
previousCharacterIndex.activeSessionId = Object.keys(previousCharacterIndex.sessions).find(id => sessionMetas.value[id]) ?? ''
}
const reassignedMeta: ChatSessionMeta = { ...meta, characterId: action.characterId }
sessionMetas.value[action.sessionId] = reassignedMeta
const characterIndex = index.value.characters[action.characterId] ?? {
activeSessionId: '',
sessions: {},
}
characterIndex.sessions[action.sessionId] = reassignedMeta
index.value.characters[action.characterId] = characterIndex
// `loadSession` takes the meta of the stored record, so the record
// must change too. `persistSession` is not used here, because it
// writes the messages in memory, and this session is possibly not loaded.
await enqueuePersist(async () => {
const stored = await chatSessionsRepo.getSession(action.sessionId)
if (stored) {
await chatSessionsRepo.saveSession(action.sessionId, {
meta: { ...stored.meta, characterId: action.characterId },
messages: stored.messages,
})
}
})
}
if (isStaleEpoch())
return
// create: POST /api/v1/chats and bind. Mapper handles 409-as-claim.
const createResults = await applyCreateActions(mapper, plan.create)
if (isStaleEpoch())
@@ -912,7 +953,8 @@ export const useChatSessionStore = defineStore('chat-session', () => {
const adoptedMeta: ChatSessionMeta = {
sessionId: remote.id,
userId: currentUserId,
characterId: 'default',
// A chat without one character member stays under the built-in character.
characterId: characterIdOfRemoteChat(remote) ?? 'default',
title: remote.title ?? undefined,
createdAt: new Date(remote.createdAt).getTime() || now,
updatedAt: new Date(remote.updatedAt).getTime() || now,
@@ -284,3 +284,32 @@ describe('pushMessages', () => {
expect(messages).toHaveLength(0)
})
})
describe('listChats', () => {
let db: Database
beforeEach(async () => {
db = await mockDB(schema)
})
it('returns the members of each chat of the user', async () => {
const service = createChatService(db)
await service.createChat('owner', { id: 'luna-chat', type: 'bot', members: [{ type: 'character', characterId: 'luna' }] })
await service.createChat('owner', { id: 'sol-chat', type: 'bot', members: [{ type: 'character', characterId: 'sol' }] })
await service.createChat('stranger', { id: 'other-chat', type: 'bot', members: [{ type: 'character', characterId: 'luna' }] })
const chats = await service.listChats('owner')
const characterIdsByChatId = Object.fromEntries(chats.map(chat => [
chat.id,
chat.members.filter(member => member.memberType === 'character').map(member => member.characterId),
]))
expect(characterIdsByChatId).toEqual({ 'luna-chat': ['luna'], 'sol-chat': ['sol'] })
})
it('returns an empty list for a user without chats', async () => {
const service = createChatService(db)
expect(await service.listChats('owner')).toEqual([])
})
})
+12 -1
View File
@@ -6,6 +6,7 @@ import type { EngagementMetrics } from '../../otel'
import { useLogger } from '@guiiai/logg'
import { createBadRequestError, createConflictError, createForbiddenError, createNotFoundError } from '@proj-airi/http-error-shared'
import { and, eq, gt, inArray, isNull, sql } from 'drizzle-orm'
import { groupBy } from 'es-toolkit'
import { nanoid } from '../../utils/id'
@@ -137,7 +138,17 @@ export function createChatService(db: Database, metrics?: EngagementMetrics | nu
isNull(schema.chats.deletedAt),
))
return rows.map(r => r.chat)
if (rows.length === 0)
return []
// A second device reads the character of each chat from its members.
const members = await db
.select()
.from(schema.chatMembers)
.where(inArray(schema.chatMembers.chatId, rows.map(r => r.chat.id)))
const membersByChatId = groupBy(members, member => member.chatId)
return rows.map(r => ({ ...r.chat, members: membersByChatId[r.chat.id] }))
},
async updateChat(userId: string, chatId: string, updates: { title?: string }) {
@@ -0,0 +1,87 @@
# The chat list includes the members of each chat
Status: proposed
## Context
A client sends the character of a chat when it creates the chat. `POST /api/v1/chats` stores it in `chat_members`.
`GET /api/v1/chats` returns only the rows of `chats`.
A second device then cannot tell which character a chat belongs to. It puts every received chat under the `default` character.
`GET /api/v1/chats/:id` returns the members, but one request for each chat is too slow for a reconcile.
`docs/ai/adr/2026-10-09-character-as-chat-member.md` gives the client design.
## Decision
- `GET /api/v1/chats` returns a `members` array for each chat.
- A member is a row of `chat_members`, the same shape that `GET /api/v1/chats/:id` returns.
- `listChats` reads the members of all listed chats with one more query. It does not query once for each chat.
- The client schema for a listed chat requires `members`. The response of `POST /api/v1/chats` stays without members.
- The client reads the character of a chat from the member with the `memberType` of `character`.
- A chat with no character member, or with many, has no single character. The client keeps it under `default`.
- The tables do not change. `chat_members` already holds many members for one chat, which a group chat needs.
## Scope
- The response of `GET /api/v1/chats`.
- `listChats` in the chat service.
- The response schema and the `adopt` step of the client.
## Non-goals
- A `character_id` column on `chats`. One column cannot hold the characters of a group chat.
- A check that `characterId` refers to a row of `character_cards`. Card synchronization is an optional experiment on each device.
- Changes to the routes that add or remove a member.
- Chats with many users.
## Module dependency graph
```mermaid
flowchart LR
Store[stage-ui: session-store] --> Mapper[stage-ui: cloud-mapper]
Mapper -->|GET /api/v1/chats| Route[api: routes/chats]
Route --> Service[api: services/domain/chats]
Service --> Chats[(chats)]
Service --> Members[(chat_members)]
```
## Affected files
```text
server/apps/api/src/
├── services/domain/chats.ts listChats returns members
└── services/domain/chats.test.ts members in the list
packages/stage-ui/src/
├── libs/chat-sync/cloud-mapper.ts listed chats require members, the plan gets a reassign list
└── stores/chat/session-store.ts adopt and reassign use the character member
```
## Sequence
```mermaid
sequenceDiagram
participant A as Device A
participant S as API
participant B as Device B
A->>S: POST /api/v1/chats (id, members: character X)
S->>S: Insert chats row and chat_members rows
B->>S: GET /api/v1/chats
S->>S: Select chats of the user, then members of those chats
S-->>B: chats, each with members
B->>B: Adopt the chat under character X
B->>B: Move an old session from default to character X
```
## Deployment
- Deploy the server before a client that requires `members`.
- An older client ignores the new property, so the server change is safe alone.
## Test plan
- `chats.test.ts`: the list returns the character member of each chat, and no chats of another user.
- `cloud-mapper.test.ts`: a response without `members` fails the parse.
- `cloud-mapper.test.ts`: the plan reassigns a session only when its remote chat has one other character.
- `session-store-lifecycle.browser.test.ts`: a received chat goes under the character of its member.
- The same test uses a character that the device does not have, and the id stays.
- `session-store-lifecycle.browser.test.ts`: a session under `default` moves to its character and keeps its messages.