From ff049f1e82f04ff99e15f43132dee523f9dfe1a0 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:59:34 +0800 Subject: [PATCH] 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. --- .../client/connection/src/client/fixture.ts | 28 +++++++++++++++++++ packages/client/connection/tests/fake-api.ts | 2 ++ .../runtime/src/client/contract/session.ts | 7 +++++ .../runtime/src/client/sessions/session.ts | 19 +++++++++++++ packages/client/runtime/tests/fake-api.ts | 2 ++ packages/client/runtime/tests/session.spec.ts | 26 +++++++++++++++++ packages/client/test-runtime/src/sessions.ts | 8 ++++++ .../test-runtime/tests/runtime.spec.tsx | 1 + 8 files changed, 93 insertions(+) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index cab616aabd..a586e78477 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -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) diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 6a876c8ed4..650f982b11 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -45,6 +45,7 @@ export class FakeApiClient implements IApiClient { // Programmable slots (defaults answer OK-empty); reassign per case. onList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) + onRename: (payload: unknown) => Promise> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 })) onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) => Promise> = () => 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)), } diff --git a/packages/client/runtime/src/client/contract/session.ts b/packages/client/runtime/src/client/contract/session.ts index 31335c388c..2ba8725b02 100644 --- a/packages/client/runtime/src/client/contract/session.ts +++ b/packages/client/runtime/src/client/contract/session.ts @@ -41,6 +41,13 @@ export interface ISession { * @returns acceptance, or the business error. */ cancel(): Promise> + /** + * 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> /** * Extend the history window backwards (older messages pagination). * @returns completion; failures land in snapshot.openState/loadingOlder. diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 0f5d39ac8e..f18ced4dfe 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -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> { + 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; diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 0a1de3f7e1..04ca13ee11 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -63,6 +63,7 @@ export class FakeApiClient implements IApiClient { onList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) readonly defaultModel: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' } + onRename: (payload: unknown) => Promise> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 })) onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) => Promise> = () => 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)), } diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index c7be330d55..c1c6b572f7 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -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() diff --git a/packages/client/test-runtime/src/sessions.ts b/packages/client/test-runtime/src/sessions.ts index 5ec4652aef..59dd7d9186 100644 --- a/packages/client/test-runtime/src/sessions.ts +++ b/packages/client/test-runtime/src/sessions.ts @@ -107,6 +107,14 @@ export class FixtureSession implements SessionFace { loadOlder(): never { throw new Error(`test session "${this.sessionId}": loadOlder is not stubbed — supply it on the fixture's session face`) } + + /** + * Fail-loud stub; supply `rename` on the fixture's session face to exercise it. + * @returns never — always throws. + */ + rename(): never { + throw new Error(`test session "${this.sessionId}": rename is not stubbed — supply it on the fixture's session face`) + } } /** One live test session: fixture-derived stores plus its minted scope state. */ diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index 22d3bb5cfc..00faae9905 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -470,6 +470,7 @@ describe('fixture session face', () => { expect(() => bare.cancel()).toThrow(/cancel is not stubbed/) expect(() => bare.command()).toThrow(/command is not stubbed/) expect(() => bare.loadOlder()).toThrow(/loadOlder is not stubbed/) + expect(() => bare.rename()).toThrow(/rename is not stubbed/) await runtime.dispose() })