Merge remote-tracking branch 'origin/master' into worktree/custom-provider-edit-parity-f8c160

This commit is contained in:
Yichen Jiang
2026-08-11 15:16:23 +08:00
2325 changed files with 38307 additions and 17353 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/README.md
README.md: 567e10f74ae9d017abef1d876401a958eb80fcfd
README.zh.md: ad6a9fb199c4118b864b80a466ddef40676b7169
README.md: bbc32fb3944dcb3b7aa48ef1f8e24e5c93ff7a67
README.zh.md: 5bfbd1ce6b41a44d3ef421ea59ecc29e1c329b3c

View File

@@ -29,6 +29,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha
| [`ui-slash/`](ui-slash/README.md) | Coordinates inline command and reference suggestions. |
| [`ui-skill/`](ui-skill/README.md) | Adds skill references to inline suggestions. |
| [`ui-subagent/`](ui-subagent/README.md) | Provides subagent navigation, child transcript states, and inline references. |
| [`ui-task/`](ui-task/README.md) | Lists this session's background tasks in the conversation header. |
| [`ui-model/`](ui-model/README.md) | Provides model selection in conversation surfaces. |
| [`ui-permission/`](ui-permission/README.md) | Configures default permissions and switches the current session's access. |
| [`ui-plan/`](ui-plan/README.md) | Presents active plan-mode status and its exit control. |

View File

@@ -29,6 +29,7 @@ dsh web GUI 的浏览器侧shell 启动、浏览器与宿主通信、共享 U
| [`ui-slash/`](ui-slash/README.md) | 协调内联命令和引用建议。 |
| [`ui-skill/`](ui-skill/README.md) | 向内联建议添加 skill技能引用。 |
| [`ui-subagent/`](ui-subagent/README.md) | 提供 subagent 导航、子会话记录状态和内联引用。 |
| [`ui-task/`](ui-task/README.md) | 在会话标题栏列出当前会话的后台任务。 |
| [`ui-model/`](ui-model/README.md) | 在会话界面中提供模型选择。 |
| [`ui-permission/`](ui-permission/README.md) | 配置默认权限并切换当前会话的访问模式。 |
| [`ui-plan/`](ui-plan/README.md) | 展示生效中的 plan mode 状态及其退出控件。 |

View File

