/** * sessions.rename delegation through the composed SessionTitleService. The * agent factory is a structural stub whose createAgent forwards seed/meta into * the real SessionStore, and whose resume never runs (every source here is * already attached). Cold-session resolution is the shared `agentFor` path — * api-proxy-cold.spec.ts owns the resume evidence for every unary that rides * it, rename included. */ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import SessionStore from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionTitleService from '@deepseek-ai/dsh-session-title' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import type { Session, SessionId } from '@deepseek-ai/dsh-session' import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' const sid = (id: string): SessionId => id as SessionId let nextRpc = 1 function request

(payload: P): RpcRequest

{ return { rpcId: RpcId(`fr-${String(nextRpc++)}`), payload } } async function composed(withTitles = true): Promise { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) if (withTitles) { await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 40, maxTitleBytes: 40 }) } // Store-backed structural factory: create builds the session with the // forwarded seed/meta (the store validates the balanced prefix) and // registers an idle agent stub over it. ctx.agents.setFactory({ createAgent: (ownerCtx: Context, options: CreateAgentOptions): Promise => { const session = ctx.sessions.create(options.sessionId, { ...options.seed === undefined ? {} : { seed: [...options.seed] }, ...options.meta === undefined ? {} : { meta: options.meta }, }) const agent = { id: session.id, session, status: 'idle', ctx: ownerCtx } as Agent ctx.agents.register(agent) return Promise.resolve({ agent, dispose: () => Promise.resolve() }) }, resume: () => Promise.reject(new Error('resume must not run: every source is attached')), }) return ctx } /** Register one live agent whose log holds `turns` completed turns. */ function liveAgent(ctx: Context, id: string, turns: number): Session { const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj' } }) for (let turn = 1; turn <= turns; turn++) { session.append('turn/start', { turn }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: `prompt ${String(turn)}` }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) } ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) return session } const api = (ctx: Context) => createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) describe('sessions.rename', () => { it('accepts through the composed title service: normalized user-source event, echoed seq', async () => { const ctx = await composed() const source = liveAgent(ctx, 'session-rename', 1) const renamed = await api(ctx).sessions.rename(request({ sessionId: source.id, title: ' new name ' })) expect(renamed.result.ok).toBe(true) if (!renamed.result.ok) return expect(renamed.result.value.title).toBe('new name') const event = source.events.findLast(item => item.type === 'session/title') expect(event?.seq).toBe(renamed.result.value.seq) expect(event?.data).toMatchObject({ title: 'new name', source: { kind: 'user' } }) }) it('maps only an empty-normalizing title to title-invalid, with a presentable message', async () => { const ctx = await composed() const source = liveAgent(ctx, 'session-rename-bad', 1) // U+200B passes a client-side trim gate but normalizes to empty host-side. const response = await api(ctx).sessions.rename(request({ sessionId: source.id, title: ' ​ ' })) expect(response.result.ok).toBe(false) if (!response.result.ok) { expect(response.result.error).toMatchObject({ code: 'title-invalid', details: { sessionId: source.id }, }) // The message renders verbatim in the rename dialog's alert. expect(response.result.error.message).toBe('session title must contain visible characters') } }) it('maps a non-validation rename failure (stale session object) to internal, not title-invalid', async () => { const ctx = await composed() // The registered agent holds a session object from another store: the // title service's liveness check throws a plain Error, which must not // read as the user's fault. const foreign = await composed(false) const stale = liveAgent(foreign, 'session-rename-stale', 1) ctx.agents.register({ id: stale.id, session: stale, status: 'idle', ctx } as Agent) const response = await api(ctx).sessions.rename(request({ sessionId: stale.id, title: 'name' })) expect(response.result.ok).toBe(false) if (!response.result.ok) expect(response.result.error.code).toBe('internal') }) it('answers internal when the composition mounts no session-title service', async () => { const ctx = await composed(false) const source = liveAgent(ctx, 'session-no-titles', 1) const response = await api(ctx).sessions.rename(request({ sessionId: source.id, title: 'name' })) expect(response.result.ok).toBe(false) if (!response.result.ok) { expect(response.result.error.code).toBe('internal') expect(response.result.error.message).toMatch(/mounts no session-title service/) } }) })