Merge commit '4ebc88329d1da067edad6032d40688e52e401bf5' into codex/product-subagent-one-shot-background

This commit is contained in:
pku-xht
2026-08-13 16:05:35 +08:00
121 changed files with 1947 additions and 409 deletions

View File

@@ -24,7 +24,7 @@
import { createRequire } from 'node:module'
import {
existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync,
existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync,
} from 'node:fs'
import { basename, dirname, join } from 'node:path'
import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
@@ -182,7 +182,9 @@ function ensureSymlink(link: string, target: string): void {
throw new Error(`dsh: ${link} exists and is not a symlink; remove it so dsh can manage the installation fallback`)
}
if (readlinkSync(link) === target) return
rmSync(link)
// unlink deletes the reparse point itself on Windows too; rmSync treats a
// junction as a directory and throws EISDIR unless recursive.
unlinkSync(link)
}
try {
symlinkSync(target, link, 'junction')

View File

@@ -62,7 +62,7 @@ describe('HMR exact config paths', () => {
expect(cacheHas).toHaveBeenCalledWith(expected)
} finally {
await ctx.fiber.dispose()
rmSync(alias, { force: true })
unlinkSync(alias)
rmSync(target, { recursive: true, force: true })
}
})
@@ -78,7 +78,7 @@ describe('HMR exact config paths', () => {
.rejects.toThrow('config path already registered')
} finally {
await ctx.fiber.dispose()
rmSync(alias, { force: true })
unlinkSync(alias)
rmSync(target, { recursive: true, force: true })
}
})

View File

@@ -272,12 +272,17 @@
.scrollBody:has([data-conversation-composer-overlay]) {
position: relative;
/* A clipping box nothing scrolls out of, stated as a scroll container on both
axes rather than `overflow: hidden`: WebKit honours the reservation above
only in the `overflow-y: auto` form, and a single-axis scroller computes
the other axis to `auto`
([decision](../../../../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). */
axes rather than `overflow: hidden`: a single-axis scroller computes the
other axis to `auto`, and the seat's absolute positioning below needs the
box to stay a containing block in every engine. */
overflow-x: hidden;
overflow-y: auto;
/* The view owns its own scrollers (the trajectory ledger scrolls inside the
view), so this box never scrolls here; reserving a gutter would only
narrow the view's content by the bar's width. Chat keeps the reservation
on the same element, so the seat below compensates with the bar's width
([decision](../../../../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). */
scrollbar-gutter: auto;
}
.scrollBody:has([data-conversation-composer-overlay]) > :global([data-slot='conversation.session']) > .viewArea {
@@ -288,7 +293,15 @@
.scrollBody:has([data-conversation-composer-overlay]) > .composerSeat {
position: absolute;
right: 0;
/* Chat's seat rides this box's content box, which its own scrolling shortens
by the bar's width; the overlay seat is against the padding box, so it
must give back the same width to keep the input card in place across
tabs. Reads ui-theme's --dsh-scrollbar-width, which mirrors the WebKit
bar's layout width. Chat reserves the engine-resolved width, so on an
engine where the resolved width differs from this fixed 8px the card
drifts by half the difference — accepted residual risk, recorded in
([decision](../../../../../../.agents/notes/implemented/bug-fix/2026-08-12-composer-overlay-seat-width-compensation.md)). */
right: var(--dsh-scrollbar-width);
bottom: 0;
left: 0;
}

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-sidebar/README.md
README.md: 9974118f69901de985e012e1b62f95a0bcee64c2
README.zh.md: 11b0aa142cf62626ab6105e2c405d506e35349b0
README.md: 7cc4fe0a722fe5f8cf0e983a0e3fdb30cd31bb64
README.zh.md: 96b6f60f871758c126e6c387c6ce3f0993039af0

View File

@@ -8,6 +8,8 @@ New Session starts the runtime's page-local frontend Session Intent. The runtime
`SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared `sidebar.workspaces` and `sidebar.settings` child slots, and injected `startSession` plus sidebar-toggle callbacks. There is no plugin store.
During a live collapse, the shell holds the expanded content at its current width while it fades out for 150ms. The four upper controls—the shell toggle and New Session plus add and search rendered through `sidebar.workspaces`—then share one 150ms fade and 49px leftward translation into the 56px rail, ending with the layout's 300ms column slide; every 36px control box follows the same path to the rail's 10px left inset. The bottom-pinned `sidebar.settings` control shares the fade timing but has no horizontal translation. A page that starts collapsed renders the rail statically, and reduced-motion mode disables both transitions.
Scrollbars in the column are a pointer affordance: the shell rebinds ui-theme's [scrollbar indirection](../ui-theme/README.md) to `transparent` whenever the pointer is outside it, and keeps the thumb drawn for 2s after the pointer leaves, so a list nobody is pointing at carries no bar. The reservation that keeps rows from moving belongs to the scrolling region ([ui-workspace](../ui-workspace/README.md)), so revealing a thumb never reflows.
The foot is the `sidebar.settings` seat: the sidebar renders only the bottom-pinned layout slot and shares its column state (`wide`); ui-settings registers the trigger row and settings panel there.

View File

@@ -8,6 +8,8 @@ New Session 会启动运行时的页面局部前端 Session Intent。运行时
`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions``useWorkspaces` 钩子、已声明的 `sidebar.workspaces``sidebar.settings` 子 slot以及注入的 `startSession` 与侧边栏切换回调。这里没有插件 store。
实时收起时,外壳会把展开内容固定在当前宽度,并用 150ms 将其淡出。随后,上方四个控件——外壳的侧栏切换与新建会话,以及通过 `sidebar.workspaces` 渲染的添加和搜索——共用一次 150ms 的淡入和 49px 左移,在布局的 300ms 栏滑动结束时一起进入 56px 轨道;每个 36px 控件盒都会沿同一条路径到达轨道左侧 10px 的内边距。固定在底部的 `sidebar.settings` 控件只共用淡入时序,不发生横向位移。页面初始即为收起状态时会静态渲染轨道;减少动态效果模式会禁用两段过渡。
栏内的滚动条是一种指针可供性:只要指针不在栏内,外壳就把 ui-theme 的[滚动条间接层](../ui-theme/README.md)重新绑定为 `transparent`;指针离开后滑块再保留 2 秒,因此没人指向的列表不会带着滚动条。避免行位移的空间预留属于滚动区域本身([ui-workspace](../ui-workspace/README.md)),所以显示滑块不会引起重排。
页脚承载 `sidebar.settings`:侧边栏只渲染固定在底部的布局 slot并共享其栏状态`wide`ui-settings 在此注册触发行和设置面板。

View File

@@ -58,24 +58,35 @@
from { opacity: 0; }
}
/* Rail controls hold hidden while the column slides shut, then fade in over
the slide's tail: .railIn applies at settle (150ms into the 0.3s AppFrame
track transition), so a 100ms delay + 150ms fade starts just before the
slide ends (250ms) and finishes at 400ms; `backwards` keeps them at
opacity 0 through the delay. Only a live collapse gets .railIn — a
refresh straight into the collapsed state renders statically. */
/* At the 150ms rail settle, the four upper controls enter from the former
rail right edge over the remaining 150ms of the AppFrame track transition.
The bottom-pinned settings seat shares their opacity timeline but stays
horizontally fixed. Only a live collapse gets .railIn; a cold collapsed
render stays static. */
.railIn .iconButton,
.railIn .newSession,
.railIn .regionArea {
animation: rail-in 150ms var(--ds-ease-in-out) backwards;
}
.railIn .footArea {
animation: rail-in 150ms var(--ds-ease-in-out) 100ms backwards;
animation: rail-fade-in 150ms var(--ds-ease-in-out) backwards;
}
@keyframes rail-in {
from {
opacity: 0;
transform: translateX(49px);
}
}
@keyframes rail-fade-in {
from { opacity: 0; }
}
/* Logo row (figma pad (4,8,4,8)): brand left, panel toggle right-anchored —
the toggle is the rail's expand control and slides in with the right edge. */
/* Logo row (figma pad (4,8,4,8)): expanded keeps the panel toggle at the
right edge; the rail gives every control the same base left anchor before
the shared entry translation is applied. */
.logoRow {
flex: none;
display: flex;
@@ -93,6 +104,7 @@
height: 36px;
padding: 0;
margin-bottom: 12px;
justify-content: flex-start;
}
/* Brand group (figma I133:7632): the full wordmark rides the text ink
@@ -185,6 +197,8 @@
}
.collapsed .newSession {
align-self: flex-start;
width: 36px;
height: 36px;
padding: 0;
margin: 0 0 12px;
@@ -262,7 +276,8 @@
.fading > *,
.railIn .iconButton,
.railIn .newSession,
.railIn .footArea {
.railIn .footArea,
.railIn .regionArea {
transition: none;
animation: none;
}

View File

@@ -2,12 +2,13 @@
* Sidebar shell: column geometry only. Collapse is a slide plus crossfade:
* content freezes at its expanded width (inline style) and fades out in place
* while the sliding column (AppFrame grid tracks) clips it — nothing reflows
* mid-slide. At settle the wide-only content unmounts and the control rows
* snap to the 56px rail (one icon each, same top-down order) fading in as the
* slide ends. The workspace/session browsing region between the New Session
* button and the foot is the `sidebar.workspaces` registrant's, and the foot
* holds `sidebar.settings` plus `sidebar.footer.action`; the shell hands them the wide flag
* (plus an expand request callback for the browser).
* mid-slide. At settle the wide-only content unmounts and the four upper
* controls enter the 56px rail from the same horizontal offset (one icon each,
* same top-down order) on one fade that ends with the slide. The bottom-pinned
* settings control only fades. The workspace/session browsing region between
* the New Session button and the foot is the `sidebar.workspaces` registrant's,
* and the foot holds `sidebar.settings` plus `sidebar.footer.action`; the shell
* hands them the wide flag (plus an expand request callback for the browser).
*
* The column also owns whether the scroll regions nested in it draw a
* scrollbar at all: the shell tracks the pointer and rebinds ui-theme's

View File

@@ -1,4 +1,4 @@
/** Sidebar shell inset contract shared with the nested workspace browser. */
/** Sidebar shell style contracts shared with its slot-owned controls. */
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
@@ -25,7 +25,7 @@ function declarations(selector: string): Map<string, string> | undefined {
return undefined
}
describe('SidebarRoot.module.css inset', () => {
describe('SidebarRoot.module.css', () => {
it('shares and cancels the wide shell trailing padding structurally', () => {
const root = declarations('.root')
expect(root?.get('--dsh-sidebar-inline-padding')).toBe('12px')
@@ -39,4 +39,28 @@ describe('SidebarRoot.module.css inset', () => {
expect(declarations('.collapsed .regionArea')?.get('padding-left')).toBe('0')
expect(declarations('.collapsed .regionArea')?.get('margin-right')).toBe('0')
})
it('moves the four upper controls while the settings seat only fades', () => {
const animation = 'rail-in 150ms var(--ds-ease-in-out) backwards'
for (const selector of [
'.railIn .iconButton',
'.railIn .newSession',
'.railIn .regionArea',
]) {
expect(declarations(selector)?.get('animation')).toBe(animation)
}
expect(declarations('.railIn .footArea')?.get('animation')).toBe(
'rail-fade-in 150ms var(--ds-ease-in-out) backwards',
)
expect(css).toMatch(
/@keyframes rail-in\s*\{\s*from\s*\{\s*opacity: 0;\s*transform: translateX\(49px\);\s*}\s*}/,
)
expect(css).toMatch(/@keyframes rail-fade-in\s*\{\s*from\s*\{\s*opacity: 0;\s*}\s*}/)
})
it('gives shell rail controls the same base anchor for their shared translation', () => {
expect(declarations('.collapsed .logoRow')?.get('justify-content')).toBe('flex-start')
expect(declarations('.collapsed .newSession')?.get('align-self')).toBe('flex-start')
expect(declarations('.collapsed .newSession')?.get('width')).toBe('36px')
})
})

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-theme/README.md
README.md: 9388d5c05e5625d1a38b27c4565edf3ed47b3b6d
README.zh.md: 9995980e4a64b07c376f90e223c1ec83b5881b73
README.md: e84a21f085eaef7546321edc90847a9a3f9bb2b8
README.zh.md: 758b6f52d330f4518a2df5267117b61446102ea7

View File

@@ -8,7 +8,7 @@ When the host composition includes an HTTP server, the host half injects a synch
`src/styles/` holds five sheets, all imported by the web shell's `base.css`: `base.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. `scrollbar.css` is the sole consumer of the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them.
Scrollbar rebinding contract: `scrollbar.css` binds `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover` on `body` to the l1 (base-surface) tokens, and both rendering paths read that pair. An elevated surface (menu, popover, dialog) sets `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` and `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)` on its own container; one rebind retints whichever path the engine took. The pair's other legal target is `transparent`, which draws no thumb at all — [ui-sidebar](../ui-sidebar/README.md) rebinds its column that way while the pointer is elsewhere. A rebind to the l1 pair is not a rebind; it restates the base-surface default.
Scrollbar rebinding contract: `scrollbar.css` binds `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover` on `body` to the l1 (base-surface) tokens, and both rendering paths read that pair. An elevated surface (menu, popover, dialog) sets `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` and `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)` on its own container; one rebind retints whichever path the engine took. The pair's other legal target is `transparent`, which draws no thumb at all — [ui-sidebar](../ui-sidebar/README.md) rebinds its column that way while the pointer is elsewhere. A rebind to the l1 pair is not a rebind; it restates the base-surface default. `--dsh-scrollbar-width` mirrors the WebKit bar's layout width for surfaces that align themselves beside a space-consuming bar — [ui-conversation](../ui-conversation/README.md) reads it for the overlay composer seat's `right` offset — and the scrollbar-styles spec pairs it with the mirrored rule and the consumer.
The two paths are mutually exclusive by construction. `scrollbar-width`/`scrollbar-color` sit inside `@supports not selector(::-webkit-scrollbar)` because a non-`auto` value of either makes Chromium and Safari discard every `::-webkit-scrollbar*` rule for that element, `::-webkit-scrollbar-thumb:hover` included — declaring both unconditionally leaves `--dsh-scrollbar-thumb-hover` with no rendering anywhere. Firefox therefore takes the standard properties and WebKit-based engines take the pseudo-elements, so the hover token only ever renders through the pseudo-element path. Reasoning and the measured computed values: [the scrollbar Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md).

View File

@@ -8,7 +8,7 @@
`src/styles/` 下有五张样式表,全部由 web 壳的 `base.css` 导入:`base.css``design-platform.css``scrollbar.css``gradient-shadow-text.css``shiki.css``scrollbar.css``--dsw-alias-scrollbar-*` token 的唯一消费方,必须排在声明这些 token 的 `design-platform.css` 之后。
滚动条重新绑定约定:`scrollbar.css``body` 上把 `--dsh-scrollbar-thumb``--dsh-scrollbar-thumb-hover` 绑定到 l1基础表面token两条渲染路径都读取这一组变量。高层级表面菜单、浮层、对话框在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)``--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`;一次重新绑定即可为引擎实际走的那条路径换色。这组变量的另一个合法目标是 `transparent`,即完全不绘制滑块——[ui-sidebar](../ui-sidebar/README.md) 在指针不在栏内时就这样重新绑定自己的列。绑回 l1 那组不算重新绑定,它只是重述基础表面的默认值。
滚动条重新绑定约定:`scrollbar.css``body` 上把 `--dsh-scrollbar-thumb``--dsh-scrollbar-thumb-hover` 绑定到 l1基础表面token两条渲染路径都读取这一组变量。高层级表面菜单、浮层、对话框在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)``--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`;一次重新绑定即可为引擎实际走的那条路径换色。这组变量的另一个合法目标是 `transparent`,即完全不绘制滑块——[ui-sidebar](../ui-sidebar/README.md) 在指针不在栏内时就这样重新绑定自己的列。绑回 l1 那组不算重新绑定,它只是重述基础表面的默认值。`--dsh-scrollbar-width` 镜像 WebKit 滚动条的布局宽度,供需要与占布局宽度的滚动条对齐的表面使用——[ui-conversation](../ui-conversation/README.md) 用它作为覆盖 composer 座位 `right` 偏移——scrollbar-styles 规格把它与镜像规则及消费者配对检查。
两条路径在构造上互斥。`scrollbar-width``scrollbar-color` 写在 `@supports not selector(::-webkit-scrollbar)` 之内,因为这两个属性中的任一个只要取非 `auto`Chromium 与 Safari 就会丢弃该元素上的全部 `::-webkit-scrollbar*` 规则,`::-webkit-scrollbar-thumb:hover` 也在其中——若无条件地同时声明,`--dsh-scrollbar-thumb-hover` 在任何引擎上都不会被渲染。因此 Firefox 走标准属性WebKit 系引擎走伪元素hover token 只经由伪元素这条路径渲染。相关原理与实测计算值见[滚动条 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)。

