diff --git a/server/src/routes/session-mgmt.ts b/server/src/routes/session-mgmt.ts index 6097c8159..6489ca13e 100644 --- a/server/src/routes/session-mgmt.ts +++ b/server/src/routes/session-mgmt.ts @@ -116,6 +116,15 @@ sessionMgmtRoutes.patch('/:id/sessions/:name/label', async (c) => { const label = typeof body.label === 'string' && body.label.trim() ? body.label.trim() : null; await updateSessionLabel(c.env.DB, serverId, sessionName, label); + try { + WsBridge.get(serverId).sendToDaemon(JSON.stringify({ + type: 'session.relabel', + sessionName, + label, + })); + } catch (err) { + logger.warn({ serverId, sessionName, err }, 'WsBridge session relabel relay failed'); + } return c.json({ ok: true }); }); @@ -186,6 +195,18 @@ sessionMgmtRoutes.patch('/:id/sessions/:name', async (c) => { return c.json({ error: 'relay_failed' }, 502); } } + if (body.agentType == null && body.label !== undefined) { + try { + WsBridge.get(serverId).sendToDaemon(JSON.stringify({ + type: 'session.relabel', + sessionName, + label: body.label ?? null, + })); + } catch (err) { + logger.error({ serverId, sessionName, err }, 'WsBridge session relabel relay failed'); + return c.json({ error: 'relay_failed' }, 502); + } + } return c.json({ ok: true }); }); @@ -208,6 +229,15 @@ sessionMgmtRoutes.patch('/:id/sessions/:name/rename', async (c) => { if (!newName) return c.json({ error: 'name_required' }, 400); await updateProjectName(c.env.DB, serverId, sessionName, newName); + try { + WsBridge.get(serverId).sendToDaemon(JSON.stringify({ + type: 'session.rename', + sessionName, + projectName: newName, + })); + } catch (err) { + logger.warn({ serverId, sessionName, err }, 'WsBridge session rename relay failed'); + } return c.json({ ok: true }); }); diff --git a/server/src/routes/sub-sessions.ts b/server/src/routes/sub-sessions.ts index a7a273354..08d4ef6fd 100644 --- a/server/src/routes/sub-sessions.ts +++ b/server/src/routes/sub-sessions.ts @@ -187,6 +187,18 @@ subSessionRoutes.patch('/:id/sub-sessions/:subId', async (c) => { return c.json({ error: 'relay_failed' }, 502); } } + if (body.type == null && body.label !== undefined) { + try { + WsBridge.get(serverId).sendToDaemon(JSON.stringify({ + type: 'subsession.rename', + sessionName: `deck_sub_${subId}`, + label: body.label ?? null, + })); + } catch (err) { + logger.error({ serverId, subId, err }, 'WsBridge sub-session rename relay failed'); + return c.json({ error: 'relay_failed' }, 502); + } + } return c.json({ ok: true }); }); diff --git a/server/test/session-mgmt-routes.test.ts b/server/test/session-mgmt-routes.test.ts index f039268b8..9daa8cb40 100644 --- a/server/test/session-mgmt-routes.test.ts +++ b/server/test/session-mgmt-routes.test.ts @@ -161,4 +161,41 @@ describe('session-mgmt persistence routes', () => { description: 'next persona', }); }); + + it('PATCH /sessions/:name/rename updates the project name and relays session.rename', async () => { + const { updateProjectName } = await import('../src/db/queries.js'); + const app = await buildApp(); + const res = await app.request('/api/server/srv-1/sessions/deck_proj_brain/rename', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'new-proj' }), + }); + + expect(res.status).toBe(200); + expect(updateProjectName).toHaveBeenCalledWith({}, 'srv-1', 'deck_proj_brain', 'new-proj'); + expect(sendToDaemonMock).toHaveBeenCalledTimes(1); + expect(JSON.parse(String(sendToDaemonMock.mock.calls[0]?.[0]))).toEqual({ + type: 'session.rename', + sessionName: 'deck_proj_brain', + projectName: 'new-proj', + }); + }); + + it('PATCH /sessions/:name/label updates the label and relays session.relabel', async () => { + const { updateSessionLabel } = await import('../src/db/queries.js'); + const app = await buildApp(); + const res = await app.request('/api/server/srv-1/sessions/deck_proj_brain/label', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ label: 'Main Label' }), + }); + + expect(res.status).toBe(200); + expect(updateSessionLabel).toHaveBeenCalledWith({}, 'srv-1', 'deck_proj_brain', 'Main Label'); + expect(JSON.parse(String(sendToDaemonMock.mock.calls[0]?.[0]))).toEqual({ + type: 'session.relabel', + sessionName: 'deck_proj_brain', + label: 'Main Label', + }); + }); }); diff --git a/server/test/sub-sessions-route.test.ts b/server/test/sub-sessions-route.test.ts index 8ef833217..608034664 100644 --- a/server/test/sub-sessions-route.test.ts +++ b/server/test/sub-sessions-route.test.ts @@ -186,4 +186,36 @@ describe('sub-session routes', () => { expect(updateSubSessionMock).not.toHaveBeenCalled(); expect(sendToDaemonMock).not.toHaveBeenCalled(); }); + + it('PATCH /sub-sessions/:id relays subsession.rename when only the label changes', async () => { + const { getSubSessionById } = await import('../src/db/queries.js'); + vi.mocked(getSubSessionById).mockResolvedValue({ + id: 'sub12345', + server_id: 'srv1', + type: 'codex', + } as any); + + const res = await app.request('/api/server/srv1/sub-sessions/sub12345', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + label: 'Worker Label', + }), + }); + + expect(res.status).toBe(200); + expect(updateSubSessionMock).toHaveBeenCalledWith( + {}, + 'sub12345', + 'srv1', + { + label: 'Worker Label', + }, + ); + expect(JSON.parse(String(sendToDaemonMock.mock.calls[0]?.[0]))).toEqual({ + type: 'subsession.rename', + sessionName: 'deck_sub_sub12345', + label: 'Worker Label', + }); + }); }); diff --git a/src/daemon/command-handler.ts b/src/daemon/command-handler.ts index 5572c2202..dc80aaf0c 100644 --- a/src/daemon/command-handler.ts +++ b/src/daemon/command-handler.ts @@ -30,6 +30,7 @@ import { promisify } from 'node:util'; const execAsync = promisify(execCb); const execFileAsync = promisify(execFileCb); import { startP2pRun, cancelP2pRun, getP2pRun, listP2pRuns, serializeP2pRun, type P2pTarget } from './p2p-orchestrator.js'; +import { buildSessionList } from './session-list.js'; import { getComboRoundCount, parseModePipeline, P2P_CONFIG_MODE, type P2pSessionConfig } from '../../shared/p2p-modes.js'; import type { P2pAdvancedRound, P2pContextReducerConfig } from '../../shared/p2p-advanced.js'; import { CRON_MSG } from '../../shared/cron-types.js'; @@ -203,7 +204,6 @@ import { resolveContextWindow } from '../util/model-context.js'; import { QWEN_MODEL_IDS } from '../../shared/qwen-models.js'; import { getQwenRuntimeConfig } from '../agent/qwen-runtime-config.js'; import { getQwenDisplayMetadata } from '../agent/provider-display.js'; -import { buildSessionList } from './session-list.js'; import { getQwenOAuthQuotaUsageLabel, recordQwenOAuthRequest } from '../agent/provider-quota.js'; import { listProviderSessions as listProviderSessionsImpl } from './provider-sessions.js'; @@ -611,12 +611,63 @@ export function handleWebCommand(msg: unknown, serverLink: ServerLink): void { break; case 'subsession.rename': { const sName = cmd.sessionName as string | undefined; - const label = cmd.label as string | undefined; + const label = cmd.label === null + ? null + : (typeof cmd.label === 'string' ? cmd.label : undefined); if (sName && label !== undefined) { const record = getSession(sName); if (record) { - upsertSession({ ...record, label, updatedAt: Date.now() }); + const nextLabel = label ?? undefined; + upsertSession({ ...record, label: nextLabel, updatedAt: Date.now() }); logger.info({ sessionName: sName, label }, 'subsession.rename: label updated'); + const id = sName.replace(/^deck_sub_/, ''); + void buildSubSessionSync(id, { label: nextLabel }).then((payload) => { + try { + serverLink.send(payload); + } catch { + // not connected + } + }); + } + } + break; + } + case 'session.rename': { + const sessionName = cmd.sessionName as string | undefined; + const projectName = typeof cmd.projectName === 'string' ? cmd.projectName.trim() : ''; + if (sessionName && projectName) { + const record = getSession(sessionName); + if (record) { + upsertSession({ ...record, projectName, updatedAt: Date.now() }); + logger.info({ sessionName, projectName }, 'session.rename: project name updated'); + void buildSessionList().then((sessions) => { + try { + serverLink.send({ type: 'session_list', daemonVersion: serverLink.daemonVersion, sessions }); + } catch { + // not connected + } + }); + } + } + break; + } + case 'session.relabel': { + const sessionName = cmd.sessionName as string | undefined; + const label = cmd.label === null + ? null + : (typeof cmd.label === 'string' ? cmd.label : undefined); + if (sessionName && label !== undefined) { + const record = getSession(sessionName); + if (record) { + upsertSession({ ...record, label: label ?? undefined, updatedAt: Date.now() }); + logger.info({ sessionName, label }, 'session.relabel: label updated'); + void buildSessionList().then((sessions) => { + try { + serverLink.send({ type: 'session_list', daemonVersion: serverLink.daemonVersion, sessions }); + } catch { + // not connected + } + }); } } break; diff --git a/test/daemon/command-handler-stop.test.ts b/test/daemon/command-handler-stop.test.ts index f203427ee..f0f2069ed 100644 --- a/test/daemon/command-handler-stop.test.ts +++ b/test/daemon/command-handler-stop.test.ts @@ -1,10 +1,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -const { stopProjectMock, stopSubSessionMock, loggerErrorMock, loggerWarnMock } = vi.hoisted(() => ({ +const { stopProjectMock, stopSubSessionMock, loggerErrorMock, loggerWarnMock, buildSessionListMock } = vi.hoisted(() => ({ stopProjectMock: vi.fn(), stopSubSessionMock: vi.fn().mockResolvedValue({ ok: true, closed: ['deck_sub_worker'], failed: [] }), loggerErrorMock: vi.fn(), loggerWarnMock: vi.fn(), + buildSessionListMock: vi.fn(async () => []), })); vi.mock('../../src/store/session-store.js', () => ({ @@ -80,6 +81,10 @@ vi.mock('../../src/daemon/p2p-orchestrator.js', () => ({ serializeP2pRun: vi.fn(), })); +vi.mock('../../src/daemon/session-list.js', () => ({ + buildSessionList: buildSessionListMock, +})); + vi.mock('../../src/daemon/repo-handler.js', () => ({ handleRepoCommand: vi.fn(), })); @@ -164,4 +169,131 @@ describe('handleWebCommand shutdown failure paths', () => { message: 'Shutdown failed: backend unavailable', }); }); + + it('updates the main-session project name and pushes a refreshed session_list on session.rename', async () => { + const { getSession, upsertSession } = await import('../../src/store/session-store.js'); + vi.mocked(getSession).mockReturnValue({ + name: 'deck_proj_brain', + projectName: 'proj', + role: 'brain', + agentType: 'codex', + state: 'idle', + restarts: 0, + restartTimestamps: [], + createdAt: 1, + updatedAt: 1, + projectDir: '/tmp/proj', + } as any); + buildSessionListMock.mockResolvedValueOnce([ + { + name: 'deck_proj_brain', + project: 'new-proj', + role: 'brain', + agentType: 'codex', + state: 'idle', + }, + ]); + + handleWebCommand({ type: 'session.rename', sessionName: 'deck_proj_brain', projectName: 'new-proj' }, serverLink as any); + await flushAsync(); + + expect(upsertSession).toHaveBeenCalledWith(expect.objectContaining({ + name: 'deck_proj_brain', + projectName: 'new-proj', + })); + expect(serverLink.send).toHaveBeenCalledWith({ + type: 'session_list', + daemonVersion: '0.1.0', + sessions: [ + { + name: 'deck_proj_brain', + project: 'new-proj', + role: 'brain', + agentType: 'codex', + state: 'idle', + }, + ], + }); + }); + + it('updates the main-session label and pushes a refreshed session_list on session.relabel', async () => { + const { getSession, upsertSession } = await import('../../src/store/session-store.js'); + vi.mocked(getSession).mockReturnValue({ + name: 'deck_proj_brain', + projectName: 'proj', + role: 'brain', + agentType: 'codex', + state: 'idle', + label: null, + restarts: 0, + restartTimestamps: [], + createdAt: 1, + updatedAt: 1, + projectDir: '/tmp/proj', + } as any); + buildSessionListMock.mockResolvedValueOnce([ + { + name: 'deck_proj_brain', + project: 'proj', + role: 'brain', + agentType: 'codex', + state: 'idle', + label: 'Main Label', + }, + ]); + + handleWebCommand({ type: 'session.relabel', sessionName: 'deck_proj_brain', label: 'Main Label' }, serverLink as any); + await flushAsync(); + + expect(upsertSession).toHaveBeenCalledWith(expect.objectContaining({ + name: 'deck_proj_brain', + label: 'Main Label', + })); + expect(serverLink.send).toHaveBeenCalledWith({ + type: 'session_list', + daemonVersion: '0.1.0', + sessions: [ + { + name: 'deck_proj_brain', + project: 'proj', + role: 'brain', + agentType: 'codex', + state: 'idle', + label: 'Main Label', + }, + ], + }); + }); + + it('updates the sub-session label and emits subsession.sync on subsession.rename', async () => { + const { getSession, upsertSession } = await import('../../src/store/session-store.js'); + vi.mocked(getSession).mockReturnValue({ + name: 'deck_sub_worker', + projectName: 'proj', + role: 'w1', + agentType: 'codex', + state: 'idle', + label: 'old', + restarts: 0, + restartTimestamps: [], + createdAt: 1, + updatedAt: 1, + projectDir: '/tmp/proj', + runtimeType: 'process', + parentSession: 'deck_proj_brain', + } as any); + + handleWebCommand({ type: 'subsession.rename', sessionName: 'deck_sub_worker', label: 'Worker Label' }, serverLink as any); + await flushAsync(); + + expect(upsertSession).toHaveBeenCalledWith(expect.objectContaining({ + name: 'deck_sub_worker', + label: 'Worker Label', + })); + expect(serverLink.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'subsession.sync', + id: 'worker', + label: 'Worker Label', + })); + }); }); diff --git a/web/src/app.tsx b/web/src/app.tsx index 6ff06dd71..fb652b59e 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -520,7 +520,7 @@ export function App() { const handleRenameSession = useCallback(async (sessionName: string, newProjectName: string) => { if (!selectedServerId || !newProjectName) return; // Optimistic update - setSessions((prev) => prev.map((s) => s.name === sessionName ? { ...s, project: newProjectName, label: newProjectName } : s)); + setSessions((prev) => prev.map((s) => s.name === sessionName ? { ...s, project: newProjectName } : s)); try { await apiFetch(`/api/server/${selectedServerId}/sessions/${encodeURIComponent(sessionName)}/rename`, { method: 'PATCH', diff --git a/web/src/hooks/useSubSessions.ts b/web/src/hooks/useSubSessions.ts index 8618c2b65..9475ed1a3 100644 --- a/web/src/hooks/useSubSessions.ts +++ b/web/src/hooks/useSubSessions.ts @@ -400,10 +400,7 @@ export function useSubSessions( setSubSessions((prev) => prev.map((s) => s.id === id ? { ...s, label } : s, )); - // Sync label to daemon session store - const sessionName = `deck_sub_${id}`; - ws?.subSessionRename(sessionName, label); - }, [serverId, ws]); + }, [serverId]); /** Update local state for a sub-session (does NOT write to DB — caller handles that). */ const updateLocal = useCallback((id: string, fields: Partial>) => { diff --git a/web/test/use-sub-sessions-metadata.test.tsx b/web/test/use-sub-sessions-metadata.test.tsx index a7378a877..5d3a1ca69 100644 --- a/web/test/use-sub-sessions-metadata.test.tsx +++ b/web/test/use-sub-sessions-metadata.test.tsx @@ -12,7 +12,7 @@ import { listSubSessions, patchSubSession } from '../src/api.js'; vi.mock('../src/api.js', () => ({ listSubSessions: vi.fn().mockResolvedValue([]), createSubSession: vi.fn(), - patchSubSession: vi.fn(), + patchSubSession: vi.fn().mockResolvedValue(undefined), })); type MsgHandler = (msg: any) => void; @@ -40,6 +40,7 @@ function Harness({ ws, connected }: { ws: any; connected: boolean }) { } let closeSubSessionHook: ((id: string) => Promise) | null = null; +let renameSubSessionHook: ((id: string, label: string) => Promise) | null = null; function CloseHarness({ ws, connected }: { ws: any; connected: boolean }) { const { subSessions, close } = useSubSessions('srv1', ws, connected, null); @@ -48,6 +49,13 @@ function CloseHarness({ ws, connected }: { ws: any; connected: boolean }) { return null; } +function RenameHarness({ ws, connected }: { ws: any; connected: boolean }) { + const { subSessions, rename } = useSubSessions('srv1', ws, connected, null); + captured = subSessions; + renameSubSessionHook = rename; + return null; +} + describe('sub-session metadata via subsession.created', () => { afterEach(() => { cleanup(); vi.clearAllMocks(); captured = []; }); @@ -486,3 +494,56 @@ describe('sub-session close behavior', () => { expect(captured).toHaveLength(0); }); }); + +describe('sub-session rename behavior', () => { + afterEach(() => { cleanup(); vi.clearAllMocks(); captured = []; renameSubSessionHook = null; }); + + it('persists label changes through the API and updates local state without direct ws rename commands', async () => { + vi.mocked(listSubSessions).mockResolvedValueOnce([ + { + id: 'rename1', + serverId: 'srv1', + type: 'codex', + runtimeType: 'process', + providerId: null, + providerSessionId: null, + shellBin: null, + cwd: '/tmp/proj', + ccSessionId: null, + geminiSessionId: null, + parentSession: 'deck_app_brain', + label: 'Old Label', + description: null, + ccPresetId: null, + requestedModel: null, + activeModel: null, + qwenModel: null, + qwenAuthType: null, + qwenAvailableModels: null, + modelDisplay: null, + planLabel: null, + quotaLabel: null, + quotaUsageLabel: null, + quotaMeta: null, + effort: null, + transportConfig: null, + closedAt: null, + createdAt: 1, + updatedAt: 1, + }, + ]); + + const { ws } = createMockWs(); + (ws as any).subSessionRename = vi.fn(); + render(); + await waitFor(() => expect(captured).toHaveLength(1)); + + await act(async () => { + await renameSubSessionHook?.('rename1', 'New Label'); + }); + + expect(vi.mocked(patchSubSession)).toHaveBeenCalledWith('srv1', 'rename1', { label: 'New Label' }); + expect(captured[0]?.label).toBe('New Label'); + expect((ws as any).subSessionRename).not.toHaveBeenCalled(); + }); +});