mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts: # apps/cli/src/web.ts # apps/web/tests/smoke-fixture.e2e.ts # docs/architecture.i18n.yaml # packages/client/connection/src/client/fixture.ts # packages/client/ui-conversation/README.md # packages/client/ui-conversation/package.json # packages/client/ui-conversation/src/client/apply.ts # packages/client/ui-conversation/src/client/chat/ChatView.tsx # packages/client/ui-conversation/src/client/chat/register.ts # packages/client/ui-conversation/src/client/contract/slots.ts # packages/client/ui-conversation/src/client/contract/views.ts # packages/client/ui-conversation/src/client/service.ts # packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx # packages/client/ui-conversation/src/client/stores.ts # packages/client/ui-conversation/tests/skeleton-branches.spec.tsx # packages/cordis/tool-cordis/src/api-catalog.ts # packages/host/runtime/src/boot.ts # packages/host/runtime/tests/host-runtime.spec.ts # pnpm-lock.yaml
This commit is contained in:
@@ -13,6 +13,6 @@ None; this package neither assembles nor sends a provider request.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`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 watch-approximated** — the most recently resolved binding stands in for "who is watching"; a removed-while-watched session's scope survives until the watch moves away, not until true observer count reaches zero.
|
||||
- **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.
|
||||
|
||||
@@ -51,7 +51,7 @@ export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
*/
|
||||
export type ClientContext = Context
|
||||
|
||||
/** The conversation-snapshot selector hook (ConvViewProps/ToolViewProps take this). */
|
||||
/** The conversation-snapshot selector hook (ConvViewProps/ToolRowProps take this). */
|
||||
export type UseConversationSession = SnapshotSelectorHook<ConversationSnapshot>
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
* slot-parity design), session scope tree (mintScope pattern: no-op plugin
|
||||
* Fiber + ctx.extend scope tag), stable SessionBinding cache, ancestry walk.
|
||||
*
|
||||
* Scope lifecycle is watch-driven: a scope is minted lazily on first
|
||||
* resolution; a session leaving the list tears its scope down only when
|
||||
* nobody is watching it. "Watched" is approximated as the most recently
|
||||
* resolved binding id — SessionProvider re-resolves on every selection
|
||||
* change (keyed remount), so a switch away always re-evaluates the deferred
|
||||
* teardown; a host-side death without list removal keeps the scope (frozen
|
||||
* read-only view).
|
||||
* Scope lifecycle is stage-driven: a scope is minted lazily on first
|
||||
* resolution (pure — resolution has no side effects and is render-safe);
|
||||
* the event window and deferred teardown key off the STAGED session, which
|
||||
* follows `list.current` exactly. Staging is the open signal: the window
|
||||
* opens ⟺ the session is on stage (today the stage is `current`; the staged
|
||||
* state can widen to a multi-pane list later). A session leaving the list
|
||||
* tears its scope down immediately unless it is the staged one, whose scope
|
||||
* survives frozen (read-only view) until the stage moves on.
|
||||
*/
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import type { HostDescription, IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
@@ -97,9 +98,14 @@ export class SessionsService {
|
||||
private readonly selection: SnapshotStore<{ sessionId?: SessionId }>
|
||||
|
||||
private readonly scopes = new Map<SessionId, ScopeRecord>()
|
||||
/** Most recently resolved binding id — the watch approximation for deferred teardown. */
|
||||
/**
|
||||
* The staged session id — follows `list.current` exactly, holding its last
|
||||
* defined value across masked gaps (a transiently absent selection blanks
|
||||
* `current` without moving the stage, so reconnect re-pulls and removals
|
||||
* keep the staged scope's frozen view alive until the stage moves on).
|
||||
*/
|
||||
private watched: SessionId | undefined
|
||||
/** Removed-while-watched sessions whose teardown waits for the watch to move away. */
|
||||
/** Removed-while-staged sessions whose teardown waits for the stage to move away. */
|
||||
private readonly deferredRemovals = new Set<SessionId>()
|
||||
private description: HostDescription | undefined
|
||||
|
||||
@@ -116,6 +122,13 @@ export class SessionsService {
|
||||
// The manager owns wire truth; the store is its projection. Manager
|
||||
// notifications are already microtask-batched.
|
||||
this.manager.subscribe(() => { this.projectList() })
|
||||
// Stage follower: every current write (open() and projection alike)
|
||||
// re-evaluates staging, so startup restore (persisted selection validated
|
||||
// by the projection) and reconnect resurfacing open their window with no
|
||||
// dedicated code path. Safe to run synchronously inside the store notify:
|
||||
// the follower writes no list state — session.open()'s synchronous prefix
|
||||
// touches only session-side state and its own microtask-batched notifier.
|
||||
this.list.subscribe(() => { this.followCurrent() })
|
||||
rootCtx.reflect.provide('sessions', this, undefined)
|
||||
}
|
||||
|
||||
@@ -169,35 +182,50 @@ export class SessionsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the stable session binding (SessionProvider's resolveBinding feed).
|
||||
* Resolve the stable session binding (scope-addressed assembly feed). Pure
|
||||
* resolution — no staging, no window side effects.
|
||||
* @param id - session id.
|
||||
* @returns binding, or undefined for a session neither listed nor already scoped.
|
||||
*/
|
||||
binding(id: SessionId): SessionBinding | undefined {
|
||||
const record = this.resolve(id)
|
||||
if (record === undefined) return undefined
|
||||
if (this.watched !== id) {
|
||||
this.watched = id
|
||||
this.sweepDeferred()
|
||||
}
|
||||
return record.binding
|
||||
return this.resolve(id)?.binding
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the render-layer session cell (SessionProvider's feed through
|
||||
* the renderer host; ctx never enters the render layer). Marks the session
|
||||
* watched, same as {@link SessionsService.binding}.
|
||||
* the renderer host; ctx never enters the render layer). Pure resolution —
|
||||
* render-safe: SessionProvider calls this during render, so no staging, no
|
||||
* window side effects (StrictMode double-invokes and concurrent discarded
|
||||
* passes must stay free).
|
||||
* @param id - session id.
|
||||
* @returns cell, or undefined for a session neither listed nor already scoped.
|
||||
*/
|
||||
cell(id: string): SessionCell | undefined {
|
||||
const record = this.resolve(id as SessionId)
|
||||
if (record === undefined) return undefined
|
||||
if (this.watched !== id) {
|
||||
this.watched = id as SessionId
|
||||
this.sweepDeferred()
|
||||
return this.resolve(id as SessionId)?.cell
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the stage to the list's current session: sweep teardowns deferred
|
||||
* behind the previous occupant and pull the new occupant's history window.
|
||||
* Staging IS the open signal — the window opens ⟺ the session is on stage
|
||||
* — and open() is idempotent (an in-flight or completed open no-ops; a
|
||||
* failed one retries the next time current is touched).
|
||||
*/
|
||||
private followCurrent(): void {
|
||||
const current = this.list.getSnapshot().current
|
||||
// A masked gap (current blanked while the selection's session is
|
||||
// transiently absent) holds the stage: tearing down on the gap would
|
||||
// destroy exactly the frozen scope the mask exists to preserve.
|
||||
if (current === undefined || current === this.watched) return
|
||||
this.watched = current
|
||||
this.sweepDeferred()
|
||||
const record = this.resolve(current)
|
||||
/* v8 ignore next 3 -- defensive: current is always a listed id (open()
|
||||
* validates and the projection masks absent selections), so resolve
|
||||
* cannot miss; kept so a future current writer cannot crash the notify. */
|
||||
if (record !== undefined) {
|
||||
void record.binding.session.open()
|
||||
}
|
||||
return record.cell
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -263,7 +291,7 @@ export class SessionsService {
|
||||
this.pruneScopes(byId)
|
||||
}
|
||||
|
||||
/** Tear down scopes for removed sessions nobody watches; the watched one defers until the watch moves. */
|
||||
/** Tear down scopes for removed sessions off stage; the staged one defers until the stage moves. */
|
||||
private pruneScopes(byId: Record<SessionId, SessionSummary>): void {
|
||||
for (const [id, record] of this.scopes) {
|
||||
if (byId[id] !== undefined) continue
|
||||
@@ -285,11 +313,11 @@ export class SessionsService {
|
||||
this.rootCtx.get('slots')?.pruneStoreScope(id)
|
||||
}
|
||||
|
||||
/** Run deferred teardowns whose session is no longer watched (called when the watch moves). */
|
||||
/** Run deferred teardowns whose session is no longer staged (called when the stage moves). */
|
||||
private sweepDeferred(): void {
|
||||
for (const id of [...this.deferredRemovals]) {
|
||||
/* v8 ignore next -- defensive: only the watched id ever defers, and every
|
||||
* watch move sweeps first, so the set cannot contain the id the watch just
|
||||
/* v8 ignore next -- defensive: only the staged id ever defers, and every
|
||||
* stage move sweeps first, so the set cannot contain the id the stage just
|
||||
* moved to; kept as a guard against future extra sweep call sites. */
|
||||
if (id === this.watched) continue
|
||||
// Still absent from the list? (A re-added id cancels the deferred teardown.)
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
* SessionsService: list store projection (manager → {ids, byId, current}
|
||||
* with derived titles), the migrated current-selection account (open
|
||||
* validation, persisted mask semantics, cell resolution), scope-tree
|
||||
* lifecycle (lazy mint / frozen survival / removed teardown with watch
|
||||
* deferral), binding identity, ancestry walk, create.
|
||||
* lifecycle (lazy mint / frozen survival / removed teardown with staged
|
||||
* deferral — the stage follows list.current), binding identity, ancestry
|
||||
* walk, create.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -76,21 +77,21 @@ describe('scope tree', () => {
|
||||
expect(binding?.ctx).toBe(scoped)
|
||||
})
|
||||
|
||||
it('tears down an unwatched removed session but defers the watched one until the watch moves', async () => {
|
||||
it('tears down an off-stage removed session but defers the staged one until the stage moves', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }, { id: 's2' }])
|
||||
const ctx1 = b.svc.scope(sid('s1'))
|
||||
b.svc.binding(sid('s1')) // s1 is watched
|
||||
b.svc.scope(sid('s2')) // s2 scoped but not watched
|
||||
b.svc.open(sid('s1')) // s1 staged (current)
|
||||
b.svc.scope(sid('s2')) // s2 scoped but off stage
|
||||
|
||||
await feedList(b, [{ id: 's1' }]) // s2 removed, unwatched: torn down
|
||||
await feedList(b, [{ id: 's1' }]) // s2 removed, off stage: torn down
|
||||
expect(b.svc.scope(sid('s2'))).toBeUndefined()
|
||||
|
||||
await feedList(b, []) // s1 removed while watched: deferred, scope survives
|
||||
await feedList(b, []) // s1 removed while staged (current masks): deferred, scope survives
|
||||
expect(b.svc.scope(sid('s1'))).toBe(ctx1)
|
||||
|
||||
await feedList(b, [{ id: 's3' }])
|
||||
b.svc.binding(sid('s3')) // watch moves: deferred teardown sweeps s1
|
||||
b.svc.open(sid('s3')) // stage moves: deferred teardown sweeps s1
|
||||
expect(b.svc.scope(sid('s1'))).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -106,10 +107,10 @@ describe('scope tree', () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
const scoped = b.svc.scope(sid('s1'))
|
||||
b.svc.binding(sid('s1'))
|
||||
await feedList(b, []) // removed while watched → deferred
|
||||
await feedList(b, [{ id: 's1' }, { id: 's2' }]) // reappears
|
||||
b.svc.binding(sid('s2')) // watch moves; sweep must NOT tear down the re-listed s1
|
||||
b.svc.open(sid('s1'))
|
||||
await feedList(b, []) // removed while staged → deferred
|
||||
await feedList(b, [{ id: 's1' }, { id: 's2' }]) // reappears (current resurfaces, stage unchanged)
|
||||
b.svc.open(sid('s2')) // stage moves; sweep must NOT tear down the re-listed s1
|
||||
expect(b.svc.scope(sid('s1'))).toBe(scoped)
|
||||
})
|
||||
})
|
||||
@@ -168,15 +169,52 @@ describe('cell (render-layer session kit)', () => {
|
||||
expect(b.svc.cell('ghost')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('moves the watch like binding(): switching cells sweeps a deferred removal', async () => {
|
||||
it('cell()/binding() are pure resolution: no staging, no deferred sweep', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
b.svc.cell('s1') // watched
|
||||
await feedList(b, []) // removed while watched → deferred, scope survives
|
||||
await feedList(b, [{ id: 's1' }, { id: 's2' }])
|
||||
b.svc.open(sid('s1')) // staged
|
||||
b.svc.cell('s2') // resolution only — must NOT move the stage
|
||||
b.svc.binding(sid('s2'))
|
||||
await feedList(b, [{ id: 's2' }]) // s1 removed: still staged → deferred, scope survives
|
||||
expect(b.svc.scope(sid('s1'))).toBeDefined()
|
||||
await feedList(b, [{ id: 's2' }])
|
||||
b.svc.cell('s2') // watch moves → sweep tears s1 down
|
||||
expect(b.svc.scope(sid('s1'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('staging (current write) opens the session event window; resolution and re-staging do not re-pull', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }, { id: 's2' }])
|
||||
const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history')
|
||||
// Resolution is addressing, not staging: no window pull.
|
||||
b.svc.scope(sid('s1'))
|
||||
b.svc.cell('s1')
|
||||
b.svc.binding(sid('s1'))
|
||||
expect(historyCalls()).toHaveLength(0)
|
||||
b.svc.open(sid('s1'))
|
||||
expect(historyCalls().map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1'])
|
||||
// Same current again: no second pull.
|
||||
b.svc.open(sid('s1'))
|
||||
expect(historyCalls()).toHaveLength(1)
|
||||
// Stage moves: the new occupant opens.
|
||||
b.svc.open(sid('s2'))
|
||||
expect(historyCalls().map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1', 's2'])
|
||||
})
|
||||
|
||||
it('startup restore: a persisted selection validated by the first projection opens its window unprompted', async () => {
|
||||
const storage = new Map<string, string>([
|
||||
['dsh.sessions.current', JSON.stringify({ sessionId: 's1' })],
|
||||
])
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (k: string) => storage.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => { storage.set(k, v) },
|
||||
})
|
||||
try {
|
||||
const b = bench()
|
||||
expect(b.api.calls.filter(c => c.method === 'session.history')).toHaveLength(0)
|
||||
await feedList(b, [{ id: 's1' }]) // projection validates the persisted id → current lands → stage follows
|
||||
const historyCalls = b.api.calls.filter(c => c.method === 'session.history')
|
||||
expect(historyCalls.map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1'])
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -187,12 +225,13 @@ describe('slot-store scope prune hook', () => {
|
||||
b.ctx.reflect.provide('slots', { pruneStoreScope })
|
||||
await feedList(b, [{ id: 's1' }, { id: 's2' }])
|
||||
b.svc.scope(sid('s1'))
|
||||
b.svc.binding(sid('s2')) // s2 watched
|
||||
await feedList(b, []) // s1 unwatched → immediate drop; s2 watched → deferred
|
||||
b.svc.scope(sid('s2'))
|
||||
b.svc.open(sid('s2')) // s2 staged
|
||||
await feedList(b, []) // s1 off stage → immediate drop; s2 staged → deferred
|
||||
expect(pruneStoreScope).toHaveBeenCalledWith('s1')
|
||||
expect(pruneStoreScope).not.toHaveBeenCalledWith('s2')
|
||||
await feedList(b, [{ id: 's3' }])
|
||||
b.svc.binding(sid('s3')) // watch moves → deferred sweep drops s2
|
||||
b.svc.open(sid('s3')) // stage moves → deferred sweep drops s2
|
||||
expect(pruneStoreScope).toHaveBeenCalledWith('s2')
|
||||
})
|
||||
|
||||
@@ -242,44 +281,46 @@ describe('coverage tails (branch duals)', () => {
|
||||
expect(byId[sid('empty-cwd')]?.title).toBe('empty-cwd')
|
||||
})
|
||||
|
||||
it('binding for an unknown session returns undefined without moving the watch', async () => {
|
||||
it('binding for an unknown session returns undefined and leaves the staged scope intact', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
b.svc.binding(sid('s1'))
|
||||
b.svc.open(sid('s1'))
|
||||
expect(b.svc.binding(sid('ghost'))).toBeUndefined()
|
||||
// Watch unchanged: removing s1 defers (still watched), proving the ghost lookup did not steal the watch.
|
||||
// Stage unchanged: removing s1 defers (still staged), proving the ghost lookup touched nothing.
|
||||
await feedList(b, [])
|
||||
expect(b.svc.scope(sid('s1'))).toBeDefined()
|
||||
})
|
||||
|
||||
it('sweep skips the id that is itself still watched and tolerates a scope record already gone', async () => {
|
||||
it('a masked current gap holds the stage (no teardown, no re-open) until the stage moves', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
b.svc.binding(sid('s1'))
|
||||
await feedList(b, []) // deferred removal of the watched id
|
||||
// Re-resolving the SAME watched id: sweep runs but must skip it (watched-continue branch).
|
||||
expect(b.svc.binding(sid('s1'))).toBeDefined()
|
||||
b.svc.open(sid('s1'))
|
||||
const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history')
|
||||
expect(historyCalls()).toHaveLength(1)
|
||||
await feedList(b, []) // removed while staged: current masks to undefined, stage holds → deferred
|
||||
expect(b.svc.scope(sid('s1'))).toBeDefined()
|
||||
// Resurfacing re-projects current = s1: same stage occupant, no second pull.
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
expect(historyCalls()).toHaveLength(1)
|
||||
expect(b.svc.list.getSnapshot().current).toBe('s1')
|
||||
})
|
||||
|
||||
it('sweep hits both deferral edges: watched-id skip and an already-vacated scope record', async () => {
|
||||
it('sweep hits both deferral edges: staged-id skip and an already-vacated scope record', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 'a' }, { id: 'b' }])
|
||||
b.svc.binding(sid('a'))
|
||||
b.svc.binding(sid('b')) // watch: b; both scoped
|
||||
await feedList(b, []) // a removed unwatched → torn immediately; b removed watched → deferred
|
||||
// Move the watch to a THIRD id while b stays deferred: sweep now walks a
|
||||
// set containing b (torn) — and the watched-continue branch fires when the
|
||||
// deferral set still holds the current watch target.
|
||||
b.svc.scope(sid('a'))
|
||||
b.svc.open(sid('b')) // stage: b; both scoped
|
||||
await feedList(b, []) // a removed off stage → torn immediately; b removed staged → deferred
|
||||
// Move the stage to a THIRD id while b stays deferred: sweep walks a set
|
||||
// containing b (torn).
|
||||
await feedList(b, [{ id: 'c' }])
|
||||
b.svc.binding(sid('c'))
|
||||
b.svc.open(sid('c'))
|
||||
expect(b.svc.scope(sid('b'))).toBeUndefined()
|
||||
// Deferral for an id whose record was never minted: force-add via removed
|
||||
// list state (scope teardown raced) — sweep must tolerate the missing record.
|
||||
await feedList(b, [])
|
||||
b.svc.binding(sid('c')) // c now watched+removed → deferred
|
||||
// Deferral for an id whose record was never minted: force the deferral
|
||||
// via removed list state — sweep must tolerate the missing record.
|
||||
await feedList(b, []) // c removed while staged → deferred (scope exists)
|
||||
await feedList(b, [{ id: 'd' }])
|
||||
b.svc.binding(sid('d')) // sweep tears c
|
||||
b.svc.open(sid('d')) // sweep tears c
|
||||
expect(b.svc.scope(sid('c'))).toBeUndefined()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user