Merge remote-tracking branch 'origin/master' into feat/send-unify
# Conflicts: # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl # packages/cordis/tool-cordis/src/api-catalog.ts # packages/pty/pty-local/tests/index.spec.ts # packages/session-query/session-query/tests/tracing.spec.ts
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
Client cordis boot + core services: SlotsService (Service wrapper over SlotCore + 'slots/changed' bridge), SessionsService (list store projection, scope tree, bindings, ancestry), the Session object layer (exported as a type; instances are owned and handed out by SessionsService — the manager/paging internals stay package-internal, tests reach them via src), ClientLoader (`./loader` subpath, statically held by the shell). Contract: api-contracts v3 §4.
|
||||
|
||||
## Session title projection
|
||||
|
||||
`SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the client runtime hosts browser-side services and the session object layer; nothing here reaches a model request.
|
||||
@@ -15,4 +19,3 @@ None; this package neither assembles nor sends a provider request.
|
||||
- **`loader.unload` is a stub (throws not-implemented)** — the full chain (fiber dispose → registration cascade → style removal) lands with the HMR project.
|
||||
- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`cell()`/`binding()`/`scope()`) is pure addressing, render-safe. The staged state can widen to a multi-pane list when concurrent panes land.
|
||||
- **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem).
|
||||
- **`SessionSummary.title` is a display projection** — the wire summary carries no title yet; the cwd basename stands in, then the raw id.
|
||||
|
||||
@@ -4,9 +4,15 @@
|
||||
|
||||
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
|
||||
|
||||
/** Host list summary enriched with the latest mux-projected durable title. */
|
||||
export interface TitledSessionSummary extends SessionSummary {
|
||||
title?: string
|
||||
}
|
||||
|
||||
/** One flattened session-list row (summary + lineage indent depth). */
|
||||
export interface SessionListEntry {
|
||||
sessionId: SessionId
|
||||
title?: string
|
||||
updatedAt: number
|
||||
running: boolean
|
||||
parentSessionId?: SessionId
|
||||
@@ -21,12 +27,12 @@ export interface SessionListEntry {
|
||||
* @param summaries - the host's session.list items.
|
||||
* @returns display rows in render order.
|
||||
*/
|
||||
export function flattenLineage(summaries: readonly SessionSummary[]): SessionListEntry[] {
|
||||
const byId = new Map<SessionId, SessionSummary>()
|
||||
export function flattenLineage(summaries: readonly TitledSessionSummary[]): SessionListEntry[] {
|
||||
const byId = new Map<SessionId, TitledSessionSummary>()
|
||||
for (const s of summaries) byId.set(s.sessionId, s)
|
||||
|
||||
const children = new Map<SessionId, SessionSummary[]>()
|
||||
const roots: SessionSummary[] = []
|
||||
const children = new Map<SessionId, TitledSessionSummary[]>()
|
||||
const roots: TitledSessionSummary[] = []
|
||||
for (const s of summaries) {
|
||||
if (s.parentSessionId !== undefined && byId.has(s.parentSessionId)) {
|
||||
const list = children.get(s.parentSessionId) ?? []
|
||||
@@ -37,12 +43,12 @@ export function flattenLineage(summaries: readonly SessionSummary[]): SessionLis
|
||||
}
|
||||
}
|
||||
|
||||
const byUpdatedDesc = (a: SessionSummary, b: SessionSummary): number => b.updatedAt - a.updatedAt
|
||||
const byUpdatedDesc = (a: TitledSessionSummary, b: TitledSessionSummary): number => b.updatedAt - a.updatedAt
|
||||
roots.sort(byUpdatedDesc)
|
||||
|
||||
const out: SessionListEntry[] = []
|
||||
const visited = new Set<SessionId>()
|
||||
const walk = (s: SessionSummary, depth: number): void => {
|
||||
const walk = (s: TitledSessionSummary, depth: number): void => {
|
||||
if (visited.has(s.sessionId)) {
|
||||
console.warn(`[web-runtime] lineage cycle at ${s.sessionId}; emitting as root`)
|
||||
return
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { transportError } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionListEntry } from './lineage.ts'
|
||||
import type { SessionListEntry, TitledSessionSummary } from './lineage.ts'
|
||||
import { flattenLineage } from './lineage.ts'
|
||||
import { Notifier } from './notifier.ts'
|
||||
import { Session } from './session.ts'
|
||||
@@ -19,6 +19,13 @@ export interface SessionListSnapshot {
|
||||
/** Per-session cap for pre-instantiation approval/question buffering (low-frequency frames; a few dozen covers any real backlog). */
|
||||
const PENDING_BUFFER_CAP = 32
|
||||
|
||||
/** Latest title control snapshot retained independently of list/instance arrival. */
|
||||
interface SessionTitleSnapshot {
|
||||
title: string
|
||||
eventSeq: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/** Instance cluster + frame entry + the session list (see the web client architecture RFC). */
|
||||
export class SessionManager {
|
||||
private readonly sessions = new Map<SessionId, Session>()
|
||||
@@ -27,6 +34,7 @@ export class SessionManager {
|
||||
* drop-and-backfill path; replayed and cleared on instantiation. Bounded per session (these
|
||||
* frames are low-frequency; overflow drops oldest) and dropped on session-removed (audit S7). */
|
||||
private readonly pendingBuffers = new Map<SessionId, RpcRequest<MuxFrame>[]>()
|
||||
private readonly titleSnapshots = new Map<SessionId, SessionTitleSnapshot>()
|
||||
private summaries: SessionSummary[] = []
|
||||
private listState: 'idle' | 'loading' | 'error' = 'idle'
|
||||
private listError: RpcError | null = null
|
||||
@@ -158,6 +166,24 @@ export class SessionManager {
|
||||
handleMuxEnvelope(envelope: RpcRequest<MuxFrame>): void {
|
||||
const frame = envelope.payload
|
||||
if (frame.type === 'stream/error') return // Controller already treats this as stream failure
|
||||
if (frame.type === 'session/title') {
|
||||
const current = this.titleSnapshots.get(frame.sessionId)
|
||||
if (current !== undefined && current.eventSeq >= frame.eventSeq) return
|
||||
this.titleSnapshots.set(frame.sessionId, {
|
||||
title: frame.title,
|
||||
eventSeq: frame.eventSeq,
|
||||
updatedAt: frame.updatedAt,
|
||||
})
|
||||
this.notifier.markDirty()
|
||||
return
|
||||
}
|
||||
if (frame.type === 'session/subscribed') {
|
||||
const current = this.titleSnapshots.get(frame.sessionId)
|
||||
if (current !== undefined && current.eventSeq > frame.lastSeq) {
|
||||
this.titleSnapshots.delete(frame.sessionId)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
}
|
||||
const session = this.sessions.get(frame.sessionId)
|
||||
if (session === undefined) {
|
||||
// Approval/question frames never hit history: buffer for replay on instantiation;
|
||||
@@ -204,6 +230,7 @@ export class SessionManager {
|
||||
this.summaries = this.summaries.filter(s => s.sessionId !== frame.sessionId)
|
||||
this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot
|
||||
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
|
||||
this.titleSnapshots.delete(frame.sessionId)
|
||||
this.notifier.markDirty()
|
||||
return
|
||||
}
|
||||
@@ -230,12 +257,19 @@ export class SessionManager {
|
||||
}
|
||||
|
||||
private buildListSnapshot(): SessionListSnapshot {
|
||||
const fresh = flattenLineage(this.summaries)
|
||||
const merged: TitledSessionSummary[] = this.summaries.map((summary) => {
|
||||
const title = this.titleSnapshots.get(summary.sessionId)
|
||||
return title === undefined
|
||||
? summary
|
||||
: { ...summary, title: title.title, updatedAt: Math.max(summary.updatedAt, title.updatedAt) }
|
||||
})
|
||||
const fresh = flattenLineage(merged)
|
||||
const items = fresh.map((entry) => {
|
||||
const prev = this.entryCache.get(entry.sessionId)
|
||||
if (
|
||||
prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running
|
||||
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd && prev.depth === entry.depth
|
||||
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
|
||||
&& prev.title === entry.title && prev.depth === entry.depth
|
||||
) return prev
|
||||
this.entryCache.set(entry.sessionId, entry)
|
||||
return entry
|
||||
|
||||
@@ -25,7 +25,10 @@ import type { Session } from './session.ts'
|
||||
/** Session list row projected from the host list RPC plus live stream increments. */
|
||||
export interface SessionSummary {
|
||||
id: SessionId
|
||||
title: string
|
||||
/** Latest durable log-backed title, absent until the host projects one. */
|
||||
title?: string
|
||||
/** Human-facing label: durable title, project basename, then session id. */
|
||||
displayTitle: string
|
||||
cwd?: string
|
||||
parentId?: SessionId
|
||||
running: boolean
|
||||
@@ -62,10 +65,11 @@ export function scopeOf(ctx: Context): SessionId | undefined {
|
||||
function sessionScope(): void {}
|
||||
|
||||
/**
|
||||
* Display title projection. The wire summary carries no title yet (P-I
|
||||
* ledger): the project directory's basename stands in, then the raw id.
|
||||
* Display title projection: durable title, project directory basename, then
|
||||
* the raw id.
|
||||
*/
|
||||
function titleOf(cwd: string | undefined, id: SessionId): string {
|
||||
function displayTitleOf(title: string | undefined, cwd: string | undefined, id: SessionId): string {
|
||||
if (title !== undefined) return title
|
||||
if (cwd !== undefined && cwd !== '') {
|
||||
const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
|
||||
if (base !== undefined && base !== '') return base
|
||||
@@ -259,9 +263,10 @@ export class SessionsService {
|
||||
ids.push(entry.sessionId)
|
||||
byId[entry.sessionId] = {
|
||||
id: entry.sessionId,
|
||||
title: titleOf(entry.cwd, entry.sessionId),
|
||||
displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
|
||||
running: entry.running,
|
||||
updatedAt: entry.updatedAt,
|
||||
...(entry.title !== undefined ? { title: entry.title } : {}),
|
||||
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
|
||||
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
|
||||
}
|
||||
|
||||
@@ -89,6 +89,66 @@ describe('list lifecycle', () => {
|
||||
expect(result).toMatchObject({ ok: true, value: { sessionId: S2 } })
|
||||
expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2])
|
||||
})
|
||||
|
||||
it('retains monotonic title snapshots before list arrival, merges recency, and clears them on removal', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'title-new' as never,
|
||||
payload: { type: 'session/title', sessionId: S1, title: 'Newest', eventSeq: 4, updatedAt: 300 },
|
||||
})
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'title-stale' as never,
|
||||
payload: { type: 'session/title', sessionId: S1, title: 'Stale', eventSeq: 3, updatedAt: 900 },
|
||||
})
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'title-equal' as never,
|
||||
payload: { type: 'session/title', sessionId: S1, title: 'Equal', eventSeq: 4, updatedAt: 901 },
|
||||
})
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[],
|
||||
}))
|
||||
await manager.refreshList()
|
||||
|
||||
const titled = manager.getListSnapshot()
|
||||
expect(titled.items.map(item => item.sessionId)).toEqual([S1, S2])
|
||||
expect(titled.items[0]).toMatchObject({ title: 'Newest', updatedAt: 300 })
|
||||
expect(titled.items[1]?.title).toBeUndefined()
|
||||
|
||||
manager.handleHostEnvelope({ rpcId: 'removed' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
|
||||
manager.handleHostEnvelope({ rpcId: 'readded' as never, payload: { type: 'host/session-added', sessionId: S1 } })
|
||||
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops a retained title beyond the subscription baseline before accepting its durable replay', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] }))
|
||||
const manager = new SessionManager(api)
|
||||
await manager.refreshList()
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'title-unflushed' as never,
|
||||
payload: { type: 'session/title', sessionId: S1, title: 'Unflushed', eventSeq: 4, updatedAt: 400 },
|
||||
})
|
||||
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'subscribed-recovered' as never,
|
||||
payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 2 },
|
||||
})
|
||||
expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
|
||||
expect(manager.getListSnapshot().items[0]?.updatedAt).toBe(100)
|
||||
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'title-durable' as never,
|
||||
payload: { type: 'session/title', sessionId: S1, title: 'Durable', eventSeq: 2, updatedAt: 200 },
|
||||
})
|
||||
expect(manager.getListSnapshot().items[0]).toMatchObject({ title: 'Durable', updatedAt: 200 })
|
||||
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'subscribed-current' as never,
|
||||
payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 2 },
|
||||
})
|
||||
expect(manager.getListSnapshot().items[0]).toMatchObject({ title: 'Durable', updatedAt: 200 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('host frame routing', () => {
|
||||
|
||||
@@ -41,16 +41,21 @@ async function feedList(b: Bench, rows: { id: string; cwd?: string; parentId?: s
|
||||
}
|
||||
|
||||
describe('list store projection', () => {
|
||||
it('projects ids/byId with cwd-basename titles (id fallback) and parent links', async () => {
|
||||
it('projects durable titles separately from cwd/id display fallbacks and parent links', async () => {
|
||||
const b = bench()
|
||||
b.svc.manager.handleMuxEnvelope({
|
||||
rpcId: 'title' as never,
|
||||
payload: { type: 'session/title', sessionId: sid('s1'), title: 'Durable title', eventSeq: 2, updatedAt: 3 },
|
||||
})
|
||||
await feedList(b, [
|
||||
{ id: 's1', cwd: '/home/u/proj-a/' },
|
||||
{ id: 's2', parentId: 's1', running: true },
|
||||
])
|
||||
const state = b.svc.list.getSnapshot()
|
||||
expect(state.ids).toEqual(['s1', 's2'])
|
||||
expect(state.byId[sid('s1')]).toMatchObject({ title: 'proj-a', cwd: '/home/u/proj-a/' })
|
||||
expect(state.byId[sid('s2')]).toMatchObject({ title: 's2', parentId: 's1', running: true })
|
||||
expect(state.byId[sid('s1')]).toMatchObject({ title: 'Durable title', displayTitle: 'Durable title', cwd: '/home/u/proj-a/' })
|
||||
expect(state.byId[sid('s2')]).toMatchObject({ displayTitle: 's2', parentId: 's1', running: true })
|
||||
expect(state.byId[sid('s2')]?.title).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reflects live increments (host stream via manager) into the store', async () => {
|
||||
@@ -273,12 +278,13 @@ describe('create', () => {
|
||||
})
|
||||
|
||||
describe('coverage tails (branch duals)', () => {
|
||||
it('titleOf falls back to the id for empty and separator-only cwd', async () => {
|
||||
it('displayTitleOf falls back to the id for empty and separator-only cwd', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 'no-base', cwd: '///' }, { id: 'empty-cwd', cwd: '' }])
|
||||
const { byId } = b.svc.list.getSnapshot()
|
||||
expect(byId[sid('no-base')]?.title).toBe('no-base')
|
||||
expect(byId[sid('empty-cwd')]?.title).toBe('empty-cwd')
|
||||
expect(byId[sid('no-base')]?.displayTitle).toBe('no-base')
|
||||
expect(byId[sid('empty-cwd')]?.displayTitle).toBe('empty-cwd')
|
||||
expect(byId[sid('no-base')]?.title).toBeUndefined()
|
||||
})
|
||||
|
||||
it('binding for an unknown session returns undefined and leaves the staged scope intact', async () => {
|
||||
|
||||
Reference in New Issue
Block a user