Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/sync-session-meta-global.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@moonshot-ai/kimi-code": patch
"@moonshot-ai/server": patch
---

Sync session title changes across all connected clients in server mode.
17 changes: 16 additions & 1 deletion packages/server/src/routes/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';


Expand Down Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions packages/server/src/services/gateway/wsBroadcastService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 31 additions & 0 deletions packages/server/test/sessions.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,37 @@ describe('POST /api/v1/sessions/{session_id}/profile — update profile', () =>
const env = envelopeOf<unknown>(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<unknown>(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', () => {
Expand Down
31 changes: 31 additions & 0 deletions packages/server/test/ws-broadcast.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading