fix(apiproxy): title-invalid only for the input's fault, presentable messages
The rename impl narrows on SessionTitleInvalidError: only an empty-normalizing title maps to title-invalid (its message renders verbatim in the rename dialog alert), while liveness/disposal races fall to internal; the absent-service message trims to one presentable sentence. rpc-schemas gains the title-invalid accept/missing-details lines; cosmetic ordering (type-only import comment, tsconfig reference, schema import order) restored.
This commit is contained in:
@@ -36,10 +36,10 @@ import type {} from '@deepseek-ai/dsh-session-projection-cache'
|
|||||||
import { GoalError } from '@deepseek-ai/dsh-goal'
|
import { GoalError } from '@deepseek-ai/dsh-goal'
|
||||||
import type { GoalRef as CoreGoalRef } from '@deepseek-ai/dsh-goal'
|
import type { GoalRef as CoreGoalRef } from '@deepseek-ai/dsh-goal'
|
||||||
// Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`.
|
// Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`.
|
||||||
// Type-only edge: resolves `ctx.get('sessionTitle')` for the rename impl.
|
|
||||||
import type {} from '@deepseek-ai/dsh-session-title'
|
|
||||||
import type {} from '@deepseek-ai/dsh-commands'
|
import type {} from '@deepseek-ai/dsh-commands'
|
||||||
import type {} from '@deepseek-ai/dsh-skill'
|
import type {} from '@deepseek-ai/dsh-skill'
|
||||||
|
// Value edge: the rename impl narrows the title service's validation failure; the import also resolves `ctx.get('sessionTitle')`.
|
||||||
|
import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title'
|
||||||
import type { CallId } from '@deepseek-ai/dsh-llm/brand'
|
import type { CallId } from '@deepseek-ai/dsh-llm/brand'
|
||||||
import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval'
|
import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval'
|
||||||
// Side-effect type import: resolves the `approval/request` waterfall and
|
// Side-effect type import: resolves the `approval/request` waterfall and
|
||||||
@@ -1057,16 +1057,26 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
if ('error' in found) return err(request, found.error)
|
if ('error' in found) return err(request, found.error)
|
||||||
const titles = ctx.get('sessionTitle')
|
const titles = ctx.get('sessionTitle')
|
||||||
if (titles === undefined) {
|
if (titles === undefined) {
|
||||||
return err(request, { code: 'internal', message: 'session-title service is absent: this deployment does not mount @deepseek-ai/dsh-session-title in its composition (cordis.yml or explicit assembly)', details: {} })
|
return err(request, { code: 'internal', message: 'renaming is unavailable: this deployment mounts no session-title service', details: {} })
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const accepted = titles.rename(found.agent.session, title)
|
const accepted = titles.rename(found.agent.session, title)
|
||||||
return ok(request, { title: accepted.title, seq: accepted.eventSeq })
|
return ok(request, { title: accepted.title, seq: accepted.eventSeq })
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
// Only the input's fault maps to title-invalid (the message is
|
||||||
|
// product-user-visible in the rename dialog); liveness and disposal
|
||||||
|
// races are deployment trouble, not a bad title.
|
||||||
|
if (error instanceof SessionTitleInvalidError) {
|
||||||
|
return err(request, {
|
||||||
|
code: 'title-invalid',
|
||||||
|
message: error.message,
|
||||||
|
details: { sessionId },
|
||||||
|
})
|
||||||
|
}
|
||||||
return err(request, {
|
return err(request, {
|
||||||
code: 'title-invalid',
|
code: 'internal',
|
||||||
message: `rename rejected for session "${sessionId}": ${String(error)}`,
|
message: `failed to rename session "${sessionId}": ${String(error)}`,
|
||||||
details: { sessionId },
|
details: {},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ import {
|
|||||||
import {
|
import {
|
||||||
sessionCancelValueSchema,
|
sessionCancelValueSchema,
|
||||||
sessionCreateValueSchema,
|
sessionCreateValueSchema,
|
||||||
sessionRenameValueSchema,
|
|
||||||
sessionHistoryValueSchema,
|
sessionHistoryValueSchema,
|
||||||
sessionListValueSchema,
|
sessionListValueSchema,
|
||||||
sessionModelsValueSchema,
|
sessionModelsValueSchema,
|
||||||
sessionPromptValueSchema,
|
sessionPromptValueSchema,
|
||||||
|
sessionRenameValueSchema,
|
||||||
sessionSelectModelValueSchema,
|
sessionSelectModelValueSchema,
|
||||||
} from '../api/sessions.schema.ts'
|
} from '../api/sessions.schema.ts'
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ import { clientRequestSchema, clientResponseSchema } from '../api/rpc.schema.ts'
|
|||||||
import {
|
import {
|
||||||
sessionCancelRequestSchema,
|
sessionCancelRequestSchema,
|
||||||
sessionCreateRequestSchema,
|
sessionCreateRequestSchema,
|
||||||
sessionRenameRequestSchema,
|
|
||||||
sessionHistoryRequestSchema,
|
sessionHistoryRequestSchema,
|
||||||
sessionListRequestSchema,
|
sessionListRequestSchema,
|
||||||
sessionModelsRequestSchema,
|
sessionModelsRequestSchema,
|
||||||
sessionPromptRequestSchema,
|
sessionPromptRequestSchema,
|
||||||
|
sessionRenameRequestSchema,
|
||||||
sessionSelectModelRequestSchema,
|
sessionSelectModelRequestSchema,
|
||||||
} from '../api/sessions.schema.ts'
|
} from '../api/sessions.schema.ts'
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
* sessions.rename delegation through the composed SessionTitleService. The
|
* sessions.rename delegation through the composed SessionTitleService. The
|
||||||
* agent factory is a structural stub whose createAgent forwards seed/meta into
|
* agent factory is a structural stub whose createAgent forwards seed/meta into
|
||||||
* the real SessionStore, and whose resume never runs (every source here is
|
* the real SessionStore, and whose resume never runs (every source here is
|
||||||
* already attached).
|
* 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 { describe, expect, it } from 'vitest'
|
||||||
@@ -82,20 +84,37 @@ describe('sessions.rename', () => {
|
|||||||
expect(event?.data).toMatchObject({ title: 'new name', source: { kind: 'user' } })
|
expect(event?.data).toMatchObject({ title: 'new name', source: { kind: 'user' } })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('maps an empty-normalizing title to title-invalid', async () => {
|
it('maps only an empty-normalizing title to title-invalid, with a presentable message', async () => {
|
||||||
const ctx = await composed()
|
const ctx = await composed()
|
||||||
const source = liveAgent(ctx, 'session-rename-bad', 1)
|
const source = liveAgent(ctx, 'session-rename-bad', 1)
|
||||||
|
|
||||||
const response = await api(ctx).sessions.rename(request({ sessionId: source.id, title: ' ' }))
|
// 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)
|
expect(response.result.ok).toBe(false)
|
||||||
if (!response.result.ok) {
|
if (!response.result.ok) {
|
||||||
expect(response.result.error).toMatchObject({
|
expect(response.result.error).toMatchObject({
|
||||||
code: 'title-invalid',
|
code: 'title-invalid',
|
||||||
details: { sessionId: source.id },
|
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 () => {
|
it('answers internal when the composition mounts no session-title service', async () => {
|
||||||
const ctx = await composed(false)
|
const ctx = await composed(false)
|
||||||
const source = liveAgent(ctx, 'session-no-titles', 1)
|
const source = liveAgent(ctx, 'session-no-titles', 1)
|
||||||
@@ -104,7 +123,7 @@ describe('sessions.rename', () => {
|
|||||||
expect(response.result.ok).toBe(false)
|
expect(response.result.ok).toBe(false)
|
||||||
if (!response.result.ok) {
|
if (!response.result.ok) {
|
||||||
expect(response.result.error.code).toBe('internal')
|
expect(response.result.error.code).toBe('internal')
|
||||||
expect(response.result.error.message).toMatch(/session-title service is absent/)
|
expect(response.result.error.message).toMatch(/mounts no session-title service/)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -70,11 +70,13 @@ describe('rpcErrorSchema', () => {
|
|||||||
expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy')
|
expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy')
|
||||||
expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error')
|
expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error')
|
||||||
expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command')
|
expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command')
|
||||||
|
expect(rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: { sessionId: 's' } }).code).toBe('title-invalid')
|
||||||
expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')
|
expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects a known code with missing details', () => {
|
it('rejects a known code with missing details', () => {
|
||||||
expect(() => rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: {} })).toThrow()
|
expect(() => rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: {} })).toThrow()
|
||||||
|
expect(() => rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: {} })).toThrow()
|
||||||
expect(() => rpcErrorSchema.parse({ code: 'command-error', message: 'm' })).toThrow()
|
expect(() => rpcErrorSchema.parse({ code: 'command-error', message: 'm' })).toThrow()
|
||||||
expect(() => rpcErrorSchema.parse({ code: 'nope', message: 'm', details: {} })).toThrow()
|
expect(() => rpcErrorSchema.parse({ code: 'nope', message: 'm', details: {} })).toThrow()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -39,10 +39,10 @@
|
|||||||
"path": "../../session-projection/session-projection"
|
"path": "../../session-projection/session-projection"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "../../session-title/session-title"
|
"path": "../../session-projection/session-projection-cache"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "../../session-projection/session-projection-cache"
|
"path": "../../session-title/session-title"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "../../skill/skill"
|
"path": "../../skill/skill"
|
||||||
|
|||||||
Reference in New Issue
Block a user