diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index a6e7bf867c..c114be3c66 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -71,6 +71,7 @@ type SessionListMutation = | { kind: 'upsert'; summary: SessionSummary } | { kind: 'remove'; sessionId: SessionId } | { kind: 'status'; sessionId: SessionId; running: boolean } + | { kind: 'activity'; sessionId: SessionId; updatedAt: number } /** Local first-send flip: the sender clears blank without waiting for a host frame. */ | { kind: 'engaged'; sessionId: SessionId } @@ -685,6 +686,16 @@ export class SessionManager { handleMuxEnvelope(envelope: RpcRequest): void { const frame = envelope.payload if (frame.type === 'stream/error') return // Controller already treats this as stream failure + if ( + frame.type === 'session/event' + && frame.event.type === 'user/message' + && frame.event.data.source.kind === 'user' + ) { + // session.list supplies the cold baseline, while a direct prompt or an + // admitted steer advances it between pulls. Max keeps replayed or + // repaired older user messages from moving the row backwards. + this.recordMutation({ kind: 'activity', sessionId: frame.sessionId, updatedAt: frame.event.time }) + } if (frame.type === 'session/projection') { // Finished host-computed value: land it in the resident store whether or // not the Session is instantiated (list rows read the 'title' key). The @@ -1115,6 +1126,11 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi && (summary.running !== mutation.running || (mutation.running && summary.blank)) ? { ...summary, running: mutation.running, blank: summary.blank && !mutation.running } : summary) + case 'activity': + return summaries.map(summary => summary.sessionId === mutation.sessionId + && mutation.updatedAt > summary.updatedAt + ? { ...summary, updatedAt: mutation.updatedAt } + : summary) case 'engaged': return summaries.map(summary => summary.sessionId === mutation.sessionId && summary.blank ? { ...summary, blank: false } diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 7eba55fd64..cd6e6b9a3a 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -7,7 +7,7 @@ import { describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import { SessionManager } from '../src/client/sessions/manager.ts' import { FakeApiClient, deferred, err, ok } from './fake-api.ts' -import { entries, plainTurn } from './event-script.ts' +import { entries, ev, plainTurn } from './event-script.ts' const S1 = 'fk-m1' as SessionId const S2 = 'fk-m2' as SessionId @@ -113,6 +113,46 @@ describe('list lifecycle', () => { expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1]) }) + it('advances list activity only for direct user messages', async () => { + const api = new FakeApiClient() + api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] })) + const manager = new SessionManager(api) + await manager.refreshList() + + // Both a new prompt and an admitted steer land as a user-sourced message. + const activity = { ...ev.user(10, 'new'), time: 500 } + manager.handleMuxEnvelope({ + rpcId: 'activity' as never, + payload: { type: 'session/event', sessionId: S1, event: activity }, + }) + expect(manager.getListSnapshot().items[0]?.updatedAt).toBe(500) + + manager.handleMuxEnvelope({ + rpcId: 'older' as never, + payload: { type: 'session/event', sessionId: S1, event: { ...activity, time: 400 } }, + }) + manager.handleMuxEnvelope({ + rpcId: 'assistant' as never, + payload: { type: 'session/event', sessionId: S1, event: { ...ev.assistant(11, 0, 'reply'), time: 600 } }, + }) + + const injected = ev.user(12, 'context') + if (injected.type !== 'user/message') throw new Error('user builder returned another event type') + manager.handleMuxEnvelope({ + rpcId: 'injected' as never, + payload: { + type: 'session/event', + sessionId: S1, + event: { + ...injected, + time: 700, + data: { ...injected.data, source: { kind: 'plugin', plugin: 'test' } }, + }, + }, + }) + expect(manager.getListSnapshot().items[0]?.updatedAt).toBe(500) + }) + it('keeps the error in the list snapshot on failure', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} }))