View File

@@ -17,6 +17,11 @@
body {
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l1);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l1);
/* The WebKit bar's layout width, mirrored by the ::-webkit-scrollbar rule
below. A surface that must align itself beside a space-consuming bar
(ConversationRoot's overlay composer seat) reads this instead of
hardcoding the number. */
--dsh-scrollbar-width: 8px;
}
/* The two paths are mutually exclusive, and the gate is load-bearing rather

View File

@@ -2,10 +2,11 @@
* Scrollbar stylesheet contract, asserted against the CSS text on disk: every
* --dsw-alias-scrollbar-* token design-platform.css defines has a consumer,
* scrollbar.css binds the base-surface pair through the rebindable
* indirection, and elevated surfaces rebind that indirection in complete
* pairs. The expected token set is scanned out of design-platform.css, so
* adding, renaming, or dropping a scrollbar token moves these assertions with
* it.
* indirection, the width variable mirrors the ::-webkit-scrollbar rule for
* consumers that align beside the bar, and elevated surfaces rebind that
* indirection in complete pairs. The expected token set is scanned out of
* design-platform.css, so adding, renaming, or dropping a scrollbar token
* moves these assertions with it.
*/
import { readdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
@@ -341,6 +342,67 @@ describe('scrollbar.css base-surface binding', () => {
})
})
describe('scrollbar.css width variable', () => {
const WIDTH_VARIABLE = `${INDIRECTION_PREFIX}width`
it('defines the width variable on body as a static length', () => {
// The overlay seat compensation reads a fixed number, not a second
// indirection: the mirror check below compares the WebKit rule against
// this value, so a var()-to-var() chain would compare one indirection to
// another instead of pinning the number.
const value = scrollbarRules
.filter(rule => rule.selectors.includes('body'))
.flatMap(rule => rule.declarations)
.findLast(([property]) => property === WIDTH_VARIABLE)?.[1]
expect(value, WIDTH_VARIABLE).toBeDefined()
expect(value, WIDTH_VARIABLE).toMatch(/^\d+(?:\.\d+)?px$/)
})
it('mirrors the ::-webkit-scrollbar width rule with the variable value', () => {
// The compensation stays aligned with the WebKit bar only while both read
// the same number. A change to one side without the other puts the overlay
// seat a band off from Chat on WebKit engines.
const variableValue = scrollbarRules
.filter(rule => rule.selectors.includes('body'))
.flatMap(rule => rule.declarations)
.findLast(([property]) => property === WIDTH_VARIABLE)?.[1]
const webkitWidth = scrollbarRules
.filter(rule => rule.selectors.includes('::-webkit-scrollbar'))
.flatMap(rule => rule.declarations)
.findLast(([property]) => property === 'width')?.[1]
expect(webkitWidth, '::-webkit-scrollbar width').toBeDefined()
expect(webkitWidth).toBe(variableValue)
})
it('every reader of the width variable outside ui-theme references a defined variable', () => {
// The consumer is ConversationRoot's overlay composer seat
// (`right: var(--dsh-scrollbar-width)`); a rename in scrollbar.css without
// the consumer, or a typo in the consumer, leaves the value
// guaranteed-invalid and the seat loses the band. The equal-rectangle e2e
// would catch it only on an engine that draws the bar, so the sheet
// contract states it here.
const defined = new Set(
scrollbarRules
.flatMap(rule => rule.declarations)
.filter(([property]) => property.startsWith(INDIRECTION_PREFIX))
.map(([property]) => property),
)
expect(defined).toContain(WIDTH_VARIABLE)
const readers: string[] = []
for (const file of packageStylesheets()) {
if (file === fileURLToPath(new URL('scrollbar.css', STYLES))) continue
for (const rule of parseRules(readFileSync(file, 'utf8'))) {
for (const [property, value] of rule.declarations) {
for (const name of varReferences(value)) {
if (name === WIDTH_VARIABLE) readers.push(`${file} ${rule.selectors.join(', ')}: ${property}`)
}
}
}
}
expect(readers.length, 'compensation consumer').toBeGreaterThan(0)
})
})
describe('scrollbar.css selectors', () => {
const scrollbarColorSelectors = scrollbarRules
.filter(rule => rule.declarations.some(([property]) => property === 'scrollbar-color'))

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-trajectory/README.md
README.md: d3786b6460c5df7eaa6d24e68c80025e7fb29ae4
README.zh.md: b3bc88d8ab73ccc0da9a49d1867fe2c66be5ca43
README.md: 9ad42a3d3bcfb8fbf4842a66577e65794a716dfd
README.zh.md: df7661ef7d4861dbc6c8e365a18b3e3252d4d4af

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8.
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned. While an older prefix remains unloaded, a first-row control precedes the loaded records, loads one earlier page on click, and changes in place to a disabled loading status while that page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8.
## Model Experience

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明直到鼠标悬停该区域或其中包含键盘焦点时才显示同时不改变滚动条预留的几何空间。独立运行的压缩compaction请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面的加载尚未完成时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定api-contracts v3 §8。
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明直到鼠标悬停该区域或其中包含键盘焦点时才显示同时不改变滚动条预留的几何空间。独立运行的压缩compaction请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前,记录表会用明确的加载行遮住真实记录。更早的前缀仍未加载时,已加载记录前会始终保留首行控件;单击它会加载一页更早的历史,页面加载期间则会原地变为禁用的加载状态。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定api-contracts v3 §8。
## 模型体验

View File

@@ -55,6 +55,53 @@
animation: history-loading-spin 700ms linear infinite;
}
.table tbody .historyLoadRow td {
height: 30px;
padding: 0;
}
/* The pagination control does not create a trajectory turn boundary. */
.table tbody .historyLoadRow + tr[data-turn-start='true'] td::before {
content: none;
}
.historyLoadButton {
display: flex;
width: 100%;
height: 29px;
align-items: center;
justify-content: center;
gap: 6px;
border: 0;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-secondary);
cursor: pointer;
font: var(--dsw-font-xxs-12);
}
.historyLoadButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.historyLoadButton:focus-visible {
outline: 2px solid var(--dsw-alias-state-business-primary);
outline-offset: -2px;
}
.historyLoadButton:disabled {
cursor: default;
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.table:not([data-scroll-ready='true']) {
visibility: hidden;
}
@@ -137,7 +184,7 @@
border: 0;
}
.table tbody tr:not([data-collapsed-summary]):not([data-virtual-spacer]) {
.table tbody tr:not([data-collapsed-summary]):not([data-virtual-spacer]):not([data-history-load]) {
cursor: default;
outline: none;
transition:
@@ -149,7 +196,7 @@
opacity: 0.24;
}
.table tbody tr:not([data-collapsed-summary]):not([data-virtual-spacer]):not([data-selected='true']):hover {
.table tbody tr:not([data-collapsed-summary]):not([data-virtual-spacer]):not([data-history-load]):not([data-selected='true']):hover {
background: var(--dsw-alias-interactive-bg-hover);
}

View File

@@ -30,6 +30,7 @@ import css from './TrajectoryTable.module.css'
const BOTTOM_FOLLOW_THRESHOLD_PX = 2
const OLDER_LOAD_THRESHOLD_PX = 48
const HISTORY_LOAD_ROW_HEIGHT_PX = 30
const VIRTUALIZATION_THRESHOLD = 100
const VIRTUAL_OVERSCAN_ROWS = 12
const VIRTUAL_INITIAL_VIEWPORT_HEIGHT_PX = 600
@@ -364,6 +365,8 @@ export interface TrajectoryTableProps {
recordFocus?: { readonly index: number } | null
/** Whether the initial history tail is still loading. */
historyLoading?: boolean
/** Whether one older history page request is pending anywhere. */
olderHistoryLoading?: boolean
/** First loaded raw event, used to preserve scroll position after prepending a page. */
historyStartSeq?: number | undefined
/** Whether one older history page can be requested. */
@@ -1698,6 +1701,7 @@ export function TrajectoryTable({
recordSelection = null,
recordFocus = null,
historyLoading = false,
olderHistoryLoading = false,
historyStartSeq,
hasOlderRecords = false,
onLoadOlder,
@@ -1769,6 +1773,7 @@ export function TrajectoryTable({
const virtualRowStructure = useStableVirtualRowStructure(projectedVirtualRows)
const virtualizationEnabled = hasOlderRecords
|| records.length > VIRTUALIZATION_THRESHOLD
const virtualScrollMargin = hasOlderRecords ? HISTORY_LOAD_ROW_HEIGHT_PX : 0
const estimateVirtualRowSize = useCallback(
(index: number) => virtualRowStructure[index]?.height ?? 30,
[virtualRowStructure],
@@ -1787,6 +1792,7 @@ export function TrajectoryTable({
initialRect: { width: 0, height: VIRTUAL_INITIAL_VIEWPORT_HEIGHT_PX },
anchorTo: 'end',
overscan: VIRTUAL_OVERSCAN_ROWS,
scrollMargin: virtualScrollMargin,
scrollEndThreshold: BOTTOM_FOLLOW_THRESHOLD_PX,
})
const virtualIndexByRecordId = useMemo(() => {
@@ -1801,10 +1807,15 @@ export function TrajectoryTable({
return indexes
}, [projectedVirtualRows])
const virtualItems = virtualizationEnabled ? rowVirtualizer.getVirtualItems() : []
const virtualTop = virtualItems[0]?.start ?? 0
const virtualTop = Math.max(0, (virtualItems[0]?.start ?? 0) - virtualScrollMargin)
const virtualBottom = virtualItems.length === 0
? 0
: Math.max(0, rowVirtualizer.getTotalSize() - (virtualItems.at(-1)?.end ?? 0))
: Math.max(
0,
rowVirtualizer.getTotalSize()
+ virtualScrollMargin
- (virtualItems.at(-1)?.end ?? 0),
)
const renderedRecords = virtualizationEnabled
? virtualItems.flatMap((item) => {
const row = projectedVirtualRows[item.index]
@@ -2123,12 +2134,13 @@ export function TrajectoryTable({
virtualIndexByRecordId,
virtualizationEnabled,
])
const requestOlder = useCallback((pane: HTMLDivElement) => {
const requestOlder = useCallback((pane: HTMLDivElement, requireTop: boolean) => {
if (
!hasOlderRecords
|| onLoadOlder === undefined
|| loadingOlder.current
|| pane.scrollTop > OLDER_LOAD_THRESHOLD_PX
|| olderHistoryLoading
|| (requireTop && pane.scrollTop > OLDER_LOAD_THRESHOLD_PX)
) return
loadingOlder.current = true
setOlderLoading(true)
@@ -2143,7 +2155,7 @@ export function TrajectoryTable({
loadingOlder.current = false
setOlderLoading(false)
})
}, [hasOlderRecords, historyStartSeq, onLoadOlder])
}, [hasOlderRecords, historyStartSeq, olderHistoryLoading, onLoadOlder])
useLayoutEffect(() => {
const pane = tablePaneRef.current
if (pane === null) return
@@ -2176,10 +2188,9 @@ export function TrajectoryTable({
virtualizationEnabled,
])
const loadingLabel = olderLoading
? 'Loading earlier history…'
: 'Loading trajectory…'
const showLoading = historyLoading || olderLoading || !tableScrollReady
const olderBusy = olderHistoryLoading || olderLoading
const showInitialLoading = historyLoading || !tableScrollReady
const historyRowOffset = hasOlderRecords ? 1 : 0
return (
<div ref={rootRef} className={css.split} style={splitStyle}>
@@ -2192,30 +2203,62 @@ export function TrajectoryTable({
followsTableTail.current =
pane.scrollHeight - pane.clientHeight - pane.scrollTop
<= BOTTOM_FOLLOW_THRESHOLD_PX
requestOlder(pane)
requestOlder(pane, true)
}}
onClick={(event) => {
if (event.target === event.currentTarget) clearAllSelections()
}}
>
{showLoading && (
{showInitialLoading && (
<div className={css.historyLoading} role="status" aria-live="polite">
<span className={css.historyLoadingBar}>
<span className={css.historyLoadingSpinner} aria-hidden="true" />
{loadingLabel}
Loading trajectory
</span>
</div>
)}
<table
className={css.table}
data-scroll-ready={tableScrollReady || undefined}
aria-rowcount={records.length}
aria-rowcount={records.length + historyRowOffset}
>
<colgroup>
<col className={css.eventColumn} />
<col className={css.contentColumn} />
</colgroup>
<tbody>
{hasOlderRecords && (
<tr
className={css.historyLoadRow}
data-history-load=""
aria-rowindex={1}
>
<td colSpan={2}>
<button
type="button"
className={css.historyLoadButton}
disabled={olderBusy || onLoadOlder === undefined}
aria-label={olderBusy
? 'Loading earlier history…'
: 'Load earlier history'}
onClick={() => {
const pane = tablePaneRef.current
if (pane !== null) requestOlder(pane, false)
}}
>
{olderBusy && (
<span className={css.historyLoadingSpinner} aria-hidden="true" />
)}
<span aria-hidden="true">
{olderBusy ? 'Loading earlier history…' : 'Load earlier history'}
</span>
<span className={css.visuallyHidden} role="status" aria-live="polite">
{olderBusy ? 'Loading earlier history…' : ''}
</span>
</button>
</td>
</tr>
)}
{virtualTop > 0 && (
<tr className={css.virtualSpacer} data-virtual-spacer="top" aria-hidden="true">
<td
@@ -2263,7 +2306,7 @@ export function TrajectoryTable({
return (
<tr
tabIndex={isRequestOnly ? -1 : 0}
aria-rowindex={position + 1}
aria-rowindex={position + 1 + historyRowOffset}
aria-label={isCollapsedSummary
? `Collapsed ${record.collapsedSummaryKind} summary, ${record.collapsedSummary}`
: isRequestOnly

View File

@@ -141,8 +141,8 @@ export function TrajectoryView({
} | null>(null)
const inspection = useSession(snapshot =>
snapshot.views.get('trajectory') ?? EMPTY_TRAJECTORY_SNAPSHOT)
const historyLoading = useSession(snapshot =>
snapshot.openState === 'loading' || snapshot.loadingOlder)
const historyLoading = useSession(snapshot => snapshot.openState === 'loading')
const olderHistoryLoading = useSession(snapshot => snapshot.loadingOlder)
const hasOlderHistory = useSession(snapshot => snapshot.hasMore)
const nodes = inspection.eventNodes
const eventLocations = inspection.eventLocations
@@ -488,6 +488,7 @@ export function TrajectoryView({
recordSelection={timelineRecordSelection}
recordFocus={timelineRecordFocus}
historyLoading={historyLoading}
olderHistoryLoading={olderHistoryLoading}
historyStartSeq={historyBaseSeq}
hasOlderRecords={hasOlderHistory}
onLoadOlder={loadEarlierHistory}

View File

@@ -418,7 +418,9 @@ describe('TrajectoryTable', () => {
await waitFor(() => { expect(onLoadOlder).toHaveBeenCalledOnce() })
expect(screen.getByRole('status').textContent).toContain('Loading earlier history…')
resolveOlder?.(true)
await waitFor(() => { expect(screen.queryByRole('status')).toBeNull() })
await waitFor(() => {
expect(screen.getByRole('status').textContent).toBe('')
})
scrollHeight = 260
view.rerender(
<TrajectoryTable
@@ -438,6 +440,68 @@ describe('TrajectoryTable', () => {
expect(tablePane.scrollTop).toBe(60)
})
it('keeps an idle older-history control as the first row until paging completes', async () => {
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600)
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: vi.fn(),
})
let resolveOlder: ((advanced: boolean) => void) | undefined
const older = new Promise<boolean>((resolve) => { resolveOlder = resolve })
const onLoadOlder = vi.fn(() => older)
const view = render(
<TrajectoryTable
turns={TURNS}
{...FOLD_PROPS}
hasOlderRecords
onLoadOlder={onLoadOlder}
/>,
)
const table = screen.getByRole('table')
const loadButton = screen.getByRole('button', { name: 'Load earlier history' })
const loadRow = table.querySelector('tbody > tr:first-child')
expect(loadRow?.contains(loadButton)).toBe(true)
expect(loadRow?.getAttribute('aria-rowindex')).toBe('1')
expect(screen.getByRole('status').textContent).toBe('')
expect(table.getAttribute('aria-rowcount')).toBe('4')
expect((await screen.findByRole('row', { name: /ASSISTANT/ })).getAttribute('aria-rowindex'))
.toBe('2')
fireEvent.click(loadButton)
expect(onLoadOlder).toHaveBeenCalledOnce()
expect(loadButton.hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('status').textContent).toBe('Loading earlier history…')
resolveOlder?.(false)
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Load earlier history' })
.hasAttribute('disabled')).toBe(false)
})
view.rerender(
<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />,
)
expect(screen.queryByRole('button', { name: 'Load earlier history' })).toBeNull()
expect(table.getAttribute('aria-rowcount')).toBe('3')
})
it('reflects an older page started outside the ledger in the persistent control', () => {
render(
<TrajectoryTable
turns={TURNS}
{...FOLD_PROPS}
hasOlderRecords
olderHistoryLoading
onLoadOlder={vi.fn(async () => true)}
/>,
)
expect(screen.getByRole('button', { name: 'Loading earlier history…' })
.hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('status').textContent).toBe('Loading earlier history…')
})
it('covers the ledger while the initial tail is loading', () => {
const view = render(
<TrajectoryTable turns={TURNS} {...FOLD_PROPS} historyLoading />,

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-workspace/README.md
README.md: 9d7d4d77cc064146f1fdaed615509215c64308fc
README.zh.md: ca35d7cd2e7ff176f4ea40d1e9a3a6d1a7457462
README.md: 8878aa49dcccd60ddcde5f0a9563bbfbd969c9c0
README.zh.md: c83f1c53d5471dd8d52b933163a573eb98a70c2e

View File

@@ -6,7 +6,7 @@ Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar
The browser renders grouped or flat Session rows from the global runtime hooks and owns Workspace add/rename/reorder plus Session reorder. A Workspace remembers whether it is closed or showing Sessions; an open Workspace shows five Sessions by default, offers a transient **Show more** control for the remainder, and returns to five after the whole Workspace is closed and reopened. Creating a Session from a Workspace row first opens that group so the new row remains visible when the Session state arrives. Once the Workspace list baseline is ready, browser-persisted expansion and Session-order records retain only current Workspace ids plus Ungrouped and the flat-list account. View options combine grouping with one browser-persisted Session order per account: real Workspaces initialize from `WorkspaceView.sessionIds`, while Ungrouped and the cross-Workspace flat list initialize from recency. **Manual** and **Last updated** apply in either presentation. Entering Last updated performs a complete recency sort and later user prompts or steers promote their Session once, while entering Manual preserves every current position and disables later promotion. Dragging edits the current order in either mode; Manual-mode drags for real Workspaces also update the Host Session account, while Ungrouped and flat-list orders remain browser-local because neither has one Workspace account. Flat rows omit the empty leading status slot because they have no parent hierarchy, but retain it when a Session status is visible. Workspace drag order is Host-durable in either Session order mode.
Collapsed search is one header action beside the view and add actions. Activating it expands the field across the header; an outside click collapses only a query that is empty after trimming, while the clear control always resets and collapses it. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event.
Collapsed search is one header action beside the view and add actions. In the rail, add and search render as 36px controls on the shell's shared horizontal entry path. Activating search expands the field across the header; an outside click collapses only a query that is empty after trimming, while the clear control always resets and collapses it. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event.
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Distinct canonical paths remain separate id-keyed Workspaces when their basenames and display titles match; the sidebar hover detail exposes the full path. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Add workspace...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default, under which the sidebar header drops its add button rather than offering a dead one). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. Adding has exactly one route: the occupant's own create-folder affordance already covers a brand-new directory, so no separate create-by-name dialog exists. A menu only appears where there is something to choose between — with no Workspace listed, the anchor gesture raises the flow directly instead of a one-row popover, and it waits for the list baseline before treating an empty list as final. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands.

View File

@@ -6,7 +6,7 @@
该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 添加/重命名/重排序以及 Session 重排序。每个 Workspace 会记住自身是关闭还是显示 Session打开后默认显示五条 Session其余条目通过临时的**展开其余**控件显示,而关闭并重新打开整个 Workspace 后会恢复为五条。从 Workspace 行创建 Session 时会先打开该分组,使 Session 状态到达后新行保持可见。Workspace 列表基线就绪后,浏览器持久化的展开状态与 Session 顺序记录只保留当前 Workspace id、Ungrouped 和单列表记账。视图选项把分组方式和每个记账各自的一份浏览器持久化 Session 顺序放在一起:真实 Workspace 从 `WorkspaceView.sessionIds` 初始化Ungrouped 和跨 Workspace 的单列表则从最近更新时间顺序初始化。**手动排序**和**最近更新**在两种呈现方式下都可用。进入最近更新时会执行一次完整的时间排序,后续 user prompt 或 steer 会将对应 Session 置顶一次;进入手动排序则保留所有当前位置并停用后续置顶。两种模式下的拖拽都会编辑当前顺序;真实 Workspace 在手动模式下的拖拽还会更新 Host Session 记账,而 Ungrouped 和单列表因没有单一 Workspace 记账其顺序始终只保存在浏览器本地。单列表没有父级层次因此不显示空的左侧状态槽Session 存在可见状态时仍保留该槽。无论采用哪种 Session 顺序Workspace 拖拽顺序都由 Host 持久化。
折叠搜索是视图和添加操作旁的一枚区头按钮。激活后,输入框会扩展并占据区头;点击外部只会收起经清除首尾空白后为空的查询,而清除控件总会重置并收起搜索。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL将查询限制在传输 schema 规定的 500 个 UTF-16 代码单元内且不会拆分代理项对,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。
折叠搜索是视图和添加操作旁的一枚区头按钮。在轨道中,添加和搜索会渲染为沿外壳共用横向进入路径移动的 36px 控件。激活搜索后,输入框会扩展并占据区头;点击外部只会收起经清除首尾空白后为空的查询,而清除控件总会重置并收起搜索。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL将查询限制在传输 schema 规定的 500 个 UTF-16 代码单元内且不会拆分代理项对,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。不同的规范化路径即使 basename 和显示标题相同,仍会作为由 id 区分的独立 Workspace侧边栏的悬停详情会显示完整路径。每个注册各自声明一个**目录流子 slot**`single` kind`conversation.hero.workspace.directoryFlow``sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **添加工作区…** 操作仅在当前界面的 slot 被占用时渲染每次菜单渲染读取占用状态slot 为空意味着该组合没有目录选择能力——seam 文档化的无流程默认行为,此时侧边栏区头直接不渲染添加按钮,而非留下一个点了没反应的按钮)。本包持有触发与接纳:占用方通过 slot 的属主交互约定(`open`/`busy`/`onPicked`/`onCancel`/`onError`每次打开上报一个所选路径owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace取消操作不会显示提示错误落入可重试的文件夹对话框**重新选择** 会重新打开流程。添加只有一条路径:占用者自带的新建文件夹能力已经覆盖了全新目录,因此不再单设按名称创建的对话框。菜单只在确有多个目标可选时出现——没有 Workspace 可列时,锚点手势直接拉起流程,而不是弹出只有一行的浮层;在列表基线落地前,空列表不算最终结果。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框并以该行的显示标题预填客户端不设名称冲突规则host 负责规范化,可能以 `title-invalid` 拒绝错误渲染在对话框告警区确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档归档集合回声落地后该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失失败只作为控制台诊断输出树保持不变。空白的「新会话」行只是占位符不渲染行菜单和时间标签其中还没有发生任何事重命名、fork 和归档都从首条提示词落地后才可用。

View File

@@ -240,11 +240,13 @@
/* Rail variant (own .rail class from the wide owner prop — the region never
reads the shell's class names): the two icon controls stack as 36x36
circles matching the shell's rail rhythm. */
circles matching the shell's rail rhythm. Both use the rail's shared base
left anchor so the outer shell can translate the whole region uniformly. */
.rail .sectionHeader {
gap: 0;
padding-left: 0;
margin-bottom: 12px;
justify-content: flex-start;
}
.rail .headerActions {

View File

@@ -3,11 +3,11 @@
* `sidebar.workspaces` hole: section header (title + view options + add
* workspace), search, the grouped tree or flat list, and the workspace
* dialogs. Wide state renders the full browser; rail state renders the two
* region icons (search / add workspace), each requesting shell expansion
* through the owner share. Adding is the header button's one action, so it
* raises the directory flow with no menu in between; the flow and its error
* dialog live in WorkspacePicker (same package — direct composition, no slot
* between them).
* region icons (search / add workspace) as 36px controls on the shell's shared
* rail entry path, each requesting expansion through the owner share. Adding
* is the header button's one action, so it raises the directory flow with no
* menu in between; the flow and its error dialog live in WorkspacePicker
* (same package — direct composition, no slot between them).
*/
import { useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'

View File

@@ -107,4 +107,10 @@ describe('WorkspaceBrowser.module.css list', () => {
expect(rowDeclarations('.sessionRow.selected')?.get('background'))
.toBe('var(--dsw-alias-interactive-bg-hover)')
})
it('pins both rail controls to the shared left anchor during the column slide', () => {
expect(declarations('.rail .sectionHeader')?.get('justify-content')).toBe('flex-start')
expect(declarations('.rail .iconButton')?.get('width')).toBe('36px')
expect(declarations('.rail .search')?.get('width')).toBe('36px')
})
})

View File

@@ -578,10 +578,12 @@ describe('workspace context instruction discovery', () => {
const root = await tempRepo()
const emptyHome = await tempRepo()
// Isolate the default-home fallback: blank DSH_HOME is treated as unset, and
// HOME points at an empty dir so the default ~/.dsh holds no global scope.
// Symlinks are followed, so a real ~/.dsh/AGENTS.md would otherwise leak in.
// the home dirs point at an empty dir so the default ~/.dsh holds no global
// scope. Windows homedir() reads USERPROFILE (not HOME), so both must be
// stubbed or a real ~/.dsh/AGENTS.md would otherwise leak in.
vi.stubEnv('DSH_HOME', '')
vi.stubEnv('HOME', emptyHome)
if (process.platform === 'win32') vi.stubEnv('USERPROFILE', emptyHome)
try {
const cwd = join(root, 'child')
await mkdir(cwd, { recursive: true })
@@ -622,6 +624,8 @@ describe('workspace context instruction discovery', () => {
try {
await write(join(home, '.dsh/AGENTS.md'), 'global default rule')
// A set DSH_HOME would override the homedir default and relabel the home.
vi.stubEnv('DSH_HOME', '')
vi.resetModules()
vi.doMock('node:os', () => ({ homedir: () => home }))
const isolated = await import('@deepseek-ai/dsh-agent-instructions')
@@ -629,6 +633,7 @@ describe('workspace context instruction discovery', () => {
expect(files.map(file => file.displayPath)).toEqual(['~/.dsh/AGENTS.md'])
} finally {
vi.unstubAllEnvs()
vi.doUnmock('node:os')
vi.resetModules()
await rm(root, { recursive: true, force: true })

View File

@@ -26,7 +26,7 @@ export type { SessionPreparationOptions } from './preparation.ts'
export type { AssistantMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm'
export { isJsonValue, snapshotJsonValue } from './json.ts'
export type { JsonValue } from './json.ts'
export { interruptedTurnClosers, lastActivityTime, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts'
export { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts'
export { decodeStorageRecord, packChunkRuns } from './chunk-rows.ts'
export type { ChunkRow, StorageRecord } from './chunk-rows.ts'
export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts'

View File

@@ -1,10 +1,7 @@
/**
* Crash-recovery repair for an interrupted session log. It preserves a fully
* written final turn and supplies the missing tool, step, and turn boundaries
* needed to resume with a provider-valid transcript, plus the activity-time
* read that must skip the end-seed boundary — which this module does
* not write (`Session`'s constructor does) but whose synthetic closers can
* inherit that boundary's timestamp, the one real coupling between the two.
* needed to resume with a provider-valid transcript.
* @module @deepseek-ai/dsh-session/repair
*/
@@ -12,22 +9,6 @@ import { MessageId, freezeMessage, type CallId } from '@deepseek-ai/dsh-llm'
import type { ToolResultMessage } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from './types.ts'
/**
* The `time` of the log's last event representing actual work, skipping the
* `session/end-seed` boundary — picking a session up is not activity, so
* activity ordering must exclude it.
*
* Excluded by type, so a pickup time still leaks when a boundary is the last
* event of an open turn: {@link interruptedTurnClosers} copies it onto the
* synthetic `turn/end`, which this counts as work. Reachable only by seeding an
* unbalanced log directly — `load()` balances first.
* @param events - the log to scan, in seq order.
* @returns the latest non-boundary event's `time`, or undefined when there is none.
*/
export function lastActivityTime(events: readonly SessionEvent[]): number | undefined {
return events.findLast(event => event.type !== 'session/end-seed')?.time
}
/** Recovery code for an assistant tool request that never reached a recorded call start. */
export const TOOL_NOT_STARTED = 'TOOL_NOT_STARTED'

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { CallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
import { interruptedTurnClosers, lastActivityTime, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '../src/index.ts'
import { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '../src/index.ts'
import type { SessionEvent, SurfaceEvent } from '../src/index.ts'
/**
@@ -273,44 +273,3 @@ describe('interruptedTurnClosers', () => {
expect(closers.map(e => e.type)).toEqual(['step/end', 'turn/end'])
})
})
describe('lastActivityTime', () => {
const endSeedAt = (seq: number, time: number): SessionEvent =>
({ type: 'session/end-seed', seq, time, data: {} })
it('has no answer for an empty log', () => {
expect(lastActivityTime([])).toBeUndefined()
})
it('reports the log tail when no boundary is present', () => {
const events: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'turn/end', seq: 1, time: 500, data: { turn: 1, reason: { kind: 'completed' } } },
]
expect(lastActivityTime(events)).toBe(500)
})
it('skips a trailing boundary in favour of the last real work', () => {
const events: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'turn/end', seq: 1, time: 500, data: { turn: 1, reason: { kind: 'completed' } } },
endSeedAt(2, 9_000),
]
// Resumed long after the work, but never worked in again.
expect(lastActivityTime(events)).toBe(500)
})
it('reports work appended after end-seed', () => {
const events: SessionEvent[] = [
userTurnStart(1, 0),
endSeedAt(1, 9_000),
{ type: 'turn/end', seq: 2, time: 9_500, data: { turn: 1, reason: { kind: 'completed' } } },
]
expect(lastActivityTime(events)).toBe(9_500)
})
it('has no answer for a log of nothing but boundaries', () => {
// Unreachable via the constructor, but the projection is a pure function.
expect(lastActivityTime([endSeedAt(0, 1), endSeedAt(1, 2)])).toBeUndefined()
})
})

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/host/apiproxy/README.md
README.md: 9467ec288ae597a43eaf954393005ef81ec02c66
README.zh.md: 8194bf0a72f52cf9824a067f12040167eaf005da
README.md: b7dee9488c1b123172c225d4d4235bdb3c76911d
README.zh.md: 46835a84254c4bbc45300a18d29aae5a6ac66f14

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The API gateway shared by every client consists of the TypeScript API contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{nativeOpen?, sessionExportCompressionLevel?}`, provides `ctx.apiProxy`). This package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped Web composition lives in [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml), while its default Agent model selection belongs to [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md) in the base bundle.
The API gateway shared by every client consists of the TypeScript API contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{nativeOpen?, sessionExportCompressionLevel?, coldBlankProbeMaxBytes?}`, provides `ctx.apiProxy`). This package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped Web composition lives in [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml), while its default Agent model selection belongs to [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md) in the base bundle.
## The shared Agent default (`agent-default-model` Settings section)
@@ -26,7 +26,7 @@ Question responses are validated against their pending request before the first
`session.history` reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. `maxMessages` counts `user/message` and `assistant/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only `compaction/summary` record on the same page as the replacement that cites it.
`session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds no other domain's knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface. The gateway registers exactly one unit of its own: `imageLimits`, the attachments config it enforces at prompt admission, published as a per-boot constant (`apply` keeps the state reference, so baselines alone carry it — no change frames) so clients can refuse an over-limit intake before submit and label upload affordances; the unit activates only while both the registry and the attachments service are composed.
`session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds no other domain's knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface. The gateway owns two units: `sessionListMetadata` caches the monotonic blank-to-nonblank transition and latest human prompt time used by `session.list`, while `imageLimits` publishes the attachments config enforced at prompt admission as a per-boot constant (`apply` keeps the state reference, so baselines alone carry it — no change frames) so clients can refuse an over-limit intake before submit and label upload affordances; the latter activates only while both the registry and the attachments service are composed.
Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents/<id>/`, and every image any included log references under `media/<attachmentId>.<ext>` (read and verified from the attachment store; a shared image appears once). `HEAD` runs the same root preparation and returns its status and headers without a response body, so browser clients can detect pre-stream failures before handing the GET to the native download manager. Each live root or descendant crosses the authoritative `SessionStore.flush` durability barrier immediately before its raw artifact read; cold sessions have no in-memory work to flush. Compression runs on the host with fflate's streaming Zip API at validated `sessionExportCompressionLevel` 09 (default 6), so deployments can trade CPU and latency against archive size; the response is chunked as it is produced and the host never holds the whole archive in one buffer. Once the response queue reaches its 64 KiB byte high-water mark, production waits until consumer pull restores positive capacity; fflate's synchronous callback can overshoot that bound only by the output of one bounded input push. Request abort and response-body cancellation stop lineage and artifact work, terminate the active compressor, and propagate as cancellation rather than an HTTP 500. It requires the persistence, session-query, and attachment services: a deployment without any answers 500, a persistence backend without per-session raw artifacts answers 501, a missing root session answers 404, and a descendant without a stored artifact or a referenced image that cannot be read fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it.
@@ -42,7 +42,7 @@ Pending queued input is a live control-plane contract, not conversation history.
Background jobs ride the same live-push posture. When `ctx.jobs` is composed, the gateway subscribes to its change feed and broadcasts a whole `session/jobs` snapshot after every registry commit that alters what a session can see — registration, the stopping transition, settlement, and owner-disposal removal — plus a subscription baseline for each session that already has tasks (an absent baseline is the empty set; a change that empties a set still sends `[]`). A change carrying an owner reads through that exact `Agent`, so a push stays correct while its scope tears down; the baseline reads `ctx.agents.get(sessionId)`, which yields only unowned tasks for a session with no live Agent and never resumes a cold one. An unowned change fans out to every subscribed session, because unowned tasks are visible to every caller. The wire `JobView` drops `ownerSession`, `reported`, and `outputLimitBytes`: the frame's own `sessionId` carries the first, and the other two are internal notice and model-presentation policy. A composition without the registry emits no such frames.
Workspace and Session lists are separate reconnect baselines. `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` commits one registry-order move and answers the complete order; a pure reorder emits `host/workspace-order-changed` with that complete order, while unknown sources or anchors return `workspace-not-found`. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
Workspace and Session lists are separate reconnect baselines. `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` commits one registry-order move and answers the complete order; a pure reorder emits `host/workspace-order-changed` with that complete order, while unknown sources or anchors return `workspace-not-found`. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry whether a turn has started: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority. Attached summaries fold the live log. A cold summary trusts cached `blank: false`, but treats cached `true` and a cache miss as unverified; when `locate()` reports an artifact no larger than the `coldBlankProbeMaxBytes` eligibility threshold (default 1 KiB), the gateway reads that Session with `readFrom()` and folds both blankness and the latest human prompt. A larger, location-less, vanished, or unreadable artifact remains visible. After an asynchronous cold read, a Session that attached meanwhile is summarized from its live log instead. `updatedAt` uses the live fold, the exact small-artifact fold, or the projection cache in that order, falling back to `createdAt`; pickup boundaries and other writes never promote a Session.
`session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking.
@@ -80,4 +80,4 @@ None; this package neither assembles nor sends a provider request.
- **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists.
- **Search failures include provider diagnostics** — the gateway is a single-user local service. A carrier that exposes it to multiple users must replace internal search details with a public-safe diagnostic.
- **Linux native picker requires desktop tooling** — under the `native` capability, `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the [native backend README](../directory-picker-native/README.md)).
- **A cold session's `updatedAt` counts a mere pickup as a write (per-file backends only)** — the attached projection excludes the `session/end-seed` boundary, because picking a session up is not activity, but a cold session's `updatedAt` is its log file's mtime and every durable write refreshes that, the boundary included. `agentFor()` resumes a cold session on first touch, so merely opening one in a client writes it. This applies only where `locate()` resolves a per-session artifact, i.e. JSONL; SQLite returns `undefined`, so its cold sessions fall back to `createdAt` and are skewed the other way — too old rather than too new — independently of this boundary. A session touched without being worked in therefore sorts newer than its last real activity until it attaches. Separating the two needs a log read, which is exactly what the mtime path exists to avoid; a stored last-activity field in the index would fix it at the source, scoped in the [last-activity-index Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md).
- **Cold-list hints degrade only toward visibility and older ordering** — a projection-cache miss or stale `lastPromptAt` falls back to `createdAt` unless an eligible small artifact supplies an exact fold, so a recently worked large Session may sort too low until the next checkpoint. A blank artifact larger than `coldBlankProbeMaxBytes`, or one from a backend without `locate()`, remains visible. The threshold is checked before `readFrom()` rather than enforced by persistence, so concurrent artifact growth may increase one probe's read cost without changing blankness safety. The [bounded blank-verification decision](../../../.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md) owns this safety direction; an authoritative exact recency index remains scoped in the [last-activity-index proposal](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md).

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
所有客户端共用的 API 网关由三部分组成TypeScript API 约定(`src/api/`,不依赖 Node可从浏览器导入、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts``createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{nativeOpen?, sessionExportCompressionLevel?}`,提供 `ctx.apiProxy`。该包不注册任何路由HTTP 等载体自行包装 `ctx.apiProxy`。随发行版交付的 Web 组合位于 [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml),其默认 Agent智能体模型选择属于 base 组合包中的 [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md)。
所有客户端共用的 API 网关由三部分组成TypeScript API 约定(`src/api/`,不依赖 Node可从浏览器导入、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts``createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{nativeOpen?, sessionExportCompressionLevel?, coldBlankProbeMaxBytes?}`,提供 `ctx.apiProxy`。该包不注册任何路由HTTP 等载体自行包装 `ctx.apiProxy`。随发行版交付的 Web 组合位于 [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml),其默认 Agent智能体模型选择属于 base 组合包中的 [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md)。
## 共享 Agent 默认值(`agent-default-model` Settings 分节)
@@ -26,7 +26,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中
`session.history` 会读取已附加 Session 的内存状态,或通过持久化检查冷日志,而不会恢复或发布 agent然后按追加来源的消息边界分页`maxMessages` 统计以追加方式进入 surface 的 `user/message``assistant/message` 事件因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间从而让压缩compaction的仅日志 `compaction/summary` 记录与引用它的替换留在同一页。
`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections``@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有其他领域的知识(每个值在注册表内部已过其单元自己的 schema协议 schema 对 `values`/`value` 保持宽松loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。网关唯一自己注册的单元是 `imageLimits`:它在 prompt 准入时执行的 attachments 配置,以每次启动恒定的值发布(`apply` 保持状态引用不变,因此只靠基线携带、绝不产生变更帧),供客户端在提交前拒绝超限的加入并给上传入口标注上限;该单元仅在注册表与 attachments 服务同时组合时激活。
`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections``@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有其他领域的知识(每个值在注册表内部已过其单元自己的 schema协议 schema 对 `values`/`value` 保持宽松loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。网关拥有两个单元:`sessionListMetadata` 缓存用于 `session.list` 的单调 blank→nonblank 转换与最新真人 prompt 时间;`imageLimits` 则把 prompt 准入时执行的 attachments 配置作为每次启动恒定的值发布(`apply` 保持状态引用不变,因此只靠基线携带、绝不产生变更帧),供客户端在提交前拒绝超限的加入并给上传入口标注上限,后者仅在注册表与 attachments 服务同时组合时激活。
会话日志导出是宿主侧的下载面,不是 RPC`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP其中每个文件都是会话存储工件的逐字原文持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents/<id>/` 下,每个被任何包含的日志引用的图片放在 `media/<attachmentId>.<ext>` 下(从附件存储读取并校验;共享图片只出现一次)。`HEAD` 会执行相同的根工件准备,并在没有响应 body 的情况下返回状态与响应头,使浏览器 Client 可以在把 GET 交给原生下载管理器前发现流式传输前的失败。每个实时根会话或后代都会在读取原始工件前立即通过权威的 `SessionStore.flush` 持久性屏障;冷会话没有需要 flush 的内存工作。压缩在宿主侧使用 fflate 流式 Zip API 和已验证的 `sessionExportCompressionLevel` 09默认 6使部署可以在 CPU延迟与归档大小之间取舍响应边生成边分块写出宿主从不把整个归档放进单个缓冲区。响应队列达到 64 KiB 字节高水位后,生产会等待 Consumer pull 恢复正容量fflate 的同步回调最多只会让该界限多出一次有界输入 push 的输出。请求中止或响应 body 取消会停止血缘与工件工作、终止活跃压缩器,并继续按取消传播,而不会变成 HTTP 500。它要求同时挂载持久化、session-query 与附件服务:任一缺失应答 500持久化后端不提供每会话原始工件时应答 501根会话缺失时应答 404后代缺少存储工件或引用的图片无法读取则整个流失败fail-loud绝不静默少导出。端点由传输层挂载`ApiProxy.downloads.sessionLog` 实现它。
@@ -42,7 +42,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中
后台任务沿用同一种实时推送姿态。当组合中有 `ctx.jobs` 时,网关订阅它的变更订阅,并在注册表每一次改变某个会话可见内容的提交后——注册、转入 stopping、结算以及 owner 销毁时的移除——广播一份完整的 `session/jobs` 快照,另外为每个已经有任务的会话发送订阅 baseline没有 baseline 即表示空集;把集合清空的那次变更仍然发送 `[]`)。带 owner 的变更通过那个确切的 `Agent` 读取,因此推送在其 scope 拆除期间依然正确baseline 读 `ctx.agents.get(sessionId)`,对没有活体 Agent 的会话只得到无主任务,且绝不恢复冷会话。无主变更向每一个已订阅会话扇出,因为无主任务对所有调用方可见。线路上的 `JobView` 丢弃 `ownerSession``reported``outputLimitBytes`:第一个由帧自身的 `sessionId` 携带,另外两个分别是内部通知位和模型呈现策略。没有该注册表的组合不发出这类帧。
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` 提交一次注册表顺序移动并应答完整顺序;单纯重排序会通过 `host/workspace-order-changed` 推送同一份完整顺序,而未知来源或锚点返回 `workspace-not-found``workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id`host/workspace-changed``host/workspace-removed``host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非活动会话也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank``host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()`
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` 提交一次注册表顺序移动并应答完整顺序;单纯重排序会通过 `host/workspace-order-changed` 推送同一份完整顺序,而未知来源或锚点返回 `workspace-not-found``workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id`host/workspace-changed``host/workspace-removed``host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非活动会话也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank``host/session-added` 帧携带是否已开始过轮次:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank并以 `session.list` 作为重连权威。已附加摘要折叠实时日志。冷摘要信任缓存的 `blank: false`,但把缓存的 `true` 与 cache miss 都视为未经验证;当 `locate()` 报告的工件不大于 `coldBlankProbeMaxBytes` 资格阈值(默认 1 KiB网关通过 `readFrom()` 读取该 Session同时折叠空白状态与最新真人 prompt。更大、无位置、已消失或不可读的工件保持可见。异步冷读取结束后期间已附加的 Session 会改用实时日志生成摘要。`updatedAt` 依次采用实时折叠、小工件精确折叠或 projection cache缺失时回退到 `createdAt`;拾起边界及其他写入都不会提升 Session 排序
`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前内容视图中的 user、assistant 和 steering 匹配项,并持续消费该结果流,直到获得至多 20 个可见会话snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。
@@ -80,4 +80,4 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
- **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。
- **搜索失败会包含提供方诊断信息**:网关是单用户本地服务。将其暴露给多名用户的载体必须用可安全公开的诊断信息替代内部搜索细节。
- **Linux 原生选择器依赖桌面工具**:在 `native` 能力下Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是 browse 后端(见 [native 后端 README](../directory-picker-native/README.md))。
- **冷会话的 `updatedAt` 会把一次单纯的拾起算作写入(仅逐文件后端)**:已附加投影排除了 `session/end-seed` 边界,因为接手一个会话不算活动;但冷会话的 `updatedAt` 取自其日志文件的 mtime而每一次持久写入都会刷新它包括这条边界。`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就会写入它。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONLSQLite 返回 `undefined`,因此它的冷会话回退到 `createdAt`,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会按晚于其最后一次真实活动的时间排序。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见 [最后活动索引 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)。
- **冷列表提示只向“保持可见、排序偏旧”降级**projection cache miss 或陈旧的 `lastPromptAt` 会回退到 `createdAt`,除非符合资格的小工件提供精确折叠,因此最近工作过的大 Session 可能在下一个 checkpoint 前排得偏低。大于 `coldBlankProbeMaxBytes` 的空白工件,或来自不提供 `locate()` 的后端的空白工件会保持可见。该阈值在 `readFrom()` 前检查,而非由 persistence 强制,因此工件并发增长可能增加一次探测的读取成本,但不会改变空白状态的安全方向。[有界空白验证决策](../../../.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md)规定了这个安全方向;权威且精确的最近时间索引仍属于[最后活动索引提案](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)的范围

View File

@@ -15,7 +15,7 @@ import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import { contentHasImage, createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import { isAppendSurfaceEvent, isJsonValue, lastActivityTime } from '@deepseek-ai/dsh-session'
import { isAppendSurfaceEvent, isJsonValue } from '@deepseek-ai/dsh-session'
import type { JsonValue, Session, SessionEvent, SessionEventMap, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query'
@@ -38,7 +38,7 @@ import type {} from '@deepseek-ai/dsh-tools'
import type {
ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame,
ModelCatalogFailure, ModelProviderGroup,
ModelReasoning, MuxFrame, PromptContentPart, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem,
ModelReasoning, MuxFrame, PromptContentPart, QuestionResponsePayload, SessionListMetadata, SessionProjectionsBlock, SessionSearchItem,
QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, JobView, ToolEventView,
WorkspaceId, WorkspaceView,
} from './api/index.ts'
@@ -90,7 +90,7 @@ import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-a
// `ctx.get('approval')` without a value dependency on the seam (optional composition).
import type {} from '@deepseek-ai/dsh-user-approval'
import { approvalResponsePayloadSchema } from './api/approvals.schema.ts'
import { imageLimitsProjectionSchema } from './api/sessions.schema.ts'
import { imageLimitsProjectionSchema, sessionListMetadataProjectionSchema } from './api/sessions.schema.ts'
import { questionResponsePayloadSchema } from './api/questions.schema.ts'
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts'
import { RpcId } from './api/rpc.ts'
@@ -132,6 +132,8 @@ const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100
/** Bound cold-log stat fan-out and settle each started batch before cancellation returns. */
const COLD_SUMMARY_BATCH_SIZE = 16
/** Default maximum artifact size eligible for one cold blankness read. */
export const DEFAULT_COLD_BLANK_PROBE_MAX_BYTES = 1024
/** Conversation message event types (the pagination counting unit). */
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
@@ -506,6 +508,29 @@ function sessionBlank(session: Session): boolean {
return !session.events.some(event => event.type === 'turn/start')
}
/** Advance the Session-list hint projection by one committed event. */
function applySessionListMetadata(state: SessionListMetadata, event: SessionEvent): SessionListMetadata {
const blank = state.blank && event.type !== 'turn/start'
const lastPromptAt = event.type === 'user/message' && event.data.source.kind === 'user'
? event.time
: state.lastPromptAt
return blank === state.blank && lastPromptAt === state.lastPromptAt
? state
: { blank, lastPromptAt }
}
/** Fold exact list metadata for an attached Session. */
function sessionListMetadata(events: readonly SessionEvent[]): SessionListMetadata {
let state: SessionListMetadata = { blank: true, lastPromptAt: null }
for (const event of events) state = applySessionListMetadata(state, event)
return state
}
/** Sort by creation or latest human prompt, whichever is newer. */
function sessionListUpdatedAt(header: SessionHeader, metadata: SessionListMetadata | undefined): number {
return Math.max(header.createdAt, metadata?.lastPromptAt ?? 0)
}
/** Shared Session-header projection for list baselines and creation frames. */
function sessionListFields(header: SessionHeader, events: readonly SessionEvent[] = []): {
parentSessionId?: SessionId
@@ -527,47 +552,71 @@ function sessionListFields(header: SessionHeader, events: readonly SessionEvent[
/** SessionSummary projection for attached (in-memory) sessions. */
function summarize(session: Session, running: boolean): SessionSummary {
const metadata = sessionListMetadata(session.events)
return {
sessionId: session.id,
// Excludes end-seed: a resumed-but-untouched session
// must not sort as freshly worked in.
updatedAt: lastActivityTime(session.events) ?? session.header.createdAt,
updatedAt: sessionListUpdatedAt(session.header, metadata),
running,
blank: sessionBlank(session),
blank: metadata.blank,
...sessionListFields(session.header, session.events),
}
}
/**
* SessionSummary projection for cold (persisted, unattached) sessions.
* updatedAt is the log file's mtime; backends without a per-session file
* (locate() undefined) fall back to the header's createdAt.
* Verify a possibly blank cold Session only when its physical artifact passes
* the configured per-Session size check. A stale `blank: true`, an
* absent cache row, a large or location-less artifact, and read failures all
* resolve to visible (`false`); listing must never hide a conversation on a
* cache hint or an unavailable optimization.
*/
async function summarizeCold(
async function probeColdSessionMetadata(
ctx: Context,
persistence: SessionPersistence,
meta: SessionHeader,
maxBytes: number,
signal?: AbortSignal,
): Promise<SessionListMetadata | undefined> {
if (maxBytes === 0) return undefined
signal?.throwIfAborted()
const location = persistence.locate(meta)
if (location === undefined) return undefined
signal?.throwIfAborted()
let size: number
try {
size = (await stat(location.path)).size
} catch {
signal?.throwIfAborted()
return undefined
}
if (size > maxBytes) return undefined
try {
const { events } = await persistence.readFrom(meta.id, 0, signal)
signal?.throwIfAborted()
return sessionListMetadata(events)
} catch (error) {
signal?.throwIfAborted()
ctx.logger.warn(`session.list: blank probe for "${meta.id}" failed (serving it as visible): ${String(error)}`)
return undefined
}
}
/** SessionSummary projection for a cold persisted Session. */
async function summarizeCold(
ctx: Context,
persistence: SessionPersistence,
meta: SessionHeader,
metadata: SessionListMetadata | undefined,
blankProbeMaxBytes: number,
signal?: AbortSignal,
): Promise<SessionSummary> {
signal?.throwIfAborted()
let updatedAt = meta.createdAt
const location = persistence.locate(meta)
signal?.throwIfAborted()
if (location !== undefined) {
try {
updatedAt = (await stat(location.path)).mtimeMs
} catch {
// The log vanished between list() and stat() (concurrent cleanup); createdAt stands in.
}
signal?.throwIfAborted()
}
const probed = metadata?.blank === false
? undefined
: await probeColdSessionMetadata(ctx, persistence, meta, blankProbeMaxBytes, signal)
return {
sessionId: meta.id,
updatedAt,
updatedAt: sessionListUpdatedAt(meta, probed ?? metadata),
running: false,
// Lazy persistence keeps never-appended sessions out of list(); reading
// a cold log to check for turns would defeat the index read, so a listed
// cold session is served as not-blank (its log holds its conversation).
blank: false,
blank: metadata?.blank === false ? false : probed?.blank ?? false,
// Header-only: reading the log for a blank-window preset switch would
// defeat the same index read, and attaching the session replaces this row
// with `summarize()`, which resolves the switch from the events.
@@ -608,6 +657,8 @@ export interface ApiProxyDefaults {
openTextFile?: (path: string, signal: AbortSignal) => Promise<void>
/** Validated DEFLATE level for session-log ZIP entries; defaults to 6. */
sessionExportCompressionLevel?: SessionLogCompressionLevel
/** Maximum artifact size eligible for one cold blankness read. */
coldBlankProbeMaxBytes?: number
/**
* Whether handing a path to the native opener can work at all — the
* `hasDocument` capability the preset roster reports, and the switch
@@ -1055,6 +1106,8 @@ function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceVie
export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
const sessionExportCompressionLevel = defaults.sessionExportCompressionLevel
?? DEFAULT_SESSION_LOG_COMPRESSION_LEVEL
const coldBlankProbeMaxBytes = defaults.coldBlankProbeMaxBytes
?? DEFAULT_COLD_BLANK_PROBE_MAX_BYTES
/** The seed model each create/resume declares; re-read so it never goes stale. */
const agentOptions = (): AgentOptions => {
const { provider, model } = defaults.defaultModelSelection()
@@ -1233,6 +1286,19 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
})
})
// The cache supplies recency and a monotonic non-blank hint. A cached
// `blank: true` remains only a prefix fact and is verified on the cold path.
ctx.inject(['sessionProjections'], (projectionCtx) => {
projectionCtx.sessionProjections.register<'sessionListMetadata', SessionListMetadata>({
key: 'sessionListMetadata',
schema: sessionListMetadataProjectionSchema,
init: () => ({ blank: true, lastPromptAt: null }),
apply: applySessionListMetadata,
view: state => state,
stateVersion: 1,
})
})
// The imageLimits projection unit: the attachments config this proxy
// enforces at prompt admission, constant per host boot. `apply` keeps the
// same state reference for every event, so no change frames are ever
@@ -1658,14 +1724,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
*/
async function listVisibleSessionSummaries(signal?: AbortSignal): Promise<SessionSummary[]> {
signal?.throwIfAborted()
const items = ctx.sessions.list().map((session) => {
const summarizeAttached = (session: Session): SessionSummary => {
const agent = ctx.agents.get(session.id)
const projections = listProjectionsFor(ctx, session.header, session)
return {
...summarize(session, agent?.status === 'running'),
...projections === undefined ? {} : { projections },
}
})
}
const items = ctx.sessions.list().map(summarizeAttached)
signal?.throwIfAborted()
const attached = new Set(items.map(item => item.sessionId))
const persistence = ctx.get('sessionPersistence')
@@ -1678,11 +1745,21 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const batch = cold.slice(offset, offset + COLD_SUMMARY_BATCH_SIZE)
const settled = await Promise.allSettled(
batch.map(async (meta) => {
// Cold rows read the persisted projection cache only — never a
// log load; a session without a cache row simply has no column.
// Projection hints remain optional. Blank verification may read
// this Session's artifact only when it passes the configured size check.
const projections = listProjectionsFor(ctx, meta, undefined)
const summary = await summarizeCold(
ctx,
persistence,
meta,
projections?.values.sessionListMetadata,
coldBlankProbeMaxBytes,
signal,
)
const attachedSession = ctx.sessions.get(meta.id)
if (attachedSession !== undefined) return summarizeAttached(attachedSession)
return {
...await summarizeCold(persistence, meta, signal),
...summary,
...projections === undefined ? {} : { projections },
}
}),

View File

@@ -45,7 +45,7 @@ export interface ApiProxy {
export type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelSelection, PromptContentPart, QueueAction, SessionModels,
SessionProjectionsBlock, SessionSearchItem, SessionsApi, SessionSummary,
SessionListMetadata, SessionProjectionsBlock, SessionSearchItem, SessionsApi, SessionSummary,
} from './sessions.ts'
export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'
export type {

View File

@@ -12,7 +12,7 @@ import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
import type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelSelection, SessionProjectionsBlock, SessionSearchItem, SessionSummary,
ModelReasoningEffort, ModelSelection, SessionListMetadata, SessionProjectionsBlock, SessionSearchItem, SessionSummary,
} from './sessions.ts'
import type { ToolEventView } from './events.ts'
import type { AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
@@ -215,6 +215,12 @@ export const sessionProjectionsBlockSchema = z.object({
values: z.record(z.string(), z.unknown()),
}) as unknown as z.ZodType<Wire<SessionProjectionsBlock>>
/** Host-side validation for the persisted Session-list projection. */
export const sessionListMetadataProjectionSchema: z.ZodType<SessionListMetadata> = z.object({
blank: z.boolean(),
lastPromptAt: z.number().nullable(),
})
/**
* imageLimits projection unit schema (host-side view validation). zod widens
* `readonly ImageMediaType[]` to `string[]`; on the JSON wire the two

View File

@@ -17,6 +17,13 @@ import type { WorkspaceId } from './workspace.ts'
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionMap {
/**
* Session-list hints persisted by the projection cache. `blank: false`
* is monotonic and may suppress a cold-log probe; `blank: true` is only a
* checkpoint-prefix fact and must not hide a cold Session without direct
* verification. `lastPromptAt` is the latest human-authored prompt time.
*/
sessionListMetadata: SessionListMetadata
/**
* The deployment's image-intake limits: the attachments service's config
* as this proxy enforces it at prompt admission, constant per host boot.
@@ -28,6 +35,14 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
}
}
/** Persisted hints used to summarize a cold Session without reading a large log. */
export interface SessionListMetadata {
/** Whether the checkpoint prefix contains no turn/start event. */
blank: boolean
/** Latest source.kind=user message time in the checkpoint prefix. */
lastPromptAt: number | null
}
declare module '@deepseek-ai/dsh-llm' {
interface MessageSourceMap {
/**
@@ -158,25 +173,25 @@ export type QueueAction =
| { kind: 'remove' }
| { kind: 'steer' }
/** Session list entry (v1 builds no index: list does readdir+stat). */
/** One Session list entry. */
export interface SessionSummary {
sessionId: SessionId
/**
* Last activity. Attached: the last non-`session/end-seed` event, since a
* pickup is not activity. Cold: the log's mtime, or `createdAt` for a backend
* with no per-session file (README Known Limitations covers the skew).
* The later of creation and the latest human-authored prompt. Attached
* Sessions fold their live log; cold Sessions use a projection-cache hint or
* an exact small-artifact read, falling back to creation time.
*/
updatedAt: number
/** Status of the attached agent; always false for cold (unattached) sessions. */
running: boolean
/**
* Derived conversation-not-started bit: true while no turn has run (no
* prompt was accepted yet). Standalone plugin events — command lifecycle
* Derived conversation-not-started bit: true while no turn has run.
* Standalone plugin events — command lifecycle
* records, plan/mode, titles, goals — do not open a turn and therefore do
* not clear it. Clients hide blank sessions from lists and reuse them for
* New Session on the same workspace. Always false for cold sessions —
* lazy persistence keeps a never-appended session out of the store, and a
* listed cold session's log holds its turns.
* not clear it. Clients hide blank Sessions from lists and reuse them for
* New Session on the same workspace. A cold Session is true only when a
* small-artifact read verifies that no `turn/start` exists; unavailable
* or oversized artifacts conservatively report false.
*/
blank: boolean
/** fork/spawn lineage (session.header.parentSession passthrough); absent for root sessions. */

View File

@@ -16,7 +16,7 @@ import { Context, Service } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type {} from '@deepseek-ai/dsh-agent-default-model'
import type { ApiProxy } from './api/index.ts'
import { createApiProxy } from './api-proxy.ts'
import { createApiProxy, DEFAULT_COLD_BLANK_PROBE_MAX_BYTES } from './api-proxy.ts'
import {
DEFAULT_SESSION_LOG_COMPRESSION_LEVEL,
type SessionLogCompressionLevel,
@@ -53,6 +53,12 @@ export interface Config {
* @default 6
*/
sessionExportCompressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
/**
* Maximum physical size of a cold Session artifact eligible for blankness
* verification. Zero disables probes.
* @default 1024
*/
coldBlankProbeMaxBytes?: number
}
/**
@@ -70,6 +76,7 @@ export class ApiProxyService extends Service implements ApiProxy {
nativeOpen: z.boolean(),
sessionExportCompressionLevel: z.number().step(1).min(0).max(9)
.default(DEFAULT_SESSION_LOG_COMPRESSION_LEVEL) as z<SessionLogCompressionLevel>,
coldBlankProbeMaxBytes: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_BYTES),
})
readonly sessions: ApiProxy['sessions']
@@ -96,6 +103,9 @@ export class ApiProxyService extends Service implements ApiProxy {
...(config.sessionExportCompressionLevel === undefined
? {}
: { sessionExportCompressionLevel: config.sessionExportCompressionLevel }),
...(config.coldBlankProbeMaxBytes === undefined
? {}
: { coldBlankProbeMaxBytes: config.coldBlankProbeMaxBytes }),
})
this.sessions = api.sessions
this.subagents = api.subagents

View File

@@ -4,7 +4,7 @@
* isolation, and prompt failure mapping.
*/
import { mkdtempSync, writeFileSync, utimesSync } from 'node:fs'
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
@@ -13,7 +13,7 @@ import SessionStore from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import { MessageId } from '@deepseek-ai/dsh-llm'
import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import UserQuestionService from '@deepseek-ai/dsh-user-questions'
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
@@ -39,55 +39,183 @@ function header(id: string, createdAt: number, extra: Partial<SessionHeader> = {
}
describe('sessions.list cold merge', () => {
it('summarizes unattached sessions: log mtime, locate-less and vanished-log createdAt fallbacks, lineage', async () => {
it('verifies only small possibly-blank artifacts and treats every unavailable probe as visible', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(UserQuestionService)
const root = mkdtempSync(join(tmpdir(), 'dsh-cold-'))
const logPath = join(root, 'a.log')
writeFileSync(logPath, 'log-bytes')
utimesSync(logPath, 5000, 5000) // mtime 5_000_000 ms — newer than every createdAt below
const smallPath = join(root, 'small.log')
const largePath = join(root, 'large.log')
writeFileSync(smallPath, 'x'.repeat(1024))
writeFileSync(largePath, 'x'.repeat(1025))
const metas = [
header('session-a', 1000),
header('session-b', 2000, { parentSession: sid('session-parent'), origin: 'subagent' }),
header('session-c', 1500),
header('small-blank', 100),
header('small-conversation', 200),
header('large-unknown', 300),
header('cached-nonblank', 400),
header('locationless', 500, { parentSession: sid('session-parent'), origin: 'subagent' }),
header('vanished', 600),
header('read-failure', 700),
]
// Structural fake of the persistence face list() consumes: list + locate.
// locate: a real per-session file (mtime wins), a backend without one
// (SQLite shape → createdAt), and a path whose file vanished (stat ENOENT
// → createdAt).
const readFrom = vi.fn(async (id: SessionId) => {
if (id === sid('small-blank')) {
return {
meta: metas[0]!,
events: [{ type: 'session/end-seed', seq: 0, time: 700, data: {} }] as SessionEvent[],
}
}
if (id === sid('small-conversation')) {
return {
meta: metas[1]!,
events: [
{ type: 'turn/start', seq: 0, time: 800, data: { turn: 1 } },
{
type: 'user/message', seq: 1, time: 1200,
data: createUserMessage({ content: [{ type: 'text', text: 'worked' }], source: { kind: 'user' } }),
surfaceOp: 'append',
},
] as SessionEvent[],
}
}
if (id === sid('read-failure')) throw new Error('simulated read failure')
throw new Error(`unexpected cold read: ${id}`)
})
ctx.provide('sessionPersistence', {
list: () => Promise.resolve(metas),
locate: (meta: SessionHeader) => {
if (meta.id === sid('session-a')) return { kind: 'jsonl', path: logPath }
if (meta.id === sid('session-c')) return { kind: 'jsonl', path: join(root, 'vanished.log') }
if (meta.id === sid('large-unknown')) return { kind: 'jsonl', path: largePath }
if (meta.id === sid('locationless')) return undefined
if (meta.id === sid('vanished')) return { kind: 'jsonl', path: join(root, 'vanished.log') }
return { kind: 'jsonl', path: smallPath }
},
readFrom,
} as never)
ctx.provide('sessionProjectionCache', {
cachedSnapshot: (meta: SessionHeader) => {
if (meta.id === sid('small-blank')) {
return { asOfSeq: 0, values: { sessionListMetadata: { blank: true, lastPromptAt: null } } }
}
if (meta.id === sid('small-conversation')) {
return { asOfSeq: 0, values: { sessionListMetadata: { blank: true, lastPromptAt: 900 } } }
}
if (meta.id === sid('cached-nonblank')) {
return { asOfSeq: 1, values: { sessionListMetadata: { blank: false, lastPromptAt: 1000 } } }
}
return undefined
},
})
} as never)
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const response = await api.sessions.list(request({}))
expect(response.result.ok).toBe(true)
if (!response.result.ok) throw new Error('unreachable')
const items = response.result.value.items
expect(items.map(item => item.sessionId)).toEqual(['session-a', 'session-b', 'session-c'])
const [a, b, c] = items
expect(a?.updatedAt).toBeCloseTo(5_000_000, -3)
expect(a?.running).toBe(false)
// Cold summaries are never blank: lazy persistence keeps never-appended
// sessions out of list(), so a listed session necessarily has events.
expect(items.every(item => !item.blank)).toBe(true)
expect(a?.cwd).toBe('/proj')
expect(a?.parentSessionId).toBeUndefined()
expect(b?.updatedAt).toBe(2000)
expect(b?.parentSessionId).toBe('session-parent')
expect(b?.origin).toBe('subagent')
expect(c?.updatedAt).toBe(1500)
const byId = Object.fromEntries(response.result.value.items.map(item => [item.sessionId, item]))
expect(byId['small-blank']).toMatchObject({ blank: true, updatedAt: 100, running: false })
// A stale true hint cannot hide the turn found in the bounded read.
expect(byId['small-conversation']).toMatchObject({ blank: false, updatedAt: 1200 })
expect(byId['large-unknown']).toMatchObject({ blank: false, updatedAt: 300 })
// false is monotonic, so this row skips stat/read and keeps cached recency.
expect(byId['cached-nonblank']).toMatchObject({ blank: false, updatedAt: 1000 })
expect(byId['locationless']).toMatchObject({
blank: false,
updatedAt: 500,
parentSessionId: 'session-parent',
origin: 'subagent',
})
expect(byId['vanished']).toMatchObject({ blank: false, updatedAt: 600 })
expect(byId['read-failure']).toMatchObject({ blank: false, updatedAt: 700 })
expect(readFrom).toHaveBeenCalledTimes(3)
expect(readFrom.mock.calls.map(([id]) => id)).toEqual(expect.arrayContaining([
sid('small-blank'),
sid('small-conversation'),
sid('read-failure'),
]))
})
it('can disable bounded blank probes without hiding cold Sessions', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(UserQuestionService)
const meta = header('probe-disabled', 100)
const readFrom = vi.fn()
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]),
locate: () => ({ kind: 'jsonl', path: '/not-read' }),
readFrom,
} as never)
const api = createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
cwd: '/tmp',
coldBlankProbeMaxBytes: 0,
})
const response = await api.sessions.list(request({}))
if (!response.result.ok) throw new Error('unreachable')
expect(response.result.value.items).toEqual([
expect.objectContaining({ sessionId: meta.id, blank: false, updatedAt: meta.createdAt }),
])
expect(readFrom).not.toHaveBeenCalled()
})
it('replaces a probed cold row with the live Session that attached during the read', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(UserQuestionService)
await ctx.plugin(AgentRegistry)
const meta = header('attached-during-probe', 100)
const root = mkdtempSync(join(tmpdir(), 'dsh-cold-race-'))
const path = join(root, 'small.log')
writeFileSync(path, 'x')
const started = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]),
locate: () => ({ kind: 'jsonl', path }),
readFrom: async () => {
started.resolve(undefined)
await release.promise
return {
meta,
events: [{ type: 'session/end-seed', seq: 0, time: 110, data: {} }] as SessionEvent[],
}
},
} as never)
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const listing = api.sessions.list(request({}))
await started.promise
const session = ctx.sessions.create(meta.id, {
seed: [
{ type: 'turn/start', seq: 0, time: 200, data: { turn: 1 } },
{
type: 'user/message', seq: 1, time: 300,
data: createUserMessage({ content: [{ type: 'text', text: 'live' }], source: { kind: 'user' } }),
surfaceOp: 'append',
},
],
meta: {
...meta.cwd === undefined ? {} : { cwd: meta.cwd },
createdAt: meta.createdAt,
},
})
ctx.agents.register({ id: session.id, session, status: 'running', ctx } as Agent)
release.resolve(undefined)
const response = await listing
if (!response.result.ok) throw new Error('list failed')
expect(response.result.value.items).toEqual([
expect.objectContaining({
sessionId: meta.id,
blank: false,
running: true,
updatedAt: 300,
}),
])
})
})
describe('attached updatedAt excludes end-seed', () => {
it('reports the last real work, not the pickup, so a resumed-untouched session does not float', async () => {
describe('attached updatedAt tracks human prompts', () => {
it('ignores pickup and non-prompt work after the latest human message', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(UserQuestionService)
@@ -99,7 +227,12 @@ describe('attached updatedAt excludes end-seed', () => {
const resumed = ctx.sessions.create(sid('resumed-untouched'), {
seed: [
{ type: 'turn/start', seq: 0, time: worked, data: { turn: 1 } },
{ type: 'turn/end', seq: 1, time: worked, data: { turn: 1, reason: { kind: 'completed' } } },
{
type: 'user/message', seq: 1, time: worked,
data: createUserMessage({ content: [{ type: 'text', text: 'worked' }], source: { kind: 'user' } }),
surfaceOp: 'append',
},
{ type: 'turn/end', seq: 2, time: worked + 1, data: { turn: 1, reason: { kind: 'completed' } } },
],
meta: { cwd: '/proj', createdAt: 500 },
})
@@ -113,12 +246,21 @@ describe('attached updatedAt excludes end-seed', () => {
const summary = listed.result.value.items.find(item => item.sessionId === 'resumed-untouched')
expect(summary?.updatedAt).toBe(worked)
// Real work appended after end-seed does move it.
// A lifecycle boundary is not a human update.
resumed.append('turn/start', { turn: 2 })
const afterBoundary = await api.sessions.list(request({}))
if (!afterBoundary.result.ok) throw new Error('list failed')
expect(afterBoundary.result.value.items.find(item => item.sessionId === 'resumed-untouched')?.updatedAt)
.toBe(worked)
const prompt = resumed.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'new prompt' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
const after = await api.sessions.list(request({}))
if (!after.result.ok) throw new Error('list failed')
const moved = after.result.value.items.find(item => item.sessionId === 'resumed-untouched')
expect(moved?.updatedAt).toBeGreaterThan(worked)
expect(moved?.updatedAt).toBe(prompt.time)
})
})

View File

@@ -7,7 +7,7 @@
* pushed to mux consumers as a session/projection frame minted here.
*/
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { z } from 'zod'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
@@ -163,10 +163,29 @@ describe('session.history projections block', () => {
dispose()
const after = await proxy.sessions.history(request({ sessionId: session.id }))
if (!after.result.ok) throw new Error('unreachable')
// The registry is still mounted, so the block itself stays (asOfSeq cut
// with zero keys); the disposed key reads as capability absence.
// The registry stays mounted; only the disposed key leaves while the
// gateway-owned Session-list unit remains.
expect(after.result.value.projections?.asOfSeq).toBe(session.seq - 1)
expect(after.result.value.projections?.values).toEqual({})
expect('test/last-user' in (after.result.value.projections?.values ?? {})).toBe(false)
expect(after.result.value.projections?.values.sessionListMetadata).toEqual({
blank: true,
lastPromptAt: session.events.at(-1)?.time,
})
})
it('removes the gateway-owned Session-list unit when the gateway fiber unloads', async () => {
const { ctx, session } = await harness(true)
expect('sessionListMetadata' in ctx.sessionProjections.snapshot(session).values).toBe(false)
const fiber = ctx.plugin(Object.assign((gatewayCtx: Context) => {
createApiProxy(gatewayCtx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
}, { inject: ['sessions', 'agents', 'userQuestions', 'sessionProjections'] }))
await fiber.await()
await vi.waitFor(() => {
expect(ctx.sessionProjections.snapshot(session).values.sessionListMetadata)
.toEqual({ blank: true, lastPromptAt: null })
})
await fiber.dispose()
expect('sessionListMetadata' in ctx.sessionProjections.snapshot(session).values).toBe(false)
})
})
@@ -174,11 +193,18 @@ describe('session.list projections column', () => {
it('serves attached rows from the live registry cut, watermarked for client seeding', async () => {
const { ctx, session } = await harness(true)
ctx.sessionProjections.register(lastUserUnit())
const gateway = api(ctx)
await new Promise(resolve => setTimeout(resolve, 0))
session.append('turn/start', { turn: 1 })
seedMessages(session, 1)
const response = await api(ctx).sessions.list(request({}))
const response = await gateway.sessions.list(request({}))
if (!response.result.ok) throw new Error('unreachable')
const row = response.result.value.items.find(item => item.sessionId === session.id)
expect(row?.projections?.values['test/last-user']).toEqual({ text: 'm0' })
expect(row?.projections?.values.sessionListMetadata).toEqual({
blank: false,
lastPromptAt: session.events.at(-1)?.time,
})
expect(row?.projections?.asOfSeq).toBe(session.seq - 1)
})
@@ -266,21 +292,33 @@ describe('session/projection push frame', () => {
await new Promise(resolve => setTimeout(resolve, 0))
const abort = new AbortController()
const stream = proxy.events.mux({ rpcId: RpcId('t-proj-mux'), payload: {} }, abort.signal)
const collected = collect(stream, 2, abort)
const collected = collect(stream, 5, abort)
const now = vi.spyOn(Date, 'now').mockReturnValue(100)
seedMessages(session, 1)
// Same-reference apply: turn/start does not concern the unit — no frame.
now.mockReturnValue(200)
session.append('turn/start', { turn: 1 })
now.mockReturnValue(300)
seedMessages(session, 1)
now.mockRestore()
const frames = await collected
const pushes = frames.filter(
(f): f is Extract<MuxFrame, { type: 'session/projection' }> => f.type === 'session/projection',
(f): f is Extract<MuxFrame, { type: 'session/projection' }> =>
f.type === 'session/projection' && f.key === 'test/last-user',
)
expect(pushes).toEqual([
{ type: 'session/projection', sessionId: session.id, key: 'test/last-user', value: { text: 'm0' }, seq: 0 },
{ type: 'session/projection', sessionId: session.id, key: 'test/last-user', value: { text: 'm0' }, seq: 2 },
])
expect(frames.filter(
(f): f is Extract<MuxFrame, { type: 'session/projection' }> =>
f.type === 'session/projection' && f.key === 'sessionListMetadata',
)).toEqual([
{ type: 'session/projection', sessionId: session.id, key: 'sessionListMetadata', value: { blank: true, lastPromptAt: 100 }, seq: 0 },
{ type: 'session/projection', sessionId: session.id, key: 'sessionListMetadata', value: { blank: false, lastPromptAt: 100 }, seq: 1 },
{ type: 'session/projection', sessionId: session.id, key: 'sessionListMetadata', value: { blank: false, lastPromptAt: 300 }, seq: 2 },
])
// Frame seq aligns with the tail block's asOfSeq vocabulary (higher-seq-wins compatible).
const tail = await proxy.sessions.history(request({ sessionId: session.id }))
if (!tail.result.ok) throw new Error('unreachable')

View File

@@ -95,7 +95,12 @@ function bench(options: {
})
// The gateway's own projection push feed subscribes at construction; the
// no-op disposer keeps that feed quiet while these tests pin history reads.
ctx.provide('sessionProjections', { snapshot, restore, onChanged: () => () => {} })
ctx.provide('sessionProjections', {
snapshot,
restore,
onChanged: () => () => {},
register: () => () => {},
})
ctx.provide('userQuestions', { registerProvider: () => () => {} })
const api = createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp',

View File

@@ -127,17 +127,32 @@ async function responseBytes(response: Response): Promise<Uint8Array> {
describe('session export compression config', () => {
it('defaults to level 6 and rejects values outside the integer 0-9 range', () => {
expect(ApiProxyService.Config({})).toEqual({ sessionExportCompressionLevel: 6 })
expect(ApiProxyService.Config({})).toEqual({
sessionExportCompressionLevel: 6,
coldBlankProbeMaxBytes: 1024,
})
expect(ApiProxyService.Config({ sessionExportCompressionLevel: 0 }))
.toEqual({ sessionExportCompressionLevel: 0 })
.toEqual({ sessionExportCompressionLevel: 0, coldBlankProbeMaxBytes: 1024 })
expect(ApiProxyService.Config({ sessionExportCompressionLevel: 9 }))
.toEqual({ sessionExportCompressionLevel: 9 })
.toEqual({ sessionExportCompressionLevel: 9, coldBlankProbeMaxBytes: 1024 })
for (const value of [-1, 10, 1.5]) {
expect(() => ApiProxyService.Config({ sessionExportCompressionLevel: value } as never)).toThrow()
}
})
})
describe('cold blank probe config', () => {
it('accepts a per-Session byte bound including zero and rejects invalid bounds', () => {
expect(ApiProxyService.Config({ coldBlankProbeMaxBytes: 0 }))
.toEqual({ sessionExportCompressionLevel: 6, coldBlankProbeMaxBytes: 0 })
expect(ApiProxyService.Config({ coldBlankProbeMaxBytes: 2048 }))
.toEqual({ sessionExportCompressionLevel: 6, coldBlankProbeMaxBytes: 2048 })
for (const value of [-1, 1.5]) {
expect(() => ApiProxyService.Config({ coldBlankProbeMaxBytes: value })).toThrow()
}
})
})
describe('session.export download endpoint', () => {
it('streams a ZIP with the root artifact verbatim under its original filename', async () => {
const api = await buildApi({ 'session-root': artifact('session-root') })

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/shell/pwsh-local/README.md
README.md: cfb58b569022bca11d18c196e0bce104247e4c81
README.zh.md: 9393c5d210743d8423457f2d7df5900c1e0df182
README.md: 2eccc59b919d1f729eef52a42581f4da0f1d9e60
README.zh.md: a03f80343711a1471bc96017e23476a3253ec46c

View File

@@ -30,7 +30,7 @@ The Windows counterpart of `dsh-bash-local`, deliberately mirroring its semantic
- **Spawn per call, no shell state** — every call is a fresh non-interactive `pwsh -Command` (deterministic; no profile files). The `-NoLogo -NoProfile -NonInteractive` flags disable startup banners, profile loading, and prompts that would garble tool output.
- **The composition entry is a layer, not the last word** — when a settings provider is composed, this executor registers the capability's [`bash` namespace](../shell/README.md) with the entry above as its base, so a user section in `settings.yaml` layers over it and the next command runs with the new budgets. The namespace is shared with the POSIX family because a host composes exactly one provider of `ctx.shell`; a document written on either platform keeps resolving on the other. Values the schema cannot judge (positive and finite, the `graceMs` timer bound) are refused at the write, leaving the running executor on its last good section.
- **UTF-8 output pinned** — every command runs with `[Console]::OutputEncoding` and `$OutputEncoding` set to UTF-8 first, so the Windows PowerShell 5.1 fallback (or any host whose console code page is not UTF-8) cannot garble non-ASCII output: the subprocess collector decodes bytes as UTF-8. Input encoding is left at the host default; pwsh 7 defaults to UTF-8 and is unaffected.
- **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking `existsSync` on each; elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)`; it runs at construction and again only when a stored `pwshPath` differs from the one the current executable was resolved from, so an unrelated settings change never re-probes the filesystem.
- **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking each candidate with an lstat probe that accepts a real file or a link-shaped reparse point (a Store app execution alias stat-fails against its target's ACL, but lstat sees the alias itself); elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)`; it runs at construction and again only when a stored `pwshPath` differs from the one the current executable was resolved from, so an unrelated settings change never re-probes the filesystem.
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Tree termination (taskkill on Windows, process-group signals on POSIX), the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `ShellExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
- **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-terminated command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). Windows reports forced termination as exit 1 without a signal, so signal-stamped facts (`signal`, `killed` status) are POSIX-only there; the timeout/abort classification is platform-independent.
- **Model-friendly terminal env** — `NO_COLOR=1 PAGER=cat GIT_PAGER=cat` (no `TERM=dumb`: that is a POSIX concept; `NO_COLOR` is honored by modern PowerShell renderers) merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins.

View File

@@ -30,7 +30,7 @@
- **每次调用新建进程,无 shell 状态**——每次调用都是全新的非交互 `pwsh -Command`(确定性;不加载 profile 文件)。`-NoLogo -NoProfile -NonInteractive` 关闭启动横幅、profile 加载与会干扰工具输出的提示符。
- **组装条目是一层,而不是最终值**——当组装中存在 settings 提供方时,本执行器以上面的条目为 base 注册该能力的 [`bash` 命名空间](../shell/README.md),因此 `settings.yaml` 中的用户段会叠加其上,下一条命令即按新预算运行。该命名空间与 POSIX 家族共用,因为一个宿主只组装一个 `ctx.shell` 提供方在任一平台写下的文档在另一平台仍能解析。schema 无法判定的值(正有限、`graceMs` 的定时器上界)会在写入时被拒绝,运行中的执行器保持它最后一份可用的段。
- **UTF-8 输出固定**——每条命令都先以 UTF-8 设置 `[Console]::OutputEncoding``$OutputEncoding`,因此 Windows PowerShell 5.1 兜底(或任何控制台代码页非 UTF-8 的主机)不会破坏非 ASCII 输出subprocess 收集器以 UTF-8 解码字节。输入编码保持宿主默认pwsh 7 默认为 UTF-8不受影响。
- **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一检查 `existsSync`;其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数;它在构造时执行,此后仅当存储的 `pwshPath` 与当前可执行文件所依据的值不同才再次执行,因此无关的设置变更绝不会重新探测文件系统。
- **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一用 lstat 探测检查接受真实文件或链接形态的重解析点Store 的 app execution alias 对其目标 stat 会因 ACL 失败,但 lstat 能看到别名本身);其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数;它在构造时执行,此后仅当存储的 `pwshPath` 与当前可执行文件所依据的值不同才再次执行,因此无关的设置变更绝不会重新探测文件系统。
- **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程树终止Windows 用 taskkillPOSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `ShellExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算stderr 与后台运行仍使用 `maxOutputBytes`
- **超时与取消分类**——`run()` 通过一个 deadline 融合按配置上限截取的超时与调用方信号;只有执行器自身超时报告 `timedOut`,上游取消报告 `aborted`,自我终止的命令两者都不报告(见 [timeout 库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)。Windows 将强制终止报告为退出码 1 且无信号,因此带信号标记的事实(`signal``killed` 状态)在那里仅限 POSIX超时/取消分类与平台无关。
- **面向模型的终端环境**——`NO_COLOR=1 PAGER=cat GIT_PAGER=cat`(没有 `TERM=dumb`:那是 POSIX 概念;现代 PowerShell 渲染器遵循 `NO_COLOR`),作为普通 env 在服务的凭据清理与 `DSH_*` 通道规则之下合并;显式调用方条目仍然优先。

View File

@@ -8,7 +8,7 @@
* @module @deepseek-ai/dsh-pwsh-local/resolve
*/
import { existsSync } from 'node:fs'
import { lstatSync } from 'node:fs'
import { join } from 'node:path'
/**
@@ -36,6 +36,25 @@ export function candidatePwshPaths(env: NodeJS.ProcessEnv = process.env): string
return candidates
}
/**
* Whether a candidate can be spawned. lstat opens the entry itself instead of
* following reparse points, so it sees the Store app execution alias where
* stat hits the target's ACL (EACCES); Node reports that alias as a symlink
* on current releases and as a plain file on older ones, and CreateProcess
* resolves either shape. A real directory never matches.
*/
function candidateExists(candidate: string): boolean {
try {
const stat = lstatSync(candidate)
return stat.isFile() || stat.isSymbolicLink()
} catch {
// ENOENT (the candidate vanished between listing and probing) is the only
// expected failure; any other error names an unspawnable path, so false
// is the safe answer for it too.
return false
}
}
/**
* Resolve the pwsh executable this executor spawns.
* @param configured - an explicit `pwshPath` config value, trusted as-is.
@@ -53,7 +72,7 @@ export function resolvePwshPath(
if (configured !== undefined && configured.length > 0) return configured
if (platform === 'win32') {
for (const candidate of candidatePwshPaths(env)) {
if (existsSync(candidate)) return candidate
if (candidateExists(candidate)) return candidate
}
}
return 'pwsh'

View File

@@ -9,7 +9,7 @@
* writes CRLF on Windows, so exact text assertions normalize line endings.
*/
import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs'
import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
@@ -126,6 +126,29 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () =>
expect(resolvePwshPath(undefined, { ProgramFiles: join(dir, 'missing'), PATH: join(dir, 'empty'), SystemRoot: join(dir, 'no-windows') }, 'win32'))
.toBe('pwsh')
})
it('accepts a link-shaped PATH candidate whose target cannot be stat-ed', () => {
// Store app execution aliases stat as EACCES but lstat as a link; a
// dangling symlink reproduces that split on every platform.
const dir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-resolve-link-'))
const store = join(dir, 'store')
mkdirSync(store, { recursive: true })
const link = join(store, 'pwsh.exe')
symlinkSync(join(dir, 'no-such-target.exe'), link)
expect(resolvePwshPath(undefined, { ProgramFiles: join(dir, 'missing'), PATH: store }, 'win32'))
.toBe(link)
})
it('skips a directory candidate and falls through to the PATH-resolution default', () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-resolve-dir-'))
const store = join(dir, 'store')
mkdirSync(join(store, 'pwsh.exe'), { recursive: true })
expect(resolvePwshPath(undefined, {
ProgramFiles: join(dir, 'missing'),
PATH: store,
SystemRoot: join(dir, 'no-windows'),
}, 'win32')).toBe('pwsh')
})
})
describe('spawn construction (pure, every platform)', () => {
@@ -298,8 +321,10 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)'
it('start returns immediately with a running handle that settles as completed', async () => {
const { bash } = await setup()
const before = Date.now()
const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Milliseconds 200; Write-Output done' }))
expect(Date.now() - before).toBeLessThan(150)
// The sleep outlasts any realistic spawn latency, so returning while the
// child still sleeps proves start() does not wait for completion.
const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Milliseconds 2000; Write-Output done' }))
expect(Date.now() - before).toBeLessThan(1000)
expect(proc.status).toBe('running')
await proc.done
expect(proc.status).toBe('completed')

View File

@@ -17,7 +17,7 @@ import type {
SDKSystemMessage,
} from '@anthropic-ai/claude-agent-sdk'
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
@@ -87,6 +87,22 @@ const roots: string[] = []
const fixtures: MessagesFixture[] = []
const contexts: Context[] = []
// Ambient Anthropic model env leaks into the real CLI and overrides the
// fixture settings.json on developer machines; delete it for this file and
// restore it after, like the workspace-context USERPROFILE isolation.
const ambientAnthropicModel = process.env.ANTHROPIC_MODEL
const ambientAnthropicSmallFastModel = process.env.ANTHROPIC_SMALL_FAST_MODEL
beforeAll(() => {
delete process.env.ANTHROPIC_MODEL
delete process.env.ANTHROPIC_SMALL_FAST_MODEL
})
afterAll(() => {
if (ambientAnthropicModel !== undefined) process.env.ANTHROPIC_MODEL = ambientAnthropicModel
if (ambientAnthropicSmallFastModel !== undefined) process.env.ANTHROPIC_SMALL_FAST_MODEL = ambientAnthropicSmallFastModel
})
afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
await Promise.all(fixtures.splice(0).map(fixture => fixture.close()))

View File

@@ -53,7 +53,7 @@ class GatedAdapter extends LlmAdapter {
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
})
/** Boot the full continuable stack: loop, persistence, providers, and subagents. */

View File

@@ -28,7 +28,7 @@ type Script = ConstructorParameters<typeof MockAdapter>[0]
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
})
/** Boot the continuable stack with real JSONL session persistence. */

View File

@@ -47,7 +47,7 @@ const testToolSignal = new AbortController().signal
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
})
async function setupWith(adapter: MockAdapter | GatedAdapter) {

View File

@@ -70,7 +70,7 @@ describe('todos projection provider', () => {
const bench = await harness(true)
seedMessage(bench.session)
const projections = await bench.tailProjections()
expect(projections?.values).toEqual({ todos: null })
expect(projections?.values.todos).toBeNull()
expect(projections?.asOfSeq).toBe(bench.session.seq - 1)
})
@@ -117,7 +117,7 @@ describe('todos projection provider', () => {
const bench = await harness(false)
seedMessage(bench.session)
const fiber = await bench.ctx.plugin(ToolTodo, { allowParallelInProgress: true })
expect((await bench.tailProjections())?.values).toEqual({ todos: null })
expect((await bench.tailProjections())?.values.todos).toBeNull()
await fiber.dispose()
expect('todos' in ((await bench.tailProjections())?.values ?? {})).toBe(false)
})

View File

@@ -6,6 +6,7 @@
* @module @deepseek-ai/dsh-workflow-worker-thread/host
*/
import { tmpdir } from 'node:os'
import { Worker } from 'node:worker_threads'
import type { WorkerOptions } from 'node:worker_threads'
import { fileURLToPath } from 'node:url'
@@ -28,18 +29,45 @@ interface ChildRecord {
disposal?: Promise<void>
}
/**
* The scrubbed worker environment: no ambient credentials, no loader flags.
* Windows derives `os.tmpdir()` from `TMP`/`TEMP` and falls back to the
* literal relative path `undefined\temp` when the environment is empty, so
* tsx's transform cache would land in a cwd-relative `undefined/temp`
* directory; the host's real temp path (not a credential) is injected there.
* The unbuilt shape additionally forwards `TSX_TSCONFIG_PATH` for path
* resolution.
* @param platform - host platform; overridable so tests exercise both peer arms.
* @param tsconfigPath - the tsconfig pin to forward; only the unbuilt caller
* passes one, so the built worker never observes the host's pin.
* @returns the scrubbed worker environment object.
*/
export function workerSpawnEnv(
platform: NodeJS.Platform = process.platform,
tsconfigPath?: string,
): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {}
if (platform === 'win32') {
const tmp = tmpdir()
env.TMP = tmp
env.TEMP = tmp
}
if (tsconfigPath !== undefined) env.TSX_TSCONFIG_PATH = tsconfigPath
return env
}
/**
* Resolve a built worker bundle or an unbuilt bootstrap that installs both tsx
* transforms inside the worker. Both shapes clear `execArgv` and the ambient
* environment; the unbuilt shape forwards only `TSX_TSCONFIG_PATH` for path
* resolution.
* environment (the worker only sees the platform temp path and, unbuilt,
* `TSX_TSCONFIG_PATH`).
* @param init - the run payload, passed as `workerData`.
* @returns the entry path or URL and the Worker options to spawn it with.
*/
function resolveWorkerSpawn(init: WorkerInit): { entry: string | URL; options: WorkerOptions } {
/* v8 ignore next 3 -- the built-output arm: tests always run unbuilt (src/); the built-worker e2e exercises this shape for real */
if (!import.meta.url.endsWith('.ts')) {
return { entry: fileURLToPath(new URL('./worker.cjs', import.meta.url)), options: { workerData: init, env: {}, execArgv: [] } }
return { entry: fileURLToPath(new URL('./worker.cjs', import.meta.url)), options: { workerData: init, env: workerSpawnEnv(), execArgv: [] } }
}
// Resolve tsx only for unbuilt consumers and install it before importing TS.
const workerEntry = new URL('./worker.ts', import.meta.url)
@@ -56,7 +84,7 @@ function resolveWorkerSpawn(init: WorkerInit): { entry: string | URL; options: W
entry: new URL(`data:text/javascript,${encodeURIComponent(bootstrap)}`),
options: {
workerData: init,
env: process.env.TSX_TSCONFIG_PATH === undefined ? {} : { TSX_TSCONFIG_PATH: process.env.TSX_TSCONFIG_PATH },
env: workerSpawnEnv(undefined, process.env.TSX_TSCONFIG_PATH),
execArgv: [],
},
}

View File

@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { tmpdir } from 'node:os'
import { fileURLToPath } from 'node:url'
import type { Worker } from 'node:worker_threads'
import { Context } from '@deepseek-ai/cordis'
@@ -9,6 +10,7 @@ import type { SubagentCapabilities, SubagentProvider, SubagentResult, SubagentRu
import type { WorkflowMeta, WorkflowResult, WorkflowResultInfo, WorkflowRun, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow'
import * as workerEngineModule from '../src/index.ts'
import WorkerThreadWorkflowEngine, { type Config } from '../src/index.ts'
import { workerSpawnEnv } from '../src/host.ts'
import { HostToWorkerType, WorkerToHostType } from '../src/protocol.ts'
import { SessionId } from '@deepseek-ai/dsh-session'
@@ -559,24 +561,51 @@ describe('dsh-workflow-worker-thread', () => {
expect(result.value).toBe('fine')
})
it('the worker spawns with an EMPTY environment: an escaped script finds no ambient credentials', async () => {
it('the worker spawns with a scrubbed environment: an escaped script finds no ambient credentials', async () => {
const { ctx, parent } = await setup()
// A canary in the HARNESS process's env: with an inherited environment
// the escape below would read it back (exactly how DEEPSEEK_API_KEY
// would leak); env: {} in the spawn options is what keeps it out.
// would leak); the worker env keeps every ambient variable out. Windows
// additionally receives the host temp path (TMP/TEMP) so `os.tmpdir()`
// inside the worker resolves instead of degrading to a cwd-relative
// `undefined\temp` (tsx writes its transform cache there).
process.env.WORKFLOW_ENV_CANARY = 'leak me'
// The unbuilt worker forwards TSX_TSCONFIG_PATH (a path pin, not a
// credential); clear it so this test observes the empty ambient case
// regardless of the parent's environment.
const tsconfigPath = process.env.TSX_TSCONFIG_PATH
delete process.env.TSX_TSCONFIG_PATH
try {
const result = await run(ctx, parent, scripted(`
const proc = ${ESCAPE}
return { canary: proc.env.WORKFLOW_ENV_CANARY ?? null, keys: Object.keys(proc.env).length }
return { canary: proc.env.WORKFLOW_ENV_CANARY ?? null, keys: Object.keys(proc.env).sort() }
`))
expect(result.stopReason).toBe('completed')
expect(result.value).toEqual({ canary: null, keys: 0 })
const expectedKeys = process.platform === 'win32' ? ['TEMP', 'TMP'] : []
expect(result.value).toEqual({ canary: null, keys: expectedKeys })
} finally {
if (tsconfigPath === undefined) delete process.env.TSX_TSCONFIG_PATH
else process.env.TSX_TSCONFIG_PATH = tsconfigPath
delete process.env.WORKFLOW_ENV_CANARY
}
})
it('workerSpawnEnv injects the host temp path on win32 and leaves the POSIX peer empty', () => {
const tmp = tmpdir()
expect(workerSpawnEnv('win32')).toEqual({ TMP: tmp, TEMP: tmp })
expect(workerSpawnEnv('linux')).toEqual({})
})
it('workerSpawnEnv forwards TSX_TSCONFIG_PATH when the snapshot harness pins it', () => {
const tsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
expect(workerSpawnEnv('linux', tsconfig)).toEqual({ TSX_TSCONFIG_PATH: tsconfig })
expect(workerSpawnEnv('win32', tsconfig)).toEqual({
TMP: tmpdir(),
TEMP: tmpdir(),
TSX_TSCONFIG_PATH: tsconfig,
})
})
it('the unbuilt worker forwards exactly TSX_TSCONFIG_PATH through the scrub: the paths-map pin survives, secrets do not', async () => {
const { ctx, parent } = await setup()
// The ACP snapshot harness runs the parent with its cwd OUTSIDE the
@@ -589,10 +618,13 @@ describe('dsh-workflow-worker-thread', () => {
try {
const result = await run(ctx, parent, scripted(`
const proc = ${ESCAPE}
return { keys: Object.keys(proc.env), tsconfig: proc.env.TSX_TSCONFIG_PATH }
return { keys: Object.keys(proc.env).sort(), tsconfig: proc.env.TSX_TSCONFIG_PATH }
`))
expect(result.stopReason).toBe('completed')
expect(result.value).toEqual({ keys: ['TSX_TSCONFIG_PATH'], tsconfig })
const expectedKeys = process.platform === 'win32'
? ['TEMP', 'TMP', 'TSX_TSCONFIG_PATH']
: ['TSX_TSCONFIG_PATH']
expect(result.value).toEqual({ keys: expectedKeys, tsconfig })
} finally {
delete process.env.TSX_TSCONFIG_PATH
delete process.env.WORKFLOW_ENV_CANARY