/**
* 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/)
}
})
})