From 3edf4be327088c437328e8d74503890ccee057a8 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Wed, 24 Jun 2026 22:25:20 +0800 Subject: [PATCH] fix(server): broadcast session metadata updates to all clients - deliver session.meta.updated to every connection instead of only session subscribers, so title changes sync across all clients - emit session.meta.updated when a session is explicitly renamed --- .changeset/sync-session-meta-global.md | 6 ++++ packages/server/src/routes/sessions.ts | 17 +++++++++- .../services/gateway/wsBroadcastService.ts | 4 +++ packages/server/test/sessions.e2e.test.ts | 31 +++++++++++++++++++ packages/server/test/ws-broadcast.e2e.test.ts | 31 +++++++++++++++++++ 5 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 .changeset/sync-session-meta-global.md diff --git a/.changeset/sync-session-meta-global.md b/.changeset/sync-session-meta-global.md new file mode 100644 index 0000000000..a31a7b2ab1 --- /dev/null +++ b/.changeset/sync-session-meta-global.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code": patch +"@moonshot-ai/server": patch +--- + +Sync session title changes across all connected clients in server mode. diff --git a/packages/server/src/routes/sessions.ts b/packages/server/src/routes/sessions.ts index 3f3cf0c028..245817b804 100644 --- a/packages/server/src/routes/sessions.ts +++ b/packages/server/src/routes/sessions.ts @@ -21,8 +21,9 @@ import { undoSessionRequestSchema, undoSessionResponseSchema, workspaceIdSchema, + type Event, } from '@moonshot-ai/protocol'; -import { IPromptService, ISessionService, SessionNotFoundError, SessionUndoUnavailableError, ErrorCodes, KimiError, IWorkspaceRegistry, WorkspaceNotFoundError, type IInstantiationService, type SessionClientTelemetry } from '@moonshot-ai/agent-core'; +import { IPromptService, ISessionService, SessionNotFoundError, SessionUndoUnavailableError, ErrorCodes, KimiError, IWorkspaceRegistry, WorkspaceNotFoundError, IEventService, type IInstantiationService, type SessionClientTelemetry } from '@moonshot-ai/agent-core'; import { z } from 'zod'; @@ -375,6 +376,20 @@ export function registerSessionsRoutes( const session = await ix.invokeFunction((a) => a.get(ISessionService).update(session_id, body), ); + // Broadcast the title change to every connection (including clients not + // subscribed to this session, and covering inactive sessions whose rename + // does not go through the live Session path), so session lists stay in sync. + if (typeof body.title === 'string' && body.title.trim().length > 0) { + ix.invokeFunction((a) => + a.get(IEventService).publish({ + type: 'session.meta.updated', + agentId: 'main', + sessionId: session_id, + title: session.title, + patch: { title: session.title, isCustomTitle: true }, + } as Event), + ); + } reply.send(okEnvelope(session, req.id)); } catch (err) { sendMappedError(reply, req.id, err); diff --git a/packages/server/src/services/gateway/wsBroadcastService.ts b/packages/server/src/services/gateway/wsBroadcastService.ts index b2e969bab3..053fd1f890 100644 --- a/packages/server/src/services/gateway/wsBroadcastService.ts +++ b/packages/server/src/services/gateway/wsBroadcastService.ts @@ -240,6 +240,10 @@ function isGlobalSessionEvent(type: string): boolean { return ( type === 'event.session.created' || type === 'event.session.status_changed' || + // Session metadata (e.g. title) must reach every connection, including + // clients not yet subscribed to the session, so session lists stay in sync + // when another client creates or renames a session. + type === 'session.meta.updated' || type === 'event.config.changed' || // Workspace registry is not session-scoped: workspace lifecycle events ride // the '__global__' watermark and fan out to every connection. diff --git a/packages/server/test/sessions.e2e.test.ts b/packages/server/test/sessions.e2e.test.ts index 52231b3d6a..090edf346b 100644 --- a/packages/server/test/sessions.e2e.test.ts +++ b/packages/server/test/sessions.e2e.test.ts @@ -446,6 +446,37 @@ describe('POST /api/v1/sessions/{session_id}/profile — update profile', () => const env = envelopeOf(res.json()); expect(env.code).toBe(40401); }); + + it('broadcasts session.meta.updated to clients not subscribed to the session on rename', async () => { + const r = await bootDaemon(); + const { ws, received } = await openSessionListListener(r); + const cwd = join(tmpDir, 'workspace-profile-rename-broadcast'); + const created = envelopeOf<{ id: string }>( + ( + await appOf(r).inject({ + method: 'POST', + url: '/api/v1/sessions', + payload: { metadata: { cwd } }, + }) + ).json(), + ).data!; + + const res = await appOf(r).inject({ + method: 'POST', + url: `/api/v1/sessions/${created.id}/profile`, + payload: { title: 'Renamed' }, + }); + expect(envelopeOf(res.json()).code).toBe(0); + + const frame = await waitFor(received, (f) => f['type'] === 'session.meta.updated'); + expect(frame['session_id']).toBe(created.id); + expect(frame['payload']).toMatchObject({ + title: 'Renamed', + patch: { title: 'Renamed', isCustomTitle: true }, + }); + + ws.close(); + }); }); describe('POST /api/v1/sessions/{session_id}:fork — fork', () => { diff --git a/packages/server/test/ws-broadcast.e2e.test.ts b/packages/server/test/ws-broadcast.e2e.test.ts index 92680884e0..a544906b31 100644 --- a/packages/server/test/ws-broadcast.e2e.test.ts +++ b/packages/server/test/ws-broadcast.e2e.test.ts @@ -384,6 +384,37 @@ describe('WS broadcast + per-session seq (W5.2)', () => { a.ws.close(); b.ws.close(); }); + + it('session.meta.updated is broadcast to all connections regardless of subscription', async () => { + const r = await spawn(); + const a = await openConn(wsUrl(r.address)); + const b = await openConn(wsUrl(r.address)); + // `a` is subscribed to the session; `b` is not. A title change (e.g. a + // rename by another client) must still reach `b` so its session list + // stays in sync. + await helloAndSubscribe(a, 'A', 'sid_meta'); + await helloAndSubscribe(b, 'B', 'sid_other'); + + r.services.invokeFunction((acc) => + acc.get(IEventService).publish({ + type: 'session.meta.updated', + agentId: 'main', + sessionId: 'sid_meta', + title: 'Renamed', + patch: { title: 'Renamed', isCustomTitle: true }, + } as unknown as Event), + ); + + const evA = await receiveType(a, 'session.meta.updated', 1000); + const evB = await receiveType(b, 'session.meta.updated', 1000); + expect(evA.session_id).toBe('sid_meta'); + expect(evB.session_id).toBe('sid_meta'); + expect(evA.seq).toBe(1); + expect(evB.seq).toBe(1); + + a.ws.close(); + b.ws.close(); + }); }); /** Spin until `cond()` returns true or 2s elapses. */