@@ -1,8 +1,15 @@
{
"name": "@deepseek-ai/dsh-client-connection",
"description": "Wire consumer layer: HTTP-up/WebSocket-down client, ConnectionController dual streams with reconnect, and fixture api",
"version": "0.0.1",
"private": true,
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/client/connection"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
@@ -37,7 +44,7 @@
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"schemastery": "^3.18.0",
"@deepseek-ai/schemastery": "workspace:^",
"ws": "^8.21.0"
},
"files": [
@@ -47,14 +54,14 @@
"lib/types/**/*.d.ts"
],
"peerDependencies": {
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/ws": "^8.18.1",
"cordis": "^4.0.0-rc.7"
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -17,6 +17,7 @@ export type {
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi,
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
TaskView,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
export type {

View File

@@ -2834,6 +2834,12 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
})
return Promise.resolve({ accepted: true })
},
// Satisfies the ApiProxy contract type only: the browser export button
// fetches GET /api/session.export directly (window.fetch), so this stub is
// never reached through the fixture's dispatch.
downloads: {
sessionLog: () => Promise.resolve(new Response('fixture mode does not serve session export', { status: 404 })),
},
}
const rpc: ClientConnectionRpc = {

View File

@@ -3,7 +3,7 @@
* the shared API client, and lets the runtime object layer start the stream
* controller with its sinks.
*/
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { IApiClient } from './api.ts'
import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
import { FixtureApiClient } from './fixture.ts'
@@ -22,6 +22,7 @@ export type {
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
MessageId, ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels,
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
TaskView,
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
HostDescription, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,

View File

@@ -1,6 +1,6 @@
/** Host HTTP bridge for browser-client RPC. */
import type { Context } from 'cordis'
import z from 'schemastery'
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type {} from '@deepseek-ai/dsh-attachment'
// Activates the httpServer Context merge used below.
import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'

View File

@@ -4,7 +4,7 @@
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-connection'

View File

@@ -1,6 +1,6 @@
/** Host registry and HTTP adapter for generic Connection RPC channels. */
import { Context, Service } from 'cordis'
import { Context, Service } from '@deepseek-ai/cordis'
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
import {
clientRequestSchema,
@@ -32,7 +32,7 @@ interface ConnectionRpcInterceptor {
readonly options: ConnectionRpcHandlerOptions
}
declare module 'cordis' {
declare module '@deepseek-ai/cordis' {
interface Context {
/** Host Connection transport and RPC registrations. */
connection: HostConnectionHandle

View File

@@ -2,7 +2,7 @@
* Connection plugin browser-half apply: ctx.connection handle mounting, mode
* selection off the page URL, and the single-consumer stream-loop ownership.
*/
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { apply, type ConnectionHandle } from '../src/client/index.ts'
import type { RpcMessage } from '../src/client/api.ts'

View File

@@ -2,7 +2,7 @@
import { EventEmitter, once } from 'node:events'
import { createServer, request as httpRequest } from 'node:http'
import { PassThrough, Readable } from 'node:stream'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import type { AddressInfo } from 'node:net'
import type { IncomingMessage, ServerResponse } from 'node:http'

View File

@@ -1,8 +1,15 @@
{
"name": "@deepseek-ai/dsh-client-hmr",
"description": "Dev-only hot-reload driver for script-loaded client entries: SSE rebuilt frames → invalidate/prefetch → fiber swap through the vendored Loader entry",
"version": "0.0.1",
"private": true,
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/client/hmr"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
@@ -31,21 +38,21 @@
},
"license": "BSD-3-Clause",
"dependencies": {
"schemastery": "^3.18.0"
"@deepseek-ai/schemastery": "workspace:^"
},
"peerDependencies": {
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-client-modules": "^0.0.1",
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-client-modules": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-client-modules": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"files": [
"lib/index.js",

View File

@@ -61,8 +61,8 @@
* fiberless (the next rebuilt frame retries from scratch); an apply failure
* leaves a FAILED fiber for the shell's status projection. Both log loudly.
*/
import type { Context } from 'cordis'
import type { Entry, Loader } from '@cordisjs/plugin-loader'
import type { Context } from '@deepseek-ai/cordis'
import type { Entry, Loader } from '@deepseek-ai/cordis-plugin-loader'
import type { PluginsEventFrame } from '../events.ts'
import { EVENTS_ENDPOINT } from '../events.ts'

View File

@@ -8,8 +8,8 @@
*/
import { statSync } from 'node:fs'
import type { ServerResponse } from 'node:http'
import type { Context } from 'cordis'
import z from 'schemastery'
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
// Empty type imports carry the clientModuleHost/httpServer Context merges.
import type {} from '@deepseek-ai/dsh-client-modules'
import type {} from '@deepseek-ai/dsh-host-webserver'

View File

@@ -3,7 +3,7 @@
* @module @deepseek-ai/dsh-client-hmr/invariant
*/
import type { Context, Fiber } from 'cordis'
import type { Context, Fiber } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-hmr'

View File

@@ -5,7 +5,7 @@
import { mkdtempSync, rmSync, statSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { WebBootGraph, ClientModuleHostService } from '@deepseek-ai/dsh-client-modules'
import type { WebRoute, HttpServerService } from '@deepseek-ai/dsh-host-webserver'

View File

@@ -1,8 +1,15 @@
{
"name": "@deepseek-ai/dsh-client-locale",
"description": "Locale plugin: Host-backed zh/en preference, browser-derived fallback, locale snapshots, and typed namespace dictionaries",
"version": "0.0.1",
"private": true,
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/client/locale"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
@@ -34,12 +41,12 @@
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-client-connection": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"devDependencies": {
@@ -48,12 +55,12 @@
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"dependencies": {
"@deepseek-ai/dsh-settings": "workspace:^",
"schemastery": "^3.18.0"
"@deepseek-ai/schemastery": "workspace:^"
},
"files": [
"lib/index.js",

View File

@@ -9,7 +9,7 @@
* ui-slots): in THIS unit the map holds only this package's own merges, but
* consumers merge more namespaces in and the intersection keeps them
* string-typed. The rule fires on the narrow-map view, not real redundancy. */
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import {
type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS,
} from '@deepseek-ai/dsh-client-ui-slots'
@@ -68,7 +68,7 @@ export interface LocaleSnapshot {
revision: number
}
declare module 'cordis' {
declare module '@deepseek-ai/cordis' {
interface Context {
locale: LocaleService
}

View File

@@ -1,6 +1,6 @@
/** Host registration for the browser locale preference. */
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { LOCALE_SETTINGS_NAMESPACE, LocaleSettingsSchema } from './locale-settings.ts'

View File

@@ -4,7 +4,7 @@
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-locale'

View File

@@ -1,6 +1,6 @@
/** Locale preference stored in the Host user-settings document. */
import z from 'schemastery'
import z from '@deepseek-ai/schemastery'
/** Settings namespace owned by the locale plugin. */
export const LOCALE_SETTINGS_NAMESPACE = 'locale'

View File

@@ -1,7 +1,7 @@
/** locale apply wiring: service + dictionaries provision, declaration-aware
* Language row registration, snapshot projection into the row store, and
* recovery after an HMR collapse of the declaring entry. */
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import {

View File

@@ -1,4 +1,4 @@
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
import {

View File

@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { apply as nodeApply } from '@deepseek-ai/dsh-client-locale'
import { apply as clientApply, COMMON_NS, LocaleService, inject } from '@deepseek-ai/dsh-client-locale/client'
import * as LocaleInvariant from '@deepseek-ai/dsh-client-locale/invariant'

View File

@@ -16,7 +16,7 @@ const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }]
/** Empty global standard-kit hooks (the row reads neither). */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined })
return bindSnapshotSelector(store)
}
function emptyWorkspaces() {

View File

@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import type { LocaleSettings, LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'

View File

@@ -1,8 +1,15 @@
{
"name": "@deepseek-ai/dsh-client-modules",
"description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam",
"version": "0.0.1",
"private": true,
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/client/modules"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
@@ -35,10 +42,10 @@
},
"license": "BSD-3-Clause",
"devDependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
"@deepseek-ai/cordis": "workspace:^"
},
"files": [
"lib/index.js",
@@ -47,7 +54,7 @@
"lib/types/**/*.d.ts"
],
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -9,7 +9,7 @@
* a no-op against the already-registered entry.
* @module @deepseek-ai/dsh-client-modules/client
*/
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { DshWindow } from './manifest.ts'
export { ClientModuleSystem } from './system.ts'

View File

@@ -30,10 +30,10 @@
* composes the wire.
*/
import type {} from 'cordis'
import type {} from '@deepseek-ai/cordis'
import type { ClientModuleSystem } from './system.ts'
declare module 'cordis' {
declare module '@deepseek-ai/cordis' {
interface Context {
/** The client module system the web shell builds at boot (provided by the `./client` wrapper plugin). */
modules: ClientModuleLoader

View File

@@ -26,9 +26,9 @@ import { readFile } from 'node:fs/promises'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { Service } from 'cordis'
import type { Context } from 'cordis'
import type {} from '@cordisjs/plugin-loader'
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/cordis-plugin-loader'
import type {} from '@deepseek-ai/dsh-host-webserver'
import type { WebBootEntry, WebBootGraph } from './client/manifest.ts'
@@ -36,7 +36,7 @@ export type {
BootManifest, BootModuleRow, BootPluginRow, WebBootEntry, WebBootGraph,
} from './client/manifest.ts'
declare module 'cordis' {
declare module '@deepseek-ai/cordis' {
interface Context {
/** The web plugin table (provided by the client-modules node half). */
clientModuleHost: ClientModuleHostService

View File

@@ -4,7 +4,7 @@
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-modules'

View File

@@ -5,7 +5,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it } from 'vitest'
import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
import { ClientModuleHostService } from '../src/index.ts'

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: 1ec6cc38aed1bebff6b6ecb40faee7ae3ba9e412
README.zh.md: 6602152790a1d433371e27b274a4eb8c9e3cfcd8
README.md: 7c835deb58db149710495f97a2553c3de58d99da
README.zh.md: edf4473bec7df2253c032c3da86da878cdeade09

View File

@@ -2,10 +2,9 @@
English | [中文](README.zh.md)
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `session/preset-changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. `host/session-preset-changed` also folds its preset into the session row, because the switch's RPC echo reaches only the client that issued it. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list and scope state, and the shared event window and history paging used by registered conversation view targets. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into Session and Workspace owners and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `session/preset-changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. `host/session-preset-changed` also folds its preset into the session row, because the switch's RPC echo reaches only the client that issued it. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
`bindSettingsScope` is the browser mirror of the Host-side settings owner seam for one domain-owned namespace. It subscribes before starting a nonblocking initial read, publishes a uSES snapshot (status, section value, revision, writability, host/memory mode), serializes `set` writes with the latest known namespace revision, suppresses stale publications, recovers a rejected latest write from Host state, and reaches quiescence on plugin disposal. The default decoder validates each section against the namespace's own serialized wire schema (rehydrated through dsh-client-schema-form), so a domain adds a decoder only to narrow beyond that schema. Loopback pages use the Host settings API; remote pages stay in memory mode. Domain packages own the namespace schema, default, and live service rather than putting product policy in runtime.
## Slot declaration injection
`ctx.slots.inject(name, callback)` makes a full `SlotMap` key the dependency for a contribution whose plugin can activate independently from the declaring entry. It runs `callback` synchronously when the declaration exists, otherwise waits; declaration collapse disposes the callback effect, and redeclaration reruns it. The controller belongs to the caller's plugin fiber, so unloading the contributor cancels either the wait or its active registrations. A direct `slots.register()` into an undeclared slot still throws.
@@ -26,6 +25,8 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
`indexSubagentDescendants()` derives per-parent total and running descendant counts from the retained list mirror. It follows only uninterrupted `origin: 'subagent'` ancestry, so an ordinary fork starts a separate ownership subtree; cycles stop without throwing, and a missing parent remains a harmless key until its summary arrives.
`SessionListState.tasksBySession` mirrors the Host's `session/tasks` frames last-wins, keyed by session and needing no Session instance. An emptied set is stored as an absent key, so absence and `[]` are one representation and consumers never test a sentinel. Two clears keep it from outliving its truth: `session/subscribed` drops the session's mirror, because a fresh generation sends a baseline only for a non-empty set and a retained list would survive as a phantom, and `host/session-removed` drops it again, because owner disposal removed the records on the mux stream while the removal frame rides the host stream, leaving the two with no relative order.
`SessionsService.search(query, signal)` is a stateless one-shot action over the `session.search` RPC. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. `searchResultLimit` re-exposes `SESSION_SEARCH_RESULT_LIMIT` — the bound the response schema itself enforces — as injected presentation data, so client plugins do not duplicate it. It is a protocol constant rather than per-connection state, so the connection handle does not carry it.
## New Session and the blank mirror
@@ -42,17 +43,17 @@ Each `Session` gives its contiguous event window to a `ConversationNodeAssembler
Definition authors keep matching local to the current event, give every correlated event a stable business id, and make updates replayable by log `seq`; renderers consume final Node data and constrained Location values rather than scanning Session or Chat collections. The [Conversation Node cookbook](../../../docs/cookbook/adding-a-conversation-node.md) gives the complete registration and pagination path.
`ui-conversation` registers the built-in Chat Definitions and the keyed Chat snapshot builder. Append-origin user, assistant, and Tool results remain the human record; model-only replacement copies stay out, except that a compaction checkpoint becomes its own marker and resolves missing summary provenance when an older page supplies it. Durable inbox splice Contexts classify next-step user messages as steering without making inbox state a Session special case. Context messages retain producer provenance and form. StatsLine reads `ConversationSnapshot.chat.legacy.nodes`, while Session mirrors that legacy slice into the top-level `nodes`, `partial`, and `runningCalls` public compatibility fields without running a second business fold. Trajectory consumes neither compatibility surface; its activated `session-history` inspection keeps an independent fold until it gains its own registered target.
`ui-conversation` registers the built-in Chat Definitions and the keyed Chat snapshot builder. Append-origin user, assistant, and Tool results remain the human record; model-only replacement copies stay out, except that a compaction checkpoint becomes its own marker and resolves missing summary provenance when an older page supplies it. Durable inbox splice Contexts classify next-step user messages as steering without making inbox state a Session special case. Context messages retain producer provenance and form. StatsLine reads `ConversationSnapshot.chat.legacy.nodes`, while Session mirrors that legacy slice into the top-level `nodes`, `partial`, and `runningCalls` public compatibility fields without running a second business fold. `ui-trajectory` registers independent Definitions and a target builder over the same Session window; it preserves the existing stage-oriented view model without consuming the Chat compatibility fields or running another history fold.
The Chat builder keeps one mutable keyed store per Session. Content updates notify only the affected node key, structural changes rebuild order and Location membership, and a prepend adds rows without replacing existing keyed values. Assistant chunks update Definition State for every event but request at most one materialization per animation frame; final messages and Turn/Step closure publish immediately. See the [client Tool presentation decision](../../../.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md).
## Request inspection
## Trajectory request data
`SessionHistoryInspection.requests` is one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric `turn` and `step`; compaction requests carry `step: 0` and a `turn` owner that may be `null`. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn. A `session/end-seed` boundary closes an unmatched compaction request as an error at the boundary time with `Compaction was interrupted before completion.`; a later start projects as an independent request instead of overwriting the orphan.
Trajectory Definitions assemble one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric `turn` and `step`; compaction requests carry `step: 0` and a `turn` owner that may be `null`. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn. A `session/end-seed` boundary closes an unmatched compaction request as an error at the boundary time with `Compaction was interrupted before completion.`; a later start projects as an independent request instead of overwriting the orphan.
## Code Mode child-call tree
Every `ToolCallBlock` recursively owns its children through `subCalls`, in start order. Chat's Tool Definition correlates root calls and results by call id, folds Code Dispatch start/settlement records into that root Context, and projects one keyed recursive tree; child calls never become independent Chat roots. When a start falls outside the loaded window, its settlement remains renderable with `callTime: null`. A child update copies only its ancestor path, so unchanged siblings retain object identity. Edges that introduce a cycle or exceed the fixed 256-call depth limit are consumed without mutating the tree. The separate Trajectory history fold still uses Runtime's `ToolCallTree` over the same nested data contract.
Every `ToolCallBlock` recursively owns its children through `subCalls`, in start order. Chat's Tool Definition correlates root calls and results by call id, folds Code Dispatch start/settlement records into that root Context, and projects one keyed recursive tree; child calls never become independent Chat roots. When a start falls outside the loaded window, its settlement remains renderable with `callTime: null`. A child update copies only its ancestor path, so unchanged siblings retain object identity. Edges that introduce a cycle or exceed the fixed 256-call depth limit are consumed without mutating the tree. Trajectory's Tool Definition independently assembles the same nested data contract for its target.
## Session title projection

View File

@@ -2,10 +2,9 @@
[English](README.md) | 中文
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点,消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 SessionWorkspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed``session/preset-changed``settings/changed``credentials/changed``models/changed`),使各表面缓存无需触碰流即可重拉。`host/session-preset-changed` 还会把其中的 preset 折进会话行,因为这次切换的 RPC 回执只会到达发起它的那个客户端。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent智能体和 cwd客户端不持有任何实体化之前的会话状态——agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf``useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象、列表与 scope 状态,以及供已注册 conversation view target 共用的事件窗口与历史分页。WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 SessionWorkspace 所有者,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed``session/preset-changed``settings/changed``credentials/changed``models/changed`),使各表面缓存无需触碰流即可重拉。`host/session-preset-changed` 还会把其中的 preset 折进会话行,因为这次切换的 RPC 回执只会到达发起它的那个客户端。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent智能体和 cwd客户端不持有任何实体化之前的会话状态——agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。约定api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf``useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
`bindSettingsScope` 面向单个由领域持有的 namespace是 Host 侧 settings owner seam 的浏览器镜像。它在开始非阻塞初始读取前建立订阅,发布 uSES 快照状态、分节值、revision、可写性、host内存模式使用已知最新 namespace revision 串行执行 `set` 写入,抑制陈旧发布,并在最新写入被拒时从 Host 状态恢复;插件释放时,它会达到完全停稳。默认解码器会对照该 namespace 自身的序列化 wire schema经 dsh-client-schema-form 还原)校验每个分节,因此领域只有在需要比该 schema 进一步收窄时才添加解码器。回环页面使用 Host settings API远程页面则停留在内存模式。namespace schema、默认值与实时服务归领域包所有而非把产品政策放入运行时。
## Slot 声明注入
`ctx.slots.inject(name, callback)` 将完整的 `SlotMap` key 作为贡献项的依赖,适用于贡献方插件可独立于声明条目激活的情形。声明存在时,它会同步运行 `callback`,否则等待;声明折叠会 dispose资源释放回调 effect重新声明则会再次运行回调。控制器归调用方的插件 fiber 所有,因此卸载贡献方会取消等待或移除其活跃注册项。直接调用 `slots.register()` 向未声明 slot 注册仍会抛出异常。
@@ -26,6 +25,8 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
`indexSubagentDescendants()` 从保留的列表镜像中派生每个 parent 的后代总数与运行中后代数。它只沿不间断的 `origin: 'subagent'` 祖先链追踪,因此普通 fork 会开启独立的归属子树;遇到环时,追踪会停止但不会抛出异常,缺失的 parent 则会保留为无害的键,直至其摘要到达。
`SessionListState.tasksBySession` 按 last-wins 镜像宿主的 `session/tasks` 帧,以会话为键,不需要 Session 实例。被清空的集合存为缺失的键,因此「缺失」与 `[]` 是同一种表示,消费方永远不必检测哨兵值。两处清理让它不至于比它所反映的真相活得更久:`session/subscribed` 丢弃该会话的镜像,因为新一代只为非空集合发送 baseline被留下的列表会变成幽灵`host/session-removed` 再丢一次,因为 owner 销毁是在 mux 流上移除记录的,而移除帧走 host 流,两者没有相对顺序。
`SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit``SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。
## New Session 与 blank 镜像
@@ -42,17 +43,17 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
Definition 作者只根据当前事件完成匹配,为每条关联事件提供稳定业务 id并保证 update 能按日志 `seq` 回放renderer 只消费最终 Node data 与受限 Location value不扫描 Session 或 Chat 集合。完整注册和分页路径见 [Conversation Node 实操手册](../../../docs/cookbook/adding-a-conversation-node.md)。
`ui-conversation` 注册内建 Chat Definition 与 keyed Chat snapshot builder。append 来源的 user、assistant 和 Tool result 构成人类可见记录;仅供模型使用的 replacement 副本不进入 Chatcompaction 检查点除外,它会成为独立标记,并在更早分页补齐 summary 溯源后更新。持久 inbox splice Context 能把 next-step 用户消息判定为 steering无须让 inbox 状态成为 Session 特例。上下文消息保留生产者 provenance 与 form。StatsLine 读取 `ConversationSnapshot.chat.legacy.nodes`Session 则把该 legacy slice 镜像到顶层 `nodes``partial``runningCalls` 公共兼容字段,无须运行第二套业务 fold。Trajectory 不消费这两种兼容表面;在它获得独立注册 target 之前,已激活的 `session-history` inspection 继续维护独立 fold。
`ui-conversation` 注册内建 Chat Definition 与 keyed Chat snapshot builder。append 来源的 user、assistant 和 Tool result 构成人类可见记录;仅供模型使用的 replacement 副本不进入 Chatcompaction 检查点除外,它会成为独立标记,并在更早分页补齐 summary 溯源后更新。持久 inbox splice Context 能把 next-step 用户消息判定为 steering无须让 inbox 状态成为 Session 特例。上下文消息保留生产者 provenance 与 form。StatsLine 读取 `ConversationSnapshot.chat.legacy.nodes`Session 则把该 legacy slice 镜像到顶层 `nodes``partial``runningCalls` 公共兼容字段,无须运行第二套业务 fold。`ui-trajectory` 在同一个 Session 窗口上注册独立 Definition 与 target builder它保留现有的 stage-oriented view model既不消费 Chat 兼容字段,也不运行另一套 history fold。
Chat builder 为每个 Session 保留一个 mutable keyed store。内容更新只通知受影响的 node key结构变化才重建顺序和 Location 成员关系prepend 只增加行,不替换既有 keyed value。每个 Assistant chunk 都会更新 Definition State但最多每个 animation frame 请求一次物化final message 与 Turn/Step 关闭会立即发布。参见 [Client Tool 展示所有权决策](../../../.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md)。
## 请求检查
## Trajectory 请求数据
`SessionHistoryInspection.requests`一条按时间顺序排列、以用途为判别字段的提供方请求流。助手请求始终携带数值型 `turn``step`;压缩请求携带 `step: 0`,其 `turn` 所有者可以是 `null`。这个 null 所有者表示手动压缩独立运行在两个轮次之间,并不表示它属于任一相邻轮次。`session/end-seed` 边界会在边界时刻将未匹配的压缩请求以错误状态结束,错误固定为 `Compaction was interrupted before completion.`;后续 start 会投影为独立请求,而不会覆盖这项遗留的未匹配请求。
Trajectory Definition 组装出一条按时间顺序排列、以用途为判别字段的提供方请求流。助手请求始终携带数值型 `turn``step`;压缩请求携带 `step: 0`,其 `turn` 所有者可以是 `null`。这个 null 所有者表示手动压缩独立运行在两个轮次之间,并不表示它属于任一相邻轮次。`session/end-seed` 边界会在边界时刻将未匹配的压缩请求以错误状态结束,错误固定为 `Compaction was interrupted before completion.`;后续 start 会投影为独立请求,而不会覆盖这项遗留的未匹配请求。
## Code Mode 子调用树
每个 `ToolCallBlock` 都通过 `subCalls` 按启动顺序递归拥有自己的子调用。Chat 的 Tool Definition 按 call id 关联 root call 与 result把 Code Dispatch 的 start/settlement 记录折叠进该 root Context并投影为一棵 keyed 递归树child call 不会成为独立 Chat root。start 落在已加载窗口之外时,其 settlement 仍以 `callTime: null` 渲染。一次 child 更新只复制其祖先链,因此未变化的 sibling 保持对象身份。会引入环或超过固定 256 层深度上限的边会被消费,但不会修改树。独立的 Trajectory history fold 仍通过 Runtime 的 `ToolCallTree` 生成同一种嵌套数据契约。
每个 `ToolCallBlock` 都通过 `subCalls` 按启动顺序递归拥有自己的子调用。Chat 的 Tool Definition 按 call id 关联 root call 与 result把 Code Dispatch 的 start/settlement 记录折叠进该 root Context并投影为一棵 keyed 递归树child call 不会成为独立 Chat root。start 落在已加载窗口之外时,其 settlement 仍以 `callTime: null` 渲染。一次 child 更新只复制其祖先链,因此未变化的 sibling 保持对象身份。会引入环或超过固定 256 层深度上限的边会被消费但不会修改树。Trajectory 的 Tool Definition 为自己的 target 独立组装同一种嵌套数据契约。
## Session 标题投影

View File

@@ -1,8 +1,15 @@
{
"name": "@deepseek-ai/dsh-client-runtime",
"description": "Client core services: SlotsService, SessionsService (scope tree + object layer)",
"version": "0.0.1",
"private": true,
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/client/runtime"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
@@ -39,7 +46,6 @@
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
@@ -53,10 +59,10 @@
"zustand": "~4.4.7"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-type-meta": "^0.0.1",
"@deepseek-ai/dsh-typert-registry": "^0.0.1",
"cordis": "^4.0.0-rc.7"
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
@@ -64,8 +70,8 @@
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7",
"schemastery": "^3.18.0"
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/schemastery": "workspace:^"
},
"files": [
"lib/index.js",

View File

@@ -15,8 +15,8 @@
* — a cold session's host Agent is already disposed while its client actx
* stays alive for history viewing.
*/
import { Context as CordisContext } from 'cordis'
import type { Context, Fiber } from 'cordis'
import { Context as CordisContext } from '@deepseek-ai/cordis'
import type { Context, Fiber } from '@deepseek-ai/cordis'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { TypeRTClientRemote, TypeRTRemoteScopeApi } from '@deepseek-ai/dsh-type-meta'

View File

@@ -110,6 +110,17 @@ export interface ConversationViewNode {
readonly data: unknown
}
/** Merge-extensible immutable snapshots published by registered view targets. */
export interface ConversationViewSnapshotMap {}
/** Stable reader over the latest snapshot of every registered view target. */
export interface ConversationViewSnapshotStore {
/** @param target - registered view target. @returns its current snapshot. */
get<Target extends Extract<keyof ConversationViewSnapshotMap, string>>(
target: Target,
): ConversationViewSnapshotMap[Target] | undefined
}
/** Final Chat render unit produced directly by a business Definition. */
export interface ChatConversationViewNode extends ConversationViewNode {
readonly target: 'chat'
@@ -159,6 +170,8 @@ export type ConversationLocationDataScope = 'step' | 'turn'
/** One independently registered business Event-to-Node state machine. */
export interface ConversationNodeDefinition<State = unknown> {
readonly kind: string
/** Sole view target owned by this Definition; omitted for state-only Contexts. */
readonly target?: string
/**
* Extract this Definition's stable business identity from one event.
* @param event - raw Session event; no Context or history access is available.
@@ -207,15 +220,11 @@ export interface ConversationNodeDefinition<State = unknown> {
scope: ConversationLocationDataScope,
): ConversationLocationData | null
/**
* Materialize one final Node for a registered view target.
* Materialize one final Node for this Definition's declared view target.
* @param context - latest complete Context.
* @param target - registered view target such as `chat`.
* @returns final Node, or null when this Context is not currently visible.
*/
buildViewNode(
context: ConversationNodeContext<State>,
target: string,
): ConversationViewNode | null
buildViewNode?(context: ConversationNodeContext<State>): ConversationViewNode | null
}
/** Reference-stable Turn/Step facts published beside view Nodes. */

View File

@@ -1,43 +0,0 @@
import type {
RpcError, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
import type { SessionHistoryInspection } from '../sessions/history.ts'
import type { ObservableSnapshot } from './store.ts'
/** Observable state of one independently loaded session history ledger. */
export interface SessionHistorySnapshot {
state: 'cold' | 'loading' | 'ready' | 'error'
error: RpcError | null
hasMore: boolean
/** Absolute sequence of the first loaded raw event, or zero for an empty window. */
baseSeq: number
inspection: SessionHistoryInspection
}
/** Read-only history source addressed by session id. */
export interface SessionHistoryFace
extends ObservableSnapshot<SessionHistorySnapshot> {
readonly sessionId: SessionId
/**
* Load the current tail without reading older pages.
* @param signal - Consumer lifetime.
* @returns When the tail is ready or loading fails.
*/
loadTail(signal?: AbortSignal): Promise<void>
/**
* Prepend one older page when the current window has a predecessor.
* @param signal - Consumer lifetime.
* @returns Whether the loaded window advanced.
*/
loadOlder(signal?: AbortSignal): Promise<boolean>
}
/** Runtime service resolving independent history sources. */
export interface ISessionHistory {
/**
* Resolve the identity-stable source for a session.
* @param sessionId - Host session identity.
* @returns The source owned outside Session and SessionManager.
*/
source(sessionId: SessionId): SessionHistoryFace
}

View File

@@ -7,7 +7,7 @@
* [SessionsPort](./sessions-port.ts). Widening this interface is the
* explicit act of widening what features may do to the sessions domain.
*/
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {
RpcResult, SessionId, SubagentAddress,
} from '@deepseek-ai/dsh-client-connection/client'

View File

@@ -1,4 +1,4 @@
import { Service } from 'cordis'
import { Service } from '@deepseek-ai/cordis'
/** Shared lifecycle and stable-entry storage for one Conversation Definition registry. */
export abstract class ConversationDefinitionRegistry<Definition> extends Service {

View File

@@ -1,4 +1,4 @@
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { ConversationNodeDefinition } from '../contract/conversation.ts'
import { ConversationDefinitionRegistry } from './definition-registry.ts'
@@ -17,6 +17,7 @@ export class ConversationEventRegistry extends ConversationDefinitionRegistry<Co
* @returns idempotent disposer.
*/
register(definition: ConversationNodeDefinition): () => void {
assertDefinitionTarget(definition)
return this.registerDefinition(
definition.kind,
definition,
@@ -31,6 +32,9 @@ export class ConversationEventRegistry extends ConversationDefinitionRegistry<Co
* @returns idempotent disposer.
*/
registerFallback(definition: ConversationNodeDefinition): () => void {
assertDefinitionTarget(definition)
const target = definition.target
if (target === undefined) throw new Error('conversation fallback Definition must declare a target')
if (this.fallback !== undefined) throw new Error('conversation fallback Definition is already registered')
const owner = this.ctx
const dispose = owner.effect(() => {
@@ -52,5 +56,12 @@ export class ConversationEventRegistry extends ConversationDefinitionRegistry<Co
fallbackEntry(): ConversationNodeDefinition | undefined {
return this.fallback
}
}
function assertDefinitionTarget(definition: ConversationNodeDefinition): void {
if ((definition.target === undefined) !== (definition.buildViewNode === undefined)) {
throw new Error(
`conversation Definition "${definition.kind}" must declare target and buildViewNode together`,
)
}
}

View File

@@ -1,4 +1,4 @@
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { ConversationViewDefinition } from '../contract/conversation.ts'
import { ConversationDefinitionRegistry } from './definition-registry.ts'

View File

@@ -1,12 +1,11 @@
/** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { TypeRTContext } from '@deepseek-ai/dsh-type-meta'
import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotsService } from './slots.ts'
import { SessionsService } from './sessions/service.ts'
import type { SessionListState } from './sessions/service.ts'
import { SessionHistoryService } from './session-history/service.ts'
import { WorkspacesService } from './workspaces/service.ts'
import type { ConversationSnapshot } from './sessions/conversation.ts'
import type { UseProjection } from './sessions/projection-store.ts'
@@ -28,12 +27,12 @@ export type {
ConversationLocation, ConversationMatch, ConversationMatchResult,
ConversationNodeContext, ConversationNodeDefinition, ConversationPreviousContext,
ConversationPublication, ConversationTimelineSnapshot, ConversationTurnDataMap, ConversationViewBuilder,
ConversationViewDefinition, ConversationViewNode, StepLocation, TurnLocation,
ConversationViewDefinition, ConversationViewNode, ConversationViewSnapshotMap,
ConversationViewSnapshotStore, StepLocation, TurnLocation,
} from './contract/conversation.ts'
export type { ConversationRuntime } from './sessions/conversation-assembler.ts'
export type { RootOwnerProps } from './slots.ts'
export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts'
export { SessionHistoryService } from './session-history/service.ts'
export { indexSubagentDescendants } from './sessions/subagent-lineage.ts'
export type { SubagentDescendantSummary } from './sessions/subagent-lineage.ts'
// The provide channel is shared with the client test runtime (one
@@ -48,16 +47,13 @@ export type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from './
export { resolveWorkspacePath } from './workspaces/path.ts'
export type { Session } from './sessions/session.ts'
export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts'
export type {
ISessionHistory, SessionHistoryFace, SessionHistorySnapshot,
} from './contract/session-history.ts'
export type { AgentContext, ISessions } from './contract/sessions.ts'
export type { IWorkspaces } from './contract/workspaces.ts'
export type {
SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
} from './sessions/service.ts'
export type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './sessions/manager.ts'
export type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client'
export type { SubagentAddress, TaskView } from '@deepseek-ai/dsh-client-connection/client'
export type { WorkspaceListPhase } from './workspaces/manager.ts'
export type { WorkspaceListState } from './workspaces/service.ts'
export type {
@@ -76,7 +72,9 @@ export type {
LegacyConversationSlice, PartialAssistant, RunningToolCall,
SteeringMessageNode, TodoItem, ToolCallBlock, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
export { EMPTY_CHAT_SNAPSHOT, toAssistantBlock, toAssistantBlocks } from './sessions/conversation.ts'
export {
EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, toAssistantBlock, toAssistantBlocks,
} from './sessions/conversation.ts'
export { emptyAssistantBlock } from './sessions/partial.ts'
export { isTokenDelta } from './sessions/assistant-timing.ts'
export { contextForm, contextProvenance } from './sessions/context-provenance.ts'
@@ -90,8 +88,6 @@ export type {
export type {
ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView,
} from './sessions/request-inspection.ts'
export type { ConversationHistoryProjection } from './session-history/history-fold.ts'
export type { SessionHistoryInspection } from './sessions/history.ts'
export { PendingWait } from './sessions/pending.ts'
export type {
PendingInteraction, PendingInteractionStatus, PendingKind, PendingPayloads,
@@ -146,7 +142,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
}
}
declare module 'cordis' {
declare module '@deepseek-ai/cordis' {
interface Events {
/**
* A slot's definition or registration set changed.
@@ -211,8 +207,6 @@ declare module 'cordis' {
conversationViews: import('./conversation/view-registry.ts').ConversationViewRegistry
/** The outward face only; the concrete service stays inside the runtime. */
sessions: import('./contract/sessions.ts').ISessions
/** Read-only history sources isolated from Chat sessions and workspace state. */
sessionHistory: import('./contract/session-history.ts').ISessionHistory
/** The outward face only; the concrete service stays inside the runtime. */
workspaces: import('./contract/workspaces.ts').IWorkspaces
}
@@ -235,7 +229,6 @@ export function apply(ctx: Context): void {
ctx.typert.contexts.registerClient('agent', {
identity: candidate => sessions.scopeOf(candidate),
})
const sessionHistory = new SessionHistoryService(ctx, connection.api)
const workspaces = new WorkspacesService(ctx, connection.api, sessions)
ctx.effect(
() => workspaces.startInitialSelection(),
@@ -244,11 +237,6 @@ export function apply(ctx: Context): void {
const loop = connection.start({
onMuxEnvelope: (envelope) => {
sessions.handleMuxEnvelope(envelope)
try {
sessionHistory.handleMuxEnvelope(envelope)
} catch (error) {
console.error('[web-runtime] history frame routing failed:', error)
}
},
onHostEnvelope: (envelope) => {
sessions.handleHostEnvelope(envelope)
@@ -264,21 +252,11 @@ export function apply(ctx: Context): void {
else if (frame.type === 'host/settings-changed') ctx.emit('settings/changed', frame.ns)
else if (frame.type === 'host/credentials-changed') ctx.emit('credentials/changed', frame.ref)
else if (frame.type === 'host/models-changed') ctx.emit('models/changed')
try {
sessionHistory.handleHostEnvelope(envelope)
} catch (error) {
console.error('[web-runtime] history host-frame routing failed:', error)
}
},
onConnected: () => {
sessions.handleConnected()
workspaces.handleConnected()
ctx.emit('connection/reset')
try {
sessionHistory.handleConnected()
} catch (error) {
console.error('[web-runtime] history reconnect failed:', error)
}
},
onStateChange: (state) => {
// Generation death fires before any next-generation frame can arrive
@@ -286,11 +264,6 @@ export function apply(ctx: Context): void {
// the only safe moment to drop generation-scoped interaction state.
if (state === 'reconnecting') {
sessions.handleDisconnected()
try {
sessionHistory.handleDisconnected()
} catch (error) {
console.error('[web-runtime] history disconnect failed:', error)
}
}
},
})

View File

@@ -1,428 +0,0 @@
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import {
SurfaceManager, isSurfaceEligibleType, isSurfaceEvent,
} from '@deepseek-ai/dsh-session/surface'
import type {
HistoryEntry, ToolCallView, ToolResultView,
} from '@deepseek-ai/dsh-client-connection/client'
import type {
AssistantRequestConfig, AssistantTiming, ConversationNode,
PartialAssistant, RunningToolCall,
} from '../sessions/conversation.ts'
import { toAssistantBlocks } from '../sessions/conversation.ts'
import { contextForm, contextProvenance } from '../sessions/context-provenance.ts'
import { SteeringHistory } from '../sessions/steering-history.ts'
import type {
ConversationContext, ConversationContextOriginKind,
} from '../sessions/conversation-context.ts'
import type { ConversationPromptSnapshot } from '../sessions/request-inspection.ts'
import { PartialAccumulator } from '../sessions/partial.ts'
import type { AssistantStepMetadata } from '../sessions/assistant-timing.ts'
import { indexAssistantStepTiming, settledAssistantTiming } from '../sessions/assistant-timing.ts'
import { ToolCallTree } from '../sessions/tool-call-tree.ts'
interface CallIndexEntry {
name: string
argsRaw: string
time: number
callView: ToolCallView | null
}
interface FoldedContext {
generation: number
nodes: readonly number[]
originSeq?: number
}
/** Immutable conversation projections derived only from the history source. */
export interface ConversationHistoryProjection {
eventNodes: readonly ConversationNode[]
contexts: readonly ConversationContext[]
interruptedNodes: readonly ConversationNode[]
partial: PartialAssistant | null
runningCalls: readonly RunningToolCall[]
}
function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean {
if (!isSurfaceEvent(event) || event.surfaceOp === 'append') return false
return event.surfaceOp.start < baseSeq || event.surfaceOp.end < baseSeq
}
function contextOriginKind(event: SessionEvent | undefined): ConversationContextOriginKind {
if (event?.type !== 'user/message') return 'rewrite'
const source = event.data.source
if (typeof source === 'object' && 'kind' in source && 'plugin' in source) {
if (source.plugin === 'compact') return 'compaction'
if (source.plugin === 'rewind') return 'rewind'
}
return 'rewrite'
}
function foldContexts(events: readonly SessionEvent[]): readonly FoldedContext[] {
const replay: SessionEvent[] = []
const originalSeqs: number[] = []
const rebasedSeqByOriginal = new Map<number, number>()
const surface = new SurfaceManager(replay)
const contexts: FoldedContext[] = []
let generation = 0
let originSeq: number | undefined
const originalNodes = () => surface.nodes.map((seq) => {
const original = originalSeqs[seq]
if (original === undefined) throw new Error(`rebased surface seq ${seq} has no origin`)
return original
})
for (const event of events) {
if (!isSurfaceEvent(event)) continue
if (event.surfaceOp !== 'append') {
contexts.push({
generation,
nodes: originalNodes(),
...(originSeq === undefined ? {} : { originSeq }),
})
generation++
originSeq = event.seq
}
const rebasedSeq = replay.length
const {
sourceEventSeqs: rawSources,
...eventWithoutSources
} = event as SessionEvent & { sourceEventSeqs?: readonly number[] }
const mappedSourceEventSeqs = rawSources?.flatMap((seq) => {
const rebased = rebasedSeqByOriginal.get(seq)
return rebased === undefined ? [] : [rebased]
})
const sourceEventSeqs = mappedSourceEventSeqs?.length === 0
? undefined
: mappedSourceEventSeqs
const surfaceOp = event.surfaceOp === 'append'
? event.surfaceOp
: {
...event.surfaceOp,
start: rebasedSeqByOriginal.get(event.surfaceOp.start) ?? event.surfaceOp.start,
end: rebasedSeqByOriginal.get(event.surfaceOp.end) ?? event.surfaceOp.end,
}
originalSeqs.push(event.seq)
rebasedSeqByOriginal.set(event.seq, rebasedSeq)
replay.push({
...eventWithoutSources,
seq: rebasedSeq,
surfaceOp,
...(sourceEventSeqs === undefined ? {} : { sourceEventSeqs }),
} as SessionEvent)
}
contexts.push({
generation,
nodes: originalNodes(),
...(originSeq === undefined ? {} : { originSeq }),
})
return contexts
}
// History projection owns its node mapping so Chat's live adapter remains free
// of inspection metadata and lifecycle coupling.
/* jscpd:ignore-start */
function materializeNode(
event: SessionEvent,
callIndex: ReadonlyMap<string, CallIndexEntry>,
resultView: ToolResultView | null,
assistantTiming: AssistantTiming | undefined,
requestConfig: AssistantRequestConfig | undefined,
steering: boolean,
): ConversationNode {
switch (event.type) {
case 'user/message':
if (event.data.source.kind !== 'user') {
return {
kind: 'context', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
provenance: contextProvenance(event.data.source),
form: contextForm(event.data.source),
}
}
if (steering) {
return {
kind: 'steering', messageId: event.data.id,
seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
}
}
return {
kind: 'user', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
}
case 'assistant/message':
return {
kind: 'assistant', seq: event.seq, time: event.time,
turn: event.data.turn, step: event.data.step,
blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage,
provenance: {
provider: event.data.message.source.provider,
model: event.data.message.source.model,
},
...(requestConfig === undefined ? {} : { requestConfig }),
...(assistantTiming === undefined ? {} : { timing: assistantTiming }),
}
case 'tool/result': {
const result = event.data.message.content[0]
const callId = String(event.data.message.source.callId)
const call = callIndex.get(callId)
return {
kind: 'tool-result', seq: event.seq, time: event.time,
callId,
call: call === undefined ? null : { name: call.name, argsRaw: call.argsRaw },
callTime: call?.time ?? null,
content: result.content, isError: result.isError === true,
...(event.data.error === undefined ? {} : { error: event.data.error }),
meta: event.data.meta,
callView: call?.callView ?? null,
resultView,
subCalls: [],
}
}
default:
return {
kind: 'unknown', seq: event.seq, time: event.time,
type: event.type, data: (event as { data?: unknown }).data,
}
}
}
/* jscpd:ignore-end */
interface TransientProjection extends Pick<
ConversationHistoryProjection,
'interruptedNodes' | 'partial' | 'runningCalls'
> {
toolCallTree: ToolCallTree
}
function projectTransient(entries: readonly HistoryEntry[]): TransientProjection {
let partial: PartialAccumulator | null = null
const openCalls = new Map<string, RunningToolCall>()
const interruptedNodes: ConversationNode[] = []
const toolCallTree = new ToolCallTree()
for (const entry of entries) {
const { event } = entry
if (toolCallTree.apply(event)) continue
switch (event.type) {
case 'assistant/chunk': {
const { turn, step, chunk } = event.data
if (partial === null || partial.turn !== turn || partial.step !== step) {
partial = new PartialAccumulator(turn, step)
}
partial.push(chunk)
break
}
case 'assistant/message':
if (partial?.turn === event.data.turn && partial.step === event.data.step) partial = null
break
case 'tool/call':
// History reconstructs its own in-flight index; this intentionally
// mirrors the published Chat node shape, not Chat's mutable state.
/* jscpd:ignore-start */
openCalls.set(String(event.data.callId), {
callId: String(event.data.callId),
name: event.data.name,
argsRaw: event.data.arguments,
turn: event.data.turn,
step: event.data.step,
time: event.time,
callView: entry.view?.for === 'call' ? entry.view.view : null,
subCalls: [],
})
/* jscpd:ignore-end */
break
case 'tool/result':
openCalls.delete(String(event.data.message.source.callId))
break
case 'turn/end': {
if (partial !== null && partial.turn === event.data.turn) {
const { blocks } = partial.toPartial()
const visible = blocks.some(block =>
block.kind === 'text' || block.kind === 'reasoning' ? block.text !== '' : true)
if (visible) {
interruptedNodes.push({
kind: 'assistant', seq: event.seq - 0.9, time: event.time,
turn: partial.turn, step: partial.step, blocks, interrupted: true,
})
}
partial = null
}
let callOffset = 0
for (const [callId, call] of openCalls) {
if (call.turn !== event.data.turn) continue
openCalls.delete(callId)
// Interrupted terminal nodes are reconstructed independently so a
// Trajectory replay cannot observe Session's frozen-node lifecycle.
/* jscpd:ignore-start */
interruptedNodes.push({
kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01,
time: event.time,
callId,
call: { name: call.name, argsRaw: call.argsRaw },
callTime: call.time,
content: [],
isError: true,
error: { name: 'Interrupted', code: 'interrupted' },
callView: call.callView,
resultView: null,
subCalls: [],
})
/* jscpd:ignore-end */
}
break
}
default:
break
}
}
return {
interruptedNodes,
partial: partial?.toPartial() ?? null,
runningCalls: [...openCalls.values()],
toolCallTree,
}
}
/**
* Project one immutable history ledger without reading or mutating Chat state.
* @param entries - Contiguous history entries in sequence order.
* @returns Event order, context lineage, and transient tail state.
*/
export function projectConversationHistory(
entries: readonly HistoryEntry[],
): ConversationHistoryProjection {
const events = entries.map(entry => entry.event)
const steeringHistory = new SteeringHistory()
const steeringSeqs = new Set<number>()
for (const event of events) {
if (steeringHistory.apply(event)) steeringSeqs.add(event.seq)
}
const baseSeq = events[0]?.seq ?? 0
const eventsBySeq = new Map(events.map(event => [event.seq, event]))
const callIndex = new Map<string, CallIndexEntry>()
const resultViews = new Map<number, ToolResultView>()
const assistantSteps = new Map<string, AssistantStepMetadata>()
const assistantTimings = new Map<number, AssistantTiming>()
const assistantRequestConfigs = new Map<number, AssistantRequestConfig>()
const promptsByContext = new Map<number, ConversationPromptSnapshot>()
let activeRequestConfig: AssistantRequestConfig | undefined
let activePrompt: ConversationPromptSnapshot | undefined
let contextGeneration = 0
for (const [index, event] of events.entries()) {
const view = entries[index]?.view
if (event.type === 'tool/call') {
callIndex.set(String(event.data.callId), {
name: event.data.name,
argsRaw: event.data.arguments,
time: event.time,
callView: view?.for === 'call' ? view.view : null,
})
} else if (event.type === 'tool/result' && view?.for === 'result') {
resultViews.set(event.seq, view.view)
}
if (isSurfaceEvent(event) && event.surfaceOp !== 'append') {
contextGeneration++
if (activePrompt !== undefined) promptsByContext.set(contextGeneration, activePrompt)
}
indexAssistantStepTiming(assistantSteps, event)
if (event.type === 'request/header') {
activeRequestConfig = event.data.header.config
activePrompt = {
config: event.data.header.config,
system: event.data.header.system ?? '',
tools: event.data.header.tools ?? [],
}
promptsByContext.set(contextGeneration, activePrompt)
} else if (event.type === 'assistant/message') {
assistantTimings.set(
event.seq,
settledAssistantTiming(assistantSteps, event.data.turn, event.data.step, event.time),
)
if (activeRequestConfig !== undefined) {
assistantRequestConfigs.set(event.seq, activeRequestConfig)
}
}
}
const nodeCache = new Map<number, ConversationNode>()
const materialize = (seq: number): ConversationNode | undefined => {
const cached = nodeCache.get(seq)
if (cached !== undefined) return cached
const event = eventsBySeq.get(seq)
if (event === undefined || !isSurfaceEligibleType(event.type)) return
const node = materializeNode(
event,
callIndex,
resultViews.get(seq) ?? null,
assistantTimings.get(seq),
assistantRequestConfigs.get(seq),
steeringSeqs.has(seq),
)
nodeCache.set(seq, node)
return node
}
const eventNodes = events.flatMap((event) => {
const node = materialize(event.seq)
return node === undefined ? [] : [node]
})
let contexts: readonly ConversationContext[]
if (events.some(event => replacementCrossesWindowHead(event, baseSeq))) {
contexts = [{
id: 0,
...(activePrompt === undefined ? {} : { prompt: activePrompt }),
nodes: eventNodes,
}]
} else {
try {
contexts = foldContexts(events).map((context): ConversationContext => {
const nodes = context.nodes.flatMap((seq) => {
const node = materialize(seq)
return node === undefined ? [] : [node]
})
const prompt = promptsByContext.get(context.generation)
if (context.originSeq === undefined) {
return {
id: context.generation,
...(prompt === undefined ? {} : { prompt }),
nodes,
}
}
const originEvent = eventsBySeq.get(context.originSeq)
return {
id: context.generation,
parentId: context.generation - 1,
origin: contextOriginKind(originEvent),
originSeq: context.originSeq,
...(originEvent === undefined ? {} : { createdAt: originEvent.time }),
...(prompt === undefined ? {} : { prompt }),
nodes,
}
})
} catch (error) {
console.error('[web-runtime] history surface fold failed, using event order:', error)
contexts = [{
id: 0,
...(activePrompt === undefined ? {} : { prompt: activePrompt }),
nodes: eventNodes,
}]
}
}
const transient = projectTransient(entries)
const projectedEventNodes = transient.toolCallTree.projectNodes(eventNodes)
const projectedContexts = contexts.map((context): ConversationContext => {
const nodes = transient.toolCallTree.projectNodes(context.nodes)
return nodes === context.nodes ? context : { ...context, nodes }
})
return {
eventNodes: projectedEventNodes,
contexts: projectedContexts,
interruptedNodes: transient.toolCallTree.projectNodes(transient.interruptedNodes),
partial: transient.partial,
runningCalls: transient.toolCallTree.projectRunningCalls(transient.runningCalls),
}
}

View File

@@ -1,66 +0,0 @@
import type { Context } from 'cordis'
import type {
HostFrame, IApiClient, MuxFrame, RpcRequest, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
import type {
ISessionHistory, SessionHistoryFace,
} from '../contract/session-history.ts'
import { SessionHistorySource } from './source.ts'
/** Root registry and frame router for independent inspection histories. */
export class SessionHistoryService implements ISessionHistory {
private readonly sources = new Map<SessionId, SessionHistorySource>()
/**
* @param ctx - Client root context.
* @param api - Shared wire client.
*/
constructor(ctx: Context, private readonly api: IApiClient) {
ctx.reflect.provide('sessionHistory', this, undefined)
}
/**
* Resolve one identity-stable history source.
* @param sessionId - Host session identity.
* @returns Source independent from SessionManager.
*/
source(sessionId: SessionId): SessionHistoryFace {
let source = this.sources.get(sessionId)
if (source === undefined) {
source = new SessionHistorySource(sessionId, this.api)
this.sources.set(sessionId, source)
}
return source
}
/**
* Route history-relevant mux frames only to an existing source.
* @param envelope - Validated mux envelope.
*/
handleMuxEnvelope(envelope: RpcRequest<MuxFrame>): void {
const frame = envelope.payload
if (frame.type === 'stream/error') return
this.sources.get(frame.sessionId)?.handleMuxFrame(frame)
}
/**
* Drop a removed session's independent history source.
* @param envelope - Validated host envelope.
*/
handleHostEnvelope(envelope: RpcRequest<HostFrame>): void {
const frame = envelope.payload
if (frame.type !== 'host/session-removed') return
this.sources.get(frame.sessionId)?.dispose()
this.sources.delete(frame.sessionId)
}
/** Invalidate requests from the dead connection generation. */
handleDisconnected(): void {
for (const source of this.sources.values()) source.handleDisconnected()
}
/** Rebuild every previously activated source from the new generation. */
handleConnected(): void {
for (const source of this.sources.values()) source.resync()
}
}

View File

@@ -1,432 +0,0 @@
import type {
HistoryEntry, IApiClient, MuxFrame, RpcError, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type {
SessionHistoryFace, SessionHistorySnapshot,
} from '../contract/session-history.ts'
import {
compactHistoryInspectionEntries, createHistoryInspection,
} from '../sessions/history.ts'
import { Notifier } from '../sessions/notifier.ts'
import { isVisibleAssistantChunk, PartialAccumulator } from '../sessions/partial.ts'
const HISTORY_PAGE_MESSAGES = 50
function isAborted(signal: AbortSignal | undefined): boolean {
return signal?.aborted === true
}
/** Independent raw-history owner used only by inspection consumers. */
export class SessionHistorySource implements SessionHistoryFace {
private entries: HistoryEntry[] = []
private inspectionEntries: readonly HistoryEntry[] = []
private baseSeq = 0
private hasMore = false
private state: SessionHistorySnapshot['state'] = 'cold'
private error: RpcError | null = null
private generation = 0
private persistentConsumer = false
private readonly consumerSignals = new Set<AbortSignal>()
private openPromise: Promise<void> | null = null
private olderPromise: Promise<void> | null = null
private stitching = false
private liveBuffer: HistoryEntry[] = []
private subscribedLastSeq: number | null = null
private inspectionCache: {
entries: readonly HistoryEntry[]
value: SessionHistorySnapshot['inspection']
} | null = null
private streamPublishToken: object | null = null
private streamPartial: PartialAccumulator | null = null
private snapshotCache: SessionHistorySnapshot
private readonly notifier = new Notifier(() => {
this.snapshotCache = this.buildSnapshot()
})
/**
* @param sessionId - Host session identity.
* @param api - Shared wire client.
*/
constructor(
readonly sessionId: SessionId,
private readonly api: IApiClient,
) {
this.snapshotCache = this.buildSnapshot()
}
/**
* Subscribe to ledger changes.
* @param listener - Change callback.
* @returns Unsubscribe function.
*/
subscribe(listener: () => void): () => void {
return this.notifier.subscribe(listener)
}
/**
* Read the cached ledger snapshot.
* @returns Stable snapshot until the source changes.
*/
getSnapshot(): SessionHistorySnapshot {
this.notifier.ensureFresh()
return this.snapshotCache
}
/**
* Load the current tail without reading older pages.
* @param signal - Consumer lifetime.
* @returns When the tail is ready or loading fails.
*/
async loadTail(signal?: AbortSignal): Promise<void> {
if (isAborted(signal)) return
this.trackConsumer(signal)
await this.open()
}
/**
* Prepend one older page when the current window has a predecessor.
* @param signal - Consumer lifetime.
* @returns Whether the loaded window advanced.
*/
async loadOlder(signal?: AbortSignal): Promise<boolean> {
if (isAborted(signal)) return false
this.trackConsumer(signal)
await this.open()
if (isAborted(signal)) return false
const previousBaseSeq = this.baseSeq
await this.loadOlderPage()
return this.baseSeq !== previousBaseSeq
}
/**
* Route a relevant mux frame without involving the Chat session.
* @param frame - Session-addressed frame.
*/
handleMuxFrame(frame: MuxFrame): void {
if (frame.type === 'session/subscribed') {
this.subscribedLastSeq = frame.lastSeq
return
}
if (frame.type !== 'session/event') return
this.acceptLive({ event: frame.event, ...(frame.view === undefined ? {} : { view: frame.view }) })
}
/** Invalidate dead-generation requests while retaining the last readable snapshot. */
handleDisconnected(): void {
this.generation++
this.openPromise = null
this.olderPromise = null
this.stitching = false
this.liveBuffer = []
this.subscribedLastSeq = null
if (this.state !== 'cold') {
this.state = 'cold'
this.error = null
this.publishDirtyNow()
}
}
/** Rebuild an activated ledger from the new connection generation. */
resync(): void {
if (!this.hasConsumer()) return
this.generation++
this.openPromise = null
this.olderPromise = null
this.stitching = false
this.liveBuffer = []
this.subscribedLastSeq = null
this.entries = []
this.inspectionEntries = []
this.baseSeq = 0
this.hasMore = false
this.state = 'cold'
this.error = null
this.publishDirtyNow()
void this.open()
}
/** Stop future refresh work after the host removes the session. */
dispose(): void {
this.persistentConsumer = false
this.consumerSignals.clear()
this.generation++
this.openPromise = null
this.olderPromise = null
this.liveBuffer = []
this.streamPublishToken = null
this.streamPartial = null
}
private open(): Promise<void> {
if (this.state === 'ready') return Promise.resolve()
if (this.openPromise !== null) return this.openPromise
const generation = this.generation
const operation = this.doOpen(generation)
const settled = operation.finally(() => {
if (this.openPromise === settled) this.openPromise = null
})
this.openPromise = settled
return settled
}
private trackConsumer(signal: AbortSignal | undefined): void {
if (signal === undefined) {
this.persistentConsumer = true
return
}
if (this.consumerSignals.has(signal)) return
this.consumerSignals.add(signal)
signal.addEventListener('abort', () => {
this.consumerSignals.delete(signal)
}, { once: true })
}
private hasConsumer(): boolean {
return this.persistentConsumer || this.consumerSignals.size > 0
}
private async doOpen(generation: number): Promise<void> {
this.state = 'loading'
this.error = null
this.publishDirtyNow()
try {
let { result } = await this.api.sessions.history({
sessionId: this.sessionId,
maxMessages: HISTORY_PAGE_MESSAGES,
})
if (generation !== this.generation) return
if (!result.ok) {
this.state = 'error'
this.error = result.error
return
}
this.installTail(result.value.events, result.value.hasMore, true)
const tailSeq = this.tailSeq()
if (
this.subscribedLastSeq !== null
&& tailSeq !== null
&& this.subscribedLastSeq > tailSeq
) {
result = (await this.api.sessions.history({
sessionId: this.sessionId,
maxMessages: HISTORY_PAGE_MESSAGES,
})).result
if (generation !== this.generation) return
if (result.ok) this.installTail(result.value.events, result.value.hasMore, true)
}
this.state = 'ready'
} catch (error) {
if (generation !== this.generation) return
this.state = 'error'
const folded = transportError<never>(error)
/* v8 ignore next -- transportError always returns the error branch. */
this.error = folded.ok ? null : folded.error
} finally {
if (generation === this.generation) this.publishDirtyNow()
}
}
private loadOlderPage(): Promise<void> {
if (this.olderPromise !== null) return this.olderPromise
if (this.state !== 'ready' || !this.hasMore) return Promise.resolve()
const generation = this.generation
const operation = (async () => {
try {
const { result } = await this.api.sessions.history({
sessionId: this.sessionId,
beforeSeq: this.baseSeq,
maxMessages: HISTORY_PAGE_MESSAGES,
})
if (generation !== this.generation || this.state !== 'ready' || !result.ok) return
const older = result.value.events
if (older.length === 0) {
this.hasMore = result.value.hasMore
return
}
const tail = older.at(-1)
if (tail === undefined || tail.event.seq + 1 !== this.baseSeq) {
console.error(
`[web-runtime] inspection history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${this.baseSeq}`,
)
this.hasMore = false
return
}
this.entries = [...older, ...this.entries]
this.inspectionEntries = compactHistoryInspectionEntries([...this.entries])
this.baseSeq = older[0]?.event.seq ?? this.baseSeq
this.hasMore = result.value.hasMore
} catch (error) {
console.error('[web-runtime] inspection history paging failed:', error)
}
})()
const settled = operation.finally(() => {
if (this.olderPromise !== settled) return
this.olderPromise = null
this.publishDirtyNow()
})
this.olderPromise = settled
return settled
}
private installTail(
tail: readonly HistoryEntry[],
hasMore: boolean,
replace: boolean,
): void {
if (replace) {
this.entries = [...tail]
this.hasMore = hasMore
} else {
const firstSeq = tail[0]?.event.seq
const prefix = firstSeq === undefined
? this.entries
: this.entries.filter(entry => entry.event.seq < firstSeq)
this.entries = [...prefix, ...tail]
}
this.baseSeq = this.entries[0]?.event.seq ?? 0
this.inspectionEntries = compactHistoryInspectionEntries([...this.entries])
const buffered = this.liveBuffer
this.liveBuffer = []
for (const entry of buffered) this.appendLive(entry)
this.publishDirtyNow()
}
private acceptLive(entry: HistoryEntry): void {
if (this.state === 'loading' || this.stitching) {
this.liveBuffer.push(entry)
return
}
if (this.state !== 'ready') return
const tailSeq = this.tailSeq()
if (tailSeq !== null && entry.event.seq > tailSeq + 1) {
this.liveBuffer.push(entry)
void this.repairGap()
return
}
if (
entry.event.type === 'assistant/chunk'
&& entry.event.data.chunk.type !== 'usage'
) {
if (!this.appendIncrementalChunk(entry, entry.event)) return
this.publishStreamDirty()
return
}
this.appendLive(entry)
this.publishDirtyNow()
}
private appendLive(entry: HistoryEntry): void {
const tailSeq = this.tailSeq()
if (tailSeq !== null && entry.event.seq <= tailSeq) return
this.entries.push(entry)
this.inspectionEntries = [...this.inspectionEntries, entry]
if (entry.event.type === 'assistant/message') {
this.inspectionEntries = compactHistoryInspectionEntries(this.inspectionEntries)
}
}
/** Append a chunk against the cached finalized projection; false means no visible publish. */
private appendIncrementalChunk(
entry: HistoryEntry,
event: SessionEvent<'assistant/chunk'>,
): boolean {
const { turn, step, chunk } = event.data
if (!isVisibleAssistantChunk(chunk.type)) {
const inspection = this.currentInspection()
this.appendLive(entry)
this.inspectionCache = { entries: this.inspectionEntries, value: inspection }
return false
}
const base = this.currentInspection()
if (
this.streamPartial === null
|| this.streamPartial.turn !== turn
|| this.streamPartial.step !== step
) {
const current = base.partial
this.streamPartial = new PartialAccumulator(
turn,
step,
current?.turn === turn && current.step === step ? current.blocks : [],
)
}
this.streamPartial.push(chunk)
this.appendLive(entry)
this.inspectionCache = {
entries: this.inspectionEntries,
value: { ...base, partial: this.streamPartial.toPartial() },
}
return true
}
/** Coalesce token-stream projection and rendering work to one publish per browser frame. */
private publishStreamDirty(): void {
if (this.streamPublishToken !== null) return
const token = {}
this.streamPublishToken = token
const publish = () => {
if (this.streamPublishToken !== token) return
this.streamPublishToken = null
this.notifier.markDirty()
}
if (typeof globalThis.requestAnimationFrame === 'function') {
globalThis.requestAnimationFrame(publish)
} else {
queueMicrotask(publish)
}
}
/** Publish structural changes immediately and invalidate an older scheduled stream publish. */
private publishDirtyNow(): void {
this.streamPublishToken = null
this.streamPartial = null
this.notifier.markDirty()
}
private async repairGap(): Promise<void> {
if (this.stitching) return
this.stitching = true
const generation = this.generation
try {
const { result } = await this.api.sessions.history({
sessionId: this.sessionId,
maxMessages: HISTORY_PAGE_MESSAGES,
})
if (result.ok && generation === this.generation && this.state === 'ready') {
this.installTail(result.value.events, result.value.hasMore, false)
}
} catch (error) {
console.error('[web-runtime] inspection history gap repair failed:', error)
} finally {
if (generation === this.generation) this.stitching = false
}
}
private tailSeq(): number | null {
return this.entries.at(-1)?.event.seq ?? null
}
private buildSnapshot(): SessionHistorySnapshot {
return {
state: this.state,
error: this.error,
hasMore: this.hasMore,
baseSeq: this.baseSeq,
inspection: this.currentInspection(),
}
}
/** Inspection pinned to the source's current immutable entry array. */
private currentInspection(): SessionHistorySnapshot['inspection'] {
if (this.inspectionCache?.entries !== this.inspectionEntries) {
const entries = this.inspectionEntries
this.inspectionCache = {
entries,
value: createHistoryInspection(() => entries),
}
}
return this.inspectionCache.value
}
}

View File

@@ -2,7 +2,8 @@ import type {
ConversationContextReader, ConversationEventInput, ConversationLocationData, ConversationMatch,
ConversationNodeContext, ConversationNodeDefinition, ConversationPreviousContext,
ConversationLocationDataScope, ConversationPublication, ConversationViewBuilder,
ConversationViewDefinition, ConversationViewNode,
ConversationViewDefinition, ConversationViewNode, ConversationViewSnapshotMap,
ConversationViewSnapshotStore,
} from '../contract/conversation.ts'
import { conversationContextKey } from '../contract/conversation.ts'
import {
@@ -133,7 +134,7 @@ export interface ConversationViewDefinitions {
* Session-owned incremental engine that assembles business Contexts from a
* contiguous Event window and materializes registered view snapshots.
*/
export class ConversationNodeAssembler {
export class ConversationNodeAssembler implements ConversationViewSnapshotStore {
private readonly contexts = new Map<string, InternalContext>()
private readonly contextsByKind = new Map<string, InternalContext[]>()
private readonly contextsBySeq = new Map<number, Set<InternalContext>>()
@@ -266,11 +267,11 @@ export class ConversationNodeAssembler {
const allByTarget = new Map<string, ConversationViewNode[]>()
for (const target of this.views.keys()) allByTarget.set(target, [])
for (const context of this.contexts.values()) {
for (const target of this.views.keys()) {
const node = this.buildNode(context, target)
context.current.set(target, node)
if (node !== null) allByTarget.get(target)?.push(node)
}
const target = context.definition.target
if (target === undefined || !this.views.has(target)) continue
const node = this.buildNode(context, target)
context.current.set(target, node)
if (node !== null) allByTarget.get(target)?.push(node)
}
for (const view of this.views.values()) {
view.snapshot = view.builder.replace({
@@ -288,17 +289,17 @@ export class ConversationNodeAssembler {
for (const target of this.views.keys()) upsertsByTarget.set(target, [])
if (this.applyDirtyLocationData()) this.timelineDirty = true
for (const context of this.dirty) {
for (const target of this.views.keys()) {
const previous = context.current.get(target) ?? null
const node = this.buildNode(context, target)
if (node === null && previous !== null) {
throw new Error(
`conversation Definition "${context.kind}" withdrew materialized target "${target}"; return the same key with hidden visibility instead`,
)
}
context.current.set(target, node)
if (node !== null) upsertsByTarget.get(target)?.push(node)
const target = context.definition.target
if (target === undefined || !this.views.has(target)) continue
const previous = context.current.get(target) ?? null
const node = this.buildNode(context, target)
if (node === null && previous !== null) {
throw new Error(
`conversation Definition "${context.kind}" withdrew materialized target "${target}"; return the same key with hidden visibility instead`,
)
}
context.current.set(target, node)
if (node !== null) upsertsByTarget.get(target)?.push(node)
}
this.dirty.clear()
const timelineDirty = this.timelineDirty
@@ -323,6 +324,12 @@ export class ConversationNodeAssembler {
return this.views.get(target)?.snapshot
}
get<Target extends Extract<keyof ConversationViewSnapshotMap, string>>(
target: Target,
): ConversationViewSnapshotMap[Target] | undefined {
return this.snapshot(target) as ConversationViewSnapshotMap[Target] | undefined
}
private sortedInputs(): ConversationEventInput[] {
return [...this.inputs.values()].sort((left, right) => left.event.seq - right.event.seq)
}
@@ -358,18 +365,19 @@ export class ConversationNodeAssembler {
role: ConversationMatch['role'],
) => ConversationPublication,
): ConversationPublication {
let matched = false
const matchedTargets = new Set<string>()
let publication: ConversationPublication = 'none'
for (const definition of this.eventDefinitions.entries()) {
const result = definition.match(input.event)
if (result === null) continue
matched = true
if (definition.target !== undefined) matchedTargets.add(definition.target)
publication = maximumPublication(publication, accept(definition, result.id, result.role))
}
if (!matched) {
const fallback = this.eventDefinitions.fallbackEntry()
const result = fallback?.match(input.event) ?? null
if (fallback !== undefined && result !== null) {
const fallback = this.eventDefinitions.fallbackEntry()
const target = fallback?.target
if (fallback !== undefined && target !== undefined && !matchedTargets.has(target)) {
const result = fallback.match(input.event)
if (result !== null) {
publication = maximumPublication(publication, accept(fallback, result.id, result.role))
}
}
@@ -697,7 +705,8 @@ export class ConversationNodeAssembler {
}
private buildNode(context: InternalContext, target: string): ConversationViewNode | null {
const node = context.definition.buildViewNode(contextSnapshot(context), target)
if (context.definition.target !== target || context.definition.buildViewNode === undefined) return null
const node = context.definition.buildViewNode(contextSnapshot(context))
if (node === null) return null
if (node.key !== context.key) {
throw new Error(`conversation Definition "${context.kind}" returned unstable key "${node.key}"; expected "${context.key}"`)

View File

@@ -17,7 +17,7 @@ import type {
import type { PendingInteraction } from './pending.ts'
import type { ContextProvenanceView, KnownContextForm } from './context-provenance.ts'
import type {
ChatConversationViewNode, ConversationTimelineSnapshot,
ChatConversationViewNode, ConversationTimelineSnapshot, ConversationViewSnapshotStore,
} from '../contract/conversation.ts'
export type { TodoItem }
@@ -384,6 +384,11 @@ export interface ChatSnapshot {
const EMPTY_LIST: readonly never[] = []
const EMPTY_TIMELINE: ConversationTimelineSnapshot = { turnOrder: EMPTY_LIST, turns: new Map() }
/** Empty target store used by fixtures and Sessions without registered views. */
export const EMPTY_CONVERSATION_VIEWS: ConversationViewSnapshotStore = {
get: () => undefined,
}
/** Empty Chat target used before a view builder is registered. */
export const EMPTY_CHAT_SNAPSHOT: ChatSnapshot = {
order: EMPTY_LIST,
@@ -408,6 +413,8 @@ export const EMPTY_CHAT_SNAPSHOT: ChatSnapshot = {
/** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */
export interface ConversationSnapshot {
sessionId: SessionId
/** Registered target snapshots assembled from Session events. */
views: ConversationViewSnapshotStore
/** Final Chat target assembled from independently registered business Definitions. */
chat: ChatSnapshot
/** Legacy top-level compatibility field mirrored from the registered Chat Definitions. */

View File

@@ -1,121 +0,0 @@
import type { ToolSchema } from '@deepseek-ai/dsh-llm/types'
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
import type {
ConversationNode, PartialAssistant, RunningToolCall,
} from './conversation.ts'
import type { ConversationContext } from './conversation-context.ts'
import { projectConversationHistory } from '../session-history/history-fold.ts'
import { inspectRequests, type RequestView } from './request-inspection.ts'
function assistantStepKey(turn: number, step: number): string {
return `${turn}\u0000${step}`
}
function isFirstTokenCandidate(entry: HistoryEntry): boolean {
const event = entry.event
if (event.type !== 'assistant/chunk') return false
switch (event.data.chunk.type) {
case 'text-delta':
case 'reasoning-delta':
return event.data.chunk.text !== ''
case 'tool-call-delta':
return event.data.chunk.argumentsDelta !== '' || event.data.chunk.name !== undefined
default:
return false
}
}
/** Lazily derived inspection data for one immutable session-history window. */
export interface SessionHistoryInspection {
eventNodes: readonly ConversationNode[]
contexts: readonly ConversationContext[]
requests: readonly RequestView[]
callSchemas: ReadonlyMap<string, ToolSchema>
interruptedNodes: readonly ConversationNode[]
partial: PartialAssistant | null
runningCalls: readonly RunningToolCall[]
}
/**
* Remove completed-step token payloads that no inspection projection reads.
* The first visible token preserves timing, usage chunks preserve accounting,
* and unfinished steps retain every chunk for live or interrupted content.
* @param entries - Contiguous raw history entries in sequence order.
* @returns A projection-equivalent, usually much smaller entry ledger.
*/
export function compactHistoryInspectionEntries(
entries: readonly HistoryEntry[],
): readonly HistoryEntry[] {
const completedSteps = new Set<string>()
for (const { event } of entries) {
if (event.type === 'assistant/message') {
completedSteps.add(assistantStepKey(event.data.turn, event.data.step))
}
}
const firstTokenSteps = new Set<string>()
const compacted: HistoryEntry[] = []
let changed = false
for (const entry of entries) {
const event = entry.event
if (event.type !== 'assistant/chunk') {
compacted.push(entry)
continue
}
const key = assistantStepKey(event.data.turn, event.data.step)
if (!completedSteps.has(key) || event.data.chunk.type === 'usage') {
compacted.push(entry)
continue
}
if (isFirstTokenCandidate(entry) && !firstTokenSteps.has(key)) {
firstTokenSteps.add(key)
compacted.push(entry)
} else {
changed = true
}
}
return changed ? compacted : entries
}
/**
* Create a lazy inspection projection over an immutable history window.
* Conversation consumers retain the cheap wrapper; only Trajectory snapshots
* the entries and replays event order and request lifecycle state.
* @param loadEntries - Lazily snapshots contiguous raw entries in sequence order.
* @returns Lazy, memoized inspection fields for that exact window.
*/
export function createHistoryInspection(
loadEntries: () => readonly HistoryEntry[],
): SessionHistoryInspection {
let entries: readonly HistoryEntry[] | undefined
let conversation: ReturnType<typeof projectConversationHistory> | undefined
let requests: ReturnType<typeof inspectRequests> | undefined
const historyEntries = () => entries ??= loadEntries()
const conversationProjection = () =>
conversation ??= projectConversationHistory(historyEntries())
const requestProjection = () =>
requests ??= inspectRequests(historyEntries())
return {
get eventNodes() {
return conversationProjection().eventNodes
},
get contexts() {
return conversationProjection().contexts
},
get interruptedNodes() {
return conversationProjection().interruptedNodes
},
get partial() {
return conversationProjection().partial
},
get runningCalls() {
return conversationProjection().runningCalls
},
get requests() {
return requestProjection().requests
},
get callSchemas() {
return requestProjection().callSchemas
},
}
}

View File

@@ -4,7 +4,7 @@
import type {
IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId,
SessionSummary, SubagentAddress, SubagentCatalog, WorkspaceId,
SessionSummary, SubagentAddress, SubagentCatalog, TaskView, WorkspaceId,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
@@ -48,6 +48,8 @@ export interface SessionListSnapshot {
phase: SessionListPhase
error: RpcError | null
subagentsByParent: Readonly<Record<SessionId, SubagentCatalogSnapshot>>
/** Background tasks per session; an absent key is an empty set. */
tasksBySession: Readonly<Record<SessionId, readonly TaskView[]>>
currentAddress: SubagentAddress | undefined
}
@@ -138,6 +140,11 @@ export class SessionManager {
private readonly catalogStale = new Set<SessionId>()
private readonly openCatalogs = new Set<SessionId>()
private readonly catalogDebounce = new Map<SessionId, ReturnType<typeof setTimeout>>()
/**
* Background tasks per session, last-wins from `session/tasks`. An empty set
* is stored as an absent key, so absence and `[]` are one representation.
*/
private readonly tasksBySession = new Map<SessionId, readonly TaskView[]>()
private selected: SessionId | undefined
@@ -682,10 +689,23 @@ export class SessionManager {
this.notifier.markDirty()
return
}
if (frame.type === 'session/tasks') {
// Whole-set snapshot, so last-wins with no reconciliation. The Host omits
// the baseline for an empty set, which is the same fact an emptying change
// reports as `[]` — both land as an absent key.
if (frame.tasks.length === 0) this.tasksBySession.delete(frame.sessionId)
else this.tasksBySession.set(frame.sessionId, frame.tasks)
this.notifier.markDirty()
return
}
if (frame.type === 'session/subscribed') {
// Rows past the host's durable baseline rode state a restart lost; drop
// them so last-wins cannot pin a phantom value over recomputed truth.
this.projectionStores.get(frame.sessionId)?.truncate(frame.lastSeq)
// Same re-baseline reasoning as the queue below: this generation sends a
// task baseline only when the set is non-empty, so a mirror kept from the
// previous generation would survive as a phantom list.
this.tasksBySession.delete(frame.sessionId)
this.notifier.markDirty()
// New mux-generation baseline: discard the previous queue snapshot.
// The host omits session/queue when the live queue is empty, so retaining
@@ -804,6 +824,11 @@ export class SessionManager {
}
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
this.pendingInteractions.delete(frame.sessionId) // a removed session cannot wait on anyone
// Owner disposal already dropped these registry-side, but that lands on
// the mux stream while this frame rides the host stream, so the two have
// no relative order. Clearing here makes a detached Activation's rows
// disappear whichever arrives first.
this.tasksBySession.delete(frame.sessionId)
if (!durableSubagent) this.projectionStores.delete(frame.sessionId)
// A pull already in flight was requested before this removal and can
// carry the pre-removal parentAvailable:true, which would resurrect
@@ -1040,6 +1065,7 @@ export class SessionManager {
phase: this.listPhase,
error: this.listError,
subagentsByParent: Object.fromEntries(this.catalogs),
tasksBySession: Object.fromEntries(this.tasksBySession),
currentAddress: current === undefined ? undefined : this.addresses.get(current),
}
}

View File

@@ -1,17 +1,7 @@
// Request-centric inspection read model. Ordinary generation and compaction
// calls share one chronological projection; presentation-specific grouping
// remains in the trajectory consumer.
import type { ContentBlock, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm/types'
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {} from '@deepseek-ai/dsh-compact/types'
import type {} from '@deepseek-ai/dsh-llm-retry/types'
import type {} from '@deepseek-ai/dsh-tools/types'
import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm/types'
import type {
AssistantProvenanceView, AssistantRequestConfig,
} from './conversation.ts'
import { displayFailureMessage } from './failure-display.ts'
export type {
AssistantProvenanceView, AssistantRequestConfig,
@@ -54,7 +44,7 @@ interface RequestViewBase {
resultSeq?: number
}
/** One ordinary assistant generation reconstructed from durable request events. */
/** One ordinary assistant generation assembled from durable request events. */
interface AssistantRequestView extends RequestViewBase {
purpose: 'assistant'
turn: number
@@ -85,321 +75,11 @@ interface CompactionRequestView extends RequestViewBase {
rawOutput?: readonly ContentBlock[]
}
/** One provider request reconstructed from durable request lifecycle events. */
/** One provider request assembled from durable request lifecycle events. */
export type RequestView = AssistantRequestView | CompactionRequestView
/** Immutable request-centric projection derived from one history window. */
/** Request data consumed by the stage-oriented Trajectory layout. */
export interface RequestInspectionSnapshot {
requests: readonly RequestView[]
callSchemas: ReadonlyMap<string, ToolSchema>
}
/**
* Derive the request-centric read model from one immutable history window.
* Compaction participates as a request purpose rather than a parallel
* top-level collection. A leading resume/change header exposes its prompt but
* cannot project a change until the preceding header enters the window.
* @param entries - Contiguous raw session history.
* @returns Requests and call-time schemas derived from that history.
*/
export function inspectRequests(
entries: readonly HistoryEntry[],
): RequestInspectionSnapshot {
const events = entries.map(entry => entry.event)
return {
requests: deriveRequests(events),
callSchemas: deriveCallSchemas(events),
}
}
function requestKey(turn: number, step: number): string {
return `${turn}\u0000${step}`
}
function addTokenUsage(current: unknown, next: TokenUsage): TokenUsage {
const previous = current as TokenUsage | undefined
return {
inputTokens: (previous?.inputTokens ?? 0) + next.inputTokens,
outputTokens: (previous?.outputTokens ?? 0) + next.outputTokens,
...(previous?.cacheReadTokens === undefined && next.cacheReadTokens === undefined
? {}
: {
cacheReadTokens:
(previous?.cacheReadTokens ?? 0) + (next.cacheReadTokens ?? 0),
}),
...(previous?.cacheWriteTokens === undefined && next.cacheWriteTokens === undefined
? {}
: {
cacheWriteTokens:
(previous?.cacheWriteTokens ?? 0) + (next.cacheWriteTokens ?? 0),
}),
...(previous?.reasoningTokens === undefined && next.reasoningTokens === undefined
? {}
: {
reasoningTokens:
(previous?.reasoningTokens ?? 0) + (next.reasoningTokens ?? 0),
}),
}
}
function deriveCallSchemas(
events: readonly SessionEvent[],
): ReadonlyMap<string, ToolSchema> {
let active = new Map<string, ToolSchema>()
const calls = new Map<string, ToolSchema>()
const capture = (callId: string, name: string): void => {
if (calls.has(callId)) return
const schema = active.get(name)
if (schema !== undefined) calls.set(callId, schema)
}
for (const event of events) {
if (event.type === 'request/header') {
const tools: unknown = event.data.header.tools
active = new Map(
Array.isArray(tools)
? (tools as ToolSchema[]).map(schema => [schema.name, schema])
: [],
)
continue
}
if (event.type === 'tool/call') {
capture(String(event.data.callId), event.data.name)
continue
}
if (event.type === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch') {
capture(String(event.data.subCallId), event.data.name)
}
}
return calls
}
function promptChange(
previous: ConversationPromptSnapshot | undefined,
prompt: ConversationPromptSnapshot,
event: SessionEvent<'request/header'>,
): RequestPromptChange | undefined {
if (previous === undefined && event.data.reason !== 'initial') return
const systemChanged = previous !== undefined && previous.system !== prompt.system
const toolsChanged = previous !== undefined
&& JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools)
if (previous !== undefined && !systemChanged && !toolsChanged) return
return {
seq: event.seq,
time: event.time,
kind: previous === undefined
? 'initial'
: systemChanged && toolsChanged
? 'system-and-tools'
: systemChanged
? 'system'
: 'tools',
...(previous === undefined ? {} : { previous }),
}
}
/** Project ordinary and compaction provider calls into one chronological request stream. */
function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[] {
const requests: RequestView[] = []
const ordinaryByStep = new Map<string, number>()
const lastStepByTurn = new Map<number, string>()
let activeStep: string | undefined
let activePrompt: ConversationPromptSnapshot | undefined
let activeCompaction: number | undefined
const updateAssistant = (
index: number | undefined,
change: Partial<Omit<AssistantRequestView, 'purpose'>>,
): void => {
if (index === undefined) return
const request = requests[index]
if (request?.purpose === 'assistant') requests[index] = { ...request, ...change }
}
const updateCompaction = (
index: number | undefined,
change: Partial<Omit<CompactionRequestView, 'purpose'>>,
): void => {
if (index === undefined) return
const request = requests[index]
if (request?.purpose === 'compaction') requests[index] = { ...request, ...change }
}
for (const sourceEvent of events) {
if (sourceEvent.type === 'step/start') {
const { turn, step } = sourceEvent.data
const key = requestKey(turn, step)
ordinaryByStep.set(key, requests.length)
lastStepByTurn.set(turn, key)
requests.push({
purpose: 'assistant',
startSeq: sourceEvent.seq,
turn,
step,
startedAt: sourceEvent.time,
completedAt: null,
status: 'running',
...(activePrompt === undefined
? {}
: { prompt: activePrompt, requestConfig: activePrompt.config }),
})
activeStep = key
continue
}
if (sourceEvent.type === 'request/header') {
const tools: unknown = sourceEvent.data.header.tools
const prompt: ConversationPromptSnapshot = {
config: sourceEvent.data.header.config,
system: sourceEvent.data.header.system ?? '',
tools: Array.isArray(tools) ? tools as ToolSchema[] : [],
}
const change = promptChange(activePrompt, prompt, sourceEvent)
activePrompt = prompt
updateAssistant(activeStep === undefined ? undefined : ordinaryByStep.get(activeStep), {
prompt,
requestConfig: prompt.config,
...(change === undefined ? {} : { promptChange: change }),
})
continue
}
if (
sourceEvent.type === 'assistant/chunk'
&& sourceEvent.data.chunk.type === 'usage'
) {
const index = ordinaryByStep.get(
requestKey(sourceEvent.data.turn, sourceEvent.data.step),
)
const request = index === undefined ? undefined : requests[index]
updateAssistant(index, {
usage: addTokenUsage(
request?.purpose === 'assistant' ? request.usage : undefined,
sourceEvent.data.chunk.usage,
),
})
continue
}
if (sourceEvent.type === 'assistant/message') {
const index = ordinaryByStep.get(
requestKey(sourceEvent.data.turn, sourceEvent.data.step),
)
const request = index === undefined ? undefined : requests[index]
updateAssistant(index, {
completedAt: sourceEvent.time,
status: 'complete',
resultSeq: sourceEvent.seq,
provenance: {
provider: sourceEvent.data.message.source.provider,
model: sourceEvent.data.message.source.model,
},
...(request?.purpose === 'assistant'
&& request.usage !== undefined
|| sourceEvent.data.usage === undefined
? {}
: { usage: sourceEvent.data.usage }),
})
continue
}
if (sourceEvent.type === 'step/end') {
const key = requestKey(sourceEvent.data.turn, sourceEvent.data.step)
const index = ordinaryByStep.get(key)
const request = index === undefined ? undefined : requests[index]
if (request?.purpose === 'assistant' && request.status === 'running') {
updateAssistant(index, {
completedAt: sourceEvent.time,
status: 'error',
})
}
if (activeStep === key) activeStep = undefined
continue
}
if (sourceEvent.type === 'llm/retry') {
const data = sourceEvent.data
updateAssistant(ordinaryByStep.get(requestKey(data.turn, data.step)), {
status: 'error',
error: displayFailureMessage(data.failure),
retry: data.retry,
...data.mode === 'normal' ? { maxRetries: data.maxRetries } : {},
retryDelayMs: data.delayMs,
})
continue
}
if (sourceEvent.type === 'turn/end') {
const lastStep = lastStepByTurn.get(sourceEvent.data.turn)
if (sourceEvent.data.reason.kind === 'error') {
updateAssistant(lastStep === undefined ? undefined : ordinaryByStep.get(lastStep), {
status: 'error',
error: displayFailureMessage(sourceEvent.data.reason.error),
})
}
lastStepByTurn.delete(sourceEvent.data.turn)
continue
}
if (sourceEvent.type === 'session/end-seed' && activeCompaction !== undefined) {
updateCompaction(activeCompaction, {
completedAt: sourceEvent.time,
status: 'error',
error: 'Compaction was interrupted before completion.',
})
activeCompaction = undefined
continue
}
if (sourceEvent.type === 'compact/start') {
activeCompaction = requests.length
requests.push({
purpose: 'compaction',
startSeq: sourceEvent.seq,
turn: sourceEvent.data.turn,
step: 0,
startedAt: sourceEvent.time,
completedAt: null,
status: 'running',
})
continue
}
if (sourceEvent.type === 'compact/summary' && activeCompaction !== undefined) {
const data = sourceEvent.data
updateCompaction(activeCompaction, {
resultSeq: sourceEvent.seq,
summary: data.summary,
...(data.rawOutput === undefined ? {} : { rawOutput: data.rawOutput }),
provenance: {
provider: data.provider,
model: data.model,
},
requestConfig: {
provider: data.provider,
model: data.model,
purpose: 'compaction',
...(data.maxTokens === undefined ? {} : { maxTokens: data.maxTokens }),
},
...(data.usage === undefined ? {} : { usage: data.usage }),
})
continue
}
if (
sourceEvent.type === 'user/message'
&& activeCompaction !== undefined
&& isCompactionSource(sourceEvent.data.source)
) {
updateCompaction(activeCompaction, { replacementSeq: sourceEvent.seq })
continue
}
if (sourceEvent.type !== 'compact/end' || activeCompaction === undefined) continue
updateCompaction(activeCompaction, {
completedAt: sourceEvent.time,
status: sourceEvent.data.error === undefined ? 'complete' : 'error',
...(sourceEvent.data.error === undefined ? {} : { error: sourceEvent.data.error }),
})
activeCompaction = undefined
}
return requests.sort((left, right) => left.startSeq - right.startSeq)
}
function isCompactionSource(source: unknown): boolean {
return typeof source === 'object'
&& source !== null
&& 'kind' in source
&& source.kind === 'plugin'
&& 'plugin' in source
&& source.plugin === 'compact'
}

View File

@@ -14,9 +14,9 @@
* 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 { Context, Fiber } from '@deepseek-ai/cordis'
import type {
IApiClient, RpcError, RpcResult, SessionId, SubagentAddress, WorkspaceId,
IApiClient, RpcError, RpcResult, SessionId, SubagentAddress, TaskView, WorkspaceId,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
@@ -86,6 +86,12 @@ export interface SessionListState {
phase: SessionListPhase
/** Direct durable catalogs keyed by their selected parent address. */
subagentsByParent: Readonly<Record<SessionId, SubagentCatalogSnapshot>>
/**
* Background tasks each session can see, mirrored last-wins from
* `session/tasks`. A missing key is an empty set — the Host sends no baseline
* for a session without tasks — so consumers read absence, never a sentinel.
*/
tasksBySession: Readonly<Record<SessionId, readonly TaskView[]>>
/** Current session's catalog-derived address, absent on ordinary navigation. */
currentAddress: SubagentAddress | undefined
}
@@ -291,7 +297,7 @@ export class SessionsService implements ISessions {
)
this.list = createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'pending',
subagentsByParent: {}, currentAddress: undefined,
subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
})
// The manager owns wire truth; the store is its projection. Manager
// notifications are already microtask-batched.
@@ -649,7 +655,7 @@ export class SessionsService implements ISessions {
/** Project the manager's list snapshot into the store (title derivation is display-only). */
private projectList(): void {
const {
items, current, phase, subagentsByParent, currentAddress,
items, current, phase, subagentsByParent, tasksBySession, currentAddress,
} = this.manager.getListSnapshot()
const ids: SessionId[] = []
const byId: Record<SessionId, SessionSummary> = {}
@@ -719,7 +725,7 @@ export class SessionsService implements ISessions {
...(currentAddress === undefined ? {} : { subagentAddress: currentAddress }),
})
}
this.list.set({ ids, byId, current, phase, subagentsByParent, currentAddress })
this.list.set({ ids, byId, current, phase, subagentsByParent, tasksBySession, currentAddress })
this.pruneScopes()
}

View File

@@ -1,6 +1,6 @@
// Sessions remain resident after creation so they continue consuming mux frames off-screen.
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
@@ -727,6 +727,7 @@ export class Session implements SessionFace {
const legacy = chat.legacy
return {
sessionId: this.sessionId,
views: this.conversation,
chat,
nodes: legacy.nodes,
turnTimings: legacy.turnTimings,

View File

@@ -1,6 +1,6 @@
/** Host-backed settings-namespace synchronization for browser plugins. */
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {
ConnectionHandle, IApiClient, SettingsNamespaceView,
} from '@deepseek-ai/dsh-client-connection/client'

View File

@@ -14,8 +14,8 @@
* holds this package's 'root' row in this compilation unit, but consumers
* merge keys in; the rule fires on the narrow-map view, not on real
* redundancy. */
import { Service } from 'cordis'
import type { Context } from 'cordis'
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
import type {
LocaleFace, OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost,

View File

@@ -1,6 +1,6 @@
/** WorkspacesService projects the Workspace object manager for UI consumers. */
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {
DirectoryListing, IApiClient, RpcError,
SessionId, WorkspaceId, WorkspaceView,

View File

@@ -8,7 +8,7 @@
* `keyof SlotMap & string` is the declare-merge key pattern: SlotMap is empty
* in this compilation unit (intersection reads `never`) but consumers merge
* keys in; the rule fires on the empty-map view, not on real redundancy. */
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { SlotMap } from '@deepseek-ai/dsh-client-ui-slots'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'

View File

@@ -3,7 +3,7 @@
* connection handle, stream-loop sink wiring into the object layer, and the
* fiber-scoped loop teardown.
*/
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
@@ -126,6 +126,7 @@ describe('runtime client apply', () => {
const rebuild = vi.spyOn(Session.prototype, 'rebuildConversationRegistry')
const definition: ConversationNodeDefinition<null> = {
kind: 'registry-probe',
target: 'chat',
match: () => null,
start: () => null,
update: context => context.state,

View File

@@ -30,10 +30,16 @@ interface TestSnapshot {
}
class TestEventDefinitions {
readonly definitions: readonly ConversationNodeDefinition[]
readonly fallback: ConversationNodeDefinition | undefined
constructor(
readonly definitions: readonly ConversationNodeDefinition[],
readonly fallback?: ConversationNodeDefinition,
) {}
definitions: readonly ConversationNodeDefinition[],
fallback?: ConversationNodeDefinition,
) {
this.definitions = definitions
this.fallback = fallback
}
entries(): readonly ConversationNodeDefinition[] {
return this.definitions
@@ -93,7 +99,10 @@ function chatSnapshot(assembler: ConversationNodeAssembler): TestSnapshot | unde
return assembler.snapshot('chat') as TestSnapshot | undefined
}
function node(context: Parameters<ConversationNodeDefinition['buildViewNode']>[0], data: unknown): ConversationViewNode {
function node(
context: Parameters<NonNullable<ConversationNodeDefinition['buildViewNode']>>[0],
data: unknown,
): ConversationViewNode {
return {
key: context.key,
kind: context.kind,
@@ -103,6 +112,17 @@ function node(context: Parameters<ConversationNodeDefinition['buildViewNode']>[0
}
}
function fallbackDefinition(start: () => string): ConversationNodeDefinition<string> {
return {
kind: 'fallback',
target: 'chat',
match: event => ({ id: String(event.seq), role: 'start' }),
start,
update: context => context.state,
buildViewNode: context => node(context, context.state),
}
}
describe('ConversationNodeAssembler', () => {
it('appends through an exact business-id Context without replaying unrelated Contexts', () => {
const starts = vi.fn((
@@ -122,6 +142,7 @@ describe('ConversationNodeAssembler', () => {
},
start: starts,
update: updates,
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
@@ -173,6 +194,7 @@ describe('ConversationNodeAssembler', () => {
matchCollections.add(context.matches)
return updates(context)
},
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
@@ -208,6 +230,7 @@ describe('ConversationNodeAssembler', () => {
},
start: starts,
update: updates,
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
@@ -247,6 +270,7 @@ describe('ConversationNodeAssembler', () => {
},
start: () => ({ settled: false }),
update: updates,
target: 'chat',
buildViewNode: context => node(context, context.state ?? { pendingStart: true }),
}
const assembler = new ConversationNodeAssembler(
@@ -280,6 +304,7 @@ describe('ConversationNodeAssembler', () => {
: event.type === 'turn/start' ? { id: 'one', role: 'update' } : null,
start: () => null,
update: context => context.state,
target: 'chat',
buildViewNode: () => null,
}
const assembler = new ConversationNodeAssembler(
@@ -301,6 +326,7 @@ describe('ConversationNodeAssembler', () => {
: null,
start: (_context, match) => Number((match.event.data as { value?: unknown }).value ?? 0),
update: context => context.state,
target: 'chat',
buildViewNode: () => null,
}
const consumerStart = vi.fn((
@@ -315,6 +341,7 @@ describe('ConversationNodeAssembler', () => {
: null,
start: consumerStart,
update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
@@ -344,6 +371,7 @@ describe('ConversationNodeAssembler', () => {
: null,
start: (_context, match) => match.event.seq,
update: context => context.state,
target: 'chat',
buildViewNode: () => null,
}
const consumer: ConversationNodeDefinition<number> = {
@@ -353,6 +381,7 @@ describe('ConversationNodeAssembler', () => {
: null,
start: (_context, _match, reader) => reader.previous<number>('source')?.state ?? -1,
update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
@@ -397,6 +426,7 @@ describe('ConversationNodeAssembler', () => {
: null,
start: consumerStart,
update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
@@ -425,6 +455,7 @@ describe('ConversationNodeAssembler', () => {
},
start: () => 1,
update: (_context, match) => (match.event.data as unknown as { value: number }).value,
target: 'chat',
buildViewNode: () => null,
}
const consumerStart = vi.fn((
@@ -439,6 +470,7 @@ describe('ConversationNodeAssembler', () => {
: null,
start: consumerStart,
update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
@@ -468,6 +500,7 @@ describe('ConversationNodeAssembler', () => {
},
start: () => 1,
update: (_context, match) => (match.event.data as unknown as { value: number }).value,
target: 'chat',
buildViewNode: () => null,
}
const sourceX: ConversationNodeDefinition<number> = {
@@ -479,6 +512,7 @@ describe('ConversationNodeAssembler', () => {
},
start: () => 10,
update: (_context, match) => (match.event.data as unknown as { value: number }).value,
target: 'chat',
buildViewNode: () => null,
}
const middle: ConversationNodeDefinition<number> = {
@@ -491,6 +525,7 @@ describe('ConversationNodeAssembler', () => {
+ (reader.previous<number>('diamond-x')?.state ?? 0)
),
update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const consumer: ConversationNodeDefinition<number> = {
@@ -503,6 +538,7 @@ describe('ConversationNodeAssembler', () => {
+ (reader.previous<number>('diamond-b')?.state ?? 0)
),
update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
@@ -538,6 +574,7 @@ describe('ConversationNodeAssembler', () => {
: null,
start: starts,
update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
@@ -609,6 +646,7 @@ describe('ConversationNodeAssembler', () => {
value: { valueSeenFromStep: stepValue ?? -1 },
}
},
target: 'chat',
buildViewNode: (context) => {
const location = context.start?.location
if (location?.kind !== 'step') return null
@@ -646,6 +684,7 @@ describe('ConversationNodeAssembler', () => {
: null,
start: () => null,
update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.start?.location.kind === 'turn'
? context.start.location.turn.steps.length
: -1),
@@ -701,6 +740,7 @@ describe('ConversationNodeAssembler', () => {
},
start: () => null,
update: context => context.state,
target: 'chat',
buildViewNode: (context) => {
const location = context.start?.location
const data = location?.kind === 'step'
@@ -734,6 +774,7 @@ describe('ConversationNodeAssembler', () => {
: null,
start: () => null,
update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.start?.location.kind),
}
const assembler = new ConversationNodeAssembler(
@@ -760,6 +801,7 @@ describe('ConversationNodeAssembler', () => {
: null,
start: () => null,
update: context => context.state,
target: 'chat',
buildViewNode: (context) => {
const location = context.start?.location
return node(context, location?.kind === 'step'
@@ -792,6 +834,7 @@ describe('ConversationNodeAssembler', () => {
: null,
start: () => null,
update: context => context.state,
target: 'chat',
buildViewNode: (context) => {
const location = context.start?.location
return node(context, location?.kind === 'step'
@@ -826,6 +869,7 @@ describe('ConversationNodeAssembler', () => {
: null,
start: seen,
update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
@@ -841,24 +885,64 @@ describe('ConversationNodeAssembler', () => {
expect(seen).toHaveBeenCalledTimes(2)
})
it('does not invoke the fallback when an ordinary non-rendering Definition claims an event', () => {
it('invokes the fallback when only a State-only Definition claims an event', () => {
const fallbackStart = vi.fn(() => 'fallback')
const claimed: ConversationNodeDefinition<null> = {
kind: 'claimed-state',
match: event => (event.type as string) === 'command/run'
? { id: 'claimed', role: 'start' }
: null,
start: () => null,
update: context => context.state,
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([claimed], fallbackDefinition(fallbackStart)),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false)
assembler.flush()
expect(fallbackStart).toHaveBeenCalledOnce()
expect(chatSnapshot(assembler)?.order).toHaveLength(1)
})
it('invokes the fallback when only another target claims an event', () => {
const fallbackStart = vi.fn(() => 'fallback')
const claimed: ConversationNodeDefinition<null> = {
kind: 'claimed-trajectory',
target: 'trajectory',
match: event => (event.type as string) === 'command/run'
? { id: 'claimed', role: 'start' }
: null,
start: () => null,
update: context => context.state,
buildViewNode: () => null,
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([claimed], fallbackDefinition(fallbackStart)),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false)
assembler.flush()
expect(fallbackStart).toHaveBeenCalledOnce()
expect(chatSnapshot(assembler)?.order).toHaveLength(1)
})
it('suppresses the fallback when the same target claims an event', () => {
const fallbackStart = vi.fn(() => 'fallback')
const claimed: ConversationNodeDefinition<null> = {
kind: 'claimed',
target: 'chat',
match: event => (event.type as string) === 'command/run' ? { id: 'claimed', role: 'start' } : null,
start: () => null,
update: context => context.state,
buildViewNode: () => null,
}
const fallback: ConversationNodeDefinition<string> = {
kind: 'fallback',
match: event => ({ id: String(event.seq), role: 'start' }),
start: fallbackStart,
update: context => context.state,
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([claimed], fallback),
new TestEventDefinitions([claimed], fallbackDefinition(fallbackStart)),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false)
@@ -878,6 +962,7 @@ describe('ConversationNodeAssembler', () => {
},
start: () => true,
update: () => false,
target: 'chat',
buildViewNode: context => context.state === true ? node(context, true) : null,
}
const assembler = new ConversationNodeAssembler(
@@ -900,6 +985,7 @@ describe('ConversationNodeAssembler', () => {
match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null,
start: () => undefined,
update: context => context.state,
target: 'chat',
buildViewNode: () => null,
}
const startAssembler = new ConversationNodeAssembler(
@@ -919,6 +1005,7 @@ describe('ConversationNodeAssembler', () => {
},
start: () => true,
update: () => undefined as never,
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const updateAssembler = new ConversationNodeAssembler(
@@ -939,6 +1026,7 @@ describe('ConversationNodeAssembler', () => {
match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null,
start: (_context, match) => match.event.seq,
update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(

View File

@@ -1,4 +1,4 @@
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { ConversationEventRegistry } from '../src/client/conversation/event-registry.ts'
@@ -13,6 +13,7 @@ import { FakeApiClient, ok } from './fake-api.ts'
function eventDefinition(kind: string): ConversationNodeDefinition<null> {
return {
kind,
target: 'chat',
match: () => null,
start: () => null,
update: context => context.state,
@@ -71,6 +72,40 @@ describe('Conversation registries', () => {
expect(events.fallbackEntry()).toBeUndefined()
})
it('rejects rendering Definitions that omit either target or builder', async () => {
const { events } = await bootRegistries()
const targetOnly: ConversationNodeDefinition<null> = {
kind: 'target-only',
target: 'chat',
match: () => null,
start: () => null,
update: context => context.state,
}
const builderOnly: ConversationNodeDefinition<null> = {
kind: 'builder-only',
match: () => null,
start: () => null,
update: context => context.state,
buildViewNode: () => null,
}
expect(() => events.register(targetOnly)).toThrow(/target and buildViewNode together/)
expect(() => events.register(builderOnly)).toThrow(/target and buildViewNode together/)
})
it('rejects a State-only Definition as the unmatched-event fallback', async () => {
const { events } = await bootRegistries()
const fallback: ConversationNodeDefinition<null> = {
kind: 'state-only-fallback',
match: () => null,
start: () => null,
update: context => context.state,
}
expect(() => events.registerFallback(fallback))
.toThrow('conversation fallback Definition must declare a target')
})
it('rejects duplicate view targets and disposes a view registration once', async () => {
const { views } = await bootRegistries()
const definition = viewDefinition('chat')

View File

@@ -1,232 +0,0 @@
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { describe, expect, it } from 'vitest'
import { projectConversationHistory } from '../src/client/session-history/history-fold.ts'
import { compactHistoryInspectionEntries } from '../src/client/sessions/history.ts'
import { inspectRequests } from '../src/client/sessions/request-inspection.ts'
import { ev } from './event-script.ts'
const at = (seq: number, event: Record<string, unknown>): SessionEvent =>
({ seq, time: 1_700_000_000_000 + seq, ...event }) as unknown as SessionEvent
describe('projectConversationHistory', () => {
it('names an injected context node from its durable source, like the live adapter', () => {
// The fold declares its own node mapping (jscpd:ignore in the source), so
// the source projection is pinned on both sides independently.
const injected = at(0, {
type: 'user/message',
surfaceOp: 'append',
data: createUserMessage({
content: [{ type: 'text', text: '<available_skills>…</available_skills>' }],
// A plugin source, because the client program does not see the host
// packages that merge richer source kinds; those arms are pinned in
// context-provenance.spec.ts.
source: { kind: 'plugin', plugin: 'dsh-tool-skill', form: 'catalog' },
}),
})
const { contexts } = projectConversationHistory([{ event: injected }])
expect(contexts[contexts.length - 1]?.nodes).toMatchObject([{
kind: 'context',
seq: 0,
provenance: { role: 'inject', label: 'dsh-tool-skill' },
form: 'catalog',
}])
})
it('projects next-step human input as durable steering', () => {
const steering = createUserMessage({
content: [{ type: 'text', text: 'change course' }],
source: { kind: 'user' },
})
const events = [
at(0, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, inserted: [steering],
} }),
at(1, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, removedCount: 1, inserted: [],
} }),
at(2, { type: 'user/message', surfaceOp: 'append', data: steering }),
]
const projection = projectConversationHistory(events.map(event => ({ event })))
expect(projection.eventNodes).toMatchObject([{
kind: 'steering', messageId: steering.id, seq: 2,
}])
})
it('projects a high-sequence history window without synthesizing its unloaded prefix', () => {
const baseSeq = 400_000
const events = [
ev.user(baseSeq, 'loaded tail'),
at(baseSeq + 1, {
type: 'assistant/message',
surfaceOp: { op: 'replace', start: baseSeq, end: baseSeq },
sourceEventSeqs: [baseSeq],
data: {
turn: 80,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'tail summary' }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
}),
},
}),
]
const projection = projectConversationHistory(events.map(event => ({ event })))
expect(projection.eventNodes.map(node => node.seq)).toEqual([baseSeq, baseSeq + 1])
expect(projection.contexts.map(context => ({
originSeq: context.originSeq,
nodes: context.nodes.map(node => node.seq),
}))).toEqual([
{ originSeq: undefined, nodes: [baseSeq] },
{ originSeq: baseSeq + 1, nodes: [baseSeq + 1] },
])
})
it('projects frozen surface generations without widening the core live surface', () => {
const events = [
ev.user(0, 'a'),
ev.user(1, 'b'),
at(2, {
type: 'assistant/message',
surfaceOp: { op: 'replace', start: 0, end: 0 },
sourceEventSeqs: [0],
data: {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'summary' }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
}),
},
}),
at(3, {
type: 'assistant/message',
surfaceOp: { op: 'replace', start: 2, end: 1 },
sourceEventSeqs: [2, 1],
data: {
turn: 1,
step: 2,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'summary 2' }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
}),
},
}),
]
expect(projectConversationHistory(events.map(event => ({ event }))).contexts.map(context => ({
id: context.id,
parentId: context.parentId,
originSeq: context.originSeq,
nodes: context.nodes.map(node => node.seq),
}))).toEqual([
{ id: 0, parentId: undefined, originSeq: undefined, nodes: [0, 1] },
{ id: 1, parentId: 0, originSeq: 2, nodes: [2, 1] },
{ id: 2, parentId: 1, originSeq: 3, nodes: [3] },
])
})
it('projects assistant timing and the active request header from history', () => {
const projection = projectConversationHistory([
ev.stepStart(0, 1, 2),
at(1, { type: 'request/header', data: {
reason: 'initial',
header: {
config: { provider: 'fake', model: 'first' },
tools: [],
},
} }),
ev.chunkStart(2, 1, 2),
ev.chunkText(3, 1, 'token', 2),
ev.assistant(4, 1, 'done', 2),
ev.stepStart(5, 2, 1),
ev.chunkText(6, 2, 'next', 1),
ev.assistant(7, 2, 'next done', 1),
].map(event => ({ event })))
expect(projection.eventNodes[0]).toMatchObject({
kind: 'assistant',
timing: {
stepStartTime: 1_700_000_000_000,
firstTokenTime: 1_700_000_000_003,
completedTime: 1_700_000_000_004,
},
requestConfig: { provider: 'fake', model: 'first' },
})
expect(projection.eventNodes.at(-1)).toMatchObject({
timing: {
stepStartTime: 1_700_000_000_005,
firstTokenTime: 1_700_000_000_006,
completedTime: 1_700_000_000_007,
},
requestConfig: { provider: 'fake', model: 'first' },
})
})
it('projects nested dispatches onto settled and interrupted history calls', () => {
const projection = projectConversationHistory([
ev.turnStart(0, 1),
ev.toolCall(1, 1, 'settled', 'run_code', '{}'),
ev.codeDispatchStart(2, 'settled', 1, 'run_code', { code: 'nested' }),
ev.codeDispatchStart(3, 'settled:code:1', 1, 'read', { path: 'a.txt' }),
ev.codeDispatch(4, 'settled:code:1', 1, 'read', { path: 'a.txt' }, 'alpha'),
ev.codeDispatch(5, 'settled', 1, 'run_code', { code: 'nested' }, 'alpha'),
ev.toolResult(6, 1, 'settled', 'done'),
ev.turnEnd(7, 1),
ev.turnStart(8, 2),
ev.toolCall(9, 2, 'interrupted', 'run_code', '{}'),
ev.codeDispatchStart(10, 'interrupted', 1, 'bash', { command: 'sleep 1' }),
ev.turnEnd(11, 2, 'aborted'),
].map(event => ({ event })))
const settled = {
callId: 'settled',
subCalls: [{
callId: 'settled:code:1',
subCalls: [{ callId: 'settled:code:1:code:1', call: { name: 'read' } }],
}],
}
expect(projection.eventNodes).toMatchObject([settled])
expect(projection.contexts[0]?.nodes).toMatchObject([settled])
expect(projection.interruptedNodes).toMatchObject([{
callId: 'interrupted',
subCalls: [{ callId: 'interrupted:code:1', name: 'bash' }],
}])
})
it('drops completed token payloads without changing inspection projections', () => {
const events = [
ev.user(0, 'before'),
ev.stepStart(1, 1, 0),
ev.chunkStart(2, 1),
ev.chunkText(3, 1, ''),
ev.chunkText(4, 1, 'first'),
ev.chunkText(5, 1, ' discarded'),
at(6, { type: 'assistant/chunk', data: {
turn: 1,
step: 0,
chunk: { type: 'usage', usage: { inputTokens: 4, outputTokens: 2 } },
} }),
ev.assistant(7, 1, 'first discarded'),
ev.compactSummary(8, 'summary', 0, 7),
ev.compactCheckpoint(9, 8, 0, 7),
ev.stepStart(10, 2, 0),
ev.chunkStart(11, 2),
ev.chunkText(12, 2, 'interrupted'),
ev.turnEnd(13, 2, 'aborted'),
]
const raw = events.map(event => ({ event }))
const compacted = compactHistoryInspectionEntries(raw)
expect(compacted.map(entry => entry.event.seq)).toEqual([
0, 1, 4, 6, 7, 8, 9, 10, 11, 12, 13,
])
expect(projectConversationHistory(compacted)).toEqual(projectConversationHistory(raw))
expect(inspectRequests(compacted)).toEqual(inspectRequests(raw))
})
})

View File

@@ -3,7 +3,7 @@
* a fired key must already carry a bumped version (emission follows the
* applied mutation), bogus payloads fail loud, foreign events pass.
*/
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as RuntimeInvariant from '../src/invariant.ts'

View File

@@ -1110,3 +1110,60 @@ describe('completed reminder', () => {
expect(entry(manager, S2)?.completed).toBe(true)
})
})
describe('background-task mirror', () => {
const view = (over: Partial<{ id: string; status: string; label: string }> = {}) => ({
id: 'bash-1', kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 5, ...over,
})
const tasksFrame = (sessionId: SessionId, tasks: unknown[]) =>
({ rpcId: 't' as never, payload: { type: 'session/tasks', sessionId, tasks } as never })
it('mirrors the whole set last-wins, keyed per session, with no Session instance needed', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
manager.handleMuxEnvelope(tasksFrame(S2, [view({ id: 'pwsh-1', label: 'other' })]))
const first = manager.getListSnapshot().tasksBySession
expect(first[S1]).toEqual([view()])
expect(first[S2]?.[0]?.label).toBe('other')
// Last-wins: the newer whole set replaces, it does not merge.
manager.handleMuxEnvelope(tasksFrame(S1, [view({ status: 'completed' })]))
expect(manager.getListSnapshot().tasksBySession[S1]).toEqual([view({ status: 'completed' })])
})
it('stores an emptied set as an absent key so absence and [] read alike', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
expect(S1 in manager.getListSnapshot().tasksBySession).toBe(true)
manager.handleMuxEnvelope(tasksFrame(S1, []))
expect(S1 in manager.getListSnapshot().tasksBySession).toBe(false)
})
it('clears the mirror on re-subscribe, because a task-free generation sends no baseline', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
manager.handleMuxEnvelope({
rpcId: 's' as never,
payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 3 },
})
expect(S1 in manager.getListSnapshot().tasksBySession).toBe(false)
})
it('drops the rows when the session is removed, whichever stream lands first', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope({ rpcId: 'a' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
manager.handleHostEnvelope({ rpcId: 'r' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
expect(S1 in manager.getListSnapshot().tasksBySession).toBe(false)
})
it('notifies list subscribers so an open header re-renders without a poll', async () => {
const manager = new SessionManager(new FakeApiClient())
const seen = vi.fn()
manager.subscribe(seen)
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
// The notifier batches on a microtask; the frame itself is already applied.
await Promise.resolve()
expect(seen).toHaveBeenCalled()
})
})

View File

@@ -1,319 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { inspectRequests } from '../src/client/sessions/request-inspection.ts'
const at = (seq: number, type: string, data: unknown): SessionEvent =>
({ seq, time: 1_700_000_000_000 + seq, type, data }) as SessionEvent
const entriesOf = (events: readonly SessionEvent[]): HistoryEntry[] =>
events.map(event => ({ event }))
describe('inspectRequests', () => {
it('projects ordinary and compaction calls into one chronological request stream', () => {
const events = [
at(0, 'step/start', { turn: 1, step: 1 }),
at(1, 'request/header', {
reason: 'initial',
header: {
config: { provider: 'fake', model: 'model' },
system: 'system',
tools: [{
name: 'read',
description: 'Read a file.',
parameters: { type: 'object' },
}],
},
}),
at(2, 'tool/call', {
turn: 1,
step: 1,
callId: 'call-1',
name: 'read',
arguments: '{}',
}),
at(3, 'assistant/message', {
turn: 1,
step: 1,
message: createAssistantMessage({
content: [{ type: 'text', text: 'done' }],
source: { provider: 'fake', model: 'model' },
}),
usage: { inputTokens: 5, outputTokens: 2 },
}),
at(4, 'step/end', { turn: 1, step: 1 }),
at(5, 'compact/start', { turn: 1 }),
at(6, 'compact/summary', {
summary: [{ type: 'text', text: 'summary' }],
rawOutput: [
{ type: 'reasoning', text: 'thought' },
{ type: 'text', text: 'summary' },
],
provider: 'fake',
model: 'compact-model',
usage: { inputTokens: 8, outputTokens: 3 },
}),
at(7, 'user/message', createUserMessage({
content: [{ type: 'text', text: 'checkpoint' }],
source: { kind: 'plugin', plugin: 'compact' },
})),
at(8, 'compact/end', { turn: 1 }),
]
const snapshot = inspectRequests(entriesOf(events))
expect(snapshot.requests).toMatchObject([
{
purpose: 'assistant',
startSeq: 0,
resultSeq: 3,
status: 'complete',
prompt: {
config: { provider: 'fake', model: 'model' },
system: 'system',
},
promptChange: { seq: 1, kind: 'initial' },
},
{
purpose: 'compaction',
startSeq: 5,
resultSeq: 6,
replacementSeq: 7,
status: 'complete',
summary: [{ type: 'text', text: 'summary' }],
},
])
expect(snapshot.callSchemas.get('call-1')?.name).toBe('read')
})
it('does not promote a truncated resume or change header to the initial prompt', () => {
for (const reason of ['resume', 'change'] as const) {
const snapshot = inspectRequests(entriesOf([
at(10, 'step/start', { turn: 3, step: 1 }),
at(11, 'request/header', {
reason,
header: {
config: { provider: 'fake', model: 'model' },
system: 'tail-window prompt',
},
}),
]))
expect(snapshot.requests[0]).toMatchObject({
purpose: 'assistant',
prompt: { system: 'tail-window prompt' },
})
expect(snapshot.requests[0]).not.toHaveProperty('promptChange')
}
})
it('classifies a prompt change once the preceding header is loaded', () => {
const snapshot = inspectRequests(entriesOf([
at(0, 'step/start', { turn: 1, step: 1 }),
at(1, 'request/header', {
reason: 'initial',
header: {
config: { provider: 'fake', model: 'model' },
system: 'before',
},
}),
at(2, 'step/start', { turn: 1, step: 2 }),
at(3, 'request/header', {
reason: 'change',
header: {
config: { provider: 'fake', model: 'model' },
system: 'after',
},
}),
]))
expect(snapshot.requests[1]).toMatchObject({
promptChange: {
seq: 3,
kind: 'system',
previous: { system: 'before' },
},
})
})
it('preserves a standalone compaction owner without widening assistant turns', () => {
const snapshot = inspectRequests(entriesOf([
at(0, 'compact/start', { turn: null }),
at(1, 'compact/summary', {
summary: [{ type: 'text', text: 'standalone summary' }],
provider: 'fake',
model: 'compact-model',
}),
at(2, 'compact/end', { turn: null }),
at(3, 'step/start', { turn: 2, step: 1 }),
]))
const [compaction, assistant] = snapshot.requests
expect(compaction).toMatchObject({
purpose: 'compaction',
turn: null,
step: 0,
status: 'complete',
})
expect(assistant).toMatchObject({
purpose: 'assistant',
turn: 2,
step: 1,
status: 'running',
})
if (assistant?.purpose === 'assistant') {
const turn: number = assistant.turn
expect(turn).toBe(2)
}
})
it('interrupts an orphaned compaction at end-seed before projecting a new attempt', () => {
const snapshot = inspectRequests(entriesOf([
at(0, 'compact/start', { turn: null }),
at(1, 'session/end-seed', {}),
at(2, 'compact/start', { turn: null }),
at(3, 'compact/summary', {
summary: [{ type: 'text', text: 'replacement summary' }],
provider: 'fake',
model: 'compact-model',
}),
at(4, 'compact/end', { turn: null }),
]))
expect(snapshot.requests).toMatchObject([
{
purpose: 'compaction',
startSeq: 0,
status: 'error',
completedAt: 1_700_000_000_001,
error: 'Compaction was interrupted before completion.',
},
{
purpose: 'compaction',
startSeq: 2,
status: 'complete',
completedAt: 1_700_000_000_004,
summary: [{ type: 'text', text: 'replacement summary' }],
},
])
})
it('captures schemas for nested tool dispatches from the active request header', () => {
const snapshot = inspectRequests(entriesOf([
at(0, 'request/header', {
reason: 'initial',
header: {
config: { provider: 'fake', model: 'model' },
tools: [{
name: 'read',
description: 'Read a file.',
parameters: { type: 'object' },
}],
},
}),
at(1, 'tool/code-dispatch-start', {
parentCallId: 'parent',
subCallId: 'nested',
name: 'read',
arguments: {},
}),
]))
expect(snapshot.callSchemas.get('nested')?.name).toBe('read')
})
it('keeps chunk-reported usage through request failure and prefers it to message fallback', () => {
const chunkUsage = { inputTokens: 21, outputTokens: 3 }
const retryUsage = {
inputTokens: 5,
outputTokens: 2,
cacheReadTokens: 8,
reasoningTokens: 1,
}
const snapshot = inspectRequests(entriesOf([
at(0, 'step/start', { turn: 1, step: 1 }),
at(1, 'assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'usage', usage: chunkUsage },
}),
at(2, 'llm/retry', {
turn: 1,
step: 1,
retry: 1,
maxRetries: 2,
delayMs: 100,
failure: { message: 'rate limited' },
}),
at(3, 'assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'usage', usage: retryUsage },
}),
at(4, 'assistant/message', {
turn: 1,
step: 1,
message: createAssistantMessage({
content: [{ type: 'text', text: 'recovered' }],
source: { provider: 'fake', model: 'model' },
}),
usage: { inputTokens: 1, outputTokens: 1 },
}),
]))
expect(snapshot.requests[0]).toMatchObject({
status: 'complete',
usage: {
inputTokens: 26,
outputTokens: 5,
cacheReadTokens: 8,
reasoningTokens: 1,
},
})
})
it('keeps provider credential fragments out of projected request errors', () => {
const snapshot = inspectRequests(entriesOf([
at(0, 'step/start', { turn: 1, step: 1 }),
at(1, 'turn/end', {
turn: 1, reason: { kind: 'error', error: {
code: 'AUTH',
message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
},
},
}),
at(2, 'step/start', { turn: 2, step: 1 }),
at(3, 'turn/end', {
turn: 2, reason: { kind: 'error', error: { message: 'plugin exploded', code: 'UNKNOWN' } },
}),
]))
expect(snapshot.requests).toMatchObject([
{ status: 'error', error: 'API key is invalid' },
{ status: 'error', error: 'plugin exploded' },
])
})
it('treats a scrubbed durable-fixture tool catalog as unavailable', () => {
const snapshot = inspectRequests(entriesOf([
at(0, 'step/start', { turn: 1, step: 1 }),
at(1, 'request/header', {
reason: 'initial',
header: {
config: { provider: 'fake', model: 'model' },
tools: '{{tools}}',
},
}),
at(2, 'tool/call', {
turn: 1,
step: 1,
callId: 'call-1',
name: 'read',
arguments: '{}',
}),
]))
expect(snapshot.callSchemas).toEqual(new Map())
const [request] = snapshot.requests
expect(request?.purpose === 'assistant' ? request.prompt?.tools : undefined).toEqual([])
})
})

View File

@@ -6,14 +6,14 @@
* and a subject-less root dispatch stays unfiltered. Scope-owned listeners
* dispose with the fiber.
*/
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { createScope, scopeOf } from '../src/client/agents/scope.ts'
const sid = (k: string): SessionId => k as SessionId
declare module 'cordis' {
declare module '@deepseek-ai/cordis' {
interface Events {
/**
* Test-only routed probe event.

View File

@@ -1,180 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { SessionHistorySource } from '../src/client/session-history/source.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
import { entries, ev, plainTurn } from './event-script.ts'
const SID = 'history-s1' as SessionId
afterEach(() => {
vi.unstubAllGlobals()
})
function histResponse(events: SessionEvent[], hasMore = false) {
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
}
describe('SessionHistorySource', () => {
it('loads the tail first and prepends older pages on demand', async () => {
const pages = [
plainTurn(0, 0, '最早问', '最早答'),
plainTurn(6, 1, '中间问', '中间答'),
plainTurn(12, 2, '最新问', '最新答'),
]
const api = new FakeApiClient()
api.onHistory = (payload) => {
if (payload.beforeSeq === undefined) return histResponse(pages[2]!, true)
if (payload.beforeSeq === 12) return histResponse(pages[1]!, true)
return histResponse(pages[0]!, false)
}
const source = new SessionHistorySource(SID, api)
await source.loadTail()
expect(api.callsOf('session.history')).toHaveLength(1)
expect(source.getSnapshot().hasMore).toBe(true)
expect(source.getSnapshot().baseSeq).toBe(12)
expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
.toEqual([13, 15])
expect(await source.loadOlder()).toBe(true)
expect(await source.loadOlder()).toBe(true)
expect(await source.loadOlder()).toBe(false)
expect(api.callsOf('session.history')).toHaveLength(3)
expect(source.getSnapshot().hasMore).toBe(false)
expect(source.getSnapshot().baseSeq).toBe(0)
expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
.toEqual([1, 3, 7, 9, 13, 15])
})
it('pins a lazy inspection to the entries in its source snapshot', async () => {
const api = new FakeApiClient()
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
const source = new SessionHistorySource(SID, api)
await source.loadTail()
const before = source.getSnapshot()
source.handleMuxFrame({
type: 'session/event',
sessionId: SID,
event: ev.user(6, 'later'),
})
expect(before.inspection.eventNodes.map(node => node.seq)).toEqual([1, 3])
expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
.toEqual([1, 3, 6])
})
it('publishes multiple assistant chunks once per browser frame', async () => {
const api = new FakeApiClient()
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
const source = new SessionHistorySource(SID, api)
await source.loadTail()
const frames: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frames.push(callback)
return frames.length
})
let notifications = 0
const unsubscribe = source.subscribe(() => { notifications++ })
const before = source.getSnapshot().inspection
const finalizedNodes = before.eventNodes
const requests = before.requests
const contexts = before.contexts
for (const event of [
ev.chunkStart(6, 1),
ev.chunkText(7, 1, 'stream '),
ev.chunkText(8, 1, 'content'),
]) {
source.handleMuxFrame({
type: 'session/event',
sessionId: SID,
event,
})
}
expect(frames).toHaveLength(1)
expect(notifications).toBe(0)
frames[0]?.(0)
await Promise.resolve()
expect(notifications).toBe(1)
const streamed = source.getSnapshot().inspection
expect(streamed.eventNodes).toBe(finalizedNodes)
expect(streamed.requests).toBe(requests)
expect(streamed.contexts).toBe(contexts)
expect(streamed.partial?.blocks).toEqual([
{ kind: 'text', text: 'stream content' },
])
source.handleMuxFrame({
type: 'session/event',
sessionId: SID,
event: ev.chunkText(9, 1, ' then final'),
})
source.handleMuxFrame({
type: 'session/event',
sessionId: SID,
event: ev.assistant(10, 1, 'stream content then final'),
})
await Promise.resolve()
expect(notifications).toBe(2)
const finalized = source.getSnapshot().inspection
expect(finalized.eventNodes).not.toBe(finalizedNodes)
expect(finalized.partial).toBeNull()
frames[1]?.(0)
await Promise.resolve()
expect(notifications).toBe(2)
unsubscribe()
})
it('stops loading when an older page fails to advance', async () => {
const api = new FakeApiClient()
api.onHistory = payload => payload.beforeSeq === undefined
? histResponse(plainTurn(6, 1, '新问', '新答'), true)
: Promise.resolve(err({
code: 'internal',
message: 'page unavailable',
details: {},
}))
const source = new SessionHistorySource(SID, api)
await source.loadTail()
expect(await source.loadOlder()).toBe(false)
expect(api.callsOf('session.history')).toHaveLength(2)
expect(source.getSnapshot().hasMore).toBe(true)
})
it('finishes an already started older page after consumer cancellation', async () => {
const middle = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
const olderStarted = deferred<undefined>()
const api = new FakeApiClient()
api.onHistory = (payload) => {
if (payload.beforeSeq === undefined) {
return histResponse(plainTurn(12, 2, '最新问', '最新答'), true)
}
olderStarted.resolve(undefined)
return middle.promise
}
const source = new SessionHistorySource(SID, api)
const controller = new AbortController()
await source.loadTail(controller.signal)
const complete = source.loadOlder(controller.signal)
await olderStarted.promise
controller.abort()
middle.resolve(ok({
events: entries(plainTurn(6, 1, '中间问', '中间答')) as never[],
hasMore: true,
}))
expect(await complete).toBe(true)
expect(api.callsOf('session.history')).toHaveLength(2)
expect(source.getSnapshot().hasMore).toBe(true)
})
})

View File

@@ -123,12 +123,13 @@ function testViewDefinition(): ConversationViewDefinition<ChatConversationViewNo
const TEST_EVENT_DEFINITION: ConversationNodeDefinition<TestEventState> = {
kind: 'runtime-test-event',
target: 'chat',
match: event => ({ id: String(event.seq), role: 'start' }),
start: (_context, match) => ({ event: match.event, view: match.view }),
update: context => context.state,
publication: match => match.event.type === 'assistant/chunk' ? 'animation-frame' : 'immediate',
buildViewNode: (context, target) => {
if (target !== 'chat' || context.state === undefined || context.start === undefined) return null
buildViewNode: (context) => {
if (context.state === undefined || context.start === undefined) return null
return {
key: context.key,
kind: 'runtime-test-event',

View File

@@ -6,7 +6,7 @@
* deferral — the stage follows list.current), binding identity, breadcrumb
* projection, create.
*/
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { SessionCreateError, SessionsService, scopeOf } from '../src/client/sessions/service.ts'

View File

@@ -1,5 +1,5 @@
import { Context } from 'cordis'
import z from 'schemastery'
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { describe, expect, it, vi } from 'vitest'
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
import {

View File

@@ -5,7 +5,7 @@
* contract (double install / not installed / non-root key), store instance
* resolution and lifecycle on the ledger axis, and the entry-unload cascade.
*/
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import type { FC } from 'react'
import type { SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'

View File

@@ -4,7 +4,7 @@
* ctx 'session/preset-changed'; each established connection generation →
* ctx 'connection/reset' (the forced cache-invalidation broadcast).
*/
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'

View File

@@ -1,4 +1,4 @@
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
import { SessionsService } from '../src/client/sessions/service.ts'

View File

@@ -1,8 +1,15 @@
{
"name": "@deepseek-ai/dsh-client-schema-form",
"description": "Schema/draft model layer for settings editors: rehydrates a serialized schemastery schema, validates drafts, and edits them immutably by path",
"version": "0.0.1",
"private": true,
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/client/schema-form"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
@@ -20,15 +27,15 @@
},
"license": "BSD-3-Clause",
"dependencies": {
"schemastery": "^3.18.0"
"@deepseek-ai/schemastery": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
"@deepseek-ai/cordis": "workspace:^"
},
"files": [
"lib/index.js",

View File

@@ -4,7 +4,7 @@
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-schema-form'

View File

@@ -6,7 +6,7 @@
* @module @deepseek-ai/dsh-client-schema-form/model
*/
import Schema from 'schemastery'
import Schema from '@deepseek-ai/schemastery'
/** Live schemastery node; the renderer reads only its structural relations. */
export type SchemaNode = Schema

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import * as SchemaFormInvariant from '@deepseek-ai/dsh-client-schema-form/invariant'
import InvariantService from '@deepseek-ai/dsh-invariants'

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import Schema from 'schemastery'
import Schema from '@deepseek-ai/schemastery'
import {
deletePath, getPath, hasPath, nodeAtPath, rehydrateSchema, setPath, validateDraft,
} from '../src/model.ts'

View File

@@ -1,8 +1,15 @@
{
"name": "@deepseek-ai/dsh-client-test-runtime",
"description": "jsdom slot test runtime: real Cordis Context + SlotsService + web-react renderer with test-owned session/workspace doubles for feature specs",
"version": "0.0.1",
"private": true,
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/client/test-runtime"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
@@ -25,12 +32,12 @@
"vitest": "^4.1.8"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
"@deepseek-ai/dsh-host-apiproxy": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
@@ -42,7 +49,7 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",
"cordis": "^4.0.0-rc.7",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},

View File

@@ -2,7 +2,9 @@
import type {
ConversationSnapshot, ISession, SessionId, SessionSummary, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client'
import {
EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS,
} from '@deepseek-ai/dsh-client-runtime/client'
/**
* Fixture overrides for the session behavior face: any subset of the
@@ -46,6 +48,7 @@ export interface SessionFixture {
export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot {
return {
sessionId,
views: EMPTY_CONVERSATION_VIEWS,
chat: EMPTY_CHAT_SNAPSHOT,
nodes: [],
turnTimings: new Map(),

View File

@@ -14,8 +14,8 @@
* `keyof SlotMap & string` is the declare-merge key pattern (see ui-slots):
* this compilation unit sees only the runtime's 'root' row, but consumer
* programs merge their own keys in; the rule fires on the narrow-map view. */
import { Context, Inject } from 'cordis'
import type { Fiber, Plugin } from 'cordis'
import { Context, Inject } from '@deepseek-ai/cordis'
import type { Fiber, Plugin } from '@deepseek-ai/cordis'
import { createElement, Fragment, useSyncExternalStore } from 'react'
import type { ReactNode } from 'react'
import { act, render, within } from '@testing-library/react'

View File

@@ -4,7 +4,7 @@
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-test-runtime'

View File

@@ -1,5 +1,5 @@
/** Test-owned sessions face: the SlotsService host contract over declarative fixtures. */
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { AttachmentIdType } from '@deepseek-ai/dsh-attachment'
import { createScope, scopeOf, SessionProvideChannel } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
@@ -202,7 +202,7 @@ export class TestSessions implements ISessions {
constructor(private readonly stabilize: Stabilizer, private readonly rootCtx: Context) {
this.list = createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, currentAddress: undefined,
subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
})
this.channel = new SessionProvideChannel({
rebuildBundles: () => {

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import * as TestRuntimeInvariant from '@deepseek-ai/dsh-client-test-runtime/invariant'
import InvariantService from '@deepseek-ai/dsh-invariants'

View File

@@ -32,6 +32,14 @@ const CSS_VIRTUAL_SUFFIX = '.mjs'
*/
export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/
/**
* Vendored framework libraries: rescoped into @deepseek-ai, so the gate below
* would read them as plugin packages. They carry no cross-plugin runtime
* identity to share — the framework itself is a platform module (external),
* while these are ordinary libraries a browser bundle inlines.
*/
const VENDORED_LIBRARY = /^@deepseek-ai\/(cosmokit|schemastery)(\/|$)/
/** Generated descriptor/codec contribution with no shared runtime identity. */
const GENERATED_REMOTE = /^@deepseek-ai\/dsh-[a-z0-9]+(?:-[a-z0-9]+)*\/remote$/
@@ -208,6 +216,7 @@ function clientConfig(id: string, entry: string): UserConfig {
resolveId(source: string) {
if (!source.startsWith('@deepseek-ai/')) return null
if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins
if (VENDORED_LIBRARY.test(source)) return null // vendored library: inline, no shared identity
if (INLINE_SAFE.test(source) || GENERATED_REMOTE.test(source)) return null // wire contribution: inline is the point
throw new Error(
`client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS), an inline-safe wire layer, or a generated /remote contribution — `

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-agent-preset/README.md
README.md: 008066114e9c49e5c74299979e24c27a4c9621c9
README.zh.md: e07d5994ae196cd03be7818fe4ade1aafda9aa55
README.md: 3b0db5a3eedca256a00b65a3bd2738f22c0eb62e
README.zh.md: 6f3c350f973119c201572f2c03145338b5cc5b00

View File

@@ -36,6 +36,8 @@ A fourth surface, its own settings page (`settings.section` id `agent-presets`,
The browser edits no composition text. Editing YAML in a web textarea was a weak surface (no completion, no highlighting, no diff), so a new preset is a host-side copy of an existing one — the dialog collects an id (it becomes the directory name, which is why it must be named up front and cannot change later) and an optional display name, and `{ from, id, name? }` is all that crosses the wire. Everything else — description, composition, skills — is edited in the preset's own files, and the page's other job is getting the user TO those files: the copy completes by opening the new directory, and every custom row keeps a location action. Where the host has no desktop opener (`hasDocument: false` on the roster; remote and container deployments), the same actions answer the directory as text on the row instead of offering a button that would spawn into nothing.
A preset publishes its own description, of any length, and the grid sizes every card row alike — so an unbounded description would set the height of the whole roster. Cards clamp it to four lines and offer the rest in a tooltip, attached only while the text is actually cut off. The clamp is CSS, so the whole description stays in the accessibility tree whatever the card shows.
A shipped preset opens in the read-only viewer. It is the known-good composition a copy starts from, so reading it is the point; it offers no location and no delete — its install is overwritten by upgrades and is not the user's to manage. The intro carries the guidance a create button used to imply: duplicate an existing preset and make it yours, or let the agent draft one in Creator mode.
Beside copying sits the conversational entry: when the roster carries the self-referential `cordis` preset, a dashed add-card (the Models page's affordance) stages it and starts a new session — the section closes the settings panel through the shell's owner-prop `close` and the new-session chip's own applier composes the blank session the workspace flow produces. The seat keeps a late roster load from regressing the display: staged pick first, then the composition the current session already carries, then the deployment default.
@@ -44,7 +46,7 @@ The dialog mirrors the host's own containment rule (`[a-z0-9][a-z0-9-]*`) and re
Deleting removes the preset directory. Sessions already composed from it keep running — a composition is mounted once at session creation and nothing re-reads the file.
A roster row carrying `broken` (the host's shape check found the composition missing or unloadable) renders as a marked card: red border, a Broken badge, the reason verbatim, the body disabled — it cannot become the default — and duplication disabled, since a copy of a broken preset is another broken preset. A broken custom row keeps its location and delete actions, because the files are where it gets fixed and deleting is how a ghost directory (composition deleted by hand, directory still blocking the id) is cleared; a broken shipped row withholds the viewer too — there is no readable composition to show. The two pickers (the General row and the new-session chip) drop broken presets entirely: they choose the NEXT session's composition, and offering one that cannot compose would only defer the failure to the session start.
A roster row carrying `broken` (the host's shape check found the composition missing or unloadable) renders as a marked card: red border, a "Failed to load" badge (what discovery observed, not a claim that the files are damaged — the usual cause is a composition the user just edited or deleted), the reason verbatim, the body disabled — it cannot become the default — and duplication disabled, since a copy of a broken preset is another broken preset. A broken custom row keeps its location and delete actions, because the files are where it gets fixed and deleting is how a ghost directory (composition deleted by hand, directory still blocking the id) is cleared; a broken shipped row withholds the viewer too — there is no readable composition to show. The two pickers (the General row and the new-session chip) drop broken presets entirely: they choose the NEXT session's composition, and offering one that cannot compose would only defer the failure to the session start.
Setting the default writes the `agent-presets` settings namespace, which the host exposes to configuration clients ([`dsh-apiproxy`](../../host/apiproxy/README.md) keeps an explicit allowlist — a namespace outside it makes a picker move and then silently forget).

View File

@@ -36,6 +36,8 @@ preset 文件提供一套未国际化的 `name` 与 `description`Web 将其
浏览器不再编辑任何组装文本。在网页文本域里编 YAML 是弱功能(无补全、无高亮、无 diff因此新 preset 是宿主端对既有 preset 的一次复制——对话框只收集一个 id它将成为目录名所以必须当场取好、事后无法更改与一个可选显示名跨越传输层的只有 `{ from, id, name? }`。其余一切——描述、组装、skills——都在 preset 自己的文件里编辑,而本页的另一职责正是把用户送到那些文件面前:复制以打开新目录作为收尾,每张自定义卡片也保有一个位置操作。宿主没有桌面打开器时(名单上的 `hasDocument: false`;远程与容器部署),同样的操作改为把目录以文本显示在卡片上,而不是提供一个点了没反应的按钮。
preset 自行发布描述,长度不限,而网格让每一行卡片等高——因此不加约束的描述会决定整份名单的高度。卡片把描述截断为四行,其余内容由 tooltip 承载,且仅在文本确实被裁切时才挂载。截断由 CSS 完成,因此无论卡片显示多少,完整描述始终留在无障碍树中。
随附 preset 在只读查看器中打开。它是副本据以出发的已知良好组装,因此能读到它正是意义所在;它不提供位置也不提供删除——它的安装目录会被升级覆盖,不归用户管理。开篇引导语承担了从前创建按钮所暗示的信息:复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。
复制旁边是对话式入口:名单携带自指的 `cordis` preset 时,一张虚线添加卡(模型页的同款样式)会暂存它并开启新会话——分区经外壳的 owner-prop `close` 关闭设置面板,新会话 chip 自己的应用器负责组装工作区流程产出的空白会话。seat 会防止晚到的名单加载回退显示:暂存选择优先,其次是当前会话已携带的组装,最后才是部署默认值。
@@ -44,7 +46,7 @@ preset 文件提供一套未国际化的 `name` 与 `description`Web 将其
删除会移除整个 preset 目录。已据其组装的会话继续运行——组装在会话创建时挂载一次,此后没有任何东西会重新读取该文件。
名单行携带 `broken`(宿主的形状检查发现组装缺失或不可加载)时渲染为标记卡片:红色边框、「已损坏」徽记、原样展示的原因、卡片主体禁用——它不能成为默认——复制也禁用,因为损坏 preset 的副本只是又一个损坏的 preset。损坏的自定义行保留位置与删除动作文件正是修复它的地方而删除正是清掉幽灵目录组装文件被手动删除、目录仍占着 id的方式损坏的内置行连查看器也不提供——没有可读的组装可展示。两个选择器通用设置行与新会话 chip则完全不列出损坏的 preset它们选的是下一个会话的组装列出无法组装的选项只会把失败推迟到会话启动。
名单行携带 `broken`(宿主的形状检查发现组装缺失或不可加载)时渲染为标记卡片:红色边框、「加载失败」徽记discovery 观察到的事实,而非断言文件已损坏——常见起因是用户刚编辑或删除了组装文件)、原样展示的原因、卡片主体禁用——它不能成为默认——复制也禁用,因为损坏 preset 的副本只是又一个损坏的 preset。损坏的自定义行保留位置与删除动作文件正是修复它的地方而删除正是清掉幽灵目录组装文件被手动删除、目录仍占着 id的方式损坏的内置行连查看器也不提供——没有可读的组装可展示。两个选择器通用设置行与新会话 chip则完全不列出损坏的 preset它们选的是下一个会话的组装列出无法组装的选项只会把失败推迟到会话启动。
设置默认值写入的是 `agent-presets` settings 命名空间,宿主需将其暴露给配置客户端([`dsh-apiproxy`](../../host/apiproxy/README.md) 维护一份显式白名单——不在其中的命名空间会让选择器动一下然后悄悄忘记)。

View File

@@ -1,8 +1,15 @@
{
"name": "@deepseek-ai/dsh-client-ui-agent-preset",
"description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor",
"version": "0.0.1",
"private": true,
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/client/ui-agent-preset"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
@@ -40,16 +47,16 @@
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-client-connection": "^0.0.1",
"@deepseek-ai/dsh-client-locale": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-settings": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"devDependencies": {
@@ -64,7 +71,7 @@
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"files": [

View File

@@ -5,7 +5,7 @@
align-items: center;
gap: 4px;
max-width: 180px;
padding: 0 8px;
padding: 0 2px 0 0;
height: 22px;
border-radius: 6px;
background: var(--dsw-alias-fill-tsp-secondary);

View File

@@ -11,7 +11,7 @@
import { useEffect } from 'react'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconAgentPresetOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
// Type-only: pulls the ui-conversation SlotMap merge (the header actions).
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { AgentPresetSettingsState } from './settings-store.ts'
@@ -57,7 +57,7 @@ export function AgentPresetLabel({
const text = option === undefined ? undefined : presetDisplayText(option, t)
return (
<span className={css.label} title={text?.description ?? t('headerHint')}>
<IconThinkOutline16 className={css.icon} />
<IconAgentPresetOutline16 size={14} className={css.icon} />
{text?.name ?? preset}
</span>
)

View File

@@ -9,7 +9,7 @@
min-height: 28px;
padding: 0 8px;
border: none;
border-radius: 12px;
border-radius: 16px;
background: transparent;
color: var(--dsw-alias-label-primary);
font-size: 13px;
@@ -36,6 +36,61 @@
color: var(--dsw-alias-label-primary);
}
/* Introduce cue: the icon eases in on an overshoot-free expo curve (duration
matches INTRO_TEXT_DELAY_MS, so the characters start the moment it lands),
then the name's characters fade up on a stagger (delays set inline per
character). All chars occupy their width from the start, so nothing
reflows mid-run. */
.introIcon {
animation: seat-icon-in 0.15s cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes seat-icon-in {
from {
opacity: 0;
transform: scale(0.5);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Wraps the staggered characters into one flex item, so the chip's gap
applies around the name as a whole rather than between characters. */
.introText {
display: inline-block;
white-space: pre;
}
.introChar {
display: inline-block;
white-space: pre;
opacity: 0;
animation: seat-char-in 0.4s ease-out forwards;
}
@keyframes seat-char-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.introIcon,
.introChar {
animation: none;
opacity: 1;
}
}
.chevron {
flex: none;
color: var(--dsw-alias-label-caption);

View File

@@ -15,7 +15,7 @@
import { useEffect, useState } from 'react'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14, IconThinkOutline16, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconAgentPresetOutline16, IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
// Type-only: pulls the ui-conversation SlotMap merge (the hero seat).
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { AgentPresetSeatState } from './seat-store.ts'
@@ -32,6 +32,29 @@ export interface AgentPresetSeatInjected {
load: () => Promise<void>
/** Stage one preset for the next session. */
select: (id: string) => Promise<void>
/** Clear the one-shot introduce cue once the chip has played it. */
introduced: () => void
}
/* Introduce timeline: the icon eases in first (the CSS animation shares this
duration); the name's characters start fading up the moment it lands, each
taking the fade duration to settle. The cue clears after the last one. The
stagger is capped twice: per tick for short CJK names, and by one shared
reveal window so a long Latin name finishes in the same time as its CJK
counterpart instead of dragging the run out per character. */
const INTRO_TEXT_DELAY_MS = 150
const INTRO_CHAR_STAGGER_MS = 40
const INTRO_TEXT_REVEAL_MS = 200
const INTRO_CHAR_FADE_MS = 400
/**
* Per-character start offset for the introduce reveal.
* @param count - character count of the shown preset name.
* @returns milliseconds between successive character starts.
*/
function introStaggerMs(count: number): number {
if (count <= 1) return 0
return Math.min(INTRO_CHAR_STAGGER_MS, INTRO_TEXT_REVEAL_MS / (count - 1))
}
/** Full component props. */
@@ -45,7 +68,7 @@ export type AgentPresetSeatProps =
* @param props - composed slot props.
* @returns the chip, or null when the deployment composes no presets.
*/
export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPresetSeatProps) {
export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat, t }: AgentPresetSeatProps) {
const state = useAgentPresetSeat(snapshot => snapshot)
const [open, setOpen] = useState(false)
@@ -53,12 +76,54 @@ export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPr
void load()
}, [load])
// Nothing to choose between: the deployment composes no presets and every
// session shares the host composition.
if (state.options.length === 0 || state.current === '') return null
const chosen = state.options.find(option => option.id === state.current)
const chosenText = chosen === undefined ? undefined : presetDisplayText(chosen, t)
const label = chosenText?.name ?? state.current
const ready = state.options.length > 0 && state.current !== ''
// The introduce cue: the pick was staged from another screen (the settings
// creator entry), so the chip announces it — the icon eases in and each
// character of the name fades up on a stagger (CSS owns the motion; this
// effect only arms it and acknowledges the cue once the run is over).
const [introducing, setIntroducing] = useState(false)
useEffect(() => {
if (!state.introduce || !ready) return
const characters = Array.from(label)
if (characters.length === 0 || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
introduced()
return
}
setIntroducing(true)
const done = window.setTimeout(() => {
setIntroducing(false)
introduced()
}, INTRO_TEXT_DELAY_MS + (characters.length - 1) * introStaggerMs(characters.length) + INTRO_CHAR_FADE_MS)
return () => { window.clearTimeout(done) }
}, [state.introduce, ready, label, introduced])
// Nothing to choose between: the deployment composes no presets and every
// session shares the host composition.
if (!ready) return null
// One wrapper span: the chip is a flex row with a gap, so loose character
// spans would each pick up the gap between them.
const characters = Array.from(label)
const stagger = introStaggerMs(characters.length)
const shownLabel = introducing
? (
<span className={css.introText}>
{characters.map((character, index) => (
<span
key={index}
className={css.introChar}
style={{ animationDelay: `${INTRO_TEXT_DELAY_MS + index * stagger}ms` }}
>
{character}
</span>
))}
</span>
)
: label
return (
<Menu
@@ -95,8 +160,8 @@ export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPr
disabled={state.busy}
onClick={() => { setOpen(value => !value) }}
>
<IconThinkOutline16 className={css.seatIcon} />
{chosenText?.name ?? state.current}
<IconAgentPresetOutline16 className={introducing ? `${css.seatIcon} ${css.introIcon}` : css.seatIcon} />
{shownLabel}
<IconChevronDownOutline14 className={css.chevron} />
</button>
)}

View File

@@ -26,6 +26,12 @@
gap: 10px;
}
/* Group-to-group breathing room: the section's 12px gap plus 20px reads the
two rosters as separate blocks (32px total). */
.group + .group {
margin-top: 20px;
}
.groupHead {
margin: 0;
font-size: 12px;
@@ -155,15 +161,28 @@
color: var(--dsw-alias-bg-layer-3);
}
/* Bounded to four lines. A preset publishes its own description, so one long
one would otherwise stretch every card in its grid row (`.cards` sizes rows
1fr). Clamping is CSS alone: the whole text stays in the DOM for assistive
tech, and the card offers it on hover when it is actually cut off. The
description does not grow to fill the card — `-webkit-line-clamp` on a
flex-stretched box leaves the clamp height and the box height disagreeing,
so `.cardId` takes the free space with an auto margin instead. */
.cardDesc {
font-size: 13px;
line-height: 1.55;
color: var(--dsw-alias-label-secondary);
flex: 1;
min-height: 42px;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
overflow: hidden;
/* A user-authored description may carry an unbreakable path or URL. */
overflow-wrap: anywhere;
}
.cardId {
margin-top: auto;
font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 11px;
color: var(--dsw-alias-label-dimmed);
@@ -363,6 +382,7 @@
create button vacated. Dashed like the Models page's add affordances: it
reads as a place a preset will appear, not a command. */
.creatorButton {
box-sizing: border-box;
align-self: stretch;
display: flex;
align-items: center;
@@ -372,17 +392,18 @@
border: 1px dashed var(--dsw-alias-border-l3);
border-radius: 12px;
font: inherit;
font-size: 13px;
background: none;
color: inherit;
font-size: 14px;
line-height: 22px;
background: transparent;
color: var(--dsw-alias-label-primary);
cursor: pointer;
}
.creatorButton:hover:not(:disabled) {
background: var(--dsw-alias-bg-layer-1);
background: var(--dsw-alias-interactive-bg-hover);
}
.creatorButton:disabled {
opacity: 0.5;
opacity: 0.4;
cursor: default;
}

View File

@@ -10,10 +10,10 @@
* mounted once at session creation and nothing re-reads the file.
*/
import { useEffect } from 'react'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import {
Button, IconBrowseOutline16, IconCopyOutline16, IconFolderOpenOutline16, IconPlusOutline16, IconTrashOutline16, Modal,
Button, IconBrowseOutline16, IconCopyOutline16, IconFolderOpenOutline16, IconPlusOutline16, IconTrashOutline16, Modal, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
@@ -137,6 +137,39 @@ function CopyDialog({ state, t, actions }: CopyDialogProps): ReactNode {
)
}
/**
* Render one card's description, clamped by CSS and offered in full on hover.
* The tooltip is attached only while the text is actually cut off, so a short
* description does not answer a hover with a bubble repeating the card.
* @param props.text - the description as rendered, already localized.
* @returns the description element, tooltip-anchored while it overflows.
*/
function CardDescription({ text }: { text: string }): ReactNode {
const ref = useRef<HTMLSpanElement | null>(null)
const [truncated, setTruncated] = useState(false)
useLayoutEffect(() => {
const el = ref.current
/* v8 ignore next -- the ref is attached before layout effects run. */
if (el === null) return
const measure = () => { setTruncated(el.scrollHeight > el.clientHeight) }
measure()
// Card width follows the settings pane, which resizes with the window.
if (typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(measure)
observer.observe(el)
return () => { observer.disconnect() }
}, [text])
return (
// Capped near the card's own width: the default half-viewport bubble would
// spill a description out of the settings dialog and across the app behind it.
<Tooltip label={text} side="bottom" delayMs={400} disabled={!truncated} maxWidth={360}>
{/* The empty title stops the card body's native tooltip from climbing to
this span: a cut-off description answers with one bubble, not two. */}
<span ref={ref} className={css.cardDesc} title="">{text}</span>
</Tooltip>
)
}
/**
* Render the Agent presets section content column.
* @param props - composed slot props.
@@ -171,6 +204,30 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
)
}
/* The guided alternative to copying: the self-referential preset can
read this very composition and author a new one in conversation.
Offered only where that preset is actually on the roster and a
session can be landed; without a writable root the draft could
never be discovered, so the reason rides the disabled button. */
const creatorButton = props.startCreatorDraft !== undefined && state.rows.some(row => row.id === 'cordis')
? (
<button
type="button"
className={css.creatorButton}
disabled={!state.authorable}
title={state.authorable ? undefined : t('duplicateUnavailable')}
onClick={() => {
props.startCreatorDraft?.()
props.close()
}}
>
{/* Same glyph as the Models page's add affordances. */}
<IconPlusOutline16 size={14} />
{t('creatorDraft')}
</button>
)
: null
return (
<div className={css.section}>
<h2 className={css.title}>{t('nav')}</h2>
@@ -180,147 +237,130 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
const group = state.rows
.filter(row => row.trust === trust)
.map(row => ({ row, text: presetDisplayText(row, t) }))
if (group.length === 0) return null
// The custom group is where a preset of one's own will appear, so it
// stays on screen even while empty: heading plus the creator entry.
const tail = trust === 'user' ? creatorButton : null
if (group.length === 0 && tail === null) return null
return (
<section key={trust} className={css.group}>
<h3 className={css.groupHead}>{heading}</h3>
<ul className={css.cards}>
{group.map(({ row, text }) => (
<li
key={row.id}
className={row.broken !== undefined
? `${css.card} ${css.cardBroken}`
: row.isDefault ? `${css.card} ${css.cardActive}` : css.card}
>
{/* The card body IS the control: picking a preset is the
{group.length === 0 ? null : (
<ul className={css.cards}>
{group.map(({ row, text }) => (
<li
key={row.id}
className={row.broken !== undefined
? `${css.card} ${css.cardBroken}`
: row.isDefault ? `${css.card} ${css.cardActive}` : css.card}
>
{/* The card body IS the control: picking a preset is the
common act, so it should not hide behind a small button.
The action row sits outside it — nesting buttons is
invalid, and these act on the card rather than select it.
A broken preset cannot compose a session, so its body is
disabled and the card says why instead of offering it. */}
<button
type="button"
className={css.cardMain}
aria-pressed={row.isDefault}
disabled={row.isDefault || row.broken !== undefined}
// Without this the name is the whole card read aloud —
// title, badge, description, id.
aria-label={`${row.broken !== undefined ? t('brokenBadge') : row.isDefault ? t('inUse') : t('setDefault')}: ${text.name}`}
title={row.broken ?? (row.isDefault ? t('inUse') : t('setDefault'))}
onClick={() => { void props.makeDefault(row.id) }}
>
<span className={css.cardHead}>
<span className={css.cardName}>{text.name}</span>
{row.broken !== undefined
? <span className={css.brokenBadge}>{t('brokenBadge')}</span>
: null}
<span className={css.badge}>
{row.trust === 'user' ? t('userTrust') : t('builtIn')}
<button
type="button"
className={css.cardMain}
aria-pressed={row.isDefault}
disabled={row.isDefault || row.broken !== undefined}
// Without this the name is the whole card read aloud —
// title, badge, description, id.
aria-label={`${row.broken !== undefined ? t('brokenBadge') : row.isDefault ? t('inUse') : t('setDefault')}: ${text.name}`}
title={row.broken ?? (row.isDefault ? t('inUse') : t('setDefault'))}
onClick={() => { void props.makeDefault(row.id) }}
>
<span className={css.cardHead}>
<span className={css.cardName}>{text.name}</span>
{row.broken !== undefined
? <span className={css.brokenBadge}>{t('brokenBadge')}</span>
: null}
<span className={css.badge}>
{row.trust === 'user' ? t('userTrust') : t('builtIn')}
</span>
{row.isDefault ? <span className={css.inUse}>{t('inUse')}</span> : null}
</span>
{row.isDefault ? <span className={css.inUse}>{t('inUse')}</span> : null}
</span>
<span className={css.cardDesc}>{text.description ?? t('noDescription')}</span>
{row.broken === undefined
? null
: <span className={css.cardBrokenReason} role="alert">{row.broken}</span>}
<code className={css.cardId}>{row.id}</code>
</button>
<div className={css.cardFoot}>
{/* Shipped presets are the compositions a copy starts
<CardDescription text={text.description ?? t('noDescription')} />
{row.broken === undefined
? null
: <span className={css.cardBrokenReason} role="alert">{row.broken}</span>}
<code className={css.cardId}>{row.id}</code>
</button>
<div className={css.cardFoot}>
{/* Shipped presets are the compositions a copy starts
from, so READING one is the point; a custom preset is
edited in its files instead, which the location action
leads to. A broken shipped preset has no readable
composition to offer, so its viewer is withheld; a
broken custom one keeps the location action — the
files are where it gets fixed. */}
{row.trust === 'system'
? row.broken === undefined
? (
{row.trust === 'system'
? row.broken === undefined
? (
<button
type="button"
className={css.iconButton}
data-tip={t('view')}
aria-label={`${t('view')}: ${text.name}`}
onClick={() => { void props.view(row.id) }}
>
<IconBrowseOutline16 />
</button>
)
: null
: (
<button
type="button"
className={css.iconButton}
data-tip={t('view')}
aria-label={`${t('view')}: ${text.name}`}
onClick={() => { void props.view(row.id) }}
data-tip={state.hasDocument ? t('openLocation') : t('showLocation')}
aria-label={`${state.hasDocument ? t('openLocation') : t('showLocation')}: ${text.name}`}
onClick={() => { void props.openLocation(row.id) }}
>
<IconBrowseOutline16 />
<IconFolderOpenOutline16 />
</button>
)}
<button
type="button"
className={css.iconButton}
disabled={!state.authorable || row.broken !== undefined}
data-tip={row.broken !== undefined
? t('brokenNoCopy')
: state.authorable ? t('duplicate') : t('duplicateUnavailable')}
aria-label={`${t('duplicate')}: ${text.name}`}
onClick={() => { props.beginCopy(row.id) }}
>
<IconCopyOutline16 />
</button>
{row.trust === 'user'
? (
<button
type="button"
className={`${css.iconButton} ${css.iconDanger}`}
data-tip={t('delete')}
aria-label={`${t('delete')}: ${text.name}`}
onClick={() => { props.confirmDelete(row.id) }}
>
<IconTrashOutline16 />
</button>
)
: null
: null}
</div>
{state.revealedPaths[row.id] === undefined
? null
: (
<button
type="button"
className={css.iconButton}
data-tip={state.hasDocument ? t('openLocation') : t('showLocation')}
aria-label={`${state.hasDocument ? t('openLocation') : t('showLocation')}: ${text.name}`}
onClick={() => { void props.openLocation(row.id) }}
>
<IconFolderOpenOutline16 />
</button>
<p className={css.revealedPath}>
<span className={css.revealedPathLabel}>{t('revealedPathLabel')}</span>
<code>{state.revealedPaths[row.id]}</code>
</p>
)}
<button
type="button"
className={css.iconButton}
disabled={!state.authorable || row.broken !== undefined}
data-tip={row.broken !== undefined
? t('brokenNoCopy')
: state.authorable ? t('duplicate') : t('duplicateUnavailable')}
aria-label={`${t('duplicate')}: ${text.name}`}
onClick={() => { props.beginCopy(row.id) }}
>
<IconCopyOutline16 />
</button>
{row.trust === 'user'
? (
<button
type="button"
className={`${css.iconButton} ${css.iconDanger}`}
data-tip={t('delete')}
aria-label={`${t('delete')}: ${text.name}`}
onClick={() => { props.confirmDelete(row.id) }}
>
<IconTrashOutline16 />
</button>
)
: null}
</div>
{state.revealedPaths[row.id] === undefined
? null
: (
<p className={css.revealedPath}>
<span className={css.revealedPathLabel}>{t('revealedPathLabel')}</span>
<code>{state.revealedPaths[row.id]}</code>
</p>
)}
</li>
))}
</ul>
</li>
))}
</ul>
)}
{tail}
</section>
)
})}
{/* The guided alternative to copying: the self-referential preset can
read this very composition and author a new one in conversation.
Offered only where that preset is actually on the roster and a
session can be landed; without a writable root the draft could
never be discovered, so the reason rides the disabled button. */}
{props.startCreatorDraft !== undefined && state.rows.some(row => row.id === 'cordis')
? (
<button
type="button"
className={css.creatorButton}
disabled={!state.authorable}
title={state.authorable ? undefined : t('duplicateUnavailable')}
onClick={() => {
props.startCreatorDraft?.()
props.close()
}}
>
{/* Same glyph as the Models page's add affordances. */}
<IconPlusOutline16 size={14} />
{t('creatorDraft')}
</button>
)
: null}
<CopyDialog
state={state}
t={t}

View File

@@ -114,6 +114,7 @@ export function apply(ctx: ClientContext): void {
hooks: { agentPresetSeat: seat.store },
load: () => seat.load(),
select: (id: string) => seat.select(id),
introduced: () => { seat.introduced() },
})
const labelInjected = (): AgentPresetLabelInjected => ({
@@ -146,7 +147,9 @@ export function apply(ctx: ClientContext): void {
// on: the chip's list-change applier composes the blank session the
// workspace connect produces or reuses.
creatorDraft = () => {
seat.stage('cordis')
// The introduce cue makes the chip announce the pick the user never
// made on this screen — the stage happened back in settings.
seat.stage('cordis', true)
scope.workspaces.startSession()
}
const chip = scope.slots.register({

View File

@@ -42,7 +42,7 @@ export const en: Record<AgentPresetSettingsKey, string> = {
'All Standard mode capabilities, with tools exposed through the Code Mode SDK so the model can combine multi-step operations in one TypeScript program.',
presetMinimalName: 'Minimal mode',
presetMinimalDescription:
'Two-tool coding agent with only bash and str_replace_editor, for benchmarks and minimal reproductions.',
'Two-tool coding agent with persistent bash and str_replace_editor.',
presetCordisName: 'Creator mode',
presetCordisDescription:
'Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance.',
@@ -57,8 +57,8 @@ export const en: Record<AgentPresetSettingsKey, string> = {
builtInGroup: 'Built-in',
customGroup: 'Custom',
noDescription: 'No description.',
brokenBadge: 'Broken',
brokenNoCopy: 'Broken presets cannot be duplicated',
brokenBadge: 'Failed to load',
brokenNoCopy: 'A preset that failed to load cannot be duplicated',
copyOf: 'Copied from',
composition: 'Composition (agent.cordis.yml)',
cancel: 'Cancel',
@@ -103,7 +103,7 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
presetCodeName: '代码模式',
presetCodeDescription: '具备标准模式的全部能力,并通过 Code Mode SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。',
presetMinimalName: '极简模式',
presetMinimalDescription: '仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。',
presetMinimalDescription: '仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。',
presetCordisName: '创造模式',
presetCordisDescription: '用于创建自定义 Agent preset具备标准模式的全部能力并提供运行时检查、插件实验和 preset 创作指导。',
duplicate: '复制',
@@ -117,8 +117,8 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
builtInGroup: '内置',
customGroup: '自定义',
noDescription: '暂无描述。',
brokenBadge: '已损坏',
brokenNoCopy: '预设已损坏,无法复制',
brokenBadge: '加载失败',
brokenNoCopy: '预设加载失败,不能复制',
copyOf: '复制自',
composition: '组装agent.cordis.yml',
cancel: '取消',

View File

@@ -26,10 +26,16 @@ export interface AgentPresetSeatState {
/** A rejected apply's message, cleared by the next attempt. */
error: string | null
busy: boolean
/**
* One-shot cue that the chip should introduce itself (the creator-draft
* entry staged the pick from another screen, so the user never touched the
* chip); the renderer clears it via `introduced()` once played.
*/
introduce: boolean
}
const INITIAL: AgentPresetSeatState = {
options: [], current: '', error: null, busy: false,
options: [], current: '', error: null, busy: false, introduce: false,
}
/** One session's identity and whether it has started. */
@@ -121,10 +127,18 @@ export class AgentPresetSeatController {
* list-change applier, which fires when the started session becomes
* current.
* @param id - the preset to stage.
* @param introduce - true when the stage came from another screen and the
* chip should announce itself on the session it lands on.
*/
stage(id: string): void {
stage(id: string, introduce = false): void {
this.staged = id
this.set({ current: id, error: null })
this.set({ current: id, error: null, introduce })
}
/** Acknowledge the introduction cue once the chip has played it. */
introduced(): void {
if (!this.store.getSnapshot().introduce) return
this.set({ introduce: false })
}
/**

View File

@@ -4,7 +4,7 @@
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-agent-preset'

Some files were not shown because too many files have changed in this diff Show More