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:
imccyu
2026-07-29 18:59:34 +08:00
parent 19336686a6
commit ff049f1e82
8 changed files with 93 additions and 0 deletions

View File

@@ -943,6 +943,33 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
if (options.dropSessionCreateResponse) throw new Error('fixture: dropped session.create response after publication')
return ok(request, { sessionId: created.sessionId })
},
rename: (request) => {
const { sessionId, title } = request.payload
const source = summaryOf(sessionId)
if (source === undefined) {
return err(request, {
code: 'session-not-found',
message: `no session ${sessionId}`,
details: { sessionId },
})
}
const normalized = title.trim().replace(/\s+/g, ' ')
if (normalized.length === 0) {
return err(request, {
code: 'title-invalid',
message: `rename rejected for session ${sessionId}: empty title`,
details: { sessionId },
})
}
// The append emits the session/event and its session/projection frame
// (host parallel); the unary response settles the caller first.
append(sessionId, {
type: 'session/title',
data: { title: normalized, messageSeqs: [], source: { kind: 'user' } },
})
const log = logOf(sessionId)
return ok(request, { title: normalized, seq: log.length - 1 })
},
history: async (request) => {
const log = logs.get(request.payload.sessionId) ?? []
// Snapshot at request time, deliver after the transit delay (mirrors a real host under latency).
@@ -1486,6 +1513,7 @@ export class FixtureApiClient extends AbstractApiClient {
case 'session.history': return this.api.sessions.history(request)
case 'session.models': return this.api.sessions.models(request)
case 'session.selectModel': return this.api.sessions.selectModel(request)
case 'session.rename': return this.api.sessions.rename(request)
case 'session.prompt': return this.api.sessions.prompt(request)
case 'session.cancel': return this.api.sessions.cancel(request)
case 'host.describe': return this.api.host.describe(request)

View File

@@ -45,6 +45,7 @@ export class FakeApiClient implements IApiClient {
// Programmable slots (defaults answer OK-empty); reassign per case.
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 }))
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; modelTarget: ModelTarget }>> =
() => Promise.resolve(ok({
@@ -96,6 +97,7 @@ export class FakeApiClient implements IApiClient {
models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)),
selectModel: (payload: ModelTarget & { sessionId: SessionId }) =>
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)),
}