feat(client): ISession.rename settles the title projection cell from the response
rename is a per-session verb on the outward session face (prompt/cancel
precedent), not a list-service verb: the Session calls session.rename and
applies the response {title, seq} to its projection store under
higher-seq-wins, so the list row updates before the push frame. The fixture
api and the test-runtime double follow the same face.
This commit is contained in:
@@ -41,6 +41,13 @@ export interface ISession {
|
||||
* @returns acceptance, or the business error.
|
||||
*/
|
||||
cancel(): Promise<RpcResult<{ accepted: true }>>
|
||||
/**
|
||||
* Rename this session (explicit user title; pins it against automatic
|
||||
* regeneration).
|
||||
* @param title - raw title text (the host normalizes acceptance).
|
||||
* @returns the normalized accepted title and its event seq, or the business error.
|
||||
*/
|
||||
rename(title: string): Promise<RpcResult<{ title: string; seq: number }>>
|
||||
/**
|
||||
* Extend the history window backwards (older messages pagination).
|
||||
* @returns completion; failures land in snapshot.openState/loadingOlder.
|
||||
|
||||
@@ -252,6 +252,25 @@ export class Session implements SessionFace {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename: contract session.rename 1:1. On success settle the 'title'
|
||||
* projection cell from the response's `{title, seq}` under the store's
|
||||
* higher-seq-wins rule (the push frame arriving later is a no-op replay),
|
||||
* so the list row and any useProjection('title') reader update without
|
||||
* waiting for the mux frame.
|
||||
* @param title - raw title text (the host normalizes acceptance).
|
||||
* @returns the rename result (normalized accepted title + title event seq).
|
||||
*/
|
||||
async rename(title: string): Promise<RpcResult<{ title: string; seq: number }>> {
|
||||
try {
|
||||
const { result } = await this.api.sessions.rename({ sessionId: this.sessionId, title })
|
||||
if (result.ok) this.projections.apply('title', result.value.title, result.value.seq)
|
||||
return result
|
||||
} catch (error) {
|
||||
return transportError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute one slash-command line against this session's agent — pure
|
||||
* admission semantics (the host executor durably logs the lifecycle;
|
||||
|
||||
@@ -63,6 +63,7 @@ export class FakeApiClient implements IApiClient {
|
||||
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
|
||||
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
|
||||
readonly defaultModel: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' }
|
||||
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
|
||||
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
|
||||
() => Promise.resolve(ok({ events: [], hasMore: false }))
|
||||
@@ -115,6 +116,7 @@ export class FakeApiClient implements IApiClient {
|
||||
models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)),
|
||||
selectModel: (payload: { provider: string; model: string }) =>
|
||||
this.record('session.selectModel', payload, this.onSelectModel(payload)),
|
||||
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
|
||||
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
|
||||
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
|
||||
}
|
||||
|
||||
@@ -301,6 +301,32 @@ describe('prompt and cancel errors', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('rename', () => {
|
||||
it('settles the title projection cell from the unary response (higher-seq-wins vs the push frame)', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onRename = () => Promise.resolve(ok({ title: '正名', seq: 7 }))
|
||||
const result = await session.rename(' 正名 ')
|
||||
expect(result).toMatchObject({ ok: true, value: { title: '正名', seq: 7 } })
|
||||
expect(api.callsOf('session.rename')).toMatchObject([{ sessionId: SID, title: ' 正名 ' }])
|
||||
expect(session.projections.faceOf('title').getSnapshot()).toBe('正名')
|
||||
// A stale lower-seq apply (the push-frame path routes into this same
|
||||
// store) must not roll the settled value back.
|
||||
session.projections.apply('title', '旧名', 3)
|
||||
expect(session.projections.faceOf('title').getSnapshot()).toBe('正名')
|
||||
})
|
||||
|
||||
it('returns the business error untouched and folds a transport throw to internal', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onRename = () => Promise.resolve(err({ code: 'title-invalid', message: 'empty', details: { sessionId: SID } }))
|
||||
const rejected = await session.rename(' ')
|
||||
expect(rejected).toMatchObject({ ok: false, error: { code: 'title-invalid' } })
|
||||
expect(session.projections.faceOf('title').getSnapshot()).toBeUndefined()
|
||||
api.onRename = () => Promise.reject(new Error('rename transport down'))
|
||||
const folded = await session.rename('x')
|
||||
expect(folded).toMatchObject({ ok: false, error: { code: 'internal' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('pending interactions', () => {
|
||||
it('adds approval/question on requested and removes them on resolved', async () => {
|
||||
const { session } = makeSession()
|
||||
|
||||
Reference in New Issue
Block a user