Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

This commit is contained in:
imccyu
2026-07-29 22:51:18 +08:00
149 changed files with 1950 additions and 447 deletions

View File

@@ -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 }))
@@ -117,6 +118,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)),
attachment: (payload: unknown) => this.record('session.attachment', payload, this.onAttachment(payload)),
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),

View File

@@ -316,6 +316,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()