From cdcdd2221edd2b5e55b18a62070e4f776068d4c8 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 12:52:59 +0800 Subject: [PATCH 01/72] feat(host): show-hidden toggle in the directory browser footer --- .../src/client/DirectoryBrowser.module.css | 26 +++++++++++++++++++ .../src/client/DirectoryBrowser.tsx | 22 +++++++++++++--- .../src/client/index.ts | 4 +++ .../tests/client-flow.spec.tsx | 2 ++ .../tests/directory-browser.spec.tsx | 17 ++++++++++++ 5 files changed, 67 insertions(+), 4 deletions(-) diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index 800af854a1..bdb57ca848 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -242,6 +242,32 @@ border-top: 1px solid var(--dsw-alias-border-l3); } +/* Show-hidden toggle: a subtle text button in the footer, left of the gap. */ +.showHiddenToggle { + border: none; + background: transparent; + padding: 0; + font-size: 13px; + line-height: 20px; + font-weight: 500; + color: var(--dsw-alias-label-secondary); + cursor: pointer; + white-space: nowrap; +} + +.showHiddenToggle:hover { + color: var(--dsw-alias-label-primary); +} + +.showHiddenToggle:disabled { + color: var(--dsw-alias-label-caption); + cursor: default; +} + +.showHiddenToggleActive { + color: var(--dsw-alias-label-primary); +} + .footerGap { flex: 1 1 0; } diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index f348f1dd8d..20fef7a827 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -10,8 +10,8 @@ * selects the created folder. Open adopts the selected folder, falling back * to the listed level. Pure consumer of the injected browse calls — the * owning flow decides what "Open" means and owns the workspace-creation - * error surface. Hidden entries are host-flagged and filtered here (a - * show-hidden toggle is deferred work, client-side only). + * error surface. Hidden entries are host-flagged and hidden by default; + * a "Show hidden files" toggle in the footer reveals them (client-side only). */ import { useCallback, useEffect, useRef, useState } from 'react' import clsx from 'clsx' @@ -60,16 +60,17 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE } /** One column of folder rows (the Miller view renders one or two of these). */ -function LevelColumn({ entries, selectedPath, busy, onPick, wide }: { +function LevelColumn({ entries, selectedPath, busy, onPick, wide, showHidden }: { entries: readonly DirectoryEntry[] selectedPath: string | null busy: boolean onPick: (entry: DirectoryEntry) => void wide: boolean + showHidden: boolean }) { return (
- {entries.filter(entry => !entry.hidden).map((entry) => { + {entries.filter(entry => showHidden || !entry.hidden).map((entry) => { const selected = entry.path === selectedPath return ( // The wrapper carries the list semantics; the row keeps its NATIVE @@ -110,6 +111,8 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const [error, setError] = useState(null) // Path-edit state: null = breadcrumb mode; a string = the draft being typed. const [pathDraft, setPathDraft] = useState(null) + // Show-hidden toggle state (pure client-side filter, reset on close). + const [showHidden, setShowHidden] = useState(false) // Create-folder state: null = closed; a string = the nested dialog's draft. const [folderDraft, setFolderDraft] = useState(null) const [creatingFolder, setCreatingFolder] = useState(false) @@ -213,6 +216,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setSelected(null) setChild(null) setCreatingFolder(false) + setShowHidden(false) navigate() return } @@ -409,6 +413,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, busy={parentInert} onPick={select} wide={!twoPane} + showHidden={showHidden} /> )} {twoPane && } @@ -419,6 +424,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, busy={parentInert} onPick={advance} wide={false} + showHidden={showHidden} /> )}
@@ -442,6 +448,14 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, > {t('browser.newFolder')} + diff --git a/packages/host/directory-picker-browse/src/client/index.ts b/packages/host/directory-picker-browse/src/client/index.ts index e47613cdaf..a458ca94c7 100644 --- a/packages/host/directory-picker-browse/src/client/index.ts +++ b/packages/host/directory-picker-browse/src/client/index.ts @@ -47,7 +47,6 @@ export function apply(ctx: ClientContext): void { 'browser.loading': '加载中…', 'browser.truncated': '文件夹过多,仅显示开头部分。', 'browser.showHidden': '显示隐藏文件', - 'browser.hideHidden': '隐藏隐藏文件', }], ['en', { 'browser.title': 'Select Workspace Directory', @@ -63,7 +62,6 @@ export function apply(ctx: ClientContext): void { 'browser.loading': 'Loading…', 'browser.truncated': 'Too many folders to list; only the beginning is shown.', 'browser.showHidden': 'Show hidden files', - 'browser.hideHidden': 'Hide hidden files', }], ] try { diff --git a/packages/host/directory-picker-browse/tests/client-flow.spec.tsx b/packages/host/directory-picker-browse/tests/client-flow.spec.tsx index c29afc935c..31ec5a4927 100644 --- a/packages/host/directory-picker-browse/tests/client-flow.spec.tsx +++ b/packages/host/directory-picker-browse/tests/client-flow.spec.tsx @@ -163,7 +163,6 @@ describe('directory-picker-browse client half', () => { expect(injected.t('browser.title')).toBe('选择工作区目录') expect(injected.t('browser.newFolder')).toBe('新建文件夹') expect(injected.t('browser.showHidden')).toBe('显示隐藏文件') - expect(injected.t('browser.hideHidden')).toBe('隐藏隐藏文件') }) it('drives the injected browse calls through the hole entry', async () => { diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index 624a2c8705..bc2ad81f76 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -107,11 +107,14 @@ describe('DirectoryBrowser', () => { const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) expect(screen.queryByText('.config')).toBeNull() - // Toggle hidden files on. - fireEvent.click(screen.getByRole('button', { name: 'browser.showHidden' })) + // The fixed-label toggle reports its state through aria-pressed. + const toggle = screen.getByRole('button', { name: 'browser.showHidden' }) + expect(toggle.getAttribute('aria-pressed')).toBe('false') + fireEvent.click(toggle) + expect(toggle.getAttribute('aria-pressed')).toBe('true') expect(screen.getByText('.config')).toBeTruthy() - // Toggle hidden files off. - fireEvent.click(screen.getByRole('button', { name: 'browser.hideHidden' })) + fireEvent.click(toggle) + expect(toggle.getAttribute('aria-pressed')).toBe('false') expect(screen.queryByText('.config')).toBeNull() // Close resets the toggle. b.view.rerender() From 60383efef93e083763f7b86239afc7e4798c181e Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 13:42:11 +0800 Subject: [PATCH 03/72] feat(host): blur cancels path editing; scrollbar clearance in the miller columns --- .../src/client/DirectoryBrowser.module.css | 11 ++++- .../src/client/DirectoryBrowser.tsx | 42 +++++++++++-------- .../tests/directory-browser.spec.tsx | 14 +++++++ 3 files changed, 48 insertions(+), 19 deletions(-) diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index a2c712c811..444ff19cbf 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -55,7 +55,9 @@ align-items: stretch; flex: 1 1 0; min-height: 0; - gap: 20px; + /* Columns already end in an 8px scrollbar clearance, so the divider only + * needs a slim gap of its own on each side. */ + gap: 12px; overflow-x: auto; scrollbar-width: none; } @@ -136,7 +138,9 @@ flex-direction: column; flex: 1 1 0; min-height: 0; - padding: 16px 24px; + /* Right inset is slimmer than the left: the trailing column's own 8px + * scrollbar clearance makes up the optical difference. */ + padding: 16px 16px 16px 24px; } /* Two-pane columns split the row evenly around the divider; 256px is the @@ -149,6 +153,9 @@ flex: 1 1 0; min-width: 256px; overflow-y: auto; + /* The overlay scrollbar paints at the column's edge; keep the row pills + * clear of the thumb. */ + padding-right: 8px; } .columnWide { diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 149e04d87e..5c3391454e 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -200,6 +200,25 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }) }, [launchListing]) + /** Abandon path editing (Escape or clicking away) and restore the crumb view. */ + const cancelPathEdit = useCallback(() => { + // Cancel also withdraws a navigation the editor already launched: its + // late success must not jump to the cancelled path, so the pending + // request is superseded and the view leaves the loading state. + supersede() + setLoading(false) + setPathDraft(null) + setError(null) + // Editing may have superseded the selection's preview request; a + // selection with no preview would render a half-empty two-pane view, so + // cancel falls back to the single-pane level. + if (child === null) setSelected(null) + // With no level listed yet (the editor superseded the initial home + // listing), plain cancellation would leave a permanently blank picker: + // restart the home listing. + if (parent === null) navigate() + }, [supersede, child, parent, navigate]) + /** A right-column pick advances the view one level: child becomes the level. */ const advance = useCallback((entry: DirectoryEntry) => { /* v8 ignore next -- narrowing guard: the right column only renders with a child listing. */ @@ -382,25 +401,14 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, } if (event.key === 'Escape') { event.stopPropagation() - // Cancel also withdraws a navigation the editor already - // launched: its late success must not jump to the - // cancelled path, so the pending request is superseded - // and the view leaves the loading state. - supersede() - setLoading(false) - setPathDraft(null) - setError(null) - // Editing may have superseded the selection's preview - // request; a selection with no preview would render a - // half-empty two-pane view, so cancel falls back to the - // single-pane level. - if (child === null) setSelected(null) - // With no level listed yet (the editor superseded the - // initial home listing), plain cancellation would leave a - // permanently blank picker: restart the home listing. - if (parent === null) navigate() + cancelPathEdit() } }} + // Clicking anywhere outside the editor reads as leaving it: + // focus loss cancels the edit like Escape. Enter keeps focus + // in the input while its navigation is in flight, so a + // submitted path is never withdrawn by this handler. + onBlur={cancelPathEdit} /> )} diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index bc2ad81f76..d4fc9e2b89 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -220,6 +220,20 @@ describe('DirectoryBrowser', () => { expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() }) + it('clicking away from the path editor cancels it back to the crumb view', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: '/somewhere/else' } }) + // Focus moving anywhere outside the editor abandons the draft like Escape. + fireEvent.blur(input) + expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + // The crumb view is back and the abandoned draft was never navigated to. + expect(screen.getByRole('button', { name: 'browser.editPath' })).toBeTruthy() + expect(screen.getByRole('listitem').textContent).toBe('Documents') + }) + it('restarts the home listing when Escape cancels an edit opened before any level listed', async () => { // The initial home listing hangs; Edit Path supersedes it while parent // is still null, and Escape must not strand a blank picker. From e561c28232a273f834e66e557e8375a3bb2cf7d0 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 13:47:56 +0800 Subject: [PATCH 04/72] feat(host): seed path editor with a trailing separator; prefix-filter levels from the draft tail --- .../src/client/DirectoryBrowser.tsx | 44 +++++++++++++++-- .../tests/directory-browser.spec.tsx | 47 ++++++++++++++++++- 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 5c3391454e..01e69f314c 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -12,7 +12,10 @@ * owning flow decides what "Open" means and owns the workspace-creation * error surface. Hidden entries are host-flagged and hidden by default; the * footer's fixed-label "Show hidden files" toggle (aria-pressed, check when - * on) reveals them (client-side only). + * on) reveals them (client-side only). The path editor opens seeded with a + * trailing separator, and while the draft's directory part names a listed + * level, its final segment prefix-filters that level's rows (a dot-led + * prefix also reveals the hidden entries it names). */ import { useCallback, useEffect, useRef, useState } from 'react' import clsx from 'clsx' @@ -60,18 +63,45 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE return [{ name: homeLabel, path: listing.home, hidden: false }, ...tail] } +/** The separator a Host path's own platform uses (Windows listings carry backslashes). */ +function separatorOf(path: string): string { + return path.includes('\\') ? '\\' : '/' +} + +/** + * The path draft's final segment, when its directory part is exactly the + * level `listing` lists — the segment the level prefix-filters on while the + * user types. Any other draft (no separator yet, or naming some other + * directory) leaves the level unfiltered. + */ +function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null { + if (draft === null) return null + const sep = separatorOf(draft) + const cut = draft.lastIndexOf(sep) + if (cut === -1) return null + const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}` + return draft.slice(0, cut + 1) === level ? draft.slice(cut + 1) : null +} + /** One column of folder rows (the Miller view renders one or two of these). */ -function LevelColumn({ entries, selectedPath, busy, onPick, wide, showHidden }: { +function LevelColumn({ entries, selectedPath, busy, onPick, wide, showHidden, filterPrefix }: { entries: readonly DirectoryEntry[] selectedPath: string | null busy: boolean onPick: (entry: DirectoryEntry) => void wide: boolean showHidden: boolean + filterPrefix: string | null }) { + const visible = entries.filter((entry) => { + if (filterPrefix !== null && !entry.name.toLowerCase().startsWith(filterPrefix.toLowerCase())) return false + // A dot-led prefix names hidden entries explicitly, so matching ones + // surface even while the toggle keeps the rest hidden. + return showHidden || !entry.hidden || filterPrefix?.startsWith('.') === true + }) return (
- {entries.filter(entry => showHidden || !entry.hidden).map((entry) => { + {visible.map((entry) => { const selected = entry.path === selectedPath return ( // The wrapper carries the list semantics; the row keeps its NATIVE @@ -370,7 +400,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // otherwise close the editor via navigate's draft reset. supersede() setLoading(false) - setPathDraft(selected?.path ?? parent?.path ?? '') + // Seed with a trailing separator so typing immediately + // continues into child names (and prefix-filters below). + const base = selected?.path ?? parent?.path ?? '' + const sep = separatorOf(base) + setPathDraft(base === '' || base.endsWith(sep) ? base : `${base}${sep}`) }} /> @@ -423,6 +457,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, onPick={select} wide={!twoPane} showHidden={showHidden} + filterPrefix={draftPrefixFor(parent, pathDraft)} /> )} {twoPane && } @@ -434,6 +469,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, onPick={advance} wide={false} showHidden={showHidden} + filterPrefix={draftPrefixFor(child, pathDraft)} /> )}
diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index d4fc9e2b89..98f3bd6e4f 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -206,7 +206,9 @@ describe('DirectoryBrowser', () => { await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) const input = screen.getByLabelText('browser.editPath') - expect(input.value).toBe(HOME) + // The editor seeds with a trailing separator so typing continues into + // child names. + expect(input.value).toBe(`${HOME}/`) fireEvent.change(input, { target: { value: DOCS } }) fireEvent.keyDown(input, { key: 'Enter' }) await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) @@ -220,6 +222,49 @@ describe('DirectoryBrowser', () => { expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() }) + it('prefix-filters the listed level from the draft tail, dot revealing hidden matches', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + // The seeded empty segment leaves the level as-is: hidden stays hidden. + expect(screen.getByRole('listitem').textContent).toBe('Documents') + // Case-insensitive prefix narrows the rows. + fireEvent.change(input, { target: { value: `${HOME}/do` } }) + expect(screen.getByRole('listitem').textContent).toBe('Documents') + // A dot-led prefix names hidden entries, so it reveals the match. + fireEvent.change(input, { target: { value: `${HOME}/.co` } }) + expect(screen.getByRole('listitem').textContent).toBe('.config') + // A prefix matching nothing empties the level (no stale rows linger). + fireEvent.change(input, { target: { value: `${HOME}/zzz` } }) + expect(screen.queryByRole('listitem')).toBeNull() + // A draft naming some other directory (or none) leaves the level whole. + fireEvent.change(input, { target: { value: 'no-separator' } }) + expect(screen.getByRole('listitem').textContent).toBe('Documents') + }) + + it('seeds and filters with backslashes on a Windows-rooted listing', async () => { + const ROOT = 'C:\\' + const windowsListing: DirectoryListing = { + path: ROOT, + home: ROOT, + crumbs: [{ name: 'C:\\', path: ROOT, hidden: false }], + entries: [ + { name: 'Program Files', path: `${ROOT}Program Files`, hidden: false }, + { name: 'Users', path: `${ROOT}Users`, hidden: false }, + ], + truncated: false, + } + mount({ listDirectory: vi.fn(async () => windowsListing) }) + await waitFor(() => { expect(screen.getAllByRole('listitem')).toHaveLength(2) }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + // The root already ends in its separator: no doubled backslash. + expect(input.value).toBe(ROOT) + fireEvent.change(input, { target: { value: `${ROOT}u` } }) + expect(screen.getByRole('listitem').textContent).toBe('Users') + }) + it('clicking away from the path editor cancels it back to the crumb view', async () => { mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) From 7714c9fa8b4c890ebc495e766a9d2f778e141953 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 13:52:44 +0800 Subject: [PATCH 05/72] doc(host): document the show-hidden toggle and path-draft prefix filter; snapshot the flow --- ...directory-picker-capability-seam.i18n.yaml | 4 +-- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- apps/web/tests/workspace-flow.snapshot.ts | 33 +++++++++++++++++++ .../directory-picker-browse/README.i18n.yaml | 4 +-- .../host/directory-picker-browse/README.md | 2 +- .../host/directory-picker-browse/README.zh.md | 2 +- 7 files changed, 41 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index bb9425fa64..6f21a60e83 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 7c8f8cb67690cb4c5858cefb52b8cd79e649ec38 -2026-07-28-directory-picker-capability-seam.zh.md: 05545fc3cd758523814b31afa705249972d86464 +2026-07-28-directory-picker-capability-seam.md: 3f5e1436f3af14ce06ffab00ceca90167e16afd0 +2026-07-28-directory-picker-capability-seam.zh.md: 09a3e20f7c12e3c22d63875091593f9a6ca8a1ad diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 7c8f8cb676..3f5e1436f3 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -18,7 +18,7 @@ Placement and policy rulings folded into this decision: - **Not the `ctx.fs` seam.** `packages/fs/` is the model/session-facing storage stack (policy events, sandbox-swappable backends). Riding it would couple GUI browsing to the model's confinement backend — swapping `fs-sandbox` for the model must never change GUI behavior — and OS facts (home anchoring, hidden conventions) are not storage primitives. The picker seam stays presentation-free and model-free; `packages/host/` is its consumer-domain home. - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. -- **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the planned show-hidden toggle becomes a client-only change. Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. +- **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change (the browse client's footer toggle). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. - **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 05545fc3cd..09a3e20f7c 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -18,7 +18,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **不用 `ctx.fs` seam。** `packages/fs/` 是面向模型/会话的存储栈(policy 事件、sandbox 可换后端)。骑上去会把 GUI 浏览耦合进模型的限制后端——为模型换 `fs-sandbox` 绝不能改变 GUI 行为——而 OS 事实(home 锚定、隐藏约定)也不是存储原语。picker seam 保持无展示、无模型;`packages/host/` 是它消费方域的家。 - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 -- **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,计划中的"显示隐藏"开关变成纯客户端改动。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 +- **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地(browse 客户端的 footer 开关)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts index 86a20e73fe..32d44b6fff 100644 --- a/apps/web/tests/workspace-flow.snapshot.ts +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -214,6 +214,39 @@ it('adopts a directory through the composed in-app browse flow and lands in its }) }) +it('reveals hidden fixture entries via the footer toggle and prefix-filters from the path draft', async () => { + boot('?fixture=empty') + + await findLockedComposer() + fireEvent.click(workspaceChip()) + fireEvent.click(await screen.findByRole('menuitem', { name: 'Open local folder…' })) + const dialog = await screen.findByRole('dialog', { name: '选择工作区目录' }, { timeout: 10_000 }) + await within(dialog).findByText('Documents', {}, { timeout: 10_000 }) + // The host flags .config hidden; the level filters it until the + // fixed-label footer toggle presses on (state lives in aria-pressed). + expect(within(dialog).queryByText('.config')).toBeNull() + const toggle = within(dialog).getByRole('button', { name: '显示隐藏文件' }) + expect(toggle.getAttribute('aria-pressed')).toBe('false') + fireEvent.click(toggle) + expect(toggle.getAttribute('aria-pressed')).toBe('true') + await within(dialog).findByText('.config', {}, { timeout: 10_000 }) + fireEvent.click(toggle) + expect(within(dialog).queryByText('.config')).toBeNull() + // The path editor seeds the level's path with a trailing separator and the + // draft's final segment prefix-filters the listed rows while typing. + fireEvent.click(within(dialog).getByRole('button', { name: '编辑路径' })) + const input = within(dialog).getByLabelText('编辑路径') + expect(input.value).toBe('/home/fixture/') + fireEvent.change(input, { target: { value: '/home/fixture/do' } }) + expect(within(dialog).getByText('Documents')).toBeDefined() + expect(within(dialog).getByText('Downloads')).toBeDefined() + expect(within(dialog).queryByText('.config')).toBeNull() + // A dot-led prefix names hidden entries, so its matches surface. + fireEvent.change(input, { target: { value: '/home/fixture/.c' } }) + await within(dialog).findByText('.config', {}, { timeout: 10_000 }) + expect(within(dialog).queryByText('Documents')).toBeNull() +}) + it('selects the recent Workspace and opens its blank Session on first load', async () => { boot('?fixture') diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 4454afde3a..f40742fc0d 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/README.i18n.yaml @@ -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/directory-picker-browse/README.md -README.md: 318380405214d5f25ad77e348c4e134a8981ffb3 -README.zh.md: 2f88f64cc2974b8535e34eb9798f512ea109b754 +README.md: 9772baa2a6e632a5f0f18cc18b9b55b45cd845ca +README.zh.md: 682495fe10bdeed41f709a438dcd222d612129e3 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 3183804052..9772baa2a6 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -6,7 +6,7 @@ The **in-app browsing backend** of the [directory-picker seam](../directory-pick Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated); window insertion is binary with an O(1) full-window tail rejection, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). -**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view, breadcrumb with a click-to-edit path zone, nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). +**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view; breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing, and cancels on Escape or focus loss; a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). ## Model Experience diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 2f88f64cc2..682495fe10 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -6,7 +6,7 @@ 行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 -**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图、带点击即编辑路径区的面包屑、嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 +**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图;带点击即编辑路径区的面包屑,其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤、按 Escape 或失焦即取消;基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 ## 模型体验 From 665c21693b927daef27b9e81b581d777909c7628 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 14:12:27 +0800 Subject: [PATCH 06/72] feat(tools): add persistent bash and str-replace editor --- ...rsistent-bash-str-replace-editor.i18n.yaml | 6 + ...7-29-persistent-bash-str-replace-editor.md | 33 ++ ...9-persistent-bash-str-replace-editor.zh.md | 33 ++ docs/config-catalog.md | 57 ++- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 2 +- docs/module-graph.md | 12 + docs/tool-catalog.md | 95 +++++ packages/core/system-prompt/README.i18n.yaml | 6 +- packages/core/system-prompt/README.md | 5 +- packages/core/system-prompt/README.zh.md | 5 +- packages/core/system-prompt/src/index.ts | 15 +- .../system-prompt/tests/system-prompt.spec.ts | 12 + .../agent-spine-demo/README.i18n.yaml | 4 +- packages/examples/agent-spine-demo/README.md | 8 +- .../examples/agent-spine-demo/README.zh.md | 8 +- .../examples/agent-spine-demo/src/index.ts | 24 +- .../agent-spine-demo/tests/agent-core.spec.ts | 21 +- packages/fs/README.i18n.yaml | 6 +- packages/fs/README.md | 1 + packages/fs/README.zh.md | 1 + .../tool-str-replace-editor/README.i18n.yaml | 6 + packages/fs/tool-str-replace-editor/README.md | 54 +++ .../fs/tool-str-replace-editor/README.zh.md | 54 +++ .../fs/tool-str-replace-editor/package.json | 48 +++ .../fs/tool-str-replace-editor/src/index.ts | 403 ++++++++++++++++++ .../tool-str-replace-editor/src/invariant.ts | 30 ++ .../tests/tools.spec.ts | 313 ++++++++++++++ .../fs/tool-str-replace-editor/tsconfig.json | 14 + packages/pty/README.i18n.yaml | 6 +- packages/pty/README.md | 1 + packages/pty/README.zh.md | 1 + .../pty/tool-bash-persistent/README.i18n.yaml | 6 + packages/pty/tool-bash-persistent/README.md | 50 +++ .../pty/tool-bash-persistent/README.zh.md | 50 +++ .../pty/tool-bash-persistent/package.json | 55 +++ .../pty/tool-bash-persistent/src/index.ts | 380 +++++++++++++++++ .../pty/tool-bash-persistent/src/invariant.ts | 30 ++ .../tests/loader-composition.spec.ts | 156 +++++++ .../tool-bash-persistent/tests/tools.spec.ts | 385 +++++++++++++++++ .../pty/tool-bash-persistent/tsconfig.json | 16 + patches/node-pty@1.1.0.patch | 60 +++ pnpm-lock.yaml | 105 ++++- pnpm-workspace.yaml | 4 + python/README.i18n.yaml | 6 +- python/README.md | 2 +- python/README.zh.md | 2 +- python/sdk-runtime/README.i18n.yaml | 6 +- python/sdk-runtime/README.md | 6 +- python/sdk-runtime/README.zh.md | 6 +- python/sdk-runtime/hatch_build.py | 16 +- python/sdk-runtime/package.json | 5 + .../src/deepseek_harness_runtime/__init__.py | 13 +- python/sdk/tests/test_release_version.py | 38 ++ scripts/build-exe-for-python-sdk.ts | 84 +++- scripts/build-python-release.py | 29 +- scripts/gen-tool-catalog.ts | 28 ++ scripts/smoke-python-runtime.py | 145 ++++++- tsconfig.host.json | 2 + 59 files changed, 2880 insertions(+), 91 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md create mode 100644 .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md create mode 100644 packages/fs/tool-str-replace-editor/README.i18n.yaml create mode 100644 packages/fs/tool-str-replace-editor/README.md create mode 100644 packages/fs/tool-str-replace-editor/README.zh.md create mode 100644 packages/fs/tool-str-replace-editor/package.json create mode 100644 packages/fs/tool-str-replace-editor/src/index.ts create mode 100644 packages/fs/tool-str-replace-editor/src/invariant.ts create mode 100644 packages/fs/tool-str-replace-editor/tests/tools.spec.ts create mode 100644 packages/fs/tool-str-replace-editor/tsconfig.json create mode 100644 packages/pty/tool-bash-persistent/README.i18n.yaml create mode 100644 packages/pty/tool-bash-persistent/README.md create mode 100644 packages/pty/tool-bash-persistent/README.zh.md create mode 100644 packages/pty/tool-bash-persistent/package.json create mode 100644 packages/pty/tool-bash-persistent/src/index.ts create mode 100644 packages/pty/tool-bash-persistent/src/invariant.ts create mode 100644 packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts create mode 100644 packages/pty/tool-bash-persistent/tests/tools.spec.ts create mode 100644 packages/pty/tool-bash-persistent/tsconfig.json create mode 100644 patches/node-pty@1.1.0.patch diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml new file mode 100644 index 0000000000..e3b6121e19 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md +2026-07-29-persistent-bash-str-replace-editor.md: 286a53c1c686cc515b65119ed4b1a01a57b0614b +2026-07-29-persistent-bash-str-replace-editor.zh.md: d2417708c8a1334e9f8930481f4218cbefc5a87b diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md new file mode 100644 index 0000000000..286a53c1c6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md @@ -0,0 +1,33 @@ +# Agent Note: Persistent Bash and string-replacement editor tools + +Status: implemented + +English | [中文](2026-07-29-persistent-bash-str-replace-editor.zh.md) + +## Problem + +Some deployments need a one-call Bash schema whose shell state survives across model turns, while others need a Claude-style `str_replace_editor` independent of their terminal choice. Bundling the two tools or naming them after one benchmark would prevent reuse and blur configuration ownership. + +## Decision + +`@deepseek-ai/dsh-tool-bash-persistent` consumes `ctx.pty` and registers one `bash(command)` tool. It lazily creates one interactive shell per exact Agent and serializes that owner's calls. Cwd, exported variables, activated environments, functions, and background jobs persist. Random private markers delimit command output. Retained scrollback is paged backward to recover the command's original prefix; a dropped prefix is reported explicitly. Timeout or cancellation closes the shell before another call can reuse uncertain state, and model-visible timeout/exit results disclose that reset. The configurable description defaults to persistence facts only, so network and package-mirror claims remain deployment-owned. + +`@deepseek-ai/dsh-tool-str-replace-editor` independently consumes `ctx.fs` and registers `str_replace_editor` with `view`, `create`, `str_replace`, and `insert`. It provides numbered text views, filtered two-level directory listings, unique literal replacement, canonical insertion boundaries, and bounded output. The public schema and failures use only `old_str`; canonical mode requires absolute paths and expands tabs before mutations. Deployments with an intentional session-cwd contract can disable the absolute-path requirement. The plugin can compose with persistent Bash, one-shot Bash, sandboxed Bash, or no shell. + +`dsh-system-prompt` accepts `includeHarnessIdentity: false`, while `dsh-agent-spine-demo` forwards that setting and accepts `toolBash: false`. A deployment can therefore own an exact persona and replace the spine's native Bash without duplicate prompt or tool registrations. Existing defaults remain unchanged. + +Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper`, each packaged runtime executable ships with an architecture-matched `-spawn-helper` sibling. A pinned `node-pty` patch resolves that sibling only when present (or when `DSH_NODE_PTY_SPAWN_HELPER` explicitly selects one), preserving upstream lookup in ordinary Node runs; the executable and runtime-wheel builders fail before publication when the helper is absent, mismatched, or not executable. + +## Alternatives considered + +**One combined compatibility plugin.** Rejected because neither tool requires the other and the combined name would tie reusable capabilities to one benchmark. + +**Reuse one-shot Bash.** Rejected because `bash -c` cannot preserve cwd or environment state across calls. + +**Expose terminal management tools.** Rejected because open/send/read/close is a different model action space from one persistent `bash` call. + +**Modify native read/write/edit.** Rejected because it would distort their general-purpose contracts instead of adding an independently composable editor. + +## Consequences + +Profiles can reproduce an external agent by configuring persona and descriptions while the underlying packages remain general. Persistent Bash requires an owning Agent and real PTY backend. Shell exit, timeout, or cancellation loses state. The editor delegates security and mutation policy to the mounted filesystem stack. Runtime-wheel consumers still need no Node installation, but the wheel now contains a main executable plus its private native helper rather than one physical file. diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md new file mode 100644 index 0000000000..d2417708c8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md @@ -0,0 +1,33 @@ +# Agent Note:持久 Bash 与字符串替换编辑器工具 + +状态:已实现 + +[English](2026-07-29-persistent-bash-str-replace-editor.md) | 中文 + +## 问题 + +部分部署需要只调用一次的 Bash schema,同时要求 shell 状态跨模型轮次保留;另一些部署需要与终端选择无关的 Claude 风格 `str_replace_editor`。把两个工具绑在一起或按某个基准命名,会阻碍复用并模糊配置归属。 + +## 决策 + +`@deepseek-ai/dsh-tool-bash-persistent` 消费 `ctx.pty` 并注册一个 `bash(command)` 工具。它为每个精确 Agent 惰性创建一个交互式 shell,并串行化该所有者的调用。Cwd、导出的变量、已激活环境、函数和后台任务会保留。随机私有标记划分命令输出;保留的 scrollback 会向前分页,以恢复命令真正的输出前缀,若前缀已被丢弃则明确告知。超时或取消会先关闭 shell,避免下一次调用复用状态不确定的会话,模型可见的超时/退出结果也会说明该重置。可配置描述默认只声明持久性事实,因此网络和软件包镜像等声明仍归部署所有。 + +`@deepseek-ai/dsh-tool-str-replace-editor` 独立消费 `ctx.fs`,注册包含 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor`。它提供带行号文本查看、过滤后的两层目录列表、唯一字面量替换、规范插入边界和有界输出。公开 schema 与错误只使用 `old_str`;规范模式要求绝对路径,并在变更前展开制表符。有明确 session-cwd 契约的部署可以关闭绝对路径要求。它可以与持久 Bash、一次性 Bash、沙箱 Bash 或无 shell 组合。 + +`dsh-system-prompt` 接受 `includeHarnessIdentity: false`;`dsh-agent-spine-demo` 会转发该设置,并接受 `toolBash: false`。因此部署可以拥有精确 persona,并替换 spine 的原生 Bash,而不会重复注册提示词或工具。既有默认值不变。 + +两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 会执行原生 `spawn-helper`,每个打包后的运行时可执行文件都会携带一个架构匹配的 `-spawn-helper` 伴随文件。固定版本的 `node-pty` 补丁只在该伴随文件存在时解析它(也可由 `DSH_NODE_PTY_SPAWN_HELPER` 显式指定),普通 Node 运行仍保留上游查找方式;若 helper 缺失、架构不匹配或不可执行,可执行文件与 runtime wheel 构建会在发布前失败。 + +## 考虑过的替代方案 + +**单一组合兼容插件。** 被拒绝,因为两个工具互不依赖,组合命名还会把可复用能力绑定到某个基准。 + +**复用一次性 Bash。** 被拒绝,因为 `bash -c` 无法跨调用保留 cwd 或环境状态。 + +**暴露终端管理工具。** 被拒绝,因为 open/send/read/close 与单个持久 `bash` 调用是不同的模型动作空间。 + +**修改原生 read/write/edit。** 被拒绝,因为这会扭曲其通用契约,而不是增加一个可独立组合的编辑器。 + +## 后果 + +Profile 可以通过配置 persona 和描述复现外部 Agent,而底层包保持通用。持久 Bash 需要拥有它的 Agent 与真实 PTY 后端;shell 退出、超时或取消会丢失状态。编辑器把安全与变更策略委托给挂载的文件系统栈。runtime wheel 的使用者仍不需要安装 Node,但 wheel 现在包含主可执行文件及其私有原生 helper,而不是单个物理文件。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 68343a6a19..585ea18b84 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -116,9 +116,9 @@ Source: [`packages/core/agent-loop/src/index.ts:155`](../packages/core/agent-loo /** * Bundle config: each field forwarded verbatim to the child that owns it — * `agents` to the agent loop (an app that pre-creates no agents, like the ACP - * bridge, simply omits it), `persona` and `toolOrder` to the system-prompt - * plugin (the deployment's persona section and the explicit model-facing tool - * order), the `tools` object to the tool registry (its presentation `mode`), + * bridge, simply omits it), `includeHarnessIdentity`, `persona`, and `toolOrder` + * to the system-prompt plugin (the fixed opener, deployment persona, and explicit + * model-facing tool order), the `tools` object to the tool registry (its presentation `mode`), * `dshHome` to bash environment and local skill discovery, `sessionTitle` to * the fallback title service, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the @@ -131,13 +131,16 @@ Source: [`packages/core/agent-loop/src/index.ts:155`](../packages/core/agent-loo * workspace context instead requires an explicit byte budget or `false` because * it changes model-visible input. Producer opt-in stays producer-local: * `toolBash` configures bash only; independently composed producers keep their - * own config. + * own config. Set `toolBash: false` when another plugin owns the model-facing + * `bash` name. */ export interface Config { /** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */ agents?: AgentLoopConfig['agents'] /** Agent-loop concurrency cap; `1` is serial. */ maxParallelToolCalls?: AgentLoopConfig['maxParallelToolCalls'] + /** Whether the system prompt includes the fixed Harness identity (default true). */ + includeHarnessIdentity?: SystemPromptConfig['includeHarnessIdentity'] /** The deployment persona (see dsh-system-prompt's `Config`). */ persona?: SystemPromptConfig['persona'] /** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */ @@ -152,8 +155,8 @@ export interface Config { workspaceContext: workspaceContext.Config | false /** Skill registry, local provider, and model-facing consumer config. */ skills?: SkillConfig - /** Model-facing bash tool config, including this producer's background opt-in. */ - toolBash?: toolBash.Config + /** Model-facing bash tool config, or false when another plugin owns `bash`. */ + toolBash?: toolBash.Config | false /** Generic background-task controls; set false to keep the task service without model-facing task tools. */ toolTasks?: toolTasks.Config | false /** Global enablement and package-name filters for invariant companions. */ @@ -185,7 +188,7 @@ export interface GoalConfig { Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) -Source: [`packages/examples/agent-spine-demo/src/index.ts:88`](../packages/examples/agent-spine-demo/src/index.ts) +Source: [`packages/examples/agent-spine-demo/src/index.ts:89`](../packages/examples/agent-spine-demo/src/index.ts) ## `@deepseek-ai/dsh-bash-local` @@ -1509,6 +1512,8 @@ Source: [`packages/subagent/subagent-spawn/src/index.ts:20`](../packages/subagen ```ts config-catalog /** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.persona} for its contract). */ export interface Config { + /** Include the fixed DeepSeek Harness identity before the deployment persona (default true). */ + includeHarnessIdentity?: boolean /** * Deployment-wide order-0 persona template. A scoped section named * `deployment:persona` shadows it; `{{variable}}` references are strict. @@ -1566,6 +1571,26 @@ export interface Config { Source: [`packages/bash/tool-bash/src/index.ts:41`](../packages/bash/tool-bash/src/index.ts) +## `@deepseek-ai/dsh-tool-bash-persistent` + +Requires: `tools` · `pty` + +```ts config-catalog +/** Configuration for the persistent Bash tool. */ +export interface Config { + /** PTY backend used for each owner-isolated persistent shell (default `shell`). */ + backendType?: string + /** Wall-clock limit for one command (default 300000). */ + timeoutMs?: number + /** Maximum returned command-output characters before clipping (default 16000). */ + maxOutputChars?: number + /** Model-facing tool description; deployments may describe their environment. */ + description?: string +} +``` + +Source: [`packages/pty/tool-bash-persistent/src/index.ts:340`](../packages/pty/tool-bash-persistent/src/index.ts) + ## `@deepseek-ai/dsh-tool-cordis` Requires: `tools` @@ -1724,6 +1749,24 @@ export interface Config { Source: [`packages/skill/tool-skill/src/index.ts:21`](../packages/skill/tool-skill/src/index.ts) +## `@deepseek-ai/dsh-tool-str-replace-editor` + +Requires: `tools` · `fs` + +```ts config-catalog +/** Configuration for the string-replacement editor tool. */ +export interface Config { + /** Maximum returned view characters before clipping (default 16000). */ + maxOutputChars?: number + /** Model-facing tool description. */ + description?: string + /** Require local absolute paths like the canonical editor contract (default true). */ + requireAbsolutePath?: boolean +} +``` + +Source: [`packages/fs/tool-str-replace-editor/src/index.ts:373`](../packages/fs/tool-str-replace-editor/src/index.ts) + ## `@deepseek-ai/dsh-tool-subagent` Requires: `tools` · `subagents` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index e0dd17fa02..c564cf53a5 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1870,7 +1870,7 @@ async assemble(context: AssembleContext = {}): Promise Types: [AssembleContext](../core-data-structures/system-prompt.md) · [PromptSection](../core-data-structures/system-prompt.md) · [ToolProviderResult](../core-data-structures/system-prompt.md) -Source: [`packages/core/system-prompt/src/index.ts:246`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:248`](../../packages/core/system-prompt/src/index.ts) ## `ctx.tasks` — `TaskService` (abstract seam) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 9d66b52bfb..919f891685 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -27,7 +27,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | -| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | +| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 703f4c8abf..b8fe4a23c7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -48,6 +48,7 @@ flowchart TD pkg_fs_sandbox["fs-sandbox"] pkg_tool_fs["tool-fs"] pkg_tool_fs_search["tool-fs-search"] + pkg_tool_str_replace_editor["tool-str-replace-editor"] end subgraph group_skill["packages/skill"] pkg_skill["skill"] @@ -202,6 +203,7 @@ flowchart TD subgraph group_pty["packages/pty"] pkg_pty["pty"] pkg_pty_local["pty-local"] + pkg_tool_bash_persistent["tool-bash-persistent"] pkg_tool_pty["tool-pty"] end subgraph group_sandbox["packages/sandbox"] @@ -663,6 +665,9 @@ flowchart TD pkg_tool_fs_search --> pkg_spill pkg_tool_fs_search --> pkg_system_prompt pkg_tool_fs_search --> pkg_tools + pkg_tool_str_replace_editor --> pkg_fs + pkg_tool_str_replace_editor --> pkg_invariants + pkg_tool_str_replace_editor --> pkg_tools pkg_tool_skill --> pkg_agent pkg_tool_skill --> pkg_invariants pkg_tool_skill --> pkg_llm @@ -777,6 +782,11 @@ flowchart TD pkg_mcp_client --> pkg_llm pkg_mcp_client --> pkg_subprocess pkg_mcp_client --> pkg_tools + pkg_tool_bash_persistent --> pkg_agent + pkg_tool_bash_persistent --> pkg_invariants + pkg_tool_bash_persistent --> pkg_pty + pkg_tool_bash_persistent --> pkg_timeout + pkg_tool_bash_persistent --> pkg_tools pkg_tool_pty --> pkg_agent pkg_tool_pty --> pkg_invariants pkg_tool_pty --> pkg_llm @@ -1077,6 +1087,7 @@ flowchart TD | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | @@ -1098,6 +1109,7 @@ flowchart TD | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | +| [`tool-bash-persistent`](../packages/pty/tool-bash-persistent) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 995a8c669d..bed13d1a2d 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -20,6 +20,8 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userInteraction (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. | | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `process-local temporary Plugin lifecycle` | - | Ships in examples/cordis-agent only (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. | +| `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.pty`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. | +| `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after successful file operations`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface. | | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | | `@deepseek-ai/dsh-tool-pty` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.pty`, `ctx.systemPrompt`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. | @@ -279,6 +281,99 @@ Source: [`packages/cordis/tool-cordis/src/index.ts`](../packages/cordis/tool-cor Ships in examples/cordis-agent only (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. +## `@deepseek-ai/dsh-tool-bash-persistent` + +### `bash` + +Run commands in a persistent bash shell. State, including the current directory and exported environment variables, persists across calls for this agent. + +```json +{ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to run. Relative path is preferred in the command." + } + }, + "required": [ + "command" + ] +} +``` + +Source: [`packages/pty/tool-bash-persistent/src/index.ts`](../packages/pty/tool-bash-persistent/src/index.ts) + +One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. + +## `@deepseek-ai/dsh-tool-str-replace-editor` + +### `str_replace_editor` + +Custom editing tool for viewing, creating and editing files +* State is persistent across command calls and discussions with the user +* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep +* The `create` command cannot be used if the specified `path` already exists as a file +* If a `command` generates a long output, it will be truncated and marked with `` + +Notes for using the `str_replace` command: +* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces! +* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique +* The `new_str` parameter should contain the edited lines that should replace the `old_str` + +```json +{ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.", + "enum": [ + "view", + "create", + "str_replace", + "insert" + ] + }, + "path": { + "type": "string", + "description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`." + }, + "file_text": { + "type": "string", + "description": "Required parameter of `create` command, with the content of the file to be created." + }, + "insert_line": { + "type": "integer", + "description": "Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`." + }, + "new_str": { + "type": "string", + "description": "Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert." + }, + "old_str": { + "type": "string", + "description": "Required parameter of `str_replace` command containing the string in `path` to replace." + }, + "view_range": { + "type": "array", + "description": "Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.", + "items": { + "type": "integer" + } + } + }, + "required": [ + "command", + "path" + ] +} +``` + +Source: [`packages/fs/tool-str-replace-editor/src/index.ts`](../packages/fs/tool-str-replace-editor/src/index.ts) + +Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface. + ## `@deepseek-ai/dsh-tool-fs` ### `edit` diff --git a/packages/core/system-prompt/README.i18n.yaml b/packages/core/system-prompt/README.i18n.yaml index 9d82f19bfd..cea643baa7 100644 --- a/packages/core/system-prompt/README.i18n.yaml +++ b/packages/core/system-prompt/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -README.md: 79badba0b84b27c01f25e9c31b5df78c556411ea -README.zh.md: 1d983e44721dbc637efc10824065aa3b88087e1d +# pnpm run verify-translation-pairing --write packages/core/system-prompt/README.md +README.md: 23bc0e8177ad2a778df9522e254bfd5e03a9871f +README.zh.md: 94c4cef4e289b94e7db79a8ad4815f7d4038cf80 diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index 79badba0b8..23bc0e8177 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -8,6 +8,7 @@ System prompt assembly registry. Plugins contribute ordered sections, tool schem | Key | Default | Meaning | |---|---|---| +| `includeHarnessIdentity` | `true` | Include the fixed `You are an AI agent powered by the DeepSeek Harness SDK.` order-−100 opener. Set false only when a compatibility deployment owns the complete system prompt. | | `persona` | `''` | The global deployment-persona default: the ONE config-authored prompt fragment, rendered as the order-0 `deployment:persona` section unless an agent-scoped contribution shadows it. A template — complete `{{…}}` groups are interpreted strictly against the registered variables (the shipped loop registers `{{model}}`/`{{cwd}}`), with no escape syntax for literal braces yet. Empty ⇒ the section is dropped at render. | | `toolOrder` | — | Explicit model-facing tool order, as a list of `ToolSchema.name`s with one `''` rest entry (`TOOL_ORDER_REST`): listed tools take their listed position, unlisted tools land at the rest entry in lexicographic name order. Absent ⇒ plain lexicographic name order. Applied to the collected tools BEFORE the `system-prompt/assemble` waterfall — like the sections' `order` sort, it canonicalizes what the registry contributed (registration order is a plugin-load artifact), and a waterfall listener that mutates the list owns the determinism of what it emits. Misconfiguration fails loud: a list without exactly one rest entry, or with duplicates, throws at load; a listed name with no registered tool rejects every `assemble()`; a tool provider returning the reserved rest-entry name also rejects. Under the shipped loop the turn fails before any model request. Why a central list and not per-plugin weights: [Explicit model-facing tool order](../../../.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.md). | @@ -48,7 +49,7 @@ Design rationale: [the prompt-variables Agent Note](../../../.agents/notes/imple #### What the model sees -Every assembly starts with the harness identity below, then the configured persona and ordered plugin sections after strict variable interpolation. Empty sections disappear; scoped sections and variables can shadow globals for one agent. The final `system-prompt/assemble` waterfall result is authoritative, so an expert listener's changes determine the delivered prompt and tool schemas. +By default every assembly starts with the harness identity below, then the configured persona and ordered plugin sections after strict variable interpolation. `includeHarnessIdentity: false` omits only that fixed opener for a deployment that owns the complete compatibility persona. Empty sections disappear; scoped sections and variables can shadow globals for one agent. The final `system-prompt/assemble` waterfall result is authoritative, so an expert listener's changes determine the delivered prompt and tool schemas. ##### Harness identity @@ -58,7 +59,7 @@ You are an AI agent powered by the DeepSeek Harness SDK. #### Token effect -Identity is a fixed per-request cost. Persona and plugin text are repeated per request and scale with their rendered content. +Identity is a fixed per-request cost when enabled. Persona and plugin text are repeated per request and scale with their rendered content. #### KV Cache effect diff --git a/packages/core/system-prompt/README.zh.md b/packages/core/system-prompt/README.zh.md index 1d983e4472..94c4cef4e2 100644 --- a/packages/core/system-prompt/README.zh.md +++ b/packages/core/system-prompt/README.zh.md @@ -8,6 +8,7 @@ | 键 | 默认值 | 含义 | |---|---|---| +| `includeHarnessIdentity` | `true` | 是否包含固定的 `You are an AI agent powered by the DeepSeek Harness SDK.`、顺序为 −100 的开场白。仅当兼容部署拥有完整系统提示词时设为 false。 | | `persona` | `''` | 全局部署 persona 默认值:唯一由配置创作的提示词片段,渲染为顺序为 0 的 `deployment:persona` 段,除非 agent 作用域的贡献将其遮蔽。它是模板,完整的 `{{…}}` 组会严格按已注册变量解释(已交付循环注册 `{{model}}`/`{{cwd}}`),目前没有表达字面量花括号的转义语法。为空 ⇒ 渲染时删除该段。 | | `toolOrder` | 无 | 显式的面向模型工具顺序:一个 `ToolSchema.name` 列表,包含一个 `''` 其余项(`TOOL_ORDER_REST`)。已列工具占据列出的位置;未列工具按名称字典序落在其余项位置。缺席 ⇒ 直接按名称字典序排列。在 `system-prompt/assemble` waterfall 之前应用于已收集工具;与段的 `order` 排序一样,它会规范化注册表贡献的内容(注册顺序是插件加载工件),而修改列表的 waterfall 监听器拥有其输出的确定性。配置错误会明确失败:列表没有恰好一个其余项或存在重复项,会在加载时抛出;已列名称没有对应已注册工具,会使每次 `assemble()` 被拒绝;工具提供方返回保留的其余项名称也会被拒绝。在已交付循环下,轮次会在任何模型请求前失败。为何采用中心列表而非每插件权重,见[显式面向模型工具顺序](../../../.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.md)。 | @@ -48,7 +49,7 @@ #### 模型所见 -每次组装都从下方 harness 身份开始,然后在严格变量插值后追加已配置 persona 与有序插件段。空段会消失;带作用域的段和变量可以为一个 agent 遮蔽全局项。最终 `system-prompt/assemble` waterfall 结果是权威来源,因此专家监听器的变更决定交付的提示词与工具 schema。 +默认情况下,每次组装都从下方 harness 身份开始,然后在严格变量插值后追加已配置 persona 与有序插件段。`includeHarnessIdentity: false` 仅为拥有完整兼容 persona 的部署省略这个固定开场白。空段会消失;带作用域的段和变量可以为一个 agent 遮蔽全局项。最终 `system-prompt/assemble` waterfall 结果是权威来源,因此专家监听器的变更决定交付的提示词与工具 schema。 ##### Harness 身份 @@ -58,7 +59,7 @@ You are an AI agent powered by the DeepSeek Harness SDK. #### Token 影响 -身份是每次请求的固定成本。Persona 与插件文本在每次请求中重复,成本随渲染内容增长。 +启用时,身份是每次请求的固定成本。Persona 与插件文本在每次请求中重复,成本随渲染内容增长。 #### KV Cache 影响 diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index c46c38515c..2e4e5e65b7 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -145,6 +145,8 @@ function compareToolNames(a: ToolSchema, b: ToolSchema): number { /** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.persona} for its contract). */ export interface Config { + /** Include the fixed DeepSeek Harness identity before the deployment persona (default true). */ + includeHarnessIdentity?: boolean /** * Deployment-wide order-0 persona template. A scoped section named * `deployment:persona` shadows it; `{{variable}}` references are strict. @@ -245,6 +247,7 @@ class PromptLayer implements ScopeLayer { /** Registry service for the prompt inputs assembled before each model step. */ export class SystemPrompt extends Service { static Config: z = z.object({ + includeHarnessIdentity: z.boolean().default(true), persona: z.string().default(''), // Preserve omission because an explicit empty order lacks the rest marker. toolOrder: z.array(z.string()).default(undefined as unknown as string[]), @@ -260,11 +263,13 @@ export class SystemPrompt extends Service { super(ctx, 'systemPrompt') this.toolOrder = validateToolOrder(config.toolOrder) // Keep harness-owned openers independent of the selected loop plugin. - this.section({ - name: 'harness:identity', - order: -100, - text: 'You are an AI agent powered by the DeepSeek Harness SDK.', - }) + if (config.includeHarnessIdentity ?? true) { + this.section({ + name: 'harness:identity', + order: -100, + text: 'You are an AI agent powered by the DeepSeek Harness SDK.', + }) + } this.section({ name: 'deployment:persona', order: 0, diff --git a/packages/core/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts index d4fdbdd684..02ac58889d 100644 --- a/packages/core/system-prompt/tests/system-prompt.spec.ts +++ b/packages/core/system-prompt/tests/system-prompt.spec.ts @@ -37,6 +37,18 @@ describe('SystemPrompt', () => { expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe(IDENTITY) }) + it('can omit the harness identity for a deployment that owns the complete persona', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt, { + includeHarnessIdentity: false, + persona: 'You are a helpful software engineer assistant.', + }) + + const assembly = await ctx.systemPrompt.assemble() + expect(assembly.sections.map(section => section.name)).toEqual(['deployment:persona']) + expect(renderPrompt(assembly)).toBe('You are a helpful software engineer assistant.') + }) + it('tolerates a schema-bypassing direct construction (persona omitted)', async () => { // ctx.plugin validates + defaults the config first; a direct construction // skips the schema, so the ctor's `?? ''` narrowing is what fires. diff --git a/packages/examples/agent-spine-demo/README.i18n.yaml b/packages/examples/agent-spine-demo/README.i18n.yaml index a7c71523b2..fd6d54918e 100644 --- a/packages/examples/agent-spine-demo/README.i18n.yaml +++ b/packages/examples/agent-spine-demo/README.i18n.yaml @@ -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/examples/agent-spine-demo/README.md -README.md: 359e7153be2f480ba3fea4b06782acdc9f89ebb9 -README.zh.md: 57fec3f32f5bbc8f3d82ff8971d36d376d722753 +README.md: 6bbd99217bcdce0e8a0e8fd22a8d39d0c64224b9 +README.zh.md: b02b035685f1902dc9b2bd1056606495e212aa4f diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 359e7153be..6bbd99217b 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -31,7 +31,7 @@ Read this package for the whole plugin tree and its composition order. @deepseek-ai/dsh-scope/invariant @deepseek-ai/dsh-agent-loop/invariant package-owned relational checks -@deepseek-ai/dsh-tool-bash the model-facing bash schema +@deepseek-ai/dsh-tool-bash the model-facing bash schema (unless toolBash=false) @deepseek-ai/dsh-workspace-context AGENTS.md/CLAUDE.md workspace context loader @deepseek-ai/dsh-tool-skill session-prefix skill catalog + model-facing loader schema @deepseek-ai/dsh-tool-tasks task_output/task_list/task_kill schemas + completion notices @@ -55,11 +55,11 @@ This is the [interface/implementation/consumer seam](../../../.agents/notes/impl ```ts import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' -// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants? } +// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants? } // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `sessionTitle` to the fallback title service; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. It always mounts `dsh-llm-retry`, while each leaf adapter owns its nested `retryPolicy`. Omitted `sessionTitle` uses the explicit example policy of 5 words, 40 fallback bytes, and 80 accepted-title bytes. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. +The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `includeHarnessIdentity`, `persona`, and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `sessionTitle` to the fallback title service; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. It always mounts `dsh-llm-retry`, while each leaf adapter owns its nested `retryPolicy`. Omitted `sessionTitle` uses the explicit example policy of 5 words, 40 fallback bytes, and 80 accepted-title bytes. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, set `toolBash: false` when another plugin owns the `bash` tool name, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bundled bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` keeps the package-owned companions mounted but suppresses the blocked owner. Blocklist matches override allowlist matches; see [`dsh-invariants`](../../support/invariants/README.md) for regex and lifecycle rules. @@ -79,5 +79,5 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work -- **Most of the spine set is fixed in code** — `apply()` always mounts the core services and `tool-bash`; config can omit bundled goals, skills, and task-control tools, but swapping the loop or dropping another spine member means composing a different bundle. +- **Most of the spine set is fixed in code** — `apply()` always mounts the core services; config can omit bundled goals, skills, bash, and task-control tools, but swapping the loop or dropping another spine member means composing a different bundle. - **The invariant seam and companions remain fixed members** — `invariants.enabled: false` or package filters suppress checks but do not remove the service or companion registrations; Session's always-on validation and freezing are separate. diff --git a/packages/examples/agent-spine-demo/README.zh.md b/packages/examples/agent-spine-demo/README.zh.md index 57fec3f32f..b02b035685 100644 --- a/packages/examples/agent-spine-demo/README.zh.md +++ b/packages/examples/agent-spine-demo/README.zh.md @@ -31,7 +31,7 @@ @deepseek-ai/dsh-scope/invariant @deepseek-ai/dsh-agent-loop/invariant package-owned relational checks -@deepseek-ai/dsh-tool-bash the model-facing bash schema +@deepseek-ai/dsh-tool-bash the model-facing bash schema (unless toolBash=false) @deepseek-ai/dsh-workspace-context AGENTS.md/CLAUDE.md workspace context loader @deepseek-ai/dsh-tool-skill session-prefix skill catalog + model-facing loader schema @deepseek-ai/dsh-tool-tasks task_output/task_list/task_kill schemas + completion notices @@ -55,11 +55,11 @@ ```ts import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' -// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants? } +// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants? } // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -组合包将每个字段转发给拥有它的子节点:`agents` 与 `maxParallelToolCalls` 交给 `agent-loop`(`agents` 默认为 `[]`,上限在该处默认),因此每个应用提供自己的预创建 agent;TUI 和无头应用预创建 `main`,ACP 应用则在 `session/new` 按需创建 agent;`persona` 与 `toolOrder` 交给 `dsh-system-prompt`;`tools` 交给工具注册表以配置呈现 mode;`sessionTitle` 交给后备标题服务;`skills.registry`、`skills.local` 与 `skills.tool` 分别交给 skill 注册表、本地提供方和面向模型的消费方;必填的 `workspaceContext` 选择交给 `dsh-workspace-context`(`{ maxBytes }` 启用加载,`false` 禁用);`invariants` 交给不变式服务;`toolBash`/`toolTasks` 交给组合包拥有的两个面向模型工具插件。组合包始终挂载 `dsh-llm-retry`,而每个叶节点适配器拥有自己的嵌套 `retryPolicy`。省略 `sessionTitle` 时采用显式示例策略:5 个词、40 个后备字节、80 个可接受标题字节。`goals` 对象会选用持久领域、模型工具和同会话驱动器,并将 `goals.domain` 与 `goals.tool` 转发给各自拥有者;省略或设为 `false` 会让整个栈缺席,使无头调用方继续以一轮结算。设置 `skills.enabled: false` 会同时省略本地提供方和面向模型的 skill 工具;设置 `toolTasks: false` 会保留供前台生产方使用的任务服务,但不公开 `task_output`/`task_list`/`task_kill`。它对 `dshHome` 只解析一次,解析通过 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 完成,并将所得绝对值转发给 tool-bash 的托管环境和已启用的本地 skill 发现。顶层 `dshHome` 缺席时采用 `skills.local.dshHome`;两者同时提供但解析后的路径不同会明确失败。`toolBash.enableRunInBackground` 只控制 bash 生产方;独立加载的生产方保留各自配置。Workspace 指令先于 skill 目录注册,因此其会话前缀消息先渲染。应用包使用 `pickSpineConfig()`,只复制这些由组合包拥有的字段。 +组合包将每个字段转发给拥有它的子节点:`agents` 与 `maxParallelToolCalls` 交给 `agent-loop`(`agents` 默认为 `[]`,上限在该处默认),因此每个应用提供自己的预创建 agent;TUI 和无头应用预创建 `main`,ACP 应用则在 `session/new` 按需创建 agent;`includeHarnessIdentity`、`persona` 与 `toolOrder` 交给 `dsh-system-prompt`;`tools` 交给工具注册表以配置呈现 mode;`sessionTitle` 交给后备标题服务;`skills.registry`、`skills.local` 与 `skills.tool` 分别交给 skill 注册表、本地提供方和面向模型的消费方;必填的 `workspaceContext` 选择交给 `dsh-workspace-context`(`{ maxBytes }` 启用加载,`false` 禁用);`invariants` 交给不变式服务;`toolBash`/`toolTasks` 交给组合包拥有的两个面向模型工具插件。组合包始终挂载 `dsh-llm-retry`,而每个叶节点适配器拥有自己的嵌套 `retryPolicy`。省略 `sessionTitle` 时采用显式示例策略:5 个词、40 个后备字节、80 个可接受标题字节。`goals` 对象会选用持久领域、模型工具和同会话驱动器,并将 `goals.domain` 与 `goals.tool` 转发给各自拥有者;省略或设为 `false` 会让整个栈缺席,使无头调用方继续以一轮结算。设置 `skills.enabled: false` 会同时省略本地提供方和面向模型的 skill 工具;当另一个插件拥有 `bash` 工具名时设置 `toolBash: false`;设置 `toolTasks: false` 会保留供前台生产方使用的任务服务,但不公开 `task_output`/`task_list`/`task_kill`。它对 `dshHome` 只解析一次,解析通过 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 完成,并将所得绝对值转发给 tool-bash 的托管环境和已启用的本地 skill 发现。顶层 `dshHome` 缺席时采用 `skills.local.dshHome`;两者同时提供但解析后的路径不同会明确失败。`toolBash.enableRunInBackground` 只控制内置 bash 生产方;独立加载的生产方保留各自配置。Workspace 指令先于 skill 目录注册,因此其会话前缀消息先渲染。应用包使用 `pickSpineConfig()`,只复制这些由组合包拥有的字段。 例如,`{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` 会让包拥有的配套插件保持挂载,但抑制被阻止的拥有者。Blocklist 匹配优先于 allowlist 匹配;正则表达式与生命周期规则见 [`dsh-invariants`](../../support/invariants/README.md)。 @@ -79,5 +79,5 @@ YAML include 可以去重配置,却无法拥有 bin 或提供前端入口默 ## 已知限制与延后工作 -- **大部分主干集合固定在代码中**:`apply()` 始终挂载核心服务与 `tool-bash`;配置可以省略组合包内的目标、skill 与任务控制工具,但要替换循环或删除其他主干成员,就必须组合另一个 bundle。 +- **大部分主干集合固定在代码中**:`apply()` 始终挂载核心服务;配置可以省略组合包内的目标、skill、bash 与任务控制工具,但要替换循环或删除其他主干成员,就必须组合另一个 bundle。 - **不变式 seam 与配套插件仍是固定成员**:`invariants.enabled: false` 或包筛选器会抑制检查,但不会移除服务或配套插件注册;Session 始终启用的校验与冻结是另一套机制。 diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index 92434f91da..cfa5ac3ccd 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -68,9 +68,9 @@ export interface GoalConfig { /** * Bundle config: each field forwarded verbatim to the child that owns it — * `agents` to the agent loop (an app that pre-creates no agents, like the ACP - * bridge, simply omits it), `persona` and `toolOrder` to the system-prompt - * plugin (the deployment's persona section and the explicit model-facing tool - * order), the `tools` object to the tool registry (its presentation `mode`), + * bridge, simply omits it), `includeHarnessIdentity`, `persona`, and `toolOrder` + * to the system-prompt plugin (the fixed opener, deployment persona, and explicit + * model-facing tool order), the `tools` object to the tool registry (its presentation `mode`), * `dshHome` to bash environment and local skill discovery, `sessionTitle` to * the fallback title service, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the @@ -83,13 +83,16 @@ export interface GoalConfig { * workspace context instead requires an explicit byte budget or `false` because * it changes model-visible input. Producer opt-in stays producer-local: * `toolBash` configures bash only; independently composed producers keep their - * own config. + * own config. Set `toolBash: false` when another plugin owns the model-facing + * `bash` name. */ export interface Config { /** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */ agents?: AgentLoopConfig['agents'] /** Agent-loop concurrency cap; `1` is serial. */ maxParallelToolCalls?: AgentLoopConfig['maxParallelToolCalls'] + /** Whether the system prompt includes the fixed Harness identity (default true). */ + includeHarnessIdentity?: SystemPromptConfig['includeHarnessIdentity'] /** The deployment persona (see dsh-system-prompt's `Config`). */ persona?: SystemPromptConfig['persona'] /** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */ @@ -104,8 +107,8 @@ export interface Config { workspaceContext: workspaceContext.Config | false /** Skill registry, local provider, and model-facing consumer config. */ skills?: SkillConfig - /** Model-facing bash tool config, including this producer's background opt-in. */ - toolBash?: toolBash.Config + /** Model-facing bash tool config, or false when another plugin owns `bash`. */ + toolBash?: toolBash.Config | false /** Generic background-task controls; set false to keep the task service without model-facing task tools. */ toolTasks?: toolTasks.Config | false /** Global enablement and package-name filters for invariant companions. */ @@ -127,7 +130,8 @@ export const SessionTitleConfigSchema: z = SessionTitleServi .default(EXAMPLE_SESSION_TITLE_CONFIG) /** The bash-tool config schema exported for app packages that forward `toolBash`. */ -export const ToolBashConfigSchema: z = toolBash.Config +export const ToolBashConfigSchema: z = + z.union([z.const(false), toolBash.Config]) /** The task-control-tool config schema exported for app packages that forward `toolTasks`. */ export const ToolTasksConfigSchema: z = toolTasks.Config @@ -163,6 +167,7 @@ export const Config = z.intersect([ export function pickSpineConfig(config: Omit): Omit { return { ...config.maxParallelToolCalls !== undefined ? { maxParallelToolCalls: config.maxParallelToolCalls } : {}, + ...config.includeHarnessIdentity !== undefined ? { includeHarnessIdentity: config.includeHarnessIdentity } : {}, ...config.persona !== undefined ? { persona: config.persona } : {}, ...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {}, ...config.tools !== undefined ? { tools: config.tools } : {}, @@ -201,6 +206,7 @@ export function apply(ctx: Context, config: Config): void { ctx.plugin(SessionTitleService, config.sessionTitle ?? EXAMPLE_SESSION_TITLE_CONFIG) // Owner schemas resolve defaults; forward toolOrder only when explicitly set. ctx.plugin(SystemPrompt, { + includeHarnessIdentity: config.includeHarnessIdentity ?? true, persona: config.persona ?? '', ...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {}, }) @@ -223,7 +229,9 @@ export function apply(ctx: Context, config: Config): void { ctx.plugin(agentInvariant) ctx.plugin(scopeInvariant) ctx.plugin(agentLoopInvariant) - ctx.plugin(toolBash, Object.assign({}, config.toolBash, { dshHome })) + if (config.toolBash !== false) { + ctx.plugin(toolBash, Object.assign({}, config.toolBash, { dshHome })) + } if (config.workspaceContext !== false) { ctx.plugin(workspaceContext, config.workspaceContext) } diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 5a6f89525c..f32e04acf8 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' +import { renderPrompt, TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import * as agentCore from '../src/index.ts' import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' @@ -515,9 +515,27 @@ describe('dsh-agent-spine-demo bundle', () => { await ctx.fiber.dispose() }) + it('can omit the bundled bash tool and Harness identity for a compatibility deployment', async () => { + const ctx = await mount({ + includeHarnessIdentity: false, + persona: 'You are a helpful software engineer assistant.', + workspaceContext: false, + skills: { enabled: false }, + toolBash: false, + toolTasks: false, + }, true) + + expect(ctx.tools.schemas()).toEqual([]) + expect(renderPrompt(await ctx.systemPrompt.assemble())) + .toBe('You are a helpful software engineer assistant.') + + await ctx.fiber.dispose() + }) + it('picks shared spine config without leaking front-door fields', () => { const appConfig = { model: 'front-door-only', + includeHarnessIdentity: false, persona: 'You are merged.', toolOrder: ['zulu'], tools: { mode: 'native' as const }, @@ -531,6 +549,7 @@ describe('dsh-agent-spine-demo bundle', () => { } expect(agentCore.pickSpineConfig(appConfig)).toEqual({ + includeHarnessIdentity: appConfig.includeHarnessIdentity, persona: appConfig.persona, toolOrder: appConfig.toolOrder, tools: appConfig.tools, diff --git a/packages/fs/README.i18n.yaml b/packages/fs/README.i18n.yaml index 1ead814be1..ecfa2e134a 100644 --- a/packages/fs/README.i18n.yaml +++ b/packages/fs/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -README.md: 4d954455ea920be4882530bcfe90b48a364c29b5 -README.zh.md: e818210abaded987edbb8bf38c6d1b43d40ad9c7 +# pnpm run verify-translation-pairing --write packages/fs/README.md +README.md: b5e0ac9d1c0c550eb372b8a66fc6358711fddc07 +README.zh.md: c5c00f76715f97b43c673b85b3e9055a500d1b5a diff --git a/packages/fs/README.md b/packages/fs/README.md index 4d954455ea..b5e0ac9d1c 100644 --- a/packages/fs/README.md +++ b/packages/fs/README.md @@ -12,6 +12,7 @@ The filesystem stack: a provider seam (text IO + atomic mutation with an optiona | `fs-policy/` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit, via the `fs/*` event gate | (no service — `fs/*` listeners) | | `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`); preserves filesystem semantics for session-cwd-relative paths and advertises sandbox escalation fields when the mounted `ctx.fs` confines | (registers on `ctx.tools`) | | `tool-fs-search/` | Model-facing `glob`/`grep` discovery tools when `rg` is available on the bash executor `PATH`, backed by fixed ripgrep commands through `ctx.bash`, NOT by `ctx.fs` provider methods | (registers on `ctx.tools`) | +| `tool-str-replace-editor/` | Model-facing `str_replace_editor` with view/create/unique literal replace/line insert operations over `ctx.fs` | (registers on `ctx.tools`) | The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas — `fs-sandbox` is the first such replacement (an in-process path fence over the shared sandbox mode; see [the cross-family fs sandbox Agent Note](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)). The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. The mode fence and the read-before-edit gate are orthogonal and compose. Discovery (`tool-fs-search/`) deliberately does NOT extend the provider seam: search is a process-backed `rg` workflow on the bash executor, so filesystem backends stay free of a universal search contract; its tools register only when that executor can find `rg`, and its results are follow-up-readable when the bash workdir and the `read` root are the same workspace (the co-located deployment its README documents). diff --git a/packages/fs/README.zh.md b/packages/fs/README.zh.md index e818210aba..c5c00f7671 100644 --- a/packages/fs/README.zh.md +++ b/packages/fs/README.zh.md @@ -12,6 +12,7 @@ | `fs-policy/` | 政策门禁插件:通过 `fs/*` 事件门禁提供已观察状态、编辑前读取和版本防护的写入/编辑 | (无服务,仅有 `fs/*` 监听器) | | `tool-fs/` | 面向模型的 `read`/`write`/`edit` 工具以及执行器(通过 `ctx.fs` 读取,拥有读取窗口逻辑,分派 `fs/*`);为会话 cwd 相对路径保留文件系统语义,并在已挂载的 `ctx.fs` 实施约束时声明沙箱升级字段 | (注册到 `ctx.tools`) | | `tool-fs-search/` | 面向模型的 `glob`/`grep` 发现工具;当 `rg` 位于 bash 执行器 `PATH` 上时注册,通过 `ctx.bash` 运行固定 ripgrep 命令,而不是使用 `ctx.fs` 提供方方法 | (注册到 `ctx.tools`) | +| `tool-str-replace-editor/` | 基于 `ctx.fs` 提供查看/创建/唯一字面量替换/按行插入的模型可见 `str_replace_editor` | (注册到 `ctx.tools`) | 接口位于 `fs/fs/`。沙箱化、远程或限定项目作用域的文件系统后端可以替换 `fs-local`,而无需更改 seam、政策门禁或面向模型的工具 schema;`fs-sandbox` 是第一个这样的替代实现(基于共享沙箱模式的进程内路径围栏;见[跨能力族 fs 沙箱 Agent Note](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md))。政策(`fs-policy/`)是一个只通过 `fs/*` 事件门禁参与的插件,不是工具注入的服务;因此移除它会平稳失去政策,留下不受约束的裸提供方,而不会破坏工具。加载 `tool-fs/` 的部署也应加载该插件。模式围栏与编辑前读取门禁彼此正交,可以组合。发现(`tool-fs-search/`)有意不扩展提供方 seam:搜索是在 bash 执行器上运行 `rg`、由进程支持的工作流,因此文件系统后端无需承担通用搜索契约;只有当执行器能找到 `rg` 时,其工具才会注册。如果 bash 工作目录与 `read` 根目录是同一工作区,结果就能继续读取,这也是其 README 所述的共置部署。 diff --git a/packages/fs/tool-str-replace-editor/README.i18n.yaml b/packages/fs/tool-str-replace-editor/README.i18n.yaml new file mode 100644 index 0000000000..1f72b1a211 --- /dev/null +++ b/packages/fs/tool-str-replace-editor/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/fs/tool-str-replace-editor/README.md +README.md: 2d98b51d5651cbc72ab8b2055d8e70a43d98157b +README.zh.md: a2ee8f1e3661044c0869ae91af6ceedb2dd8da1d diff --git a/packages/fs/tool-str-replace-editor/README.md b/packages/fs/tool-str-replace-editor/README.md new file mode 100644 index 0000000000..2d98b51d56 --- /dev/null +++ b/packages/fs/tool-str-replace-editor/README.md @@ -0,0 +1,54 @@ +# @deepseek-ai/dsh-tool-str-replace-editor + +English | [中文](README.zh.md) + +Standalone model-facing `str_replace_editor` over `ctx.fs`. It can be composed with persistent Bash, one-shot Bash, sandboxed Bash, or another terminal surface. + +## Config + +| Key | Default | Meaning | +|---|---:|---| +| `maxOutputChars` | `16000` | Prefix characters retained for file and directory views. | +| `description` | Editor command guide | Model-facing tool description. | +| `requireAbsolutePath` | `true` | Reject relative paths; disable only for deployments with a deliberate session-cwd contract. | + +## Tool + +The schema provides `view`, `create`, `str_replace`, and `insert`. File views use one-based line numbers; directory views omit hidden, dependency, and Python-cache entries and descend two levels. Replacement requires one unique literal match and reports errors only in the public `old_str` vocabulary. Insert follows the selected zero-based insertion boundary without adding an implicit trailing newline. + +## Model Experience + +### Tool schema + +#### What the model sees + +The generated [`str_replace_editor` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-str-replace-editor), including the configured `description`. The plugin contributes no standalone system-prompt section. + +#### Token effect + +Fixed schema cost while `str_replace_editor` is visible. + +#### KV Cache effect + +Prefix-stable while the configured description and schema remain unchanged. + +### Tool results + +#### What the model sees + +Views return numbered text or a shallow directory listing. Mutations return concise confirmations. Long views keep their prefix and append a clipping notice. + +#### Token effect + +Data-dependent and bounded by `maxOutputChars` plus the fixed clipping notice. + +#### KV Cache effect + +Append-only tool results follow the reusable request prefix. + +## Known Limitations and Deferred Work + +- Operations target UTF-8 text; binary files are unsupported. +- `str_replace` intentionally rejects zero or multiple matches and has no `replace_all` argument. +- Canonical mode expands tabs before replacement or insertion, matching the reference string-replacement editor. +- The package delegates security and read-before-edit policy to the mounted filesystem and policy plugins. diff --git a/packages/fs/tool-str-replace-editor/README.zh.md b/packages/fs/tool-str-replace-editor/README.zh.md new file mode 100644 index 0000000000..a2ee8f1e36 --- /dev/null +++ b/packages/fs/tool-str-replace-editor/README.zh.md @@ -0,0 +1,54 @@ +# @deepseek-ai/dsh-tool-str-replace-editor + +[English](README.md) | 中文 + +基于 `ctx.fs` 的独立模型可见 `str_replace_editor`。它可与持久 Bash、一次性 Bash、沙箱 Bash 或其他终端表面组合。 + +## 配置 + +| 键 | 默认值 | 含义 | +|---|---:|---| +| `maxOutputChars` | `16000` | 文件和目录查看结果保留的前缀字符数。 | +| `description` | 编辑器命令指南 | 面向模型的工具描述。 | +| `requireAbsolutePath` | `true` | 拒绝相对路径;仅当部署明确约定 session cwd 时才应关闭。 | + +## 工具 + +Schema 提供 `view`、`create`、`str_replace` 与 `insert`。文件查看使用从一开始的行号;目录查看忽略隐藏、依赖与 Python 缓存条目并下探两层。替换要求字面量唯一匹配,错误只使用公开的 `old_str` 词汇。插入遵循所选的零基插入边界,不会隐式补尾换行。 + +## 模型体验 + +### 工具 schema + +#### 模型所见 + +生成的 [`str_replace_editor` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-str-replace-editor),其中包含配置的 `description`。本插件不贡献独立系统提示词段。 + +#### Token 影响 + +`str_replace_editor` 可见时产生固定的 schema 成本。 + +#### KV Cache 影响 + +配置的描述与 schema 不变时前缀稳定。 + +### 工具结果 + +#### 模型所见 + +查看操作返回带行号文本或浅层目录列表。修改操作返回简洁确认。长查看结果保留前缀并追加截断提示。 + +#### Token 影响 + +随数据变化,并受 `maxOutputChars` 与固定截断提示约束。 + +#### KV Cache 影响 + +工具结果以追加方式位于可复用请求前缀之后。 + +## 已知限制与延后工作 + +- 操作面向 UTF-8 文本,不支持二进制文件。 +- `str_replace` 刻意拒绝零匹配或多匹配,且没有 `replace_all` 参数。 +- 规范模式会在替换或插入前展开制表符,与参考字符串替换编辑器保持一致。 +- 安全与先读后改策略委托给挂载的文件系统和策略插件。 diff --git a/packages/fs/tool-str-replace-editor/package.json b/packages/fs/tool-str-replace-editor/package.json new file mode 100644 index 0000000000..88d3af8d53 --- /dev/null +++ b/packages/fs/tool-str-replace-editor/package.json @@ -0,0 +1,48 @@ +{ + "name": "@deepseek-ai/dsh-tool-str-replace-editor", + "description": "Model-facing view, create, literal replace, and line insert tool over the Harness filesystem service", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-fs": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/fs/tool-str-replace-editor/src/index.ts b/packages/fs/tool-str-replace-editor/src/index.ts new file mode 100644 index 0000000000..c4a16e5437 --- /dev/null +++ b/packages/fs/tool-str-replace-editor/src/index.ts @@ -0,0 +1,403 @@ +/** + * Model-facing `str_replace_editor` over the Harness filesystem seam. + * @module @deepseek-ai/dsh-tool-str-replace-editor + */ + +import { isAbsolute } from 'node:path' +import type { Context } from 'cordis' +import z from 'schemastery' +import { FsError } from '@deepseek-ai/dsh-fs' +import type { FsInfo, FsTarget } from '@deepseek-ai/dsh-fs' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { ToolRunContext } from '@deepseek-ai/dsh-tools' + +const TRUNCATED_MESSAGE = 'To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.' + +const DEFAULT_DESCRIPTION = ` +Custom editing tool for viewing, creating and editing files +* State is persistent across command calls and discussions with the user +* If \`path\` is a file, \`view\` displays the result of applying \`cat -n\`. If \`path\` is a directory, \`view\` lists non-hidden files and directories up to 2 levels deep +* The \`create\` command cannot be used if the specified \`path\` already exists as a file +* If a \`command\` generates a long output, it will be truncated and marked with \`\` + +Notes for using the \`str_replace\` command: +* The \`old_str\` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces! +* If the \`old_str\` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in \`old_str\` to make it unique +* The \`new_str\` parameter should contain the edited lines that should replace the \`old_str\` +`.trim() + +function maybeTruncate(content: string, maxOutputChars: number): string { + return content.length <= maxOutputChars + ? content + : content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE +} + +function expandTabs(content: string, tabSize = 8): string { + let column = 0 + let result = '' + for (const character of content) { + if (character === '\t') { + const spaces = tabSize - (column % tabSize) + result += ' '.repeat(spaces) + column += spaces + continue + } + result += character + if (character === '\n' || character === '\r') column = 0 + else column += 1 + } + return result +} + +async function resolveTarget( + ctx: Context, + path: string, + requireAbsolutePath: boolean, + exec: ToolRunContext, +): Promise { + if (path.trim().length === 0) throw new Error('path must be a non-empty string') + if (requireAbsolutePath && !isAbsolute(path)) { + throw new Error(`The path ${path} is not an absolute path, it should start with \`/\`. Maybe you meant /${path}?`) + } + const cwd = exec.agent?.session.header.cwd + return ctx.fs.resolve(path, cwd === undefined ? { signal: exec.signal } : { cwd, signal: exec.signal }) +} + +async function statExisting( + ctx: Context, + target: FsTarget, + command: 'view' | 'str_replace' | 'insert', + exec: ToolRunContext, +): Promise { + const info = await ctx.fs.stat(target, exec.signal) + if (info === undefined) { + throw new FsError( + `The path ${target.displayPath} does not exist. Please provide a valid path.`, + 'FS_NOT_FOUND', + ) + } + if (info.type === 'directory' && command !== 'view') { + throw new FsError( + `The path ${target.displayPath} is a directory and only the \`view\` command can be used on directories`, + 'FS_NOT_REGULAR_FILE', + ) + } + return info +} + +function requiredForCommand( + value: string | undefined, + parameter: string, + command: string, + allowEmpty = true, +): string { + if (value === undefined) throw new Error(`Parameter \`${parameter}\` is required for command: ${command}`) + if (!allowEmpty && value.length === 0) { + throw new Error(`Parameter \`${parameter}\` is empty for command: ${command}`) + } + return value +} + +function formatFileView( + path: string, + content: string, + maxOutputChars: number, + viewRange?: number[], +): string { + const allLines = content.split('\n') + let lines = allLines + let initialLine = 1 + let finalLine: number | undefined + let prompt = `Here's the content of ${path} with line numbers (which has a total of ${allLines.length} lines)` + if (viewRange !== undefined) { + const [requestedInitialLine, requestedFinalLine] = viewRange + if ( + viewRange.length !== 2 + || requestedInitialLine === undefined + || requestedFinalLine === undefined + || !viewRange.every(Number.isInteger) + ) { + throw new Error('Invalid `view_range`. It should be a list of two integers.') + } + initialLine = requestedInitialLine + finalLine = requestedFinalLine + if (initialLine < 1 || initialLine > allLines.length) { + throw new Error( + `Invalid \`view_range\`: [${viewRange.join(', ')}]. Its first element \`${initialLine}\` should be within the range of lines of the file: [1, ${allLines.length}]`, + ) + } + if (finalLine > allLines.length) { + throw new Error( + `Invalid \`view_range\`: [${viewRange.join(', ')}]. Its second element \`${finalLine}\` should be smaller than the number of lines in the file: \`${allLines.length}\``, + ) + } + if (finalLine !== -1 && finalLine < initialLine) { + throw new Error( + `Invalid \`view_range\`: [${viewRange.join(', ')}]. Its second element \`${finalLine}\` should be larger or equal than its first \`${initialLine}\``, + ) + } + lines = finalLine === -1 + ? allLines.slice(initialLine - 1) + : allLines.slice(initialLine - 1, finalLine) + prompt += ` with view_range=[${initialLine}, ${finalLine}]` + } + const numbered = expandTabs(lines + .map((line, index) => `${String(initialLine + index).padStart(6, ' ')}\t${line}`) + .join('\n')) + return maybeTruncate(`${prompt}:\n${numbered}\n`, maxOutputChars) +} + +async function listDirectory( + ctx: Context, + target: FsTarget, + maxOutputChars: number, + exec: ToolRunContext, +): Promise { + async function visit(dir: FsTarget, depth: number): Promise { + const entries = await ctx.fs.listDir(dir, exec.signal) + const rows: string[] = [] + for (const entry of entries.filter(candidate => + !candidate.name.startsWith('.') + && !candidate.name.startsWith('node_modules') + && !candidate.name.startsWith('__pycache__'))) { + const type = entry.type === 'directory' ? 'd' : entry.type === 'file' ? 'f' : '?' + rows.push(`${type}\t${entry.target.displayPath}`) + if (entry.type === 'directory' && depth < 2) { + rows.push(...await visit(entry.target, depth + 1)) + } + } + return rows + } + const rows = [`d\t${target.displayPath}`, ...await visit(target, 1)] + rows.sort((left, right) => { + const leftPath = left.slice(left.indexOf('\t') + 1) + const rightPath = right.slice(right.indexOf('\t') + 1) + return leftPath.localeCompare(rightPath) + }) + const listing = maybeTruncate(rows.join('\n') + '\n', maxOutputChars) + return `Here're the files and directories up to 2 levels deep in ${target.displayPath}, excluding hidden items, node_modules, and Python cache directories:\n${listing}\n` +} + +async function viewPath( + ctx: Context, + path: string, + viewRange: number[] | undefined, + maxOutputChars: number, + requireAbsolutePath: boolean, + exec: ToolRunContext, +): Promise { + const target = await resolveTarget(ctx, path, requireAbsolutePath, exec) + const info = await statExisting(ctx, target, 'view', exec) + if (info.type === 'directory') { + if (viewRange !== undefined) { + throw new Error('The `view_range` parameter is not allowed when `path` points to a directory.') + } + return listDirectory(ctx, target, maxOutputChars, exec) + } + if (info.type !== 'file') { + throw new FsError(`cannot view "${target.displayPath}": not a regular file or directory`, 'FS_NOT_REGULAR_FILE') + } + const content = await ctx.fs.readText(target, exec.signal) + ctx.emit('fs/observed', target, info.version, exec) + return formatFileView(target.displayPath, content, maxOutputChars, viewRange) +} + +async function createFile( + ctx: Context, + path: string, + fileText: string | undefined, + requireAbsolutePath: boolean, + exec: ToolRunContext, +): Promise { + const content = requiredForCommand(fileText, 'file_text', 'create') + const target = await resolveTarget(ctx, path, requireAbsolutePath, exec) + if (await ctx.fs.stat(target, exec.signal) !== undefined) { + throw new Error(`File already exists at: ${target.displayPath}. Cannot overwrite files using command \`create\`.`) + } + const outcome = await ctx.fs.writeText(target, content, { kind: 'createIfAbsent' }, exec.signal) + ctx.emit('fs/observed', target, outcome.version, exec) + return `New file created successfully at: ${target.displayPath}` +} + +async function replaceInFile( + ctx: Context, + path: string, + oldStr: string | undefined, + newStr: string | undefined, + requireAbsolutePath: boolean, + exec: ToolRunContext, +): Promise { + const target = await resolveTarget(ctx, path, requireAbsolutePath, exec) + const oldValue = expandTabs(requiredForCommand(oldStr, 'old_str', 'str_replace', false)) + const newValue = expandTabs(newStr ?? '') + const info = await statExisting(ctx, target, 'str_replace', exec) + if (info.type !== 'file') { + throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') + } + const before = expandTabs(await ctx.fs.readText(target, exec.signal)) + const occurrences = before.split(oldValue).length - 1 + if (occurrences === 0) { + throw new FsError( + `No replacement was performed, old_str \`${oldValue}\` did not appear verbatim in ${target.displayPath}.`, + 'FS_EDIT_NOT_FOUND', + ) + } + if (occurrences > 1) { + const lines = before.split('\n') + .flatMap((line, index) => line.includes(oldValue) ? [index + 1] : []) + throw new FsError( + `No replacement was performed. Multiple occurrences of old_str \`${oldValue}\` in lines [${lines.join(', ')}]. Please ensure it is unique`, + 'FS_AMBIGUOUS_EDIT', + ) + } + const outcome = await ctx.fs.writeText( + target, + before.replace(oldValue, newValue), + { kind: 'replaceIfVersion', version: info.version }, + exec.signal, + ) + ctx.emit('fs/observed', target, outcome.version, exec) + return `The file ${target.displayPath} has been edited successfully.` +} + +async function insertInFile( + ctx: Context, + path: string, + insertLine: number | undefined, + newStr: string | undefined, + requireAbsolutePath: boolean, + exec: ToolRunContext, +): Promise { + if (insertLine === undefined) throw new Error('Parameter `insert_line` is required for command: insert') + const value = expandTabs(requiredForCommand(newStr, 'new_str', 'insert')) + const target = await resolveTarget(ctx, path, requireAbsolutePath, exec) + const info = await statExisting(ctx, target, 'insert', exec) + if (info.type !== 'file') { + throw new FsError(`cannot insert into "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') + } + const before = expandTabs(await ctx.fs.readText(target, exec.signal)) + const lines = before.split('\n') + if (!Number.isInteger(insertLine) || insertLine < 0 || insertLine > lines.length) { + throw new Error( + `Invalid \`insert_line\` parameter: ${insertLine}. It should be within the range of lines of the file: [0, ${lines.length}]`, + ) + } + const after = [ + ...lines.slice(0, insertLine), + ...value.split('\n'), + ...lines.slice(insertLine), + ].join('\n') + const outcome = await ctx.fs.writeText( + target, + after, + { kind: 'replaceIfVersion', version: info.version }, + exec.signal, + ) + ctx.emit('fs/observed', target, outcome.version, exec) + return `The file ${target.displayPath} has been edited successfully.` +} + +interface ResolvedConfig { + maxOutputChars: number + description: string + requireAbsolutePath: boolean +} + +/** Register the model-facing `str_replace_editor` tool. */ +function registerStrReplaceEditor(ctx: Context, config: ResolvedConfig): void { + ctx.tools.register(defineTool({ + name: 'str_replace_editor', + description: config.description, + parameters: { + command: { + type: 'string', + required: true, + enum: ['view', 'create', 'str_replace', 'insert'], + description: 'The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.', + }, + path: { + type: 'string', + required: true, + description: 'Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`.', + }, + file_text: { + type: 'string', + description: 'Required parameter of `create` command, with the content of the file to be created.', + }, + insert_line: { + type: 'integer', + description: 'Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`.', + }, + new_str: { + type: 'string', + description: 'Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert.', + }, + old_str: { + type: 'string', + description: 'Required parameter of `str_replace` command containing the string in `path` to replace.', + }, + view_range: { + type: 'array', + items: { type: 'integer' }, + description: 'Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.', + }, + }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, + async execute(args, exec) { + switch (args.command) { + case 'view': + return viewPath(ctx, args.path, args.view_range, config.maxOutputChars, config.requireAbsolutePath, exec) + case 'create': + return createFile(ctx, args.path, args.file_text, config.requireAbsolutePath, exec) + case 'str_replace': + return replaceInFile(ctx, args.path, args.old_str, args.new_str, config.requireAbsolutePath, exec) + case 'insert': + return insertInFile(ctx, args.path, args.insert_line, args.new_str, config.requireAbsolutePath, exec) + } + }, + presentCall: args => ({ + card: 'generic', + title: `${args.command} ${args.path}`, + kind: args.command === 'view' ? 'read' : 'edit', + }), + })) +} + +export const name = 'tool-str-replace-editor' +export const inject = ['tools', 'fs'] + +/** Configuration for the string-replacement editor tool. */ +export interface Config { + /** Maximum returned view characters before clipping (default 16000). */ + maxOutputChars?: number + /** Model-facing tool description. */ + description?: string + /** Require local absolute paths like the canonical editor contract (default true). */ + requireAbsolutePath?: boolean +} + +/** Runtime configuration schema for the string-replacement editor tool. */ +export const Config: z = z.object({ + maxOutputChars: z.number().default(16_000), + description: z.string().default(DEFAULT_DESCRIPTION), + requireAbsolutePath: z.boolean().default(true), +}) + +/** Register one `str_replace_editor` tool over `ctx.fs`. */ +export function apply(ctx: Context, config: Config): void { + const resolved: ResolvedConfig = { + maxOutputChars: config.maxOutputChars ?? 16_000, + description: config.description ?? DEFAULT_DESCRIPTION, + requireAbsolutePath: config.requireAbsolutePath ?? true, + } + if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) { + throw new Error('tool-str-replace-editor: maxOutputChars must be a positive safe integer') + } + if (resolved.description.trim().length === 0) { + throw new Error('tool-str-replace-editor: description must be non-empty') + } + registerStrReplaceEditor(ctx, resolved) +} diff --git a/packages/fs/tool-str-replace-editor/src/invariant.ts b/packages/fs/tool-str-replace-editor/src/invariant.ts new file mode 100644 index 0000000000..99547c02ee --- /dev/null +++ b/packages/fs/tool-str-replace-editor/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-str-replace-editor`. + * @module @deepseek-ai/dsh-tool-str-replace-editor/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-str-replace-editor' + +/** Cordis companion plugin name. */ +export const name = 'tool-str-replace-editor-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the tool adapter owns no independent durable state; + * filesystem mutation relations stay with the provider and policy plugins. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts new file mode 100644 index 0000000000..7cf9ba5212 --- /dev/null +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -0,0 +1,313 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { FsVersion } from '@deepseek-ai/dsh-fs' +import { CallId } from '@deepseek-ai/dsh-llm' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import LocalFileSystem from '@deepseek-ai/dsh-fs-local' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import * as ToolStrReplaceEditor from '@deepseek-ai/dsh-tool-str-replace-editor' + +const contexts: Context[] = [] +const roots: string[] = [] +let callNumber = 0 + +afterEach(async () => { + for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) +}) + +function agent(ctx: Context, cwd: string): Agent { + const id = SessionId(`str-replace-editor-owner-${callNumber}`) + const scope = ctx.plugin(() => {}) + const value: Agent = { + id, + options: {}, + session: new Session(id, [], { version: 0, id, createdAt: 0, cwd }), + status: 'idle', + acceptsNextStep: false, + ctx: scope.ctx, + followup: () => {}, + steer: () => {}, + inject: () => {}, + send: () => {}, + cancel() {}, + whenIdle: () => Promise.resolve(), + } + ctx.agents.register(value) + return value +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(block => block.type === 'text').map(block => block.text).join('') +} + +function call(ctx: Context, owner: Agent | undefined, args: unknown) { + return ctx.tools.execute({ + signal: new AbortController().signal, + callId: CallId(`str-replace-editor-${++callNumber}`), + name: 'str_replace_editor', + arguments: args, + ...owner === undefined ? {} : { agent: owner }, + }) +} + +async function setup(config: ToolStrReplaceEditor.Config = {}) { + const root = await mkdtemp(join(tmpdir(), 'dsh-tool-str-replace-editor-')) + roots.push(root) + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalFileSystem, { cwd: root }) + await ctx.plugin(ToolStrReplaceEditor, config) + return { ctx, root, owner: agent(ctx, root) } +} + +describe('tool-str-replace-editor', () => { + it('registers the standalone schema and configurable description', async () => { + const { ctx } = await setup({ description: 'custom editor description' }) + const schema = ctx.tools.schemas()[0] + expect(ctx.tools.schemas().map(item => item.name)).toEqual(['str_replace_editor']) + expect(schema?.description).toBe('custom editor description') + const properties = (schema?.parameters as { + properties: Record + }).properties + expect(properties).not.toHaveProperty('replace_all') + expect(properties.insert_line?.type).toBe('integer') + expect(properties.view_range?.items?.type).toBe('integer') + expect(ctx.tools.get('str_replace_editor')?.presentCall?.({ + command: 'view', + path: '/workspace/a.txt', + })).toMatchObject({ card: 'generic', kind: 'read' }) + expect(ctx.tools.get('str_replace_editor')?.presentCall?.({ + command: 'insert', + path: '/workspace/a.txt', + insert_line: 0, + new_str: 'x', + })).toMatchObject({ card: 'generic', kind: 'edit' }) + }) + + it('creates, views, replaces, and inserts with the canonical model-facing output', async () => { + const { ctx, root, owner } = await setup() + const sample = join(root, 'sample.txt') + expect(text(await call(ctx, owner, { + command: 'create', + path: sample, + file_text: 'one\ntwo\nthree\n', + }))).toBe(`New file created successfully at: ${sample}`) + + expect(text(await call(ctx, owner, { + command: 'view', + path: sample, + view_range: [2, -1], + }))).toBe([ + `Here's the content of ${sample} with line numbers (which has a total of 4 lines) with view_range=[2, -1]:`, + ' 2 two', + ' 3 three', + ' 4 ', + '', + ].join('\n')) + + expect(text(await call(ctx, owner, { + command: 'str_replace', + path: sample, + old_str: 'two', + new_str: 'TWO', + }))).toBe(`The file ${sample} has been edited successfully.`) + expect(text(await call(ctx, owner, { + command: 'str_replace', + path: sample, + old_str: 'TWO', + }))).toBe(`The file ${sample} has been edited successfully.`) + expect(text(await call(ctx, owner, { + command: 'insert', + path: sample, + insert_line: 1, + new_str: 'between', + }))).toBe(`The file ${sample} has been edited successfully.`) + expect(await readFile(sample, 'utf8')).toBe('one\nbetween\n\nthree\n') + }) + + it('lists visible entries to depth two and clips at the configured view limit', async () => { + const { ctx, root, owner } = await setup({ maxOutputChars: 10 }) + await mkdir(join(root, 'dir', 'nested', 'third'), { recursive: true }) + await mkdir(join(root, 'dir', 'node_modules', 'pkg'), { recursive: true }) + await mkdir(join(root, 'dir', '__pycache__'), { recursive: true }) + await writeFile(join(root, 'dir', 'visible.txt'), 'ok') + await writeFile(join(root, 'dir', '.hidden'), 'hidden') + await writeFile(join(root, 'dir', 'nested', 'child.txt'), 'child') + await writeFile(join(root, 'dir', 'nested', 'third', 'too-deep.txt'), 'deep') + await writeFile(join(root, 'dir', 'node_modules', 'pkg', 'index.js'), 'hidden dependency') + await writeFile(join(root, 'dir', '__pycache__', 'module.pyc'), 'cache') + const listDir = ctx.fs.listDir.bind(ctx.fs) + const otherTarget = await ctx.fs.resolve(join(root, 'dir', 'other')) + ctx.fs.listDir = async (target, signal) => { + const entries = await listDir(target, signal) + return target.displayPath === join(root, 'dir') + ? [...entries, { name: 'other', type: 'other', target: otherTarget }] + : entries + } + + const listing = text(await call(ctx, owner, { command: 'view', path: join(root, 'dir') })) + expect(listing).toContain('') + expect(listing).not.toContain('.hidden') + expect(listing).not.toContain('too-deep.txt') + expect(listing).not.toContain('index.js') + expect(listing).not.toContain('module.pyc') + + await writeFile(join(root, 'large.txt'), 'x'.repeat(100)) + expect(text(await call(ctx, owner, { command: 'view', path: join(root, 'large.txt') }))) + .toContain('') + }) + + it('matches canonical empty-line, range, and end-insert behavior', async () => { + const { ctx, root, owner } = await setup() + const empty = join(root, 'empty.txt') + const newline = join(root, 'newline.txt') + const plain = join(root, 'plain.txt') + await writeFile(empty, '') + await writeFile(newline, '\n') + await writeFile(plain, 'one\ntwo') + + expect(text(await call(ctx, owner, { command: 'view', path: empty }))) + .toContain('(which has a total of 1 lines):\n 1 \n') + expect(text(await call(ctx, owner, { command: 'view', path: newline }))) + .toContain('(which has a total of 2 lines):\n 1 \n 2 \n') + expect(text(await call(ctx, owner, { + command: 'view', + path: plain, + view_range: [1, 2], + }))).toContain(' 2 two') + expect(text(await call(ctx, undefined, { + command: 'view', + path: plain, + }))).toContain(' 1 one') + + await call(ctx, owner, { + command: 'insert', + path: plain, + insert_line: 2, + new_str: 'three', + }) + expect(await readFile(plain, 'utf8')).toBe('one\ntwo\nthree') + + await writeFile(newline, 'one\n') + await call(ctx, owner, { + command: 'insert', + path: newline, + insert_line: 2, + new_str: 'three', + }) + expect(await readFile(newline, 'utf8')).toBe('one\n\nthree') + }) + + it('uses old_str-only replacement failures and rejects relative paths', async () => { + const { ctx, root, owner } = await setup() + const ambiguous = join(root, 'ambiguous.txt') + await writeFile(ambiguous, 'same\nother\nsame') + + const missing = await call(ctx, owner, { + command: 'str_replace', + path: ambiguous, + old_str: 'absent', + new_str: 'x', + }) + expect(missing.isError).toBe(true) + expect(text(missing)).toContain(`old_str \`absent\` did not appear verbatim in ${ambiguous}`) + expect(text(missing)).not.toContain('old_string') + + const repeated = await call(ctx, owner, { + command: 'str_replace', + path: ambiguous, + old_str: 'same', + new_str: 'x', + }) + expect(repeated.isError).toBe(true) + expect(text(repeated)).toContain('Multiple occurrences of old_str `same` in lines [1, 3]') + expect(text(repeated)).not.toContain('replace_all') + + const relative = await call(ctx, owner, { command: 'view', path: 'ambiguous.txt' }) + expect(relative.isError).toBe(true) + expect(text(relative)).toContain('is not an absolute path') + expect(await readFile(ambiguous, 'utf8')).toBe('same\nother\nsame') + }) + + it('reports invalid commands or arguments without mutating files', async () => { + const { ctx, root, owner } = await setup() + const ambiguous = join(root, 'ambiguous.txt') + const empty = join(root, 'empty.txt') + const trailingNewline = join(root, 'trailing-newline.txt') + const threeLines = join(root, 'three-lines.txt') + const directory = join(root, 'directory') + await writeFile(ambiguous, 'same same') + await writeFile(empty, '') + await writeFile(trailingNewline, 'one\n') + await writeFile(threeLines, 'one\ntwo\nthree') + await mkdir(directory) + + const cases = [ + { command: 'view', path: '' }, + { command: 'view', path: join(root, 'missing.txt') }, + { command: 'view', path: ambiguous, view_range: [1] }, + { command: 'view', path: ambiguous, view_range: [0, 1] }, + { command: 'view', path: ambiguous, view_range: [1.5, 2] }, + { command: 'view', path: threeLines, view_range: [1, 99] }, + { command: 'view', path: threeLines, view_range: [2, 1] }, + { command: 'view', path: directory, view_range: [1, 1] }, + { command: 'create', path: join(root, 'new.txt') }, + { command: 'create', path: ambiguous, file_text: 'overwrite' }, + { command: 'str_replace', path: ambiguous, new_str: 'x' }, + { command: 'str_replace', path: ambiguous, old_str: '', new_str: 'x' }, + { command: 'insert', path: ambiguous, new_str: 'x' }, + { command: 'insert', path: ambiguous, insert_line: -1, new_str: 'x' }, + { command: 'insert', path: ambiguous, insert_line: 1.5, new_str: 'x' }, + { command: 'insert', path: ambiguous, insert_line: 99, new_str: 'x' }, + { command: 'insert', path: empty, insert_line: 2, new_str: 'x' }, + { command: 'insert', path: directory, insert_line: 0, new_str: 'x' }, + ] + for (const args of cases) { + expect((await call(ctx, owner, args)).isError).toBe(true) + } + expect(await readFile(ambiguous, 'utf8')).toBe('same same') + + ctx.fs.stat = async () => ({ version: FsVersion('special'), type: 'other' }) + const special = await call(ctx, owner, { command: 'view', path: join(root, 'special') }) + expect(special.isError).toBe(true) + expect(special.error).toMatchObject({ info: { code: 'FS_NOT_REGULAR_FILE' } }) + expect((await call(ctx, owner, { + command: 'str_replace', + path: join(root, 'special'), + old_str: 'x', + new_str: 'y', + })).error).toMatchObject({ info: { code: 'FS_NOT_REGULAR_FILE' } }) + expect((await call(ctx, owner, { + command: 'insert', + path: join(root, 'special'), + insert_line: 0, + new_str: 'x', + })).error).toMatchObject({ info: { code: 'FS_NOT_REGULAR_FILE' } }) + }) + + it('can opt into session-relative paths for non-canonical deployments', async () => { + const { ctx, root, owner } = await setup({ requireAbsolutePath: false }) + await writeFile(join(root, 'relative.txt'), 'relative') + expect(text(await call(ctx, owner, { command: 'view', path: 'relative.txt' }))) + .toContain("Here's the content of") + }) + + it('rejects invalid plugin config', () => { + expect(() => { + ToolStrReplaceEditor.apply(new Context(), { maxOutputChars: 0 }) + }).toThrow('maxOutputChars must be a positive safe integer') + expect(() => { + ToolStrReplaceEditor.apply(new Context(), { description: ' ' }) + }).toThrow('description must be non-empty') + }) +}) diff --git a/packages/fs/tool-str-replace-editor/tsconfig.json b/packages/fs/tool-str-replace-editor/tsconfig.json new file mode 100644 index 0000000000..2c6eb3688c --- /dev/null +++ b/packages/fs/tool-str-replace-editor/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../core/tools" }, + { "path": "../fs" }, + { "path": "../../support/invariants" } + ] +} diff --git a/packages/pty/README.i18n.yaml b/packages/pty/README.i18n.yaml index ef4c7b5c13..f986146eae 100644 --- a/packages/pty/README.i18n.yaml +++ b/packages/pty/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -README.md: a9121455519a5f83a63a005cb857fec0f0e06b92 -README.zh.md: 9fc262787b960d5bf03a59cd01bf36bd5c76614b +# pnpm run verify-translation-pairing --write packages/pty/README.md +README.md: e54dcf64db083665f37b7dc19a7a92e21494442b +README.zh.md: e06ea496e389d329671c358fbde5c0849aa03cf8 diff --git a/packages/pty/README.md b/packages/pty/README.md index a912145551..e54dcf64db 100644 --- a/packages/pty/README.md +++ b/packages/pty/README.md @@ -9,5 +9,6 @@ English | [中文](README.zh.md) | [`pty`](pty/README.md) (`@deepseek-ai/dsh-pty`) | Backend registry, branded ids, exact-Agent ownership, session operations, and awaited cleanup | `ctx.pty` | | `pty-local` (`@deepseek-ai/dsh-pty-local`) | Local `node-pty` backend, readiness detection, bounded terminal state, sandboxing, and process-session supervision | registers on `ctx.pty` | | `tool-pty` (`@deepseek-ai/dsh-tool-pty`) | Six model-facing tools and generic task integration for background sends | registers on `ctx.tools` | +| `tool-bash-persistent` (`@deepseek-ai/dsh-tool-bash-persistent`) | One model-facing `bash` backed by an owner-scoped reusable PTY shell | consumes `ctx.pty`, registers on `ctx.tools` | The design and deferred boundaries live in the [persistent PTY Agent Note](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md). diff --git a/packages/pty/README.zh.md b/packages/pty/README.zh.md index 9fc262787b..e06ea496e3 100644 --- a/packages/pty/README.zh.md +++ b/packages/pty/README.zh.md @@ -9,5 +9,6 @@ | [`pty`](pty/README.md)(`@deepseek-ai/dsh-pty`) | 后端注册表、品牌化 id、精确的 Agent 所有权、会话操作与等待完成的清理 | `ctx.pty` | | `pty-local`(`@deepseek-ai/dsh-pty-local`) | 本地 `node-pty` 后端、就绪检测、有界终端状态、沙箱与进程会话监管 | 注册到 `ctx.pty` | | `tool-pty`(`@deepseek-ai/dsh-tool-pty`) | 6 个面向模型的工具,并为后台发送集成通用任务 | 注册到 `ctx.tools` | +| `tool-bash-persistent`(`@deepseek-ai/dsh-tool-bash-persistent`) | 一个由所有者隔离可复用 PTY shell 支撑的模型可见 `bash` | 消费 `ctx.pty`,注册到 `ctx.tools` | 设计与暂缓边界记录在[持久 PTY Agent Note](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md) 中。 diff --git a/packages/pty/tool-bash-persistent/README.i18n.yaml b/packages/pty/tool-bash-persistent/README.i18n.yaml new file mode 100644 index 0000000000..2f15d109c1 --- /dev/null +++ b/packages/pty/tool-bash-persistent/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/pty/tool-bash-persistent/README.md +README.md: 04c714d5489dbae8572e9339a4387a148450a0e9 +README.zh.md: adfb38b10409174d9558b963f5a2359cf819f04b diff --git a/packages/pty/tool-bash-persistent/README.md b/packages/pty/tool-bash-persistent/README.md new file mode 100644 index 0000000000..04c714d548 --- /dev/null +++ b/packages/pty/tool-bash-persistent/README.md @@ -0,0 +1,50 @@ +# @deepseek-ai/dsh-tool-bash-persistent + +English | [中文](README.zh.md) + +Model-facing `bash(command)` backed by one owner-scoped `ctx.pty` shell. The package owns the tool contract and shell reuse; deployments select the PTY backend and sandbox policy. + +## Config + +| Key | Default | Meaning | +|---|---:|---| +| `backendType` | `shell` | Registered PTY backend used for each Agent shell. | +| `timeoutMs` | `300000` | Wall-clock limit for one command; timeout closes the shell. | +| `maxOutputChars` | `16000` | Prefix characters retained before the clipping notice. | +| `description` | Persistent-shell description | Model-facing environment contract. | + +## Model Experience + +### Tool schema + +#### What the model sees + +The generated [`bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash-persistent), including the configured `description`. The plugin contributes no standalone system-prompt section; the deployment owns persona and environment guidance. + +#### Token effect + +Fixed schema cost while `bash` is visible. + +#### KV Cache effect + +Prefix-stable while the configured description and schema remain unchanged. + +### Tool results + +#### What the model sees + +Commands share one shell per Agent, so cwd, exported variables, activated environments, functions, and background jobs persist across calls. Results exclude private completion markers and the shell prompt. Long output keeps the earliest retained prefix plus a clipping notice. If the PTY has already dropped that prefix, the result says so explicitly instead of presenting a tail as complete output. Timeout returns bounded partial output, closes the uncertain shell, and tells the model that the next call starts fresh. + +#### Token effect + +Data-dependent and bounded by `maxOutputChars` plus the fixed clipping notice. + +#### KV Cache effect + +Append-only tool results follow the reusable request prefix. + +## Known Limitations and Deferred Work + +- The tool requires an owning Agent and a real PTY backend. +- Explicit `exit`, timeout, or cancellation discards shell state; the next call starts a fresh shell. +- Environment facts such as network access and package mirrors belong in the configured `description`, not this package's default. diff --git a/packages/pty/tool-bash-persistent/README.zh.md b/packages/pty/tool-bash-persistent/README.zh.md new file mode 100644 index 0000000000..adfb38b104 --- /dev/null +++ b/packages/pty/tool-bash-persistent/README.zh.md @@ -0,0 +1,50 @@ +# @deepseek-ai/dsh-tool-bash-persistent + +[English](README.md) | 中文 + +模型可见的 `bash(command)`,底层复用一个按所有者隔离的 `ctx.pty` shell。该包拥有工具契约和 shell 复用;PTY 后端与沙箱策略由部署选择。 + +## 配置 + +| 键 | 默认值 | 含义 | +|---|---:|---| +| `backendType` | `shell` | 每个 Agent shell 使用的已注册 PTY 后端。 | +| `timeoutMs` | `300000` | 单条命令的墙钟时间上限;超时会关闭 shell。 | +| `maxOutputChars` | `16000` | 截断提示前保留的前缀字符数。 | +| `description` | 持久 shell 描述 | 面向模型的环境契约。 | + +## 模型体验 + +### 工具 schema + +#### 模型所见 + +生成的 [`bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash-persistent),其中包含配置的 `description`。本插件不贡献独立系统提示词段;persona 与环境指导由部署负责。 + +#### Token 影响 + +`bash` 可见时产生固定的 schema 成本。 + +#### KV Cache 影响 + +配置的描述与 schema 不变时前缀稳定。 + +### 工具结果 + +#### 模型所见 + +每个 Agent 的命令共享一个 shell,因此 cwd、导出的环境变量、已激活环境、函数和后台任务会跨调用保留。结果不包含私有完成标记和 shell 提示符。长输出保留仍可读取的最早前缀并追加截断提示;若 PTY 已丢弃真正的开头,结果会明确说明,而不是把尾部伪装成完整输出。超时返回有界的部分输出、关闭状态不确定的 shell,并告知模型下次调用从新 shell 开始。 + +#### Token 影响 + +随数据变化,并受 `maxOutputChars` 与固定截断提示约束。 + +#### KV Cache 影响 + +工具结果以追加方式位于可复用请求前缀之后。 + +## 已知限制与延后工作 + +- 工具需要拥有它的 Agent 和真实 PTY 后端。 +- 显式 `exit`、超时或取消会丢弃 shell 状态;下次调用创建新 shell。 +- 网络访问、软件包镜像等环境事实应写入配置的 `description`,而非包默认描述。 diff --git a/packages/pty/tool-bash-persistent/package.json b/packages/pty/tool-bash-persistent/package.json new file mode 100644 index 0000000000..5022026733 --- /dev/null +++ b/packages/pty/tool-bash-persistent/package.json @@ -0,0 +1,55 @@ +{ + "name": "@deepseek-ai/dsh-tool-bash-persistent", + "description": "Model-facing owner-scoped persistent Bash tool backed by the Harness PTY service", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-pty": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@cordisjs/plugin-include": "workspace:^", + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-pty": "workspace:^", + "@deepseek-ai/dsh-pty-local": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/pty/tool-bash-persistent/src/index.ts b/packages/pty/tool-bash-persistent/src/index.ts new file mode 100644 index 0000000000..b812fe8992 --- /dev/null +++ b/packages/pty/tool-bash-persistent/src/index.ts @@ -0,0 +1,380 @@ +/** + * Model-facing persistent `bash` tool over the owner-scoped PTY seam. + * @module @deepseek-ai/dsh-tool-bash-persistent + */ + +import { randomUUID } from 'node:crypto' +import type { Context } from 'cordis' +import z from 'schemastery' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { PtyReadResult, PtySendResult, PtySessionId } from '@deepseek-ai/dsh-pty' +import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' +import { defineTool } from '@deepseek-ai/dsh-tools' + +const TRUNCATED_MESSAGE = 'To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.' +const LOST_PREFIX_MESSAGE = 'The beginning of this command output was dropped by the terminal scrollback limit. The following text is the earliest retained output.\n' +const SHELL_RESET_MESSAGE = 'The persistent bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment.' +const SHELL_PROMPT = '__DSH_PERSISTENT_BASH_PROMPT__ ' +const TIMEOUT_CODE = 'PERSISTENT_BASH_TIMEOUT' +const SCROLLBACK_PAGE_LINES = 1_000 + +const DEFAULT_DESCRIPTION = 'Run commands in a persistent bash shell. State, including the current directory and exported environment variables, persists across calls for this agent.' + +interface ResolvedConfig { + backendType: string + timeoutMs: number + maxOutputChars: number + description: string +} + +interface CommandMarkers { + start: string + end: string +} + +interface RetainedOutput { + text: string + truncated: boolean +} + +interface CapturedOutput { + text: string + incomplete: boolean +} + +interface PersistentShells { + get(owner: Agent, signal: AbortSignal): Promise + reset(owner: Agent, reason: string): Promise +} + +function maybeTruncate(content: string, maxOutputChars: number, incomplete = false): string { + if (content.length <= maxOutputChars && !incomplete) return content + return content.length <= maxOutputChars + ? content + TRUNCATED_MESSAGE + : content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE +} + +function markers(): CommandMarkers { + const nonce = randomUUID() + return { + start: `__DSH_PERSISTENT_BASH_START_${nonce}__`, + end: `__DSH_PERSISTENT_BASH_END_${nonce}:`, + } +} + +function quoteForBash(value: string): string { + return `$'${value + .replaceAll('\\', '\\\\') + .replaceAll("'", "\\'") + .replaceAll('\r', '\\r') + .replaceAll('\n', '\\n')}'` +} + +function wrapCommand(command: string, marker: CommandMarkers): string { + // Keep the wrapper on one physical line. An interactive bash prints PS2 for + // embedded newlines before executing the buffer, which would leak terminal + // prompts and marker source text into the model-facing result. + return `printf '%s\\n' ${quoteForBash(marker.start)}; eval -- ${quoteForBash(command)}; __dsh_persistent_bash_status=$?; printf '%s%s\\n' ${quoteForBash(marker.end)} "$__dsh_persistent_bash_status"` +} + +function stripPrompt(text: string): string { + let result = text + while (result.endsWith(`${SHELL_PROMPT}\r\n`) || result.endsWith(`${SHELL_PROMPT}\n`)) { + result = result.slice(0, result.endsWith('\r\n') + ? -SHELL_PROMPT.length - 2 + : -SHELL_PROMPT.length - 1) + } + while (result.endsWith(SHELL_PROMPT)) { + result = result.slice(0, -SHELL_PROMPT.length) + } + return result.endsWith('\n') ? result.slice(0, -1) : result +} + +function commandOutput( + snapshot: RetainedOutput, + marker: CommandMarkers, +): CapturedOutput | undefined { + const text = snapshot.text + const end = text.lastIndexOf(marker.end) + if (end < 0) return undefined + const startMarker = text.lastIndexOf(marker.start, end) + const start = startMarker < 0 ? 0 : startMarker + marker.start.length + return { + text: stripPrompt(text.slice(start, end).replace(/^\r?\n/, '')), + incomplete: startMarker < 0 || snapshot.truncated, + } +} + +function promptCompleted(result: PtySendResult): boolean { + return result.viewport.endsWith(SHELL_PROMPT) + || result.viewport.endsWith(`${SHELL_PROMPT}\r\n`) + || result.viewport.endsWith(`${SHELL_PROMPT}\n`) +} + +function partialOutput( + snapshot: RetainedOutput, + marker: CommandMarkers, + fallback: string, +): CapturedOutput { + const startMarker = snapshot.text.lastIndexOf(marker.start) + if (startMarker >= 0) { + return { + text: stripPrompt(snapshot.text.slice(startMarker + marker.start.length).replace(/^\r?\n/, '')), + incomplete: snapshot.truncated, + } + } + return { + text: stripPrompt(fallback), + incomplete: snapshot.truncated, + } +} + +async function pause(): Promise { + await new Promise(resolve => setTimeout(resolve, 25)) +} + +function nextScrollbackOffset(page: PtyReadResult, offset: number): number | undefined { + if (page.text.length === 0 || page.lineEnd <= offset) return undefined + return page.lineEnd +} + +function retainedScrollback( + ctx: Context, + owner: Agent, + id: PtySessionId, +): RetainedOutput { + const pages: string[] = [] + let offset = 0 + let truncated = false + while (true) { + const page = ctx.pty.read(owner, id, { offset, count: SCROLLBACK_PAGE_LINES }) + truncated ||= page.truncated + if (page.text.length > 0) pages.unshift(page.text) + const next = nextScrollbackOffset(page, offset) + if (next === undefined || next >= page.totalLines) break + offset = next + } + return { text: pages.join('\n'), truncated } +} + +function renderCaptured(output: CapturedOutput, maxOutputChars: number): string { + const rendered = maybeTruncate(output.text, maxOutputChars, output.incomplete) + return output.incomplete && output.text.length > 0 + ? LOST_PREFIX_MESSAGE + rendered + : rendered +} + +function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells { + const pending = new WeakMap>() + const live = new Map() + const ownerCleanupInstalled = new WeakSet() + + const close = async (owner: Agent, id: PtySessionId, reason: string): Promise => { + if (!ctx.pty.list(owner).some(snapshot => snapshot.sessionId === id)) return + await ctx.pty.kill(owner, id, reason) + } + + ctx.effect(() => async () => { + const closing = [...live].map(async ([owner, id]) => { await close(owner, id, 'tool-bash-persistent disposed') }) + await Promise.all(closing) + live.clear() + }, 'tool-bash-persistent shell cleanup') + + const reset = async (owner: Agent, reason: string): Promise => { + pending.delete(owner) + const id = live.get(owner) + live.delete(owner) + if (id !== undefined) await close(owner, id, reason) + } + + const get = (owner: Agent, signal: AbortSignal): Promise => { + const existing = pending.get(owner) + if (existing !== undefined) return existing + const creating = (async () => { + try { + const cwd = owner.session.header.cwd + const spawned = await ctx.pty.spawn(owner, { + type: config.backendType, + ...cwd === undefined ? {} : { cwd }, + }, signal) + live.set(owner, spawned.sessionId) + if (!ownerCleanupInstalled.has(owner)) { + ownerCleanupInstalled.add(owner) + owner.ctx.effect(() => () => { + pending.delete(owner) + live.delete(owner) + }, 'tool-bash-persistent owner cache cleanup') + } + const setup = ctx.pty.startSend(owner, spawned.sessionId, { + text: `stty -echo; PS1=${quoteForBash(SHELL_PROMPT)}`, + submit: true, + signal, + }) + const result = await setup.done + if (result.sessionStatus.kind === 'exited' || result.waitReason === 'timeout') { + throw new Error('persistent bash shell did not accept initialization') + } + return spawned.sessionId + } catch (error: unknown) { + await reset(owner, 'persistent bash initialization failed') + throw error + } + })() + pending.set(owner, creating) + return creating + } + + return { get, reset } +} + +async function executeCommand( + ctx: Context, + shells: PersistentShells, + owner: Agent, + command: string, + config: ResolvedConfig, + upstream: AbortSignal, +): Promise { + using commandDeadline = deadline(upstream, config.timeoutMs, TIMEOUT_CODE) + const id = await shells.get(owner, commandDeadline.signal) + const marker = markers() + const wrapped = wrapCommand(command, marker) + let first = true + let fallback = '' + + while (true) { + const operation = ctx.pty.startSend(owner, id, { + text: first ? wrapped : '', + submit: first, + signal: commandDeadline.signal, + }) + first = false + const result = await operation.done + fallback += result.viewport + const snapshot = retainedScrollback(ctx, owner, id) + const timedOut = timeoutOf(commandDeadline.signal, TIMEOUT_CODE) + if (timedOut !== undefined) { + const partial = renderCaptured( + partialOutput(snapshot, marker, fallback), + config.maxOutputChars, + ) + await shells.reset(owner, 'persistent bash command timed out') + return [ + `Your command timed out after ${Math.round(timedOut.timeoutMs / 1000)} seconds or experienced an OOM error. Below is partial output:`, + partial, + SHELL_RESET_MESSAGE, + ].join('\n') + } + const complete = commandOutput(snapshot, marker) + if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars) + if (result.sessionStatus.kind === 'exited') { + await shells.reset(owner, 'persistent bash shell exited') + return [ + renderCaptured(partialOutput(snapshot, marker, fallback), config.maxOutputChars), + SHELL_RESET_MESSAGE, + ].filter(part => part.length > 0).join('\n') + } + if (commandDeadline.signal.aborted) { + await shells.reset(owner, 'persistent bash command aborted') + commandDeadline.signal.throwIfAborted() + } + if (promptCompleted(result)) { + return maybeTruncate(stripPrompt(fallback), config.maxOutputChars, result.truncated) + } + await pause() + } +} + +/** + * Register the model-facing persistent `bash` tool. + * @param ctx - plugin context carrying tools and the owner-scoped PTY service. + * @param config - selected PTY backend and command deadline. + */ +function registerPersistentBash(ctx: Context, config: ResolvedConfig): void { + const shells = persistentShells(ctx, config) + const queues = new WeakMap>() + + const serialized = async (owner: Agent, operation: () => Promise): Promise => { + const prior = queues.get(owner) ?? Promise.resolve() + const run = prior.then(operation, operation) + const tail = run.then(() => undefined, () => undefined) + queues.set(owner, tail) + try { + return await run + } finally { + if (queues.get(owner) === tail) queues.delete(owner) + } + } + + ctx.tools.register(defineTool({ + name: 'bash', + description: config.description, + parameters: { + command: { + type: 'string', + required: true, + description: 'The bash command to run. Relative path is preferred in the command.', + }, + }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, + async execute(args, exec) { + if (args.command.trim().length === 0) throw new Error('command must be a non-empty string') + const owner = exec.agent + if (owner === undefined) throw new Error('bash requires an owning agent session') + return serialized(owner, async () => { + exec.signal.throwIfAborted() + return executeCommand(ctx, shells, owner, args.command, config, exec.signal) + }) + }, + presentCall: args => ({ card: 'terminal', title: args.command }), + })) +} + +export const name = 'tool-bash-persistent' +export const inject = ['tools', 'pty'] + +/** Configuration for the persistent Bash tool. */ +export interface Config { + /** PTY backend used for each owner-isolated persistent shell (default `shell`). */ + backendType?: string + /** Wall-clock limit for one command (default 300000). */ + timeoutMs?: number + /** Maximum returned command-output characters before clipping (default 16000). */ + maxOutputChars?: number + /** Model-facing tool description; deployments may describe their environment. */ + description?: string +} + +/** Runtime configuration schema for the persistent Bash tool. */ +export const Config: z = z.object({ + backendType: z.string().default('shell'), + timeoutMs: z.number().default(300_000), + maxOutputChars: z.number().default(16_000), + description: z.string().default(DEFAULT_DESCRIPTION), +}) + +/** Register one owner-scoped persistent `bash` tool. */ +export function apply(ctx: Context, config: Config): void { + const resolved: ResolvedConfig = { + backendType: config.backendType ?? 'shell', + timeoutMs: config.timeoutMs ?? 300_000, + maxOutputChars: config.maxOutputChars ?? 16_000, + description: config.description ?? DEFAULT_DESCRIPTION, + } + if (resolved.backendType.trim().length === 0) { + throw new Error('tool-bash-persistent: backendType must be non-empty') + } + if (!Number.isSafeInteger(resolved.timeoutMs) || resolved.timeoutMs <= 0) { + throw new Error('tool-bash-persistent: timeoutMs must be a positive safe integer') + } + if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) { + throw new Error('tool-bash-persistent: maxOutputChars must be a positive safe integer') + } + if (resolved.description.trim().length === 0) { + throw new Error('tool-bash-persistent: description must be non-empty') + } + registerPersistentBash(ctx, resolved) +} diff --git a/packages/pty/tool-bash-persistent/src/invariant.ts b/packages/pty/tool-bash-persistent/src/invariant.ts new file mode 100644 index 0000000000..f6b5acfbc7 --- /dev/null +++ b/packages/pty/tool-bash-persistent/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-bash-persistent`. + * @module @deepseek-ai/dsh-tool-bash-persistent/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-bash-persistent' + +/** Cordis companion plugin name. */ +export const name = 'tool-bash-persistent-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the tool adapter owns no independent durable state; + * PTY ownership and filesystem mutation relations stay with their services. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..b8586161f2 --- /dev/null +++ b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts @@ -0,0 +1,156 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import { CallId } from '@deepseek-ai/dsh-llm' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import PtyService from '@deepseek-ai/dsh-pty' +import * as PtyLocal from '@deepseek-ai/dsh-pty-local' +import SandboxProvider from '@deepseek-ai/dsh-sandbox' +import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent' + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) + +class PassthroughSandbox extends SandboxProvider { + confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv { + return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] } + } +} + +function agent(ctx: Context, cwd: string): Agent { + const id = SessionId('persistent-bash-loader-agent') + const scope = ctx.plugin(() => {}) + const value: Agent = { + id, + options: {}, + session: new Session(id, [], { version: 0, id, createdAt: 0, cwd }), + status: 'idle', + acceptsNextStep: false, + ctx: scope.ctx, + followup: () => {}, + steer: () => {}, + inject: () => {}, + send: () => {}, + cancel() {}, + whenIdle: () => Promise.resolve(), + } + ctx.agents.register(value) + return value +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(block => block.type === 'text').map(block => block.text).join('') +} + +const suite = process.platform === 'linux' || process.platform === 'darwin' ? describe : describe.skip + +suite('persistent Bash through a real cordis.yml Loader composition', () => { + it('preserves cwd and environment across calls', async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-persistent-bash-loader-')) + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-agent'", + "- name: '@deepseek-ai/dsh-system-prompt'", + "- name: '@deepseek-ai/dsh-tools'", + "- name: '@deepseek-ai/dsh-pty'", + "- name: '@deepseek-ai/dsh-test-sandbox'", + "- name: '@deepseek-ai/dsh-sandbox-policy'", + ' config:', + ' mode: danger-full-access', + ` workspaceRoot: ${JSON.stringify(root)}`, + "- name: '@deepseek-ai/dsh-pty-local'", + ' config:', + ' pollIntervalMs: 10', + ' exactProbeAfterMs: 20', + ' idleSilenceMs: 100', + ' handoffGraceMs: 100', + ' scrollbackLines: 20000', + ' timeoutMs: 2000', + ' disposeGraceMs: 500', + "- name: '@deepseek-ai/dsh-tool-bash-persistent'", + ' config:', + ' timeoutMs: 5000', + '', + ].join('\n')) + + context = new Context() + context.baseUrl = pathToFileURL(root).href + '/' + await context.plugin(Loader) + context.loader.builtins.include = Include + const modules = new Map([ + ['@deepseek-ai/dsh-agent', AgentRegistry], + ['@deepseek-ai/dsh-system-prompt', SystemPrompt], + ['@deepseek-ai/dsh-tools', ToolRegistry], + ['@deepseek-ai/dsh-pty', PtyService], + ['@deepseek-ai/dsh-test-sandbox', PassthroughSandbox], + ['@deepseek-ai/dsh-sandbox-policy', SandboxPolicyService], + ['@deepseek-ai/dsh-pty-local', PtyLocal], + ['@deepseek-ai/dsh-tool-bash-persistent', ToolBashPersistent], + ]) + context.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await context.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } }) + await context.loader.await() + + const owner = agent(context, root) + const signal = new AbortController().signal + const execute = (id: string, command: string) => context!.tools.execute({ + signal, + callId: CallId(id), + name: 'bash', + arguments: { command }, + agent: owner, + }) + + expect(context.tools.schemas().map(schema => schema.name)).toEqual(['bash']) + await execute('state', 'export KEEP=loader; mkdir -p nested; cd nested') + const observed = text(await execute('observe', 'printf "cwd=%s keep=%s\\n" "$PWD" "$KEEP"')) + expect(observed).toContain(`cwd=${join(root, 'nested')} keep=loader`) + expect(observed).not.toContain('DSH_PERSISTENT_BASH') + + const multiline = text(await execute( + 'multiline', + 'value="line one"\nprintf "%s:%s\\n" "$value" "it\'s fine"', + )) + expect(multiline).toBe("line one:it's fine") + expect(multiline).not.toContain('DSH_PERSISTENT_BASH') + + const heredoc = text(await execute( + 'heredoc', + "cat <<'EOF'\nalpha\nbeta\nEOF", + )) + expect(heredoc).toBe('alpha\nbeta') + + const large = text(await execute('large-output', 'seq 1 12050')) + expect(large.startsWith('1\n2\n3\n')).toBe(true) + expect(large).toContain('') + expect(large).not.toContain('beginning of this command output was dropped') + + const exited = text(await execute('exit', 'exit')) + expect(exited).toContain('next bash call starts from the workspace') + expect(text(await execute('after-exit', 'printf "%s\\n" "$PWD"'))).toBe(root) + }, 20_000) +}) diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts new file mode 100644 index 0000000000..f757d6d1f8 --- /dev/null +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -0,0 +1,385 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import PtyService from '@deepseek-ai/dsh-pty' +import type { + PtyBackend, + PtyBackendSession, + PtyReadRequest, + PtySendOperation, + PtySendRequest, + PtySessionStatus, + PtySignal, + PtyWaitReason, +} from '@deepseek-ai/dsh-pty' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent' + +const contexts: Context[] = [] +let callNumber = 0 + +afterEach(async () => { + for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() +}) + +function agent(ctx: Context, cwd: string | undefined): Agent { + const id = SessionId(`persistent-bash-owner-${callNumber}`) + const scope = ctx.plugin(() => {}) + const value: Agent = { + id, + options: {}, + session: new Session(id, [], { + version: 0, + id, + createdAt: 0, + ...cwd === undefined ? {} : { cwd }, + }), + status: 'idle', + acceptsNextStep: false, + ctx: scope.ctx, + followup: () => {}, + steer: () => {}, + inject: () => {}, + send: () => {}, + cancel() {}, + whenIdle: () => Promise.resolve(), + } + ctx.agents.register(value) + return value +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(block => block.type === 'text').map(block => block.text).join('') +} + +function call( + ctx: Context, + owner: Agent | undefined, + command: string, + signal = new AbortController().signal, +) { + return ctx.tools.execute({ + signal, + callId: CallId(`persistent-bash-${++callNumber}`), + name: 'bash', + arguments: { command }, + ...owner === undefined ? {} : { agent: owner }, + }) +} + +type StubMode = + | 'normal' + | 'prompt-only' + | 'prompt-crlf' + | 'empty-read' + | 'stalled-read' + | 'exit' + | 'wait-for-abort' + | 'idle-then-normal' + | 'large' + | 'end-only' + | 'init-exit' + | 'init-timeout' + | 'spawn-error' + +class StubPtySession implements PtyBackendSession { + readonly motd = '__DSH_PERSISTENT_BASH_PROMPT__ ' + readonly pid = 123 + statusValue: PtySessionStatus = { kind: 'running' } + scrollback = this.motd + closed: string[] = [] + mode: StubMode + sends = 0 + pendingText = '' + + constructor(mode: StubMode) { + this.mode = mode + } + + startSend(request: PtySendRequest): PtySendOperation { + this.sends += 1 + if (request.text.startsWith('stty -echo')) { + if (this.mode === 'init-exit') { + this.statusValue = { kind: 'exited', exitCode: 1, signal: null } + return this.operation(Promise.resolve(this.result('', 'session_exit'))) + } + if (this.mode === 'init-timeout') { + return this.operation(Promise.resolve(this.result('', 'timeout'))) + } + return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read'))) + } + if (this.mode === 'wait-for-abort') { + const done = new Promise>((resolve) => { + request.signal?.addEventListener('abort', () => { + this.scrollback += 'partial output' + resolve(this.result('partial output', 'stdin_read')) + }, { once: true }) + }) + return this.operation(done) + } + if (this.mode === 'idle-then-normal') { + this.mode = 'normal' + this.pendingText = request.text + return this.operation(Promise.resolve(this.result('', 'inferred_idle'))) + } + if (this.mode === 'prompt-only' || this.mode === 'prompt-crlf') { + const newline = this.mode === 'prompt-crlf' ? '\r\n' : '\n' + const output = `bash: syntax error${newline}${this.motd}${newline}` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } + const sent = request.text.length > 0 ? request.text : this.pendingText + this.pendingText = '' + const start = /__DSH_PERSISTENT_BASH_START_[^_]+(?:-[^_]+)*__/.exec(sent)?.[0] + const end = /__DSH_PERSISTENT_BASH_END_[^:]+:/.exec(sent)?.[0] + if (this.mode === 'end-only') { + const output = `recovered output\n${end ?? ''}0\n${this.motd}` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } + const commandOutput = this.mode === 'large' ? 'x'.repeat(100) : 'hello from stub' + const output = `${start ?? ''}\n${commandOutput}\n${end ?? ''}0\n${this.motd}` + this.scrollback += output + if (this.mode === 'exit') { + const exitedOutput = `${start ?? ''}\nhello from stub\n` + this.scrollback = this.scrollback.slice(0, -output.length) + exitedOutput + this.statusValue = { kind: 'exited', exitCode: 0, signal: null } + return this.operation(Promise.resolve(this.result(exitedOutput, 'session_exit'))) + } + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } + + read(_request: PtyReadRequest) { + if (this.mode === 'empty-read') { + return { text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false } + } + if (this.mode === 'stalled-read') { + return { text: 'stalled', totalLines: 1, lineBegin: 0, lineEnd: 0, truncated: false } + } + const lines = this.scrollback.split('\n') + return { + text: this.scrollback, + totalLines: lines.length, + lineBegin: 0, + lineEnd: lines.length, + truncated: false, + } + } + + signal(_signal: PtySignal) { + return Promise.resolve({ delivered: true as const, targetPgid: 123 }) + } + + status() { + return this.statusValue + } + + async close(reason: string) { + this.closed.push(reason) + this.statusValue = { kind: 'exited', exitCode: 0, signal: null } + } + + private result(viewport: string, waitReason: PtyWaitReason) { + return { viewport, waitReason, sessionStatus: this.statusValue, truncated: false } + } + + private operation(done: Promise>): PtySendOperation { + return { + done, + readOutput: () => ({ delta: '', truncated: false }), + cancel: () => false, + } + } +} + +function stubBackend(initialMode: StubMode = 'normal') { + const sessions: StubPtySession[] = [] + const backend: PtyBackend = { + type: 'stub', + async spawn() { + if (initialMode === 'spawn-error') throw new Error('stub spawn failed') + const session = new StubPtySession(initialMode) + sessions.push(session) + return session + }, + } + return { backend, sessions } +} + +async function setup( + config: ToolBashPersistent.Config = { backendType: 'stub' }, + initialMode: StubMode = 'normal', +) { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(PtyService) + const stub = stubBackend(initialMode) + ctx.pty.registerBackend(stub.backend) + const fiber = await ctx.plugin(ToolBashPersistent, config) + return { ctx, stub, fiber, owner: agent(ctx, '/workspace') } +} + +describe('tool-bash-persistent', () => { + it('registers a configurable schema and reuses one owner shell', async () => { + const { ctx, owner, stub } = await setup({ + backendType: 'stub', + description: 'deployment-specific persistent shell', + }) + const schema = ctx.tools.schemas()[0] + expect(ctx.tools.schemas().map(item => item.name)).toEqual(['bash']) + expect(schema?.description).toBe('deployment-specific persistent shell') + expect(schema?.parameters).toMatchObject({ + required: ['command'], + properties: { command: { type: 'string' } }, + }) + expect(ctx.tools.get('bash')?.presentCall?.({ command: 'pwd' })) + .toEqual({ card: 'terminal', title: 'pwd' }) + + expect(text(await call(ctx, owner, 'echo one'))).toBe('hello from stub') + expect(text(await call(ctx, owner, 'echo two'))).toBe('hello from stub') + expect(stub.sessions).toHaveLength(1) + expect(stub.sessions[0]?.sends).toBe(3) + + const ownerWithoutCwd = agent(ctx, undefined) + expect(text(await call(ctx, ownerWithoutCwd, 'pwd'))).toBe('hello from stub') + expect(stub.sessions).toHaveLength(2) + }) + + it('handles inferred idle, prompt fallback, shell exit, clipping, and cleanup', async () => { + const { ctx, owner, stub, fiber } = await setup({ + backendType: 'stub', + maxOutputChars: 10, + }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + + session.mode = 'idle-then-normal' + expect(text(await call(ctx, owner, 'silent then complete'))).toContain('hello from') + + session.mode = 'prompt-only' + const promptFallback = text(await call(ctx, owner, 'bad {')) + expect(promptFallback).toContain('bash: synt') + expect(promptFallback).not.toContain('DSH_PERSISTENT_BASH_PROMPT') + + session.mode = 'prompt-crlf' + session.scrollback = '' + const crlfPromptFallback = text(await call(ctx, owner, 'bad {')) + expect(crlfPromptFallback).toContain('bash: synt') + expect(crlfPromptFallback).not.toContain('DSH_PERSISTENT_BASH_PROMPT') + + session.mode = 'end-only' + session.scrollback = '' + const missingStart = text(await call(ctx, owner, 'recover marker')) + expect(missingStart).toContain('recovered') + expect(missingStart).toContain('beginning of this command output was dropped') + expect(missingStart).toContain('') + + session.mode = 'large' + expect(text(await call(ctx, owner, 'large'))).toContain('') + + session.mode = 'exit' + const exited = text(await call(ctx, owner, 'exit')) + expect(exited).toContain('hello from') + expect(exited).toContain('next bash call starts from the workspace') + expect(session.closed).toContain('persistent bash shell exited') + + await call(ctx, owner, 'new shell') + expect(stub.sessions).toHaveLength(2) + const externallyClosed = ctx.pty.list(owner)[0]?.sessionId + expect(externallyClosed).toBeDefined() + await ctx.pty.kill(owner, externallyClosed!, 'external cleanup') + await fiber.dispose() + expect(stub.sessions[1]?.closed).toEqual(['external cleanup']) + }) + + it('marks a short missing-prefix result and tolerates exhausted scrollback pages', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + + session.mode = 'end-only' + session.scrollback = '' + expect(text(await call(ctx, owner, 'missing start'))) + .toContain('beginning of this command output was dropped') + + session.mode = 'empty-read' + expect(text(await call(ctx, owner, 'empty page'))).toContain('hello from stub') + + session.mode = 'stalled-read' + expect(text(await call(ctx, owner, 'stalled page'))).toContain('hello from stub') + }) + + it('closes a timed-out shell and reports bounded partial output', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 10 }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = 'wait-for-abort' + const result = await call(ctx, owner, 'hang') + expect(text(result)).toContain('timed out after 0 seconds or experienced an OOM error') + expect(text(result)).toContain('partial output') + expect(text(result)).toContain('next bash call starts from the workspace') + expect(stub.sessions[0]?.closed).toContain('persistent bash command timed out') + }) + + it('cancels in-flight work, resets the shell, and releases a queued call', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 5_000 }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = 'wait-for-abort' + const controller = new AbortController() + const cancelled = call(ctx, owner, 'hang', controller.signal) + const queued = call(ctx, owner, 'after cancellation') + setTimeout(() => { + controller.abort(new Error('caller stopped')) + }, 5) + + expect((await cancelled).isError).toBe(true) + expect(text(await queued)).toBe('hello from stub') + expect(stub.sessions[0]?.closed).toContain('persistent bash command aborted') + expect(stub.sessions).toHaveLength(2) + }) + + it.each(['init-exit', 'init-timeout'] as const)( + 'fails initialization and closes the unusable shell for %s', + async (mode) => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }, mode) + expect((await call(ctx, owner, 'pwd')).isError).toBe(true) + expect(stub.sessions[0]?.closed).toContain('persistent bash initialization failed') + }, + ) + + it('clears a failed spawn without trying to close an unpublished shell', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }, 'spawn-error') + expect((await call(ctx, owner, 'pwd')).isError).toBe(true) + expect(stub.sessions).toHaveLength(0) + }) + + it('rejects invalid config and invalid calls', async () => { + const { ctx, owner, stub } = await setup() + expect((await call(ctx, undefined, 'pwd')).isError).toBe(true) + expect(text(await call(ctx, owner, ' '))).toContain('command must be a non-empty string') + + const controller = new AbortController() + controller.abort(new Error('caller stopped')) + expect((await call(ctx, owner, 'pwd', controller.signal)).isError).toBe(true) + expect(stub.sessions).toHaveLength(0) + + expect(() => { + ToolBashPersistent.apply(new Context(), { backendType: '' }) + }).toThrow('backendType must be non-empty') + expect(() => { + ToolBashPersistent.apply(new Context(), { timeoutMs: 0 }) + }).toThrow('timeoutMs must be a positive safe integer') + expect(() => { + ToolBashPersistent.apply(new Context(), { maxOutputChars: 0 }) + }).toThrow('maxOutputChars must be a positive safe integer') + expect(() => { + ToolBashPersistent.apply(new Context(), { description: ' ' }) + }).toThrow('description must be non-empty') + }) +}) diff --git a/packages/pty/tool-bash-persistent/tsconfig.json b/packages/pty/tool-bash-persistent/tsconfig.json new file mode 100644 index 0000000000..b1baf9db9a --- /dev/null +++ b/packages/pty/tool-bash-persistent/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../core/agent" }, + { "path": "../../core/tools" }, + { "path": "../pty" }, + { "path": "../../support/invariants" }, + { "path": "../../util/timeout" } + ] +} diff --git a/patches/node-pty@1.1.0.patch b/patches/node-pty@1.1.0.patch new file mode 100644 index 0000000000..f0de7b9054 --- /dev/null +++ b/patches/node-pty@1.1.0.patch @@ -0,0 +1,60 @@ +diff --git a/lib/unixTerminal.js b/lib/unixTerminal.js +index 1ec12f796a822c78fba9ad7f6448c3987e325c23..5cd6b7d635f4752be5a6c5ff9cf9edf988cf94c5 100644 +--- a/lib/unixTerminal.js ++++ b/lib/unixTerminal.js +@@ -26,10 +26,22 @@ var terminal_1 = require("./terminal"); + var utils_1 = require("./utils"); + var native = utils_1.loadNativeModule('pty'); + var pty = native.module; +-var helperPath = native.dir + '/spawn-helper'; +-helperPath = path.resolve(__dirname, helperPath); +-helperPath = helperPath.replace('app.asar', 'app.asar.unpacked'); +-helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked'); ++var helperPath = process.env.DSH_NODE_PTY_SPAWN_HELPER; ++if (helperPath) { ++ helperPath = path.resolve(helperPath); ++} ++else { ++ var executableSibling = process.execPath + '-spawn-helper'; ++ if (fs.existsSync(executableSibling)) { ++ helperPath = executableSibling; ++ } ++ else { ++ helperPath = native.dir + '/spawn-helper'; ++ helperPath = path.resolve(__dirname, helperPath); ++ helperPath = helperPath.replace('app.asar', 'app.asar.unpacked'); ++ helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked'); ++ } ++} + var DEFAULT_FILE = 'sh'; + var DEFAULT_NAME = 'xterm'; + var DESTROY_SOCKET_TIMEOUT_MS = 200; +diff --git a/src/unixTerminal.ts b/src/unixTerminal.ts +index 98733dc0cd752b554bd94e45904ca341ad141bba..fa234291206617ae5a6d8605abf9771220392d17 100644 +--- a/src/unixTerminal.ts ++++ b/src/unixTerminal.ts +@@ -14,10 +14,20 @@ import { assign, loadNativeModule } from './utils'; + + const native = loadNativeModule('pty'); + const pty: IUnixNative = native.module; +-let helperPath = native.dir + '/spawn-helper'; +-helperPath = path.resolve(__dirname, helperPath); +-helperPath = helperPath.replace('app.asar', 'app.asar.unpacked'); +-helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked'); ++let helperPath = process.env.DSH_NODE_PTY_SPAWN_HELPER; ++if (helperPath) { ++ helperPath = path.resolve(helperPath); ++} else { ++ const executableSibling = process.execPath + '-spawn-helper'; ++ if (fs.existsSync(executableSibling)) { ++ helperPath = executableSibling; ++ } else { ++ helperPath = native.dir + '/spawn-helper'; ++ helperPath = path.resolve(__dirname, helperPath); ++ helperPath = helperPath.replace('app.asar', 'app.asar.unpacked'); ++ helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked'); ++ } ++} + + const DEFAULT_FILE = 'sh'; + const DEFAULT_NAME = 'xterm'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18cb1ec0d1..0fc60b40cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,7 @@ settings: patchedDependencies: '@earendil-works/pi-tui@0.80.7': 6c30c5386c0159131e1361023cddf31377f5728962524841964373312c1ed946 + node-pty@1.1.0: fa5e4a788317512778f95ef1573fec565f9b601ce10383bbf103234db3e61985 importers: @@ -631,7 +632,7 @@ importers: devDependencies: node-pty: specifier: 1.1.0 - version: 1.1.0 + version: 1.1.0(patch_hash=fa5e4a788317512778f95ef1573fec565f9b601ce10383bbf103234db3e61985) packages/acp/acp: dependencies: @@ -2535,6 +2536,40 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/fs/tool-str-replace-editor: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../fs + '@deepseek-ai/dsh-fs-local': + specifier: workspace:^ + version: link:../fs-local + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/goal/command-goal: devDependencies: '@cordisjs/plugin-loader': @@ -3307,7 +3342,7 @@ importers: dependencies: node-pty: specifier: ^1.1.0 - version: 1.1.0 + version: 1.1.0(patch_hash=fa5e4a788317512778f95ef1573fec565f9b601ce10383bbf103234db3e61985) schemastery: specifier: ^3.18.0 version: 3.18.0 @@ -3337,6 +3372,55 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/pty/tool-bash-persistent: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-pty': + specifier: workspace:^ + version: link:../pty + '@deepseek-ai/dsh-pty-local': + specifier: workspace:^ + version: link:../pty-local + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + packages/pty/tool-pty: dependencies: schemastery: @@ -5520,6 +5604,12 @@ importers: '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../packages/plan/plan-mode + '@deepseek-ai/dsh-pty': + specifier: workspace:^ + version: link:../../packages/pty/pty + '@deepseek-ai/dsh-pty-local': + specifier: workspace:^ + version: link:../../packages/pty/pty-local '@deepseek-ai/dsh-repeat-tool-guard': specifier: workspace:^ version: link:../../packages/guard/repeat-tool-guard @@ -5529,6 +5619,9 @@ importers: '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../packages/sandbox/sandbox + '@deepseek-ai/dsh-sandbox-local': + specifier: workspace:^ + version: link:../../packages/sandbox/sandbox-local '@deepseek-ai/dsh-sandbox-policy': specifier: workspace:^ version: link:../../packages/sandbox/sandbox-policy @@ -5619,6 +5712,9 @@ importers: '@deepseek-ai/dsh-tool-bash': specifier: workspace:^ version: link:../../packages/bash/tool-bash + '@deepseek-ai/dsh-tool-bash-persistent': + specifier: workspace:^ + version: link:../../packages/pty/tool-bash-persistent '@deepseek-ai/dsh-tool-cordis': specifier: workspace:^ version: link:../../packages/cordis/tool-cordis @@ -5631,6 +5727,9 @@ importers: '@deepseek-ai/dsh-tool-skill': specifier: workspace:^ version: link:../../packages/skill/tool-skill + '@deepseek-ai/dsh-tool-str-replace-editor': + specifier: workspace:^ + version: link:../../packages/fs/tool-str-replace-editor '@deepseek-ai/dsh-tool-subagent': specifier: workspace:^ version: link:../../packages/subagent/tool-subagent @@ -15119,7 +15218,7 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - node-pty@1.1.0: + node-pty@1.1.0(patch_hash=fa5e4a788317512778f95ef1573fec565f9b601ce10383bbf103234db3e61985): dependencies: node-addon-api: 7.1.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 40716f3403..8aad3a1f3d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -39,6 +39,9 @@ allowBuilds: node-addon-require-builtin: false # JSONL durability calls MoveFileExW with write-through publication on Windows. koffi: true + # The Python runtime deploy includes the reviewed workspace postinstall that + # places node-pty's spawn helper beside the compiled PTY backend. + '@deepseek-ai/dsh-pty-local@file:packages/pty/pty-local': true # The Landlock launcher family is our own sibling-repo release, consumed # fresh (hours old at each coordinated bump) — the release-age quarantine @@ -58,3 +61,4 @@ minimumReleaseAgeExclude: patchedDependencies: '@earendil-works/pi-tui@0.80.7': patches/@earendil-works__pi-tui@0.80.7.patch + node-pty@1.1.0: patches/node-pty@1.1.0.patch diff --git a/python/README.i18n.yaml b/python/README.i18n.yaml index 5df1ca291d..f0d6c67967 100644 --- a/python/README.i18n.yaml +++ b/python/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -README.md: d2b6a1cfe9897026d567b2def301799069c350fb -README.zh.md: 2ffccef922d52923f7c6e373a01ed8b19d9a55c1 +# pnpm run verify-translation-pairing --write python/README.md +README.md: aee682e25fc33287c49131d0f5b92b136ed16bae +README.zh.md: 4404114fcdab78468991769a4657370f85997a88 diff --git a/python/README.md b/python/README.md index d2b6a1cfe9..aee682e25f 100644 --- a/python/README.md +++ b/python/README.md @@ -22,7 +22,7 @@ pnpm exec tsx scripts/build-exe-for-python-sdk.ts --skip-build # lib/ artifac pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64 ``` -Products land in `dist-exe/` and are synced into this package at `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`) — after a local build the SDK finds the executable with no further setup. The `build-exe-for-python-sdk` CI workflow (manual dispatch, or the `build-exe` PR label) exercises the same binaries. A full three-target run retains four release wheels; a subset dispatch retains the SDK wheel and selected platform wheels. Which plugins the exe bundles and how the carriers are organized: [sdk-runtime README](sdk-runtime/README.md); the build also refreshes the dev-only node carrier (see "against the Node source" below). +Products land in `dist-exe/` and are synced into this package as `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--` plus the matching `-spawn-helper` required by `node-pty` (platform: `linux`/`macos`; arch: `x64`/`arm64`) — after a local build the SDK finds the runtime with no further setup. The `build-exe-for-python-sdk` CI workflow (manual dispatch, or the `build-exe` PR label) exercises the same products. A full three-target run retains four release wheels; a subset dispatch retains the SDK wheel and selected platform wheels. Which plugins the exe bundles and how the carriers are organized: [sdk-runtime README](sdk-runtime/README.md); the build also refreshes the dev-only node carrier (see "against the Node source" below). ## Validating the SDK against the executable diff --git a/python/README.zh.md b/python/README.zh.md index 2ffccef922..4404114fcd 100644 --- a/python/README.zh.md +++ b/python/README.zh.md @@ -22,7 +22,7 @@ pnpm exec tsx scripts/build-exe-for-python-sdk.ts --skip-build # lib/ artifac pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64 ``` -产物落入 `dist-exe/`,并同步进本包的 `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`),本地构建完成后 SDK 不需要额外设置就能找到可执行文件。`build-exe-for-python-sdk` CI 工作流(手动触发,或给 PR 打 `build-exe` 标签)会测试同样的二进制。完整构建三个目标时保留 4 个发布用 wheel 包;手动选择部分目标时保留 SDK wheel 与所选平台的 wheel。exe 内置哪些插件、载体如何组织,见 [sdk-runtime README](sdk-runtime/README.md);构建还会顺带刷新仅供开发使用的 `node` 载体(见下文「对着 Node 源码运行」)。 +产物落入 `dist-exe/`,并同步进本包的 `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--` 及 `node-pty` 所需的同名 `-spawn-helper` 伴随文件(platform:`linux`/`macos`;arch:`x64`/`arm64`),本地构建完成后 SDK 不需要额外设置就能找到运行时。`build-exe-for-python-sdk` CI 工作流(手动触发,或给 PR 打 `build-exe` 标签)会测试同样的产物。完整构建三个目标时保留 4 个发布用 wheel 包;手动选择部分目标时保留 SDK wheel 与所选平台的 wheel。exe 内置哪些插件、载体如何组织,见 [sdk-runtime README](sdk-runtime/README.md);构建还会顺带刷新仅供开发使用的 `node` 载体(见下文「对着 Node 源码运行」)。 ## 用可执行文件验证 SDK diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index 90433c4f5f..44fcf94b60 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -README.md: f2ccd8939e497d10359aafe8b1bd8b364875ed98 -README.zh.md: 30bdf46fee03c38a1f4b6e8b2b39d87e8174a3e0 +# pnpm run verify-translation-pairing --write python/sdk-runtime/README.md +README.md: 977bce41191d6c7716548dafde81d2c7ec14dec7 +README.zh.md: ade8455c56c27fcbe3e43a68abeaad98421cf720 diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index f2ccd8939e..977bce4119 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -8,19 +8,19 @@ Runtime carrier package for the Python SDK (dist `deepseek-harness-runtime-bin`, Two carriers coexist under `src/deepseek_harness_runtime/runtime/`, both injected by the repo's `scripts/build-exe-for-python-sdk.ts` build and both gitignored: -- **exe (production)** — single-file executables `dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`). No Node installation needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists. +- **exe (production)** — a single-file Node executable `dsh-jsonrpc-agent-pkg--` plus its native `-spawn-helper` sibling (platform: `linux`/`macos`; arch: `x64`/`arm64`). The helper is required by `node-pty`; both files are built and validated as one runtime product. No Node installation is needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists. - **node (dev-only)** — the full deploy closure under `runtime/node/` (`package.json` + `node_modules/`), executed as `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` on a system Node >= 22.19. It is the current checkout's source build, meant for repo-local development and verification only; it is never selected automatically and is excluded from distributions. Both carriers hold the same content, defined once: the [package.json](package.json) at this package's root is the deploy root of the single-exe pipeline — a pure dependency manifest (no code of its own) whose dependency closure IS both the plugin set compiled into the exe and the tree materialized into `runtime/node/`. Adding a plugin to the distribution means adding one dependency line there and rebuilding. A missing exe raises `FileNotFoundError` naming both acquisition routes: build via `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness checkout, or install the matching platform runtime wheel produced by the `build-exe-for-python-sdk` CI workflow. A missing dev-only node carrier names its sole route, the build script. The workflow retains wheels rather than standalone executable archives. Acquisition strategy is deliberately separate from the lookup interface, so an on-demand download can replace it later without touching callers. -Each wheel contains exactly one executable. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_11_0_arm64`; the build hook rejects `py3-none-any`, absent or multiple executables, and unsupported platform tags. The repository root `package.json` supplies the shared version for this package and the SDK, and a `python-vX.Y.Z` release tag must match it. +Each wheel contains exactly one runtime executable and its matching native spawn helper. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_11_0_arm64`; the build hook rejects `py3-none-any`, absent or multiple runtime files, non-executable files, and unsupported platform tags. The repository root `package.json` supplies the shared version for this package and the SDK, and a `python-vX.Y.Z` release tag must match it. ## Resolution API - `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]` — the argv tuple that launches the bundled runtime: `(exe_path,)` in exe mode, `(node_path, bin_js_path)` in node mode. Mode selection: explicit argument > `DSH_RUNTIME_MODE` env var (`exe` | `node`) > automatic. Automatic resolution finds the production exe ONLY — the dev-only node carrier must be opted into explicitly so a production deployment can never silently ride on a source build. -- `bundled_runtime_path() -> Path` — the platform exe path (exe carrier only; the node carrier has no single-path equivalent and launches via the argv tuple above). +- `bundled_runtime_path() -> Path` — the platform exe path (exe carrier only; it validates that the required sibling `-spawn-helper` is also installed). The node carrier has no single-path equivalent and launches via the argv tuple above. - `bundled_default_config_path() -> Path` — the checked-in default config (see below). - `bundled_package_dir() -> Path` — the installed package data root. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 30bdf46fee..ade8455c56 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -8,19 +8,19 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`, 两种载体并存于 `src/deepseek_harness_runtime/runtime/` 之下,均由仓库的 `scripts/build-exe-for-python-sdk.ts` 构建注入,且均被 git 忽略: -- **exe(生产)**——单文件可执行程序 `dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`)。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 +- **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg--` 及其原生 `-spawn-helper` 伴随文件(platform:`linux`/`macos`;arch:`x64`/`arm64`)。`node-pty` 需要该 helper;构建与校验会把两者视作同一个运行时产物。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 - **`node`(仅限开发)**——`runtime/node/` 下的完整部署闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。 两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](package.json) 是 single-exe 流水线的部署根目录——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 exe 缺失时抛出 `FileNotFoundError`,并写明两种获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或安装 `build-exe-for-python-sdk` CI 工作流生成的对应平台运行时 wheel 包。仅限开发的 `node` 载体缺失时只提示构建脚本这一条途径。该工作流只保留 wheel 包,不保留独立 exe 归档。获取策略与查找接口刻意分离,之后可以换成按需下载而不改动任何调用方。 -每个 wheel 包只包含一个可执行文件。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;构建钩子会拒绝 `py3-none-any`、可执行文件缺失或重复以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-vX.Y.Z` 发布标签必须与其匹配。 +每个 wheel 包只包含一个运行时可执行文件及其匹配的原生 spawn helper。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;构建钩子会拒绝 `py3-none-any`、运行时文件缺失或重复、文件不可执行以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-vX.Y.Z` 发布标签必须与其匹配。 ## 解析 API - `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]`——启动内置运行时的 argv 元组:exe 模式下为 `(exe_path,)`,`node` 模式下为 `(node_path, bin_js_path)`。模式选择:显式参数 > `DSH_RUNTIME_MODE` 环境变量(`exe` | `node`)> 自动。自动解析只找生产 exe——仅限开发的 `node` 载体必须显式选用,从而生产部署绝不会悄悄跑在源码构建上。 -- `bundled_runtime_path() -> Path`——平台 exe 路径(仅 exe 载体;`node` 载体没有单一路径的等价物,经由上面的 argv 元组启动)。 +- `bundled_runtime_path() -> Path`——平台 exe 路径(仅 exe 载体,并会校验必要的 `-spawn-helper` 伴随文件也已安装)。`node` 载体没有单一路径的等价物,经由上面的 argv 元组启动。 - `bundled_default_config_path() -> Path`——检入的默认配置(见下文)。 - `bundled_package_dir() -> Path`——已安装包的数据根目录。 diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py index 1c5b22e11a..108e77cf2c 100644 --- a/python/sdk-runtime/hatch_build.py +++ b/python/sdk-runtime/hatch_build.py @@ -13,6 +13,7 @@ _PLATFORMS = { "linux-arm64": ("manylinux_2_28_aarch64", "dsh-jsonrpc-agent-pkg-linux-arm64"), "macos-arm64": ("macosx_11_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"), } +_SPAWN_HELPER_SUFFIX = "-spawn-helper" def _host_platform_tag() -> str: @@ -46,14 +47,23 @@ class RuntimeBuildHook(BuildHookInterface): ) expected_executable = matches[0][1] runtime_dir = Path(self.root) / "src" / "deepseek_harness_runtime" / "runtime" - executables = sorted(runtime_dir.glob("dsh-jsonrpc-agent-pkg-*") if runtime_dir.is_dir() else []) + runtime_files = sorted(runtime_dir.glob("dsh-jsonrpc-agent-pkg-*") if runtime_dir.is_dir() else []) + executables = [path for path in runtime_files if not path.name.endswith(_SPAWN_HELPER_SUFFIX)] + helpers = [path for path in runtime_files if path.name.endswith(_SPAWN_HELPER_SUFFIX)] if [path.name for path in executables] != [expected_executable]: found = ", ".join(path.name for path in executables) or "none" raise RuntimeError( f"runtime wheel {platform_tag} must contain only {expected_executable}; found {found}" ) - if executables[0].stat().st_mode & stat.S_IXUSR == 0: - raise RuntimeError(f"runtime executable is not executable: {executables[0]}") + expected_helper = f"{expected_executable}{_SPAWN_HELPER_SUFFIX}" + if [path.name for path in helpers] != [expected_helper]: + found = ", ".join(path.name for path in helpers) or "none" + raise RuntimeError( + f"runtime wheel {platform_tag} must contain only {expected_helper}; found {found}" + ) + for executable in [executables[0], helpers[0]]: + if executable.stat().st_mode & stat.S_IXUSR == 0: + raise RuntimeError(f"runtime executable is not executable: {executable}") build_data["pure_python"] = False build_data["infer_tag"] = False diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index a1d8728d4c..cbec3a4923 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -42,6 +42,8 @@ "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", + "@deepseek-ai/dsh-pty": "workspace:^", + "@deepseek-ai/dsh-pty-local": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", @@ -49,6 +51,7 @@ "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", "@deepseek-ai/dsh-retention": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", @@ -75,7 +78,9 @@ "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", + "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", + "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py index b38df5a211..9228281ab2 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -3,9 +3,10 @@ Two runtime carriers coexist under ``runtime/``, both injected by the repo's ``scripts/build-exe-for-python-sdk.ts`` build (neither is checked into git): -- **exe (production)**: single-file executables named +- **exe (production)**: single-file Node executables named ``dsh-jsonrpc-agent-pkg--`` (platform in {linux, macos}, arch in - {x64, arm64}); the target machine needs no Node installation. + {x64, arm64}) plus a sibling ``-spawn-helper`` used by ``node-pty``; the + target machine needs no Node installation. - **node (dev-only)**: the full deploy closure under ``runtime/node/`` (``package.json`` + ``node_modules/``), executed as ``node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`` on a @@ -27,6 +28,7 @@ import sys from pathlib import Path PACKAGE_METADATA_FILENAME = "deepseek-harness-runtime.json" +SPAWN_HELPER_SUFFIX = "-spawn-helper" RUNTIME_MODE_ENV_VAR = "DSH_RUNTIME_MODE" @@ -82,6 +84,12 @@ def bundled_runtime_path() -> Path: f"deepseek-harness-runtime-bin is missing the runtime executable at {path}. " + _EXE_ACQUISITION_HINT ) + helper = Path(f"{path}{SPAWN_HELPER_SUFFIX}") + if not helper.is_file(): + raise FileNotFoundError( + f"deepseek-harness-runtime-bin is missing the node-pty spawn helper at {helper}. " + + _EXE_ACQUISITION_HINT + ) return path @@ -144,6 +152,7 @@ def _node_launch_args() -> tuple[str, str]: __all__ = [ "PACKAGE_METADATA_FILENAME", "RUNTIME_MODE_ENV_VAR", + "SPAWN_HELPER_SUFFIX", "bundled_default_config_path", "bundled_package_dir", "bundled_runtime_path", diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index 38cb40862d..7b7c8254b1 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -4,6 +4,7 @@ from __future__ import annotations import json import runpy +import stat from pathlib import Path from types import SimpleNamespace @@ -37,3 +38,40 @@ def test_repository_version_rejects_non_stable_versions(tmp_path: Path) -> None: with pytest.raises(ValueError, match="must be stable X.Y.Z"): build_python_release.repository_version(tmp_path) + + +def test_stage_runtime_copies_executable_and_spawn_helper(tmp_path: Path) -> None: + executable = tmp_path / "dsh-jsonrpc-agent-pkg-macos-arm64" + executable.write_bytes(b"runtime") + executable.chmod(0o755) + spawn_helper = Path(f"{executable}-spawn-helper") + spawn_helper.write_bytes(b"helper") + spawn_helper.chmod(0o751) + destination = tmp_path / "staging" + + build_python_release.stage_runtime( + destination, + "1.2.3", + executable, + executable.name, + ) + + runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" + assert (runtime_dir / executable.name).read_bytes() == b"runtime" + copied_helper = runtime_dir / spawn_helper.name + assert copied_helper.read_bytes() == b"helper" + assert copied_helper.stat().st_mode & stat.S_IXUSR + + +def test_stage_runtime_rejects_missing_spawn_helper(tmp_path: Path) -> None: + executable = tmp_path / "dsh-jsonrpc-agent-pkg-linux-x64" + executable.write_bytes(b"runtime") + executable.chmod(0o755) + + with pytest.raises(FileNotFoundError, match="spawn helper"): + build_python_release.stage_runtime( + tmp_path / "staging", + "1.2.3", + executable, + executable.name, + ) diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index b72f73671e..03c82b25b0 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -8,7 +8,7 @@ import { spawn } from 'node:child_process' import { existsSync, mkdirSync, statSync } from 'node:fs' -import { copyFile, readFile, rm, writeFile } from 'node:fs/promises' +import { chmod, copyFile, readFile, rm, writeFile } from 'node:fs/promises' import { basename, join, resolve, sep } from 'node:path' import { parseArgs } from 'node:util' @@ -19,6 +19,7 @@ const DEPLOY_ROOT_PACKAGE = 'dsh-jsonrpc-agent-pkg' /** The app entry inside the deployed closure. */ const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js' const OUTPUT_BASENAME = 'dsh-jsonrpc-agent-pkg' +const SPAWN_HELPER_SUFFIX = '-spawn-helper' /** Default Node major; SEA mode requires at least Node 22. */ const DEFAULT_NODE_RANGE = 'node24' /** Pinned for reproducible builds. */ @@ -52,6 +53,11 @@ const ARCHES = ['x64', 'arm64'] as const type Platform = (typeof PLATFORMS)[number] type Arch = (typeof ARCHES)[number] +interface RuntimeProduct { + executable: string + spawnHelper: string +} + function isPlatform(value: string): value is Platform { return (PLATFORMS as readonly string[]).includes(value) } @@ -254,6 +260,8 @@ class SingleExeBuild { '--config.node-linker=hoisted', '--config.auto-install-peers=false', '--config.link-workspace-packages=true', + // The production closure intentionally omits patched dev-only packages. + '--config.allow-unused-patches=true', this.staging, ]) if (this.cli.dryRun) { @@ -287,8 +295,9 @@ class SingleExeBuild { * @param target - the pkg target triple to build. * @returns the canonical product path `/dsh-jsonrpc-agent-pkg--`. */ - async pack(target: Target): Promise { + async pack(target: Target): Promise { const product = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`) + const spawnHelper = `${product}${SPAWN_HELPER_SUFFIX}` if (!this.cli.dryRun) mkdirSync(this.outDir, { recursive: true }) await this.run(`pkg ${target.spec}`, pnpmBin(), [ 'dlx', @@ -303,22 +312,59 @@ class SingleExeBuild { if (!this.cli.dryRun && !existsSync(product)) { throw new Error(`build-exe-for-python-sdk: product ${product} is missing after the pkg run; inspect ${this.outDir}.`) } - return product + if (this.cli.dryRun) { + console.log(`build-exe-for-python-sdk: [dry-run] copy target node-pty spawn-helper to ${spawnHelper}`) + } else { + const source = this.resolveSpawnHelper(target) + await copyFile(source, spawnHelper) + await chmod(spawnHelper, statSync(source).mode & 0o777) + } + return { executable: product, spawnHelper } + } + + /** + * Resolve the node-pty helper that matches a pkg target. + * @param target - the pkg target whose helper must be shipped. + * @returns a physical executable outside pkg's virtual snapshot. + */ + private resolveSpawnHelper(target: Target): string { + const nodePtyRoot = join(this.staging, 'node_modules', 'node-pty') + const nativePlatform = target.platform === 'macos' ? 'darwin' : 'linux' + const candidates = [ + join(nodePtyRoot, 'prebuilds', `${nativePlatform}-${target.arch}`, 'spawn-helper'), + ] + const hostPlatform = process.platform === 'darwin' ? 'macos' : process.platform + const hostArch = process.arch === 'x64' || process.arch === 'arm64' ? process.arch : undefined + if (target.platform === hostPlatform && target.arch === hostArch) { + candidates.push(join(nodePtyRoot, 'build', 'Release', 'spawn-helper')) + } + const helper = candidates.find(candidate => existsSync(candidate)) + if (helper === undefined) { + throw new Error( + `build-exe-for-python-sdk: node-pty spawn-helper for ${target.platform}-${target.arch} is missing; ` + + `checked ${candidates.join(', ')}. Build each runtime on its target platform and architecture.`, + ) + } + if (statSync(helper).mode & 0o111) return helper + throw new Error(`build-exe-for-python-sdk: node-pty spawn-helper is not executable: ${helper}`) } /** * Print each product path and, outside dry-run mode, its size. * @param products - the product paths returned by {@link pack}. */ - printProducts(products: string[]): void { + printProducts(products: RuntimeProduct[]): void { console.log(this.cli.dryRun ? 'build-exe-for-python-sdk: [dry-run] would produce:' : 'build-exe-for-python-sdk: products:') for (const product of products) { if (this.cli.dryRun) { - console.log(` ${product}`) + console.log(` ${product.executable}`) + console.log(` ${product.spawnHelper}`) continue } - const megabytes = statSync(product).size / (1024 * 1024) - console.log(` ${product} (${megabytes.toFixed(1)} MB)`) + for (const path of [product.executable, product.spawnHelper]) { + const megabytes = statSync(path).size / (1024 * 1024) + console.log(` ${path} (${megabytes.toFixed(1)} MB)`) + } } } @@ -327,19 +373,24 @@ class SingleExeBuild { * carrier is already in place, and `dist-exe/` retains upload copies. * @param products - the product paths returned by {@link pack}. */ - async syncToPythonRuntime(products: string[]): Promise { + async syncToPythonRuntime(products: RuntimeProduct[]): Promise { const destDir = resolve(root, PYTHON_RUNTIME_DIR) if (this.cli.dryRun) { for (const product of products) { - console.log(`build-exe-for-python-sdk: [dry-run] cp ${product} ${join(destDir, basename(product))}`) + for (const path of [product.executable, product.spawnHelper]) { + console.log(`build-exe-for-python-sdk: [dry-run] cp ${path} ${join(destDir, basename(path))}`) + } } return } mkdirSync(destDir, { recursive: true }) for (const product of products) { - const destination = join(destDir, basename(product)) - await copyFile(product, destination) - console.log(`build-exe-for-python-sdk: synced ${destination}`) + for (const path of [product.executable, product.spawnHelper]) { + const destination = join(destDir, basename(path)) + await copyFile(path, destination) + await chmod(destination, statSync(path).mode & 0o777) + console.log(`build-exe-for-python-sdk: synced ${destination}`) + } } } @@ -358,7 +409,12 @@ class SingleExeBuild { } console.log(`build-exe-for-python-sdk: ${label}: ${printable}`) await new Promise((resolvePromise, reject) => { - const child = spawn(command, args, { cwd: root, stdio: 'inherit' }) + const child = spawn(command, args, { + cwd: root, + stdio: 'inherit', + // Artifact builds must not mutate or validate a developer's Git hooks. + env: { ...process.env, CI: 'true' }, + }) child.once('error', (error) => { reject(new Error(`build-exe-for-python-sdk: ${label} failed to spawn: ${error.message} (${printable})`)) }) @@ -383,7 +439,7 @@ async function main(): Promise { await pipeline.build() await pipeline.deployStaging() await pipeline.injectPkgConfig() - const products: string[] = [] + const products: RuntimeProduct[] = [] for (const target of cli.targets) products.push(await pipeline.pack(target)) pipeline.printProducts(products) await pipeline.syncToPythonRuntime(products) diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index e0e90aa818..915968b30d 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -22,6 +22,7 @@ PLATFORMS = { "linux-arm64": ("manylinux_2_28_aarch64", "dsh-jsonrpc-agent-pkg-linux-arm64"), "macos-arm64": ("macosx_11_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"), } +SPAWN_HELPER_SUFFIX = "-spawn-helper" def main() -> None: @@ -136,6 +137,11 @@ def stage_runtime(destination: Path, version: str, executable: Path, executable_ raise FileNotFoundError(f"runtime executable does not exist: {executable}") if executable.stat().st_mode & stat.S_IXUSR == 0: raise PermissionError(f"runtime executable is not executable: {executable}") + spawn_helper = Path(f"{executable}{SPAWN_HELPER_SUFFIX}") + if not spawn_helper.is_file(): + raise FileNotFoundError(f"runtime spawn helper does not exist: {spawn_helper}") + if spawn_helper.stat().st_mode & stat.S_IXUSR == 0: + raise PermissionError(f"runtime spawn helper is not executable: {spawn_helper}") copy_package(ROOT / "python" / "sdk-runtime", destination) rewrite_version(destination / "pyproject.toml", version) runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" @@ -143,6 +149,9 @@ def stage_runtime(destination: Path, version: str, executable: Path, executable_ destination_executable = runtime_dir / executable_name shutil.copyfile(executable, destination_executable) destination_executable.chmod(executable.stat().st_mode & 0o777) + destination_helper = runtime_dir / f"{executable_name}{SPAWN_HELPER_SUFFIX}" + shutil.copyfile(spawn_helper, destination_helper) + destination_helper.chmod(spawn_helper.stat().st_mode & 0o777) def verify_wheel( @@ -161,16 +170,24 @@ def verify_wheel( raise RuntimeError(f"{wheel} has wrong WHEEL tags: {wheel_metadata.get_all('Tag')}") if metadata.get("Version") != version: raise RuntimeError(f"{wheel} has version {metadata.get('Version')}, expected {version}") - executables = [name for name in archive.namelist() if "/runtime/dsh-jsonrpc-agent-pkg-" in name] + runtime_files = [ + name for name in archive.namelist() if "/runtime/dsh-jsonrpc-agent-pkg-" in name + ] + helpers = [name for name in runtime_files if name.endswith(SPAWN_HELPER_SUFFIX)] + executables = [name for name in runtime_files if not name.endswith(SPAWN_HELPER_SUFFIX)] if package == "runtime": assert platform is not None if len(executables) != 1 or not executables[0].endswith(f"/runtime/{platform[1]}"): raise RuntimeError(f"{wheel} must contain exactly {platform[1]}, found {executables}") - mode = archive.getinfo(executables[0]).external_attr >> 16 - if mode & stat.S_IXUSR == 0: - raise RuntimeError(f"{wheel} runtime executable lost its executable bit") - elif executables: - raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {executables}") + expected_helper = f"{platform[1]}{SPAWN_HELPER_SUFFIX}" + if len(helpers) != 1 or not helpers[0].endswith(f"/runtime/{expected_helper}"): + raise RuntimeError(f"{wheel} must contain exactly {expected_helper}, found {helpers}") + for executable in [executables[0], helpers[0]]: + mode = archive.getinfo(executable).external_attr >> 16 + if mode & stat.S_IXUSR == 0: + raise RuntimeError(f"{wheel} runtime executable lost its executable bit: {executable}") + elif runtime_files: + raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {runtime_files}") if package == "sdk": requirements = metadata.get_all("Requires-Dist") or [] expected_requirement = f"deepseek-harness-runtime-bin=={version}" diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 7bdc8b68a8..dab261d11e 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -33,9 +33,11 @@ import * as SkillLocal from '@deepseek-ai/dsh-skill-local' import LocalTaskService from '@deepseek-ai/dsh-tasks-local' import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' +import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent' import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search' +import * as ToolStrReplaceEditor from '@deepseek-ai/dsh-tool-str-replace-editor' import PtyService from '@deepseek-ai/dsh-pty' import * as ToolPty from '@deepseek-ai/dsh-tool-pty' import * as ToolGoal from '@deepseek-ai/dsh-tool-goal' @@ -217,6 +219,32 @@ const TOOL_PACKAGES: ToolPackage[] = [ note: 'Ships in examples/cordis-agent only (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes.', }, + { + pkg: '@deepseek-ai/dsh-tool-bash-persistent', + dir: 'tool-bash-persistent', + source: 'packages/pty/tool-bash-persistent/src/index.ts', + requires: ['ctx.tools', 'ctx.pty', 'an owning Agent at execution time'], + writes: ['tool/call', 'PTY shell state', 'tool/result'], + async mount(ctx) { + await ctx.plugin(PtyService) + await ctx.plugin(ToolBashPersistent) + }, + note: + 'One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description.', + }, + { + pkg: '@deepseek-ai/dsh-tool-str-replace-editor', + dir: 'tool-str-replace-editor', + source: 'packages/fs/tool-str-replace-editor/src/index.ts', + requires: ['ctx.tools', 'ctx.fs'], + writes: ['tool/call', 'fs/observed after successful file operations', 'tool/result'], + async mount(ctx) { + await ctx.plugin(LocalFileSystem) + await ctx.plugin(ToolStrReplaceEditor) + }, + note: + 'Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface.', + }, { pkg: '@deepseek-ai/dsh-tool-fs', dir: 'tool-fs', diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 0019654fdc..11500b5ec7 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -25,6 +25,14 @@ CODE_PROMPT = "Use run_code to compute the packaged worker smoke value." CODE_WORKER_TEXT = "code worker smoke ok" WORKFLOW_PROMPT = "Use workflow to compute the packaged worker smoke value without agents." WORKFLOW_WORKER_TEXT = "workflow worker smoke ok" +PERSISTENT_TOOLS_PROMPT = "Exercise the packaged persistent Bash and string-replacement editor." +PERSISTENT_TOOLS_TEXT = "persistent tools smoke ok" +PERSISTENT_EDITOR_PATH: str | None = None +PERSISTENT_BASH_COMMAND = ( + "counter=$(( ${counter:-0} + 1 )); export counter; " + "printf 'COUNT=%s CWD=%s\\n' \"$counter\" \"$PWD\"; " + "if [ \"$counter\" -eq 1 ]; then cd /tmp; fi" +) SNAPSHOT_PROMPT = "Run the advanced packaged-runtime snapshot scenario." SNAPSHOT_SESSION_ID = "advanced-executable" SNAPSHOT_DIRECT_CHILD_PROMPT = "Reply with exactly DIRECT_CHILD_OK and nothing else." @@ -96,6 +104,49 @@ CUSTOM_CORDIS = """\ - id: cordis-tool name: '@deepseek-ai/dsh-tool-cordis' """ +PERSISTENT_TOOLS_CORDIS = """\ +- id: jsonrpc + name: '@deepseek-ai/dsh-jsonrpc' +- id: llm + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL +- id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' +- id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: danger-full-access + workspaceRoot: !!js process.env.DSH_CWD +- id: pty + name: '@deepseek-ai/dsh-pty' +- id: pty-local + name: '@deepseek-ai/dsh-pty-local' +- id: fs + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.env.DSH_CWD +- id: agent-core + name: '@deepseek-ai/dsh-agent-spine-demo' + config: + includeHarnessIdentity: false + persona: 'You are a helpful software engineer assistant.' + workspaceContext: false + skills: + enabled: false + toolBash: false + toolTasks: false +- id: sessions + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js process.env.DSH_SESSION_ROOT + compression: 'none' +- id: persistent-bash + name: '@deepseek-ai/dsh-tool-bash-persistent' +- id: str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' +""" class MockModelHandler(BaseHTTPRequestHandler): @@ -132,6 +183,9 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: if latest.get("role") == "tool": call_id, tool_name = latest_tool_call(messages) tool_text = message_text(latest.get("content")) + persistent = persistent_tool_followup(body, call_id, tool_name, tool_text) + if persistent is not None: + return persistent advanced = advanced_tool_followup(body, call_id, tool_name, tool_text) if advanced is not None: return advanced @@ -144,6 +198,15 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: raise AssertionError(f"unexpected tool follow-up: {tool_name}") prompt = message_text(latest.get("content")) + if prompt == PERSISTENT_TOOLS_PROMPT: + names = advertised_tool_names(body) + if names != {"bash", "str_replace_editor"}: + raise AssertionError(f"persistent tools smoke advertised unexpected tools: {names}") + return tool_call_chunks( + "persistent-bash-1", + "bash", + {"command": PERSISTENT_BASH_COMMAND}, + ) if prompt == SNAPSHOT_DIRECT_CHILD_PROMPT: return text_chunks("DIRECT_CHILD_OK") if prompt == SNAPSHOT_WORKFLOW_CHILD_PROMPT: @@ -178,6 +241,44 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: return text_chunks(EXPECTED_TEXT) +def persistent_tool_followup( + body: dict[str, object], + call_id: str, + tool_name: str, + tool_text: str, +) -> list[dict[str, object]] | None: + """Verify packaged PTY persistence, then invoke the packaged editor.""" + if not call_id.startswith("persistent-"): + return None + if call_id == "persistent-bash-1" and tool_name == "bash": + if "COUNT=1" not in tool_text: + raise AssertionError(f"first persistent bash call lost its output: {tool_text}") + return tool_call_chunks( + "persistent-bash-2", + "bash", + {"command": PERSISTENT_BASH_COMMAND}, + ) + if call_id == "persistent-bash-2" and tool_name == "bash": + if "COUNT=2 CWD=/tmp" not in tool_text: + raise AssertionError(f"persistent bash did not retain state: {tool_text}") + if PERSISTENT_EDITOR_PATH is None: + raise AssertionError("persistent editor smoke path was not initialized") + return tool_call_chunks( + "persistent-editor", + "str_replace_editor", + { + "command": "create", + "path": PERSISTENT_EDITOR_PATH, + "file_text": "created by packaged editor\n", + }, + ) + if call_id == "persistent-editor" and tool_name == "str_replace_editor": + if "New file created successfully" not in tool_text: + raise AssertionError(f"packaged editor did not create its file: {tool_text}") + return text_chunks(PERSISTENT_TOOLS_TEXT) + raise AssertionError(f"unexpected persistent-tools follow-up: {call_id} {tool_name}: {tool_text}") + + def advanced_tool_followup( body: dict[str, object], call_id: str, @@ -357,14 +458,14 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--scenario", - choices=("all", "sdk-default", "sdk-custom", "sdk-snapshot", "direct"), + choices=("all", "sdk-default", "sdk-custom", "sdk-persistent", "sdk-snapshot", "direct"), default="all", ) parser.add_argument("--exe", type=Path) parser.add_argument("--update-snapshots", action="store_true") args = parser.parse_args() - if args.scenario in {"all", "sdk-custom", "sdk-snapshot", "direct"} and args.exe is None: - parser.error("--exe is required for custom, snapshot, and direct scenarios") + if args.scenario in {"all", "sdk-custom", "sdk-persistent", "sdk-snapshot", "direct"} and args.exe is None: + parser.error("--exe is required for custom, persistent, snapshot, and direct scenarios") if args.update_snapshots and args.scenario not in {"all", "sdk-snapshot"}: parser.error("--update-snapshots requires --scenario sdk-snapshot or all") if args.exe is not None and not args.exe.is_file(): @@ -376,6 +477,9 @@ def main() -> None: if args.scenario in {"all", "sdk-custom"}: assert args.exe is not None smoke_sdk_custom(model.url, args.exe.resolve()) + if args.scenario in {"all", "sdk-persistent"}: + assert args.exe is not None + smoke_sdk_persistent_tools(model.url, args.exe.resolve()) if args.scenario in {"all", "sdk-snapshot"}: assert args.exe is not None smoke_sdk_snapshot(model.url, args.exe.resolve(), args.update_snapshots) @@ -439,6 +543,41 @@ def smoke_sdk_custom(base_url: str, executable: Path) -> None: assert_session_log(sessions, root, EXPECTED_TEXT, CODE_WORKER_TEXT, WORKFLOW_WORKER_TEXT) +def smoke_sdk_persistent_tools(base_url: str, executable: Path) -> None: + """Exercise native PTY state and the editor through the packaged executable.""" + global PERSISTENT_EDITOR_PATH + from deepseek_harness import DeepSeekHarness + + with tempfile.TemporaryDirectory(prefix="dsh-sdk-persistent-tools-") as temporary: + root = Path(temporary).resolve() + PERSISTENT_EDITOR_PATH = str(root / "created.txt") + sessions = root / "sessions" + cordis = root / "cordis.yml" + cordis.write_text(PERSISTENT_TOOLS_CORDIS) + with DeepSeekHarness( + provider="deepseek", + model="smoke-model", + cwd=str(root), + session_root=str(sessions), + cordis=str(cordis), + runtime_bin=str(executable), + api_key="sk-keyless-smoke", + base_url=base_url, + request_timeout_seconds=60, + ) as harness: + result = harness.run(PERSISTENT_TOOLS_PROMPT, session_id="persistent-tools-smoke") + + assert result.status == "ok", result + event_text = json.dumps(result.events) + if PERSISTENT_TOOLS_TEXT not in event_text: + raise AssertionError(f"packaged tools run emitted no final response: {result.events}") + created = root / "created.txt" + if created.read_text() != "created by packaged editor\n": + raise AssertionError(f"packaged editor wrote unexpected content: {created.read_text()!r}") + assert_session_log(sessions, root, PERSISTENT_TOOLS_TEXT, "COUNT=1", "COUNT=2 CWD=/tmp") + PERSISTENT_EDITOR_PATH = None + + def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) -> None: """Drive and compare the advanced SDK/executable behavioral snapshot.""" from deepseek_harness import DeepSeekHarness diff --git a/tsconfig.host.json b/tsconfig.host.json index e2112b7f6a..254afc9535 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -103,6 +103,7 @@ { "path": "./packages/bash/bash" }, { "path": "./packages/pty/pty" }, { "path": "./packages/pty/pty-local" }, + { "path": "./packages/pty/tool-bash-persistent" }, { "path": "./packages/pty/tool-pty" }, { "path": "./packages/code-runtime/code-runtime" }, { "path": "./packages/code-runtime/code-runtime-worker" }, @@ -120,6 +121,7 @@ { "path": "./packages/fs/fs-sandbox" }, { "path": "./packages/fs/tool-fs" }, { "path": "./packages/fs/tool-fs-search" }, + { "path": "./packages/fs/tool-str-replace-editor" }, { "path": "./packages/compact/compact" }, { "path": "./packages/compact/compact-basic" }, { "path": "./packages/compact/compact-tool-result-prune" }, From d5e7212b31c0546ddb17eab466d7932c5e29fc13 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 14:23:34 +0800 Subject: [PATCH 07/72] fix(build): refresh node-pty patch hash --- pnpm-lock.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b271ff848b..4c57d106a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: patchedDependencies: '@earendil-works/pi-tui@0.80.7': 6c30c5386c0159131e1361023cddf31377f5728962524841964373312c1ed946 - node-pty@1.1.0: fa5e4a788317512778f95ef1573fec565f9b601ce10383bbf103234db3e61985 + node-pty@1.1.0: 4a1568bc9ef77084629054d0736430818818155abcd0dce581ef8c782e974c15 importers: @@ -650,7 +650,7 @@ importers: devDependencies: node-pty: specifier: 1.1.0 - version: 1.1.0(patch_hash=fa5e4a788317512778f95ef1573fec565f9b601ce10383bbf103234db3e61985) + version: 1.1.0(patch_hash=4a1568bc9ef77084629054d0736430818818155abcd0dce581ef8c782e974c15) packages/acp/acp: dependencies: @@ -3384,7 +3384,7 @@ importers: dependencies: node-pty: specifier: ^1.1.0 - version: 1.1.0(patch_hash=fa5e4a788317512778f95ef1573fec565f9b601ce10383bbf103234db3e61985) + version: 1.1.0(patch_hash=4a1568bc9ef77084629054d0736430818818155abcd0dce581ef8c782e974c15) schemastery: specifier: ^3.18.0 version: 3.18.0 @@ -15269,7 +15269,7 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - node-pty@1.1.0(patch_hash=fa5e4a788317512778f95ef1573fec565f9b601ce10383bbf103234db3e61985): + node-pty@1.1.0(patch_hash=4a1568bc9ef77084629054d0736430818818155abcd0dce581ef8c782e974c15): dependencies: node-addon-api: 7.1.1 From 998a5f33819c0ec3246763588b4f513cd526b8a3 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 14:27:01 +0800 Subject: [PATCH 08/72] =?UTF-8?q?fix(host):=20review=20round=20=E2=80=94?= =?UTF-8?q?=20host-derived=20separator,=20click-safe=20blur=20cancel,=20de?= =?UTF-8?q?ad=20wide=20prop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../directory-picker-browse/README.i18n.yaml | 4 +- .../host/directory-picker-browse/README.md | 2 +- .../host/directory-picker-browse/README.zh.md | 2 +- .../src/client/DirectoryBrowser.module.css | 15 ++--- .../src/client/DirectoryBrowser.tsx | 66 +++++++++++++------ .../tests/directory-browser.spec.tsx | 56 +++++++++++++++- 6 files changed, 112 insertions(+), 33 deletions(-) diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index f40742fc0d..eea9966b83 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/README.i18n.yaml @@ -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/directory-picker-browse/README.md -README.md: 9772baa2a6e632a5f0f18cc18b9b55b45cd845ca -README.zh.md: 682495fe10bdeed41f709a438dcd222d612129e3 +README.md: 95d2d66406210f4ba687ef5a45a38b142abd6f26 +README.zh.md: 9da374bec80f80085ff36871c227c496bb1fde7b diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 9772baa2a6..95d2d66406 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -6,7 +6,7 @@ The **in-app browsing backend** of the [directory-picker seam](../directory-pick Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated); window insertion is binary with an O(1) full-window tail rejection, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). -**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view; breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing, and cancels on Escape or focus loss; a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). +**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view; breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing (case-insensitively, over the listed — possibly truncated — rows only; Enter still navigates by the exact text), and cancels on Escape or focus loss; a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). ## Model Experience diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 682495fe10..9da374bec8 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -6,7 +6,7 @@ 行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 -**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图;带点击即编辑路径区的面包屑,其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤、按 Escape 或失焦即取消;基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 +**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图;带点击即编辑路径区的面包屑,其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;Enter 仍按确切文本导航)、按 Escape 或失焦即取消;基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 ## 模型体验 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index 444ff19cbf..3b681de32a 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -131,8 +131,8 @@ } /* Miller content: symmetric 16px vertical padding so the divider clears the - * header and footer rules evenly; columns are 256 wide (or full width solo) - * with the hairline divider centered between them; each column scrolls alone. */ + * header and footer rules evenly; each column scrolls alone (column widths + * live at .column). */ .content { display: flex; flex-direction: column; @@ -143,9 +143,10 @@ padding: 16px 16px 16px 24px; } -/* Two-pane columns split the row evenly around the divider; 256px is the - * floor below which the row scrolls (scrollbar hidden, the effect pins the - * child pane into view) instead of squeezing the panes. */ +/* Columns split the row evenly around the divider (a solo column takes the + * whole row); 256px is the floor below which the row scrolls (scrollbar + * hidden, the effect pins the child pane into view) instead of squeezing + * the panes. */ .column { display: flex; flex-direction: column; @@ -158,10 +159,6 @@ padding-right: 8px; } -.columnWide { - width: 100%; -} - .divider { flex: none; width: 1px; diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 01e69f314c..b924c072bb 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -1,10 +1,11 @@ /** * The in-app workspace-directory browser (figma Harness 813-23126 family): a - * 600×420 dialog (clamped to short/narrow viewports — the Miller row scrolls + * 680×500 dialog (clamped to short/narrow viewports — the Miller row scrolls * sideways, the columns scroll down) whose header carries the title, the selection-path * breadcrumb, and a click-to-edit path zone; below it a Miller view — one - * full-width level until a row is selected, then two 256px columns (level | - * selected folder's children) around a hairline divider. Selecting in the + * full-width level until a row is selected, then two columns splitting the + * row evenly (256px floor; level | selected folder's children) around a + * hairline divider. Selecting in the * right column shifts the view one level deeper. "New folder" opens a nested * create dialog targeting the selected folder (or the level itself) and * selects the created folder. Open adopts the selected folder, falling back @@ -63,20 +64,26 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE return [{ name: homeLabel, path: listing.home, hidden: false }, ...tail] } -/** The separator a Host path's own platform uses (Windows listings carry backslashes). */ -function separatorOf(path: string): string { - return path.includes('\\') ? '\\' : '/' +/** + * The listing's platform separator, read from the host-stamped home path — + * never sniffed from typed text or entry paths, where a backslash is a legal + * POSIX name character rather than a platform fact. + */ +function separatorOf(listing: DirectoryListing): '\\' | '/' { + return listing.home.includes('\\') ? '\\' : '/' } /** * The path draft's final segment, when its directory part is exactly the * level `listing` lists — the segment the level prefix-filters on while the * user types. Any other draft (no separator yet, or naming some other - * directory) leaves the level unfiltered. + * directory) leaves the level unfiltered. The directory part compares + * exactly (it is the host's own path text, reached by seeding or erasing); + * only the name filter downstream is case-insensitive. */ function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null { if (draft === null) return null - const sep = separatorOf(draft) + const sep = separatorOf(listing) const cut = draft.lastIndexOf(sep) if (cut === -1) return null const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}` @@ -84,12 +91,11 @@ function draftPrefixFor(listing: DirectoryListing, draft: string | null): string } /** One column of folder rows (the Miller view renders one or two of these). */ -function LevelColumn({ entries, selectedPath, busy, onPick, wide, showHidden, filterPrefix }: { +function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPrefix }: { entries: readonly DirectoryEntry[] selectedPath: string | null busy: boolean onPick: (entry: DirectoryEntry) => void - wide: boolean showHidden: boolean filterPrefix: string | null }) { @@ -100,7 +106,7 @@ function LevelColumn({ entries, selectedPath, busy, onPick, wide, showHidden, fi return showHidden || !entry.hidden || filterPrefix?.startsWith('.') === true }) return ( -
+
{visible.map((entry) => { const selected = entry.path === selectedPath return ( @@ -112,6 +118,10 @@ function LevelColumn({ entries, selectedPath, busy, onPick, wide, showHidden, fi aria-current={selected || undefined} className={clsx(css.row, selected && css.rowSelected)} disabled={busy} + // Keep focus where it is (the path editor, notably): a focus + // steal on mousedown would blur-cancel the editor, unmount the + // filtered rows mid-gesture, and drop this very click. + onMouseDown={(event) => { event.preventDefault() }} onClick={() => { onPick(entry) }} > {selected @@ -142,7 +152,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const [error, setError] = useState(null) // Path-edit state: null = breadcrumb mode; a string = the draft being typed. const [pathDraft, setPathDraft] = useState(null) - // Show-hidden toggle state (pure client-side filter, reset on close). + // Show-hidden toggle state (pure client-side filter, reset on each open). const [showHidden, setShowHidden] = useState(false) // Create-folder state: null = closed; a string = the nested dialog's draft. const [folderDraft, setFolderDraft] = useState(null) @@ -212,6 +222,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, /** Select a row of the listed level and preview its children on the right. */ const select = useCallback((entry: DirectoryEntry) => { const { seq, scan } = launchListing(entry.path) + // A pick while the path editor is open adopts the (filtered) row and + // closes the editor — the draft served its purpose. + setPathDraft(null) setSelected(entry) setChild(null) setLoading(true) @@ -402,9 +415,15 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setLoading(false) // Seed with a trailing separator so typing immediately // continues into child names (and prefix-filters below). - const base = selected?.path ?? parent?.path ?? '' - const sep = separatorOf(base) - setPathDraft(base === '' || base.endsWith(sep) ? base : `${base}${sep}`) + // No listed level means nothing to seed from (the editor + // is the recovery path for a failed home listing). + if (parent === null) { + setPathDraft('') + return + } + const base = selected?.path ?? parent.path + const sep = separatorOf(parent) + setPathDraft(base.endsWith(sep) ? base : `${base}${sep}`) }} /> @@ -441,8 +460,15 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // Clicking anywhere outside the editor reads as leaving it: // focus loss cancels the edit like Escape. Enter keeps focus // in the input while its navigation is in flight, so a - // submitted path is never withdrawn by this handler. - onBlur={cancelPathEdit} + // submitted path is never withdrawn by this handler; rows and + // the show-hidden toggle suppress focus steal on mousedown so + // a click on them lands before any cancel. Window/tab focus + // loss also fires blur in some engines — only a focus move + // within a focused document reads as leaving the editor. + onBlur={() => { + if (!document.hasFocus()) return + cancelPathEdit() + }} /> )}
@@ -455,7 +481,6 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, selectedPath={selected?.path ?? null} busy={parentInert} onPick={select} - wide={!twoPane} showHidden={showHidden} filterPrefix={draftPrefixFor(parent, pathDraft)} /> @@ -467,7 +492,6 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, selectedPath={null} busy={parentInert} onPick={advance} - wide={false} showHidden={showHidden} filterPrefix={draftPrefixFor(child, pathDraft)} /> @@ -498,6 +522,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, className={clsx(css.showHiddenToggle, showHidden && css.showHiddenToggleActive)} aria-pressed={showHidden} disabled={parentInert} + // The toggle composes with the path editor (dot-led prefixes and + // this filter interleave): don't steal focus, so toggling never + // blur-cancels a draft mid-thought. + onMouseDown={(event) => { event.preventDefault() }} onClick={() => { setShowHidden(prev => !prev) }} > {showHidden && } diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index 98f3bd6e4f..af3aff5385 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -107,9 +107,11 @@ describe('DirectoryBrowser', () => { const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) expect(screen.queryByText('.config')).toBeNull() - // The fixed-label toggle reports its state through aria-pressed. + // The fixed-label toggle reports its state through aria-pressed. Its + // mousedown never steals focus (so it composes with the path editor). const toggle = screen.getByRole('button', { name: 'browser.showHidden' }) expect(toggle.getAttribute('aria-pressed')).toBe('false') + fireEvent.mouseDown(toggle) fireEvent.click(toggle) expect(toggle.getAttribute('aria-pressed')).toBe('true') expect(screen.getByText('.config')).toBeTruthy() @@ -243,6 +245,58 @@ describe('DirectoryBrowser', () => { expect(screen.getByRole('listitem').textContent).toBe('Documents') }) + it('filters the child pane in two-pane mode and follows the draft back up a level', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(rowButton(screen.getByRole('listitem'))) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + // The seed comes from the selection, so the draft tail addresses the + // RIGHT pane (the selection's children). + expect(input.value).toBe(`${DOCS}/`) + fireEvent.change(input, { target: { value: `${DOCS}/h` } }) + expect(within(columns()[1]!).getByText('harness')).toBeTruthy() + fireEvent.change(input, { target: { value: `${DOCS}/zzz` } }) + expect(within(columns()[1]!).queryAllByRole('listitem')).toHaveLength(0) + expect(within(columns()[0]!).getByText('Documents')).toBeTruthy() + // Erasing back into the parent's own path moves the filter to the LEFT + // pane and releases the right one. + fireEvent.change(input, { target: { value: `${HOME}/zz` } }) + expect(within(columns()[0]!).queryAllByRole('listitem')).toHaveLength(0) + expect(within(columns()[1]!).getByText('harness')).toBeTruthy() + }) + + it('keeps the path editor open when blur comes from window focus loss', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + // A blur while the document itself lost focus (window switch, dev-tools + // focus) must not discard the draft. + const hasFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(false) + fireEvent.blur(input) + expect(screen.getByLabelText('browser.editPath', { selector: 'input' })).toBeTruthy() + hasFocus.mockRestore() + }) + + it('picking a filtered row adopts it and closes the path editor', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: `${HOME}/do` } }) + // The row suppresses focus steal on mousedown (no blur-cancel unmounts + // the filtered rows mid-gesture), then the click both selects the row + // and closes the editor. + const row = rowButton(screen.getByRole('listitem')) + fireEvent.mouseDown(row) + fireEvent.click(row) + expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + await waitFor(() => { expect(columns()).toHaveLength(2) }) + expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() + }) + it('seeds and filters with backslashes on a Windows-rooted listing', async () => { const ROOT = 'C:\\' const windowsListing: DirectoryListing = { From dbdfb8d3b7de697a66c449172310868fb69a8f4b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 14:29:16 +0800 Subject: [PATCH 09/72] test(tools): include persistent tool schemas --- packages/core/tools/tests/gen-tool-catalog.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 3754595f56..1ab8bc2730 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { it('boots every shipped tool package and harvests its model-facing schemas', async () => { const catalog = await collectToolCatalog() const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort() - expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) + expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) // Every tool carries a JSON-Schema `parameters` object (what the model sees). for (const entry of catalog) { for (const schema of entry.schemas) { From 27053efffceacf9330388c062a0dfdc29c516a85 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 14:47:40 +0800 Subject: [PATCH 10/72] =?UTF-8?q?fix(host):=20review=20round=202=20?= =?UTF-8?q?=E2=80=94=20dialog-scoped=20blur=20cancel,=20editing-only=20foc?= =?UTF-8?q?us=20hold,=20selection=20exempt=20from=20filters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/client/DirectoryBrowser.module.css | 5 +- .../src/client/DirectoryBrowser.tsx | 53 ++++++++++------- .../tests/directory-browser.spec.tsx | 57 +++++++++++++++++-- 3 files changed, 89 insertions(+), 26 deletions(-) diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index 3b681de32a..d4fa986371 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -55,8 +55,9 @@ align-items: stretch; flex: 1 1 0; min-height: 0; - /* Columns already end in an 8px scrollbar clearance, so the divider only - * needs a slim gap of its own on each side. */ + /* 12px of row gap on each side of the divider; the left side reads wider + * by the column's trailing 8px scrollbar clearance, which is deliberate — + * the thumb needs that room, the right pane's rows do not. */ gap: 12px; overflow-x: auto; scrollbar-width: none; diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index b924c072bb..9c3dc66fbb 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -65,9 +65,12 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE } /** - * The listing's platform separator, read from the host-stamped home path — - * never sniffed from typed text or entry paths, where a backslash is a legal - * POSIX name character rather than a platform fact. + * The listing's platform separator, inferred from the home path the host + * stamped — never from typed text or entry paths, where a backslash is a + * legal POSIX name character. Still a heuristic at the last step: a POSIX + * home directory whose own name contains a backslash would misread. + * TODO: replace with a host-stamped `separator` field on the wire + * DirectoryListing so the platform fact travels verbatim. */ function separatorOf(listing: DirectoryListing): '\\' | '/' { return listing.home.includes('\\') ? '\\' : '/' @@ -91,15 +94,20 @@ function draftPrefixFor(listing: DirectoryListing, draft: string | null): string } /** One column of folder rows (the Miller view renders one or two of these). */ -function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPrefix }: { +function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPrefix, pathEditing }: { entries: readonly DirectoryEntry[] selectedPath: string | null busy: boolean onPick: (entry: DirectoryEntry) => void showHidden: boolean filterPrefix: string | null + pathEditing: boolean }) { const visible = entries.filter((entry) => { + // The selection is exempt from both filters: it anchors the two-pane + // view (crumbs and the child pane point at it), so neither the hidden + // filter after a dot-reveal pick nor a prefix miss may orphan it. + if (entry.path === selectedPath) return true if (filterPrefix !== null && !entry.name.toLowerCase().startsWith(filterPrefix.toLowerCase())) return false // A dot-led prefix names hidden entries explicitly, so matching ones // surface even while the toggle keeps the rest hidden. @@ -118,10 +126,11 @@ function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPr aria-current={selected || undefined} className={clsx(css.row, selected && css.rowSelected)} disabled={busy} - // Keep focus where it is (the path editor, notably): a focus - // steal on mousedown would blur-cancel the editor, unmount the - // filtered rows mid-gesture, and drop this very click. - onMouseDown={(event) => { event.preventDefault() }} + // While the path editor is open, keep focus in it: a focus + // steal on mousedown would blur the editor and (in engines + // where the blur lands before our guards) drop this click. + // Outside editing, rows keep native focus behavior. + onMouseDown={pathEditing ? (event) => { event.preventDefault() } : undefined} onClick={() => { onPick(entry) }} > {selected @@ -457,16 +466,19 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, cancelPathEdit() } }} - // Clicking anywhere outside the editor reads as leaving it: - // focus loss cancels the edit like Escape. Enter keeps focus + // Focus leaving the DIALOG reads as leaving the editor and + // cancels like Escape. Three guarded non-cancel paths: window + // or tab focus loss (document no longer focused); a focus + // move that stays inside the dialog card (keyboard Tab onto + // the filtered rows or the footer toggle); and pointer paths, + // where rows and the toggle suppress focus steal on mousedown + // while editing so their click lands first. Enter keeps focus // in the input while its navigation is in flight, so a - // submitted path is never withdrawn by this handler; rows and - // the show-hidden toggle suppress focus steal on mousedown so - // a click on them lands before any cancel. Window/tab focus - // loss also fires blur in some engines — only a focus move - // within a focused document reads as leaving the editor. - onBlur={() => { + // submitted path is never withdrawn here. + onBlur={(event) => { if (!document.hasFocus()) return + if (event.relatedTarget instanceof HTMLElement + && event.relatedTarget.closest('[role="dialog"]') !== null) return cancelPathEdit() }} /> @@ -483,6 +495,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, onPick={select} showHidden={showHidden} filterPrefix={draftPrefixFor(parent, pathDraft)} + pathEditing={draftPending} /> )} {twoPane && } @@ -494,6 +507,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, onPick={advance} showHidden={showHidden} filterPrefix={draftPrefixFor(child, pathDraft)} + pathEditing={draftPending} /> )}
@@ -523,9 +537,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, aria-pressed={showHidden} disabled={parentInert} // The toggle composes with the path editor (dot-led prefixes and - // this filter interleave): don't steal focus, so toggling never - // blur-cancels a draft mid-thought. - onMouseDown={(event) => { event.preventDefault() }} + // this filter interleave): while editing, don't steal focus, so + // toggling never blur-cancels a draft mid-thought. Outside editing + // it keeps native focus behavior. + onMouseDown={draftPending ? (event) => { event.preventDefault() } : undefined} onClick={() => { setShowHidden(prev => !prev) }} > {showHidden && } diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index af3aff5385..419572734c 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -29,6 +29,18 @@ function listingFor(path?: string): DirectoryListing { ], truncated: false, }, + [`${HOME}/.config`]: { + path: `${HOME}/.config`, + home: HOME, + crumbs: [ + { name: '/', path: '/', hidden: false }, + { name: 'home', path: '/home', hidden: false }, + { name: 'u', path: HOME, hidden: false }, + { name: '.config', path: `${HOME}/.config`, hidden: true }, + ], + entries: [], + truncated: false, + }, [DOCS]: { path: DOCS, home: HOME, @@ -261,23 +273,58 @@ describe('DirectoryBrowser', () => { expect(within(columns()[1]!).queryAllByRole('listitem')).toHaveLength(0) expect(within(columns()[0]!).getByText('Documents')).toBeTruthy() // Erasing back into the parent's own path moves the filter to the LEFT - // pane and releases the right one. + // pane and releases the right one. The selected row is exempt (it + // anchors the two-pane view), so it alone survives the miss. fireEvent.change(input, { target: { value: `${HOME}/zz` } }) - expect(within(columns()[0]!).queryAllByRole('listitem')).toHaveLength(0) + expect(within(columns()[0]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents']) expect(within(columns()[1]!).getByText('harness')).toBeTruthy() }) - it('keeps the path editor open when blur comes from window focus loss', async () => { + it('keeps the draft and filter through window focus loss and in-dialog focus moves', async () => { mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: `${HOME}/do` } }) // A blur while the document itself lost focus (window switch, dev-tools - // focus) must not discard the draft. + // focus) must not discard the draft: value and filter both survive. const hasFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(false) fireEvent.blur(input) - expect(screen.getByLabelText('browser.editPath', { selector: 'input' })).toBeTruthy() hasFocus.mockRestore() + expect(screen.getByLabelText('browser.editPath', { selector: 'input' }).value).toBe(`${HOME}/do`) + expect(screen.getByRole('listitem').textContent).toBe('Documents') + // A keyboard focus move that stays inside the dialog (Tab onto the + // filtered row) keeps the draft too — the results stay reachable. + fireEvent.blur(input, { relatedTarget: rowButton(screen.getByRole('listitem')) }) + expect(screen.getByLabelText('browser.editPath', { selector: 'input' }).value).toBe(`${HOME}/do`) + // Toggling show-hidden mid-edit suppresses focus steal: the draft and + // its filter survive the toggle in both directions. + const toggle = screen.getByRole('button', { name: 'browser.showHidden' }) + fireEvent.mouseDown(toggle) + fireEvent.click(toggle) + expect(toggle.getAttribute('aria-pressed')).toBe('true') + expect(screen.getByLabelText('browser.editPath', { selector: 'input' }).value).toBe(`${HOME}/do`) + expect(screen.getByRole('listitem').textContent).toBe('Documents') + // Focus landing outside the dialog cancels like Escape. + fireEvent.blur(input, { relatedTarget: document.body }) + expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + }) + + it('a picked dot-revealed hidden row stays visible as the selection', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: `${HOME}/.co` } }) + const row = rowButton(screen.getByRole('listitem')) + expect(row.textContent).toBe('.config') + fireEvent.mouseDown(row) + fireEvent.click(row) + // The pick cleared the draft (and with it the dot-reveal), but the + // selection is exempt from the hidden filter: the anchor row survives. + expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + await waitFor(() => { expect(columns()).toHaveLength(2) }) + expect(within(columns()[0]!).getByText('.config')).toBeTruthy() }) it('picking a filtered row adopts it and closes the path editor', async () => { From 355d505b89a763d65c9593387feeebb59d35a225 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 15:10:35 +0800 Subject: [PATCH 11/72] =?UTF-8?q?fix(host):=20review=20round=203=20?= =?UTF-8?q?=E2=80=94=20card-scope=20escape/focus-leave=20cancel,=20pick=20?= =?UTF-8?q?refocus;=20record=20display-policy=20trade-offs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 6 +- ...-28-directory-picker-capability-seam.zh.md | 6 +- .../directory-picker-browse/README.i18n.yaml | 4 +- .../host/directory-picker-browse/README.md | 2 +- .../host/directory-picker-browse/README.zh.md | 2 +- .../src/client/DirectoryBrowser.module.css | 10 +- .../src/client/DirectoryBrowser.tsx | 351 ++++++++++-------- .../tests/directory-browser.spec.tsx | 35 +- 9 files changed, 244 insertions(+), 176 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index 6f21a60e83..dc22b8f807 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 3f5e1436f3af14ce06ffab00ceca90167e16afd0 -2026-07-28-directory-picker-capability-seam.zh.md: 09a3e20f7c12e3c22d63875091593f9a6ca8a1ad +2026-07-28-directory-picker-capability-seam.md: f633cd60b32c10e63814bf06a06773d2dba396f2 +2026-07-28-directory-picker-capability-seam.zh.md: b708e6fffa46cf51fd35154bfc2b947d955e5e9c diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 3f5e1436f3..f633cd60b3 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -18,7 +18,8 @@ Placement and policy rulings folded into this decision: - **Not the `ctx.fs` seam.** `packages/fs/` is the model/session-facing storage stack (policy events, sandbox-swappable backends). Riding it would couple GUI browsing to the model's confinement backend — swapping `fs-sandbox` for the model must never change GUI behavior — and OS facts (home anchoring, hidden conventions) are not storage primitives. The picker seam stays presentation-free and model-free; `packages/host/` is its consumer-domain home. - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. -- **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change (the browse client's footer toggle). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. +- **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. +- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. - **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it. @@ -30,6 +31,9 @@ Placement and policy rulings folded into this decision: - **One uniform seam method set (`pick(): path`).** Rejected: an in-app browser cannot be served behind a single host-side call — the browsing loop lives in the client and needs primitives on the wire; the native chooser cannot implement primitives. The interaction difference is irreducible, hence the discriminant. - **Direct stdlib calls inside apiproxy (no seam).** Rejected: keeps the gateway the only swap point (source edits), loses fixture/test backends, and contradicts the plugin doctrine that motivated the work. - **Adopting a file-manager/drive-enumeration dependency.** Rejected per the survey above; recorded here as the dependency policy requires. +- **A flip-label show-hidden toggle ("Hide hidden files").** Rejected: a flipping action label is ambiguous between state and action and doubles the negative; the fixed label with a pressed presentation states both at once. +- **Pure relatedTarget blur cancellation (no mousedown suppression).** Rejected: Safari does not focus buttons on pointer down, so a click's focusout carries a null `relatedTarget` and would cancel the editor before the click lands; editing-scoped mousedown suppression plus the card-anchored relatedTarget guard covers pointer and keyboard paths together. +- **A wire `separator` field on `DirectoryListing` (host stamps `path.sep`).** Deferred, not rejected: it is the authoritative form — a POSIX home directory containing a backslash defeats the `listing.home` heuristic — but it touches the seam type and every backend; the browse client's `separatorOf` carries a TODO pointing at this alternative until a wire change is next scheduled. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 09a3e20f7c..b708e6fffa 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -18,7 +18,8 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **不用 `ctx.fs` seam。** `packages/fs/` 是面向模型/会话的存储栈(policy 事件、sandbox 可换后端)。骑上去会把 GUI 浏览耦合进模型的限制后端——为模型换 `fs-sandbox` 绝不能改变 GUI 行为——而 OS 事实(home 锚定、隐藏约定)也不是存储原语。picker seam 保持无展示、无模型;`packages/host/` 是它消费方域的家。 - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 -- **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地(browse 客户端的 footer 开关)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 +- **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 +- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 @@ -30,6 +31,9 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **统一方法集的 seam(`pick(): path`)。** 否决:应用内浏览器无法藏在一次宿主侧调用后面——浏览循环在客户端,需要协议上的原语;而对话框实现不了原语。交互差异不可约,故用判别标签。 - **apiproxy 里直接调标准库(不建 seam)。** 否决:换装点仍是改网关源码,失去 fixture/测试后端,与促成这项工作的插件教义相悖。 - **引入文件管理器/盘符枚举依赖。** 按上文调研否决;依赖政策要求记录于此。 +- **动作标签随状态翻转的"显示隐藏"开关("隐藏隐藏文件")。** 否决:会翻转的动作标签在状态与动作之间有歧义,还把否定叠了两层;固定标签加按下态呈现一次说清两者。 +- **纯 relatedTarget 失焦取消(不做 mousedown 抑制)。** 否决:Safari 在指针按下时不给按钮聚焦,点击触发的 focusout 因而携带空 `relatedTarget`,会在点击落地前就取消编辑器;编辑期作用的 mousedown 抑制加上锚定卡片的 relatedTarget 守卫才能同时覆盖指针与键盘路径。 +- **在 `DirectoryListing` 上增设线上 `separator` 字段(宿主标注 `path.sep`)。** 延期而非否决:它才是权威形态——含反斜杠的 POSIX 家目录会击穿 `listing.home` 启发式——但它触及 seam 类型与每个后端;browse 客户端的 `separatorOf` 挂着指向本方案的 TODO,直到下次安排线上变更。 ## 后果 diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index eea9966b83..e59cd22b1e 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/README.i18n.yaml @@ -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/directory-picker-browse/README.md -README.md: 95d2d66406210f4ba687ef5a45a38b142abd6f26 -README.zh.md: 9da374bec80f80085ff36871c227c496bb1fde7b +README.md: 7813e0e9c589d1f37471b08ebafcf08878cbf768 +README.zh.md: 46538ed6f1cf23357cc4f8e289a8117d0e5e017d diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 95d2d66406..7813e0e9c5 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -6,7 +6,7 @@ The **in-app browsing backend** of the [directory-picker seam](../directory-pick Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated); window insertion is binary with an O(1) full-window tail rejection, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). -**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view; breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing (case-insensitively, over the listed — possibly truncated — rows only; Enter still navigates by the exact text), and cancels on Escape or focus loss; a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). +**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view; breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing (case-insensitively, over the listed — possibly truncated — rows only; Enter still navigates by the exact text), and cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft); a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from both filters; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). ## Model Experience diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 9da374bec8..46538ed6f1 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -6,7 +6,7 @@ 行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 -**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图;带点击即编辑路径区的面包屑,其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;Enter 仍按确切文本导航)、按 Escape 或失焦即取消;基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 +**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图;带点击即编辑路径区的面包屑,其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;Enter 仍按确切文本导航)、按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿);基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受这两种过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 ## 模型体验 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index d4fa986371..e6f1daba98 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -155,7 +155,8 @@ flex: 1 1 0; min-width: 256px; overflow-y: auto; - /* The overlay scrollbar paints at the column's edge; keep the row pills + /* The themed scrollbar occupies the column's edge (styled scrollbars are + * classic, gutter-taking ones); the extra clearance keeps the row pills * clear of the thumb. */ padding-right: 8px; } @@ -283,6 +284,13 @@ color: var(--dsw-alias-label-primary); } +/* Card-scope wrapper hosting the path editor's Escape and focus-leave + * observers; display:contents keeps header/content/footer as direct flex + * children of the Modal card. */ +.editorScope { + display: contents; +} + .footerGap { flex: 1 1 0; } diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 9c3dc66fbb..37c3d33982 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -70,7 +70,8 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE * legal POSIX name character. Still a heuristic at the last step: a POSIX * home directory whose own name contains a backslash would misread. * TODO: replace with a host-stamped `separator` field on the wire - * DirectoryListing so the platform fact travels verbatim. + * DirectoryListing so the platform fact travels verbatim (the trade-off is + * recorded in the directory-picker capability seam Agent Note). */ function separatorOf(listing: DirectoryListing): '\\' | '/' { return listing.home.includes('\\') ? '\\' : '/' @@ -131,7 +132,13 @@ function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPr // where the blur lands before our guards) drop this click. // Outside editing, rows keep native focus behavior. onMouseDown={pathEditing ? (event) => { event.preventDefault() } : undefined} - onClick={() => { onPick(entry) }} + onClick={(event) => { + // A pick during editing is about to unmount the focused + // input; park focus on the picked row so keyboard traversal + // stays inside the dialog (the Modal has no focus trap). + if (pathEditing) event.currentTarget.focus() + onPick(entry) + }} > {selected ? @@ -386,177 +393,197 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, className={clsx(css.dialog)} headless > -
-

{t('browser.title')}

-
- {pathDraft === null - ? ( - <> - - {crumbs.map((crumb, index) => ( - - {index > 0 && } - - - ))} - - {/* The empty zone right of the crumbs is the path-edit affordance. */} - + + ))} + + {/* The empty zone right of the crumbs is the path-edit affordance. */} +
+
+
+
+ {parent !== null && ( + )} -
-
-
-
- {parent !== null && ( - - )} - {twoPane && } - {twoPane && child !== null && ( - - )} -
- {loading &&
{t('browser.loading')}
} - {/* The backend bounds a level at its complete-result limit; say so + {twoPane && } + {twoPane && child !== null && ( + + )} +
+ {loading &&
{t('browser.loading')}
} + {/* The backend bounds a level at its complete-result limit; say so * whenever a visible pane was cut instead of letting the tail of a * huge directory go silently missing. */} - {(parent?.truncated === true || child?.truncated === true) && !loading + {(parent?.truncated === true || child?.truncated === true) && !loading &&
{t('browser.truncated')}
} - {error !== null &&
{error}
} - -
- - - - - + {error !== null &&
{error}
} +
+
+ + + + + +
{/* Nested create dialog (figma 813:23278): names one folder inside the target. */} { // A blur while the document itself lost focus (window switch, dev-tools // focus) must not discard the draft: value and filter both survive. const hasFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(false) - fireEvent.blur(input) + fireEvent.focusOut(input) hasFocus.mockRestore() expect(screen.getByLabelText('browser.editPath', { selector: 'input' }).value).toBe(`${HOME}/do`) expect(screen.getByRole('listitem').textContent).toBe('Documents') // A keyboard focus move that stays inside the dialog (Tab onto the // filtered row) keeps the draft too — the results stay reachable. - fireEvent.blur(input, { relatedTarget: rowButton(screen.getByRole('listitem')) }) + fireEvent.focusOut(input, { relatedTarget: rowButton(screen.getByRole('listitem')) }) expect(screen.getByLabelText('browser.editPath', { selector: 'input' }).value).toBe(`${HOME}/do`) // Toggling show-hidden mid-edit suppresses focus steal: the draft and // its filter survive the toggle in both directions. @@ -305,9 +305,31 @@ describe('DirectoryBrowser', () => { expect(toggle.getAttribute('aria-pressed')).toBe('true') expect(screen.getByLabelText('browser.editPath', { selector: 'input' }).value).toBe(`${HOME}/do`) expect(screen.getByRole('listitem').textContent).toBe('Documents') - // Focus landing outside the dialog cancels like Escape. - fireEvent.blur(input, { relatedTarget: document.body }) + // Focus landing outside the dialog cancels like Escape — even when the + // departure happens from a row the user had Tabbed onto, not the input + // (the observer lives on the card scope, not the input). + fireEvent.focusOut(rowButton(screen.getByRole('listitem')), { relatedTarget: document.body }) expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + // Outside editing the card-scope observer is inert. + fireEvent.focusOut(screen.getByRole('button', { name: 'browser.showHidden' })) + expect(screen.getByRole('button', { name: 'browser.editPath' })).toBeTruthy() + }) + + it('Escape with focus on a filtered row collapses the editor, not the dialog', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: `${HOME}/do` } }) + // Tab parked focus on the result row; Escape must still mean "leave + // path editing", not "close the whole dialog". + const row = rowButton(screen.getByRole('listitem')) + fireEvent.keyDown(row, { key: 'Escape' }) + expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + expect(b.onClose).not.toHaveBeenCalled() + // With no draft left, Escape falls through to the Modal and closes. + fireEvent.keyDown(row, { key: 'Escape' }) + expect(b.onClose).toHaveBeenCalledTimes(1) }) it('a picked dot-revealed hidden row stays visible as the selection', async () => { @@ -340,6 +362,9 @@ describe('DirectoryBrowser', () => { fireEvent.mouseDown(row) fireEvent.click(row) expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + // Focus parks on the picked row (the editor's input just unmounted and + // the Modal has no focus trap to catch a fall to body). + expect(document.activeElement).toBe(row) await waitFor(() => { expect(columns()).toHaveLength(2) }) expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() }) @@ -373,7 +398,7 @@ describe('DirectoryBrowser', () => { const input = screen.getByLabelText('browser.editPath') fireEvent.change(input, { target: { value: '/somewhere/else' } }) // Focus moving anywhere outside the editor abandons the draft like Escape. - fireEvent.blur(input) + fireEvent.focusOut(input) expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() // The crumb view is back and the abandoned draft was never navigated to. expect(screen.getByRole('button', { name: 'browser.editPath' })).toBeTruthy() From 260ea24594613a00460c1e38a0dabf37579d8ba8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 15:21:56 +0800 Subject: [PATCH 12/72] fix(tools): harden persistent tool integrations --- docs/config-catalog.md | 12 +- docs/event-producer-consumer.md | 4 +- docs/module-graph.md | 4 +- examples/jsonrpc-agent/README.i18n.yaml | 6 +- examples/jsonrpc-agent/README.md | 13 + examples/jsonrpc-agent/README.zh.md | 13 + .../jsonrpc-agent/persistent-tools.cordis.yml | 58 ++++ .../tests/persistent-tools.snapshot.spec.ts | 214 +++++++++++++++ .../persistent-tools/behavior.expected.json | 57 ++++ examples/package.json | 2 + .../examples/agent-spine-demo/src/index.ts | 6 +- .../tool-str-replace-editor/README.i18n.yaml | 4 +- packages/fs/tool-str-replace-editor/README.md | 7 +- .../fs/tool-str-replace-editor/README.zh.md | 7 +- .../fs/tool-str-replace-editor/package.json | 6 + .../fs/tool-str-replace-editor/src/index.ts | 250 +++++++++++++++--- .../tests/tools.spec.ts | 131 ++++++++- .../fs/tool-str-replace-editor/tsconfig.json | 2 + .../pty/tool-bash-persistent/src/index.ts | 96 +++++-- .../tool-bash-persistent/tests/tools.spec.ts | 82 +++++- pnpm-lock.yaml | 18 ++ python/sdk-runtime/README.i18n.yaml | 4 +- python/sdk-runtime/README.md | 2 +- python/sdk-runtime/README.zh.md | 2 +- scripts/build-exe-for-python-sdk.ts | 4 +- scripts/smoke-python-runtime.py | 35 ++- 26 files changed, 927 insertions(+), 112 deletions(-) create mode 100644 examples/jsonrpc-agent/persistent-tools.cordis.yml create mode 100644 examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts create mode 100644 examples/jsonrpc-agent/tests/snapshots/persistent-tools/behavior.expected.json diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 1e3ed7b6ea..0e92e4e06e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -153,7 +153,11 @@ export interface Config { sessionTitle?: SessionTitleConfig /** Workspace-context loader controls with an explicit byte budget; set `false` for hermetic prompts. */ workspaceContext: workspaceContext.Config | false - /** Skill registry, local provider, and model-facing consumer config. */ + /** + * Skill registry, local provider, and model-facing consumer config. + * Skills use `enabled` because one nested config controls a provider stack; + * single model-tool plugins use `Config | false` to disable that one consumer. + */ skills?: SkillConfig /** Model-facing bash tool config, or false when another plugin owns `bash`. */ toolBash?: toolBash.Config | false @@ -1589,7 +1593,7 @@ export interface Config { } ``` -Source: [`packages/pty/tool-bash-persistent/src/index.ts:340`](../packages/pty/tool-bash-persistent/src/index.ts) +Source: [`packages/pty/tool-bash-persistent/src/index.ts:382`](../packages/pty/tool-bash-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` @@ -1762,10 +1766,12 @@ export interface Config { description?: string /** Require local absolute paths like the canonical editor contract (default true). */ requireAbsolutePath?: boolean + /** Expand tabs across the full file before each mutation, matching the canonical editor (default true). */ + expandTabsOnMutation?: boolean } ``` -Source: [`packages/fs/tool-str-replace-editor/src/index.ts:373`](../packages/fs/tool-str-replace-editor/src/index.ts) +Source: [`packages/fs/tool-str-replace-editor/src/index.ts:539`](../packages/fs/tool-str-replace-editor/src/index.ts) ## `@deepseek-ai/dsh-tool-subagent` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 63741d22a0..ba952ea8bb 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -26,9 +26,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | -| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | +| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | -| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | +| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 0c3b1f6280..ec2fbb2ccc 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -670,6 +670,8 @@ flowchart TD pkg_tool_fs_search --> pkg_tools pkg_tool_str_replace_editor --> pkg_fs pkg_tool_str_replace_editor --> pkg_invariants + pkg_tool_str_replace_editor --> pkg_sandbox + pkg_tool_str_replace_editor --> pkg_sandbox_policy pkg_tool_str_replace_editor --> pkg_tools pkg_tool_skill --> pkg_agent pkg_tool_skill --> pkg_invariants @@ -1095,7 +1097,7 @@ flowchart TD | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | +| [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | diff --git a/examples/jsonrpc-agent/README.i18n.yaml b/examples/jsonrpc-agent/README.i18n.yaml index 59c18e0131..1ff308b60f 100644 --- a/examples/jsonrpc-agent/README.i18n.yaml +++ b/examples/jsonrpc-agent/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -README.md: 6ee4e9d824315bde76b7a534679f018df9a6d3e8 -README.zh.md: dc9b6233e7074e7a9b13bf10bcd2f310b0ad7bf3 +# pnpm run verify-translation-pairing --write examples/jsonrpc-agent/README.md +README.md: 9a4c715e8988f52b647dbbd6b14a478cc1357d92 +README.zh.md: fa792f9cf6bdad4f32a980f478a131f19de97611 diff --git a/examples/jsonrpc-agent/README.md b/examples/jsonrpc-agent/README.md index 6ee4e9d824..9a4c715e89 100644 --- a/examples/jsonrpc-agent/README.md +++ b/examples/jsonrpc-agent/README.md @@ -25,3 +25,16 @@ The surrounding runtime also loads JSONL session persistence and automatic conte | `DSH_SYSTEM_PROMPT` | Deployment-provided coding persona | Pass the config path through the Python SDK's `cordis` option or `DSH_CORDIS_CONFIG`. The bundled executable already carries every plugin named by this file; the target machine does not need Node.js. + +## Persistent tools variant + +[`persistent-tools.cordis.yml`](persistent-tools.cordis.yml) is a minimal runnable variant whose model-facing surface is exactly: + +- owner-scoped persistent `bash` +- `str_replace_editor` with `view`, `create`, `str_replace`, and `insert` + +It composes the real local PTY, filesystem intent policy, and session sandbox policy. The keyless behavior snapshot drives the shipped JSON-RPC runtime through both tools and proves that shell cwd/environment survive across calls: + +```bash +pnpm exec vitest run examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts +``` diff --git a/examples/jsonrpc-agent/README.zh.md b/examples/jsonrpc-agent/README.zh.md index dc9b6233e7..fa792f9cf6 100644 --- a/examples/jsonrpc-agent/README.zh.md +++ b/examples/jsonrpc-agent/README.zh.md @@ -25,3 +25,16 @@ | `DSH_SYSTEM_PROMPT` | 由部署提供的编码人格 | 通过 Python SDK 的 `cordis` 选项或 `DSH_CORDIS_CONFIG` 传入配置路径。内置可执行文件已携带此文件命名的每个插件;目标机器无需 Node.js。 + +## 持久工具变体 + +[`persistent-tools.cordis.yml`](persistent-tools.cordis.yml) 是一个最小可运行变体,面向模型的能力严格只有: + +- agent 独占、状态持久的 `bash` +- 提供 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor` + +它组合真实本地 PTY、文件系统 intent 策略与 session 沙箱策略。无密钥行为快照会通过正式 JSON-RPC runtime 驱动这两个工具,并验证 shell 的 cwd 与环境变量能跨调用保留: + +```bash +pnpm exec vitest run examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts +``` diff --git a/examples/jsonrpc-agent/persistent-tools.cordis.yml b/examples/jsonrpc-agent/persistent-tools.cordis.yml new file mode 100644 index 0000000000..b5ae81b100 --- /dev/null +++ b/examples/jsonrpc-agent/persistent-tools.cordis.yml @@ -0,0 +1,58 @@ +# Minimal unattended composition for the persistent Bash and string-replace +# editor. It is runnable through the JSON-RPC example runtime and intentionally +# keeps the model-facing surface to exactly these two tools. + +- id: jsonrpc + name: '@deepseek-ai/dsh-jsonrpc' + +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL + +- id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + +- id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: danger-full-access + workspaceRoot: !!js process.env.DSH_CWD ?? process.cwd() + +- id: pty + name: '@deepseek-ai/dsh-pty' + +- id: pty-local + name: '@deepseek-ai/dsh-pty-local' + +- id: fs-sandbox + name: '@deepseek-ai/dsh-fs-sandbox' + config: + cwd: !!js process.env.DSH_CWD ?? process.cwd() + +- id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + +- id: agent-spine + name: '@deepseek-ai/dsh-agent-spine-demo' + config: + includeHarnessIdentity: false + persona: 'You are a helpful software engineer assistant.' + workspaceContext: false + skills: + enabled: false + toolBash: false + toolTasks: false + +- id: persistent-bash + name: '@deepseek-ai/dsh-tool-bash-persistent' + +- id: str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' + +- id: sessions + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions' + compression: none diff --git a/examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts b/examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts new file mode 100644 index 0000000000..18d1454837 --- /dev/null +++ b/examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts @@ -0,0 +1,214 @@ +import { createServer } from 'node:http' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' +import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client' + +const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)) +const configPath = fileURLToPath(new URL('../persistent-tools.cordis.yml', import.meta.url)) +const runtimeBin = fileURLToPath(new URL('../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url)) +const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +const expectedPath = fileURLToPath(new URL('./snapshots/persistent-tools/behavior.expected.json', import.meta.url)) + +interface ModelRequest { + messages?: Array> + tools?: Array<{ function?: { name?: string; parameters?: { required?: string[] } } }> +} + +function sseToolCall(id: string, name: string, args: Record): string[] { + return [ + 'data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n', + `data: ${JSON.stringify({ + choices: [{ + delta: { + tool_calls: [{ + index: 0, + id, + type: 'function', + function: { name, arguments: JSON.stringify(args) }, + }], + }, + }], + })}\n\n`, + 'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":3,"completion_tokens":3}}\n\n', + 'data: [DONE]\n\n', + ] +} + +function sseText(text: string): string[] { + return [ + 'data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n', + `data: ${JSON.stringify({ choices: [{ delta: { content: text } }] })}\n\n`, + 'data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":3}}\n\n', + 'data: [DONE]\n\n', + ] +} + +function messageText(content: unknown): string { + if (typeof content === 'string') return content + if (!Array.isArray(content)) return '' + return content.flatMap((block) => { + if (typeof block !== 'object' || block === null) return [] + const text = (block as { text?: unknown }).text + return typeof text === 'string' ? [text] : [] + }).join('') +} + +function latestToolCall(messages: Array>): { id: string; name: string } { + for (const message of messages.toReversed()) { + const calls = message.tool_calls + if (!Array.isArray(calls)) continue + const call = (calls as unknown[]).at(-1) + if (typeof call !== 'object' || call === null) continue + const id = (call as { id?: unknown }).id + const fn = (call as { function?: { name?: unknown } }).function + if (typeof id === 'string' && typeof fn?.name === 'string') return { id, name: fn.name } + } + throw new Error('model request has no preceding tool call') +} + +function normalize(value: string, cwd: string): string { + return value.replaceAll(cwd, '{{cwd}}') +} + +describe('jsonrpc persistent tools snapshot', () => { + it('runs persistent shell state and editor mutations keylessly', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-persistent-tools-')) + const sessionRoot = join(cwd, '.sessions') + const target = join(cwd, 'note.txt') + const requests: ModelRequest[] = [] + const modelServer = createServer((request, response) => { + let body = '' + request.setEncoding('utf8') + request.on('data', (chunk: string) => { body += chunk }) + request.on('end', () => { + const parsed = JSON.parse(body) as ModelRequest + requests.push(parsed) + const messages = parsed.messages ?? [] + const latest = messages.at(-1) + if (latest === undefined) throw new Error('model request has no messages') + let chunks: string[] + if (latest.role !== 'tool') { + chunks = sseToolCall('bash-1', 'bash', { + command: 'cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf "COUNT=%s CWD=%s\\n" "$DSH_EXAMPLE_COUNT" "$PWD"', + }) + } else { + const call = latestToolCall(messages) + const toolText = messageText(latest.content) + if (call.id === 'bash-1') { + expect(toolText).toContain('COUNT=1 CWD=/tmp') + chunks = sseToolCall('bash-2', 'bash', { + command: 'DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf "COUNT=%s CWD=%s\\n" "$DSH_EXAMPLE_COUNT" "$PWD"', + }) + } else if (call.id === 'bash-2') { + expect(toolText).toContain('COUNT=2 CWD=/tmp') + chunks = sseToolCall('editor-create', 'str_replace_editor', { + command: 'create', + path: target, + file_text: 'alpha\n', + }) + } else if (call.id === 'editor-create') { + expect(toolText).toContain('New file created successfully') + chunks = sseToolCall('editor-replace', 'str_replace_editor', { + command: 'str_replace', + path: target, + old_str: 'alpha', + new_str: 'beta', + }) + } else if (call.id === 'editor-replace') { + expect(toolText).toContain('has been edited successfully') + chunks = sseText('PERSISTENT_TOOLS_OK') + } else { + throw new Error(`unexpected tool call ${call.id}`) + } + } + response.writeHead(200, { 'content-type': 'text/event-stream' }) + for (const chunk of chunks) response.write(chunk) + response.end() + }) + }) + await new Promise(resolve => modelServer.listen(0, '127.0.0.1', resolve)) + const address = modelServer.address() + if (address === null || typeof address === 'string') throw new Error('model server did not bind') + const launch = resolveExampleLaunch({ + srcBin: runtimeBin, + configArgs: [], + tsconfigPath: repoTsconfig, + }) + const harness = new DeepSeekHarness({ + launch: { + command: launch.command, + args: launch.args, + cwd: repoRoot, + env: { + ...Object.fromEntries(Object.entries(process.env).filter(([, value]) => value !== undefined)) as Record, + ...Object.fromEntries(Object.entries(launch.env).filter(([, value]) => value !== undefined)) as Record, + DSH_CORDIS_CONFIG: configPath, + DSH_CWD: cwd, + DSH_SESSION_ROOT: sessionRoot, + DEEPSEEK_API_KEY: 'keyless-local-mock', + DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + }, + requestTimeoutMs: 60_000, + }, + cwd, + provider: 'deepseek', + model: 'deepseek-v4-flash', + }) + + try { + const result = await harness.run( + 'Prove that bash state persists, then create and edit note.txt.', + { sessionId: 'persistent-tools-snapshot' }, + ) + const calls = result.events.flatMap((event) => { + if (event.type !== 'tool/call') return [] + return [{ + name: event.data.name, + arguments: normalize(event.data.arguments, cwd), + }] + }) + const results = result.events.flatMap((event) => { + if (event.type !== 'tool/result') return [] + return event.data.message.content.flatMap((block) => { + if (block.type !== 'tool-result') return [] + return block.content.flatMap(content => + content.type === 'text' + ? [{ text: normalize(content.text, cwd) }] + : []) + }) + }) + const tools = (requests[0]?.tools ?? []).map(tool => ({ + name: tool.function?.name, + required: tool.function?.parameters?.required ?? [], + })).sort((left, right) => { + const leftName = String(left.name) + const rightName = String(right.name) + return leftName < rightName ? -1 : leftName > rightName ? 1 : 0 + }) + const behavior = { + tools, + calls, + results, + final: { + status: result.status, + reason: result.reason, + response: result.finalResponse, + file: await readFile(target, 'utf8'), + }, + } + if (process.env.DSH_SNAPSHOT === 'refresh') { + await writeFile(expectedPath, `${JSON.stringify(behavior, null, 2)}\n`) + } + expect(behavior).toEqual(JSON.parse(await readFile(expectedPath, 'utf8'))) + } finally { + await harness.close() + await new Promise(resolve => modelServer.close(() => { resolve() })) + await rm(cwd, { recursive: true, force: true }) + } + }, 75_000) +}) diff --git a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/behavior.expected.json b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/behavior.expected.json new file mode 100644 index 0000000000..18b54e9312 --- /dev/null +++ b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/behavior.expected.json @@ -0,0 +1,57 @@ +{ + "tools": [ + { + "name": "bash", + "required": [ + "command" + ] + }, + { + "name": "str_replace_editor", + "required": [ + "command", + "path" + ] + } + ], + "calls": [ + { + "name": "bash", + "arguments": "{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}" + }, + { + "name": "bash", + "arguments": "{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}" + }, + { + "name": "str_replace_editor", + "arguments": "{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}" + }, + { + "name": "str_replace_editor", + "arguments": "{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}" + } + ], + "results": [ + { + "text": "COUNT=1 CWD=/tmp" + }, + { + "text": "COUNT=2 CWD=/tmp" + }, + { + "text": "New file created successfully at: {{cwd}}/note.txt" + }, + { + "text": "The file {{cwd}}/note.txt has been edited successfully." + } + ], + "final": { + "status": "ok", + "reason": { + "kind": "completed" + }, + "response": "PERSISTENT_TOOLS_OK", + "file": "beta\n" + } +} diff --git a/examples/package.json b/examples/package.json index 51fc48b8fa..d98371a5ef 100644 --- a/examples/package.json +++ b/examples/package.json @@ -56,6 +56,7 @@ "@deepseek-ai/dsh-timeout-policy": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", "@deepseek-ai/dsh-tool-ask-user": "workspace:*", + "@deepseek-ai/dsh-tool-bash-persistent": "workspace:*", "@deepseek-ai/dsh-tool-cordis": "workspace:*", "@deepseek-ai/dsh-tool-fs": "workspace:*", "@deepseek-ai/dsh-tool-fs-search": "workspace:*", @@ -64,6 +65,7 @@ "@deepseek-ai/dsh-tool-pty": "workspace:*", "@deepseek-ai/dsh-tool-ralph": "workspace:*", "@deepseek-ai/dsh-tool-session-query": "workspace:*", + "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:*", "@deepseek-ai/dsh-tool-subagent": "workspace:*", "@deepseek-ai/dsh-tool-todo": "workspace:*", "@deepseek-ai/dsh-tool-web": "workspace:*", diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index cfa5ac3ccd..32e348bf75 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -105,7 +105,11 @@ export interface Config { sessionTitle?: SessionTitleConfig /** Workspace-context loader controls with an explicit byte budget; set `false` for hermetic prompts. */ workspaceContext: workspaceContext.Config | false - /** Skill registry, local provider, and model-facing consumer config. */ + /** + * Skill registry, local provider, and model-facing consumer config. + * Skills use `enabled` because one nested config controls a provider stack; + * single model-tool plugins use `Config | false` to disable that one consumer. + */ skills?: SkillConfig /** Model-facing bash tool config, or false when another plugin owns `bash`. */ toolBash?: toolBash.Config | false diff --git a/packages/fs/tool-str-replace-editor/README.i18n.yaml b/packages/fs/tool-str-replace-editor/README.i18n.yaml index 1f72b1a211..10b1f19182 100644 --- a/packages/fs/tool-str-replace-editor/README.i18n.yaml +++ b/packages/fs/tool-str-replace-editor/README.i18n.yaml @@ -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/fs/tool-str-replace-editor/README.md -README.md: 2d98b51d5651cbc72ab8b2055d8e70a43d98157b -README.zh.md: a2ee8f1e3661044c0869ae91af6ceedb2dd8da1d +README.md: 8ac6a22f24ddcdd3818b346e3426e58e718027e2 +README.zh.md: cf82b132b63730af209c159066a70f6a18b77f39 diff --git a/packages/fs/tool-str-replace-editor/README.md b/packages/fs/tool-str-replace-editor/README.md index 2d98b51d56..8ac6a22f24 100644 --- a/packages/fs/tool-str-replace-editor/README.md +++ b/packages/fs/tool-str-replace-editor/README.md @@ -11,6 +11,7 @@ Standalone model-facing `str_replace_editor` over `ctx.fs`. It can be composed w | `maxOutputChars` | `16000` | Prefix characters retained for file and directory views. | | `description` | Editor command guide | Model-facing tool description. | | `requireAbsolutePath` | `true` | Reject relative paths; disable only for deployments with a deliberate session-cwd contract. | +| `expandTabsOnMutation` | `true` | Preserve the canonical Claude SWE behavior that expands tabs across the whole file before replace/insert. Set `false` for atomic literal replacement that preserves unrelated tabs. | ## Tool @@ -36,7 +37,7 @@ Prefix-stable while the configured description and schema remain unchanged. #### What the model sees -Views return numbered text or a shallow directory listing. Mutations return concise confirmations. Long views keep their prefix and append a clipping notice. +Views return numbered text or a shallow directory listing. Calls expose file locations, and create/replace calls expose diff cards to presentation surfaces. Mutations return concise confirmations. Long views keep their prefix and append a clipping notice. #### Token effect @@ -50,5 +51,5 @@ Append-only tool results follow the reusable request prefix. - Operations target UTF-8 text; binary files are unsupported. - `str_replace` intentionally rejects zero or multiple matches and has no `replace_all` argument. -- Canonical mode expands tabs before replacement or insertion, matching the reference string-replacement editor. -- The package delegates security and read-before-edit policy to the mounted filesystem and policy plugins. +- Canonical mode (`expandTabsOnMutation: true`) expands tabs in the entire file before replacement or insertion, including lines outside the edited region. Set it to `false` for Makefiles and other tab-sensitive files. +- Every mutation goes through `fs/write-intent` or `fs/edit-intent`, resolves the current session sandbox policy, and delegates enforcement to the mounted filesystem and policy plugins. diff --git a/packages/fs/tool-str-replace-editor/README.zh.md b/packages/fs/tool-str-replace-editor/README.zh.md index a2ee8f1e36..cf82b132b6 100644 --- a/packages/fs/tool-str-replace-editor/README.zh.md +++ b/packages/fs/tool-str-replace-editor/README.zh.md @@ -11,6 +11,7 @@ | `maxOutputChars` | `16000` | 文件和目录查看结果保留的前缀字符数。 | | `description` | 编辑器命令指南 | 面向模型的工具描述。 | | `requireAbsolutePath` | `true` | 拒绝相对路径;仅当部署明确约定 session cwd 时才应关闭。 | +| `expandTabsOnMutation` | `true` | 保留 Claude SWE 参考行为:替换/插入前展开整个文件的制表符。设为 `false` 时使用原子字面量替换,并保留未触及的制表符。 | ## 工具 @@ -36,7 +37,7 @@ Schema 提供 `view`、`create`、`str_replace` 与 `insert`。文件查看使 #### 模型所见 -查看操作返回带行号文本或浅层目录列表。修改操作返回简洁确认。长查看结果保留前缀并追加截断提示。 +查看操作返回带行号文本或浅层目录列表。调用会向展示层提供文件位置,创建/替换还会提供 diff 卡片。修改操作返回简洁确认。长查看结果保留前缀并追加截断提示。 #### Token 影响 @@ -50,5 +51,5 @@ Schema 提供 `view`、`create`、`str_replace` 与 `insert`。文件查看使 - 操作面向 UTF-8 文本,不支持二进制文件。 - `str_replace` 刻意拒绝零匹配或多匹配,且没有 `replace_all` 参数。 -- 规范模式会在替换或插入前展开制表符,与参考字符串替换编辑器保持一致。 -- 安全与先读后改策略委托给挂载的文件系统和策略插件。 +- 规范模式(`expandTabsOnMutation: true`)会在替换或插入前展开整个文件中的制表符,包括未编辑区域。Makefile 等依赖制表符的文件应设为 `false`。 +- 每个修改操作都会经过 `fs/write-intent` 或 `fs/edit-intent`,解析当前 session 的沙箱策略,并交由挂载的文件系统与策略插件执行。 diff --git a/packages/fs/tool-str-replace-editor/package.json b/packages/fs/tool-str-replace-editor/package.json index 88d3af8d53..0fc6e54ec6 100644 --- a/packages/fs/tool-str-replace-editor/package.json +++ b/packages/fs/tool-str-replace-editor/package.json @@ -28,6 +28,8 @@ "peerDependencies": { "@deepseek-ai/dsh-fs": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-sandbox": "^0.0.1", + "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -38,8 +40,12 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-fs-policy": "workspace:^", + "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/fs/tool-str-replace-editor/src/index.ts b/packages/fs/tool-str-replace-editor/src/index.ts index c4a16e5437..5d9855c1da 100644 --- a/packages/fs/tool-str-replace-editor/src/index.ts +++ b/packages/fs/tool-str-replace-editor/src/index.ts @@ -7,9 +7,12 @@ import { isAbsolute } from 'node:path' import type { Context } from 'cordis' import z from 'schemastery' import { FsError } from '@deepseek-ai/dsh-fs' -import type { FsInfo, FsTarget } from '@deepseek-ai/dsh-fs' +import type { FsInfo, FsTarget, FsWriteIntent } from '@deepseek-ai/dsh-fs' +import { sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox' +import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' +import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { defineTool } from '@deepseek-ai/dsh-tools' -import type { ToolRunContext } from '@deepseek-ai/dsh-tools' +import type { ToolCallView, ToolRunContext } from '@deepseek-ai/dsh-tools' const TRUNCATED_MESSAGE = 'To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.' @@ -49,17 +52,68 @@ function expandTabs(content: string, tabSize = 8): string { return result } +function codepointCompare(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0 +} + +function matchOffsets(content: string, search: string): number[] { + const offsets: number[] = [] + let offset = 0 + while (true) { + const match = content.indexOf(search, offset) + if (match < 0) return offsets + offsets.push(match) + offset = match + search.length + } +} + +function lineNumbersAt(content: string, offsets: readonly number[]): number[] { + let line = 1 + let cursor = 0 + return offsets.map((offset) => { + while (cursor < offset) { + if (content[cursor] === '\n') line += 1 + cursor += 1 + } + return line + }) +} + +class MutationPolicy { + private readonly policy: SandboxPolicyService | undefined + + constructor(ctx: Context) { + this.policy = ctx.fs.sandboxMode === undefined ? undefined : ctx.get('sandboxPolicy') + if (ctx.fs.sandboxMode !== undefined && this.policy === undefined) { + throw new Error('tool-str-replace-editor: the mounted filesystem confines but ctx.sandboxPolicy is missing') + } + } + + resolve(exec: ToolRunContext): SandboxExecutionPolicy | undefined { + return this.policy?.resolve({ + ...exec.agent === undefined ? {} : { session: exec.agent.session }, + }) + } + + mapError(error: unknown, policy: SandboxExecutionPolicy | undefined): unknown { + if (!(error instanceof FsError) || error.code !== 'FS_SANDBOX_DENIED') return error + const mode = (policy as SandboxExecutionPolicy).mode + return new FsError(sandboxDenialMarker(mode), 'FS_SANDBOX_DENIED', { cause: error }) + } +} + async function resolveTarget( ctx: Context, path: string, requireAbsolutePath: boolean, exec: ToolRunContext, + workspaceRoot?: string, ): Promise { if (path.trim().length === 0) throw new Error('path must be a non-empty string') if (requireAbsolutePath && !isAbsolute(path)) { throw new Error(`The path ${path} is not an absolute path, it should start with \`/\`. Maybe you meant /${path}?`) } - const cwd = exec.agent?.session.header.cwd + const cwd = exec.agent?.session.header.cwd ?? workspaceRoot return ctx.fs.resolve(path, cwd === undefined ? { signal: exec.signal } : { cwd, signal: exec.signal }) } @@ -158,8 +212,8 @@ async function listDirectory( const rows: string[] = [] for (const entry of entries.filter(candidate => !candidate.name.startsWith('.') - && !candidate.name.startsWith('node_modules') - && !candidate.name.startsWith('__pycache__'))) { + && candidate.name !== 'node_modules' + && candidate.name !== '__pycache__')) { const type = entry.type === 'directory' ? 'd' : entry.type === 'file' ? 'f' : '?' rows.push(`${type}\t${entry.target.displayPath}`) if (entry.type === 'directory' && depth < 2) { @@ -172,7 +226,7 @@ async function listDirectory( rows.sort((left, right) => { const leftPath = left.slice(left.indexOf('\t') + 1) const rightPath = right.slice(right.indexOf('\t') + 1) - return leftPath.localeCompare(rightPath) + return codepointCompare(leftPath, rightPath) }) const listing = maybeTruncate(rows.join('\n') + '\n', maxOutputChars) return `Here're the files and directories up to 2 levels deep in ${target.displayPath}, excluding hidden items, node_modules, and Python cache directories:\n${listing}\n` @@ -204,78 +258,124 @@ async function viewPath( async function createFile( ctx: Context, + policy: MutationPolicy, path: string, fileText: string | undefined, requireAbsolutePath: boolean, exec: ToolRunContext, ): Promise { const content = requiredForCommand(fileText, 'file_text', 'create') - const target = await resolveTarget(ctx, path, requireAbsolutePath, exec) + const sandboxPolicy = policy.resolve(exec) + const target = await resolveTarget(ctx, path, requireAbsolutePath, exec, sandboxPolicy?.workspaceRoot) if (await ctx.fs.stat(target, exec.signal) !== undefined) { throw new Error(`File already exists at: ${target.displayPath}. Cannot overwrite files using command \`create\`.`) } - const outcome = await ctx.fs.writeText(target, content, { kind: 'createIfAbsent' }, exec.signal) + const intent = await ctx.waterfall( + 'fs/write-intent', + target, + exec, + () => ({ kind: 'createIfAbsent' } as const), + ) + let outcome + try { + outcome = await ctx.fs.writeText( + target, + content, + intent ?? { kind: 'createIfAbsent' }, + exec.signal, + sandboxPolicy, + ) + } catch (error: unknown) { + throw policy.mapError(error, sandboxPolicy) + } ctx.emit('fs/observed', target, outcome.version, exec) return `New file created successfully at: ${target.displayPath}` } async function replaceInFile( ctx: Context, + policy: MutationPolicy, path: string, oldStr: string | undefined, newStr: string | undefined, requireAbsolutePath: boolean, + expandTabsOnMutation: boolean, exec: ToolRunContext, ): Promise { - const target = await resolveTarget(ctx, path, requireAbsolutePath, exec) - const oldValue = expandTabs(requiredForCommand(oldStr, 'old_str', 'str_replace', false)) - const newValue = expandTabs(newStr ?? '') + const sandboxPolicy = policy.resolve(exec) + const target = await resolveTarget(ctx, path, requireAbsolutePath, exec, sandboxPolicy?.workspaceRoot) + const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined) + const rawOldValue = requiredForCommand(oldStr, 'old_str', 'str_replace', false) + const oldValue = expandTabsOnMutation ? expandTabs(rawOldValue) : rawOldValue + const newValue = expandTabsOnMutation ? expandTabs(newStr ?? '') : newStr ?? '' const info = await statExisting(ctx, target, 'str_replace', exec) if (info.type !== 'file') { throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') } - const before = expandTabs(await ctx.fs.readText(target, exec.signal)) - const occurrences = before.split(oldValue).length - 1 - if (occurrences === 0) { + const rawBefore = await ctx.fs.readText(target, exec.signal) + const before = expandTabsOnMutation ? expandTabs(rawBefore) : rawBefore + const offsets = matchOffsets(before, oldValue) + if (offsets.length === 0) { throw new FsError( `No replacement was performed, old_str \`${oldValue}\` did not appear verbatim in ${target.displayPath}.`, 'FS_EDIT_NOT_FOUND', ) } - if (occurrences > 1) { - const lines = before.split('\n') - .flatMap((line, index) => line.includes(oldValue) ? [index + 1] : []) + if (offsets.length > 1) { + const lines = lineNumbersAt(before, offsets) throw new FsError( `No replacement was performed. Multiple occurrences of old_str \`${oldValue}\` in lines [${lines.join(', ')}]. Please ensure it is unique`, 'FS_AMBIGUOUS_EDIT', ) } - const outcome = await ctx.fs.writeText( - target, - before.replace(oldValue, newValue), - { kind: 'replaceIfVersion', version: info.version }, - exec.signal, - ) + let outcome + try { + outcome = expandTabsOnMutation + ? await ctx.fs.writeText( + target, + before.replace(oldValue, newValue), + intent === undefined + ? { kind: 'replaceIfVersion', version: info.version } + : { kind: 'replaceIfVersion', version: intent.version }, + exec.signal, + sandboxPolicy, + ) + : await ctx.fs.editText( + target, + { oldString: oldValue, newString: newValue, replaceAll: false }, + intent ?? { version: info.version }, + exec.signal, + sandboxPolicy, + ) + } catch (error: unknown) { + throw policy.mapError(error, sandboxPolicy) + } ctx.emit('fs/observed', target, outcome.version, exec) return `The file ${target.displayPath} has been edited successfully.` } async function insertInFile( ctx: Context, + policy: MutationPolicy, path: string, insertLine: number | undefined, newStr: string | undefined, requireAbsolutePath: boolean, + expandTabsOnMutation: boolean, exec: ToolRunContext, ): Promise { if (insertLine === undefined) throw new Error('Parameter `insert_line` is required for command: insert') - const value = expandTabs(requiredForCommand(newStr, 'new_str', 'insert')) - const target = await resolveTarget(ctx, path, requireAbsolutePath, exec) + const rawValue = requiredForCommand(newStr, 'new_str', 'insert') + const value = expandTabsOnMutation ? expandTabs(rawValue) : rawValue + const sandboxPolicy = policy.resolve(exec) + const target = await resolveTarget(ctx, path, requireAbsolutePath, exec, sandboxPolicy?.workspaceRoot) + const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined) const info = await statExisting(ctx, target, 'insert', exec) if (info.type !== 'file') { throw new FsError(`cannot insert into "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') } - const before = expandTabs(await ctx.fs.readText(target, exec.signal)) + const rawBefore = await ctx.fs.readText(target, exec.signal) + const before = expandTabsOnMutation ? expandTabs(rawBefore) : rawBefore const lines = before.split('\n') if (!Number.isInteger(insertLine) || insertLine < 0 || insertLine > lines.length) { throw new Error( @@ -287,12 +387,15 @@ async function insertInFile( ...value.split('\n'), ...lines.slice(insertLine), ].join('\n') - const outcome = await ctx.fs.writeText( - target, - after, - { kind: 'replaceIfVersion', version: info.version }, - exec.signal, - ) + const expected: FsWriteIntent = intent === undefined + ? { kind: 'replaceIfVersion', version: info.version } + : { kind: 'replaceIfVersion', version: intent.version } + let outcome + try { + outcome = await ctx.fs.writeText(target, after, expected, exec.signal, sandboxPolicy) + } catch (error: unknown) { + throw policy.mapError(error, sandboxPolicy) + } ctx.emit('fs/observed', target, outcome.version, exec) return `The file ${target.displayPath} has been edited successfully.` } @@ -301,10 +404,59 @@ interface ResolvedConfig { maxOutputChars: number description: string requireAbsolutePath: boolean + expandTabsOnMutation: boolean +} + +function presentEditorCall(args: { + command: 'view' | 'create' | 'str_replace' | 'insert' + path: string + file_text?: string + insert_line?: number + new_str?: string + old_str?: string +}): ToolCallView { + switch (args.command) { + case 'view': + return { + card: 'generic', + title: `view ${args.path}`, + kind: 'read', + locations: [{ path: args.path }], + } + case 'create': + return { + card: 'diff', + title: `create ${args.path}`, + diffs: [{ path: args.path, oldText: null, newText: args.file_text ?? '' }], + locations: [{ path: args.path }], + } + case 'str_replace': + return { + card: 'diff', + title: `str_replace ${args.path}`, + diffs: [{ + path: args.path, + oldText: args.old_str ?? null, + newText: args.new_str ?? '', + }], + locations: [{ path: args.path }], + } + case 'insert': + return { + card: 'generic', + title: `insert ${args.path}`, + kind: 'edit', + locations: [{ + path: args.path, + ...args.insert_line === undefined ? {} : { line: Math.max(1, args.insert_line + 1) }, + }], + } + } } /** Register the model-facing `str_replace_editor` tool. */ function registerStrReplaceEditor(ctx: Context, config: ResolvedConfig): void { + const policy = new MutationPolicy(ctx) ctx.tools.register(defineTool({ name: 'str_replace_editor', description: config.description, @@ -351,18 +503,32 @@ function registerStrReplaceEditor(ctx: Context, config: ResolvedConfig): void { case 'view': return viewPath(ctx, args.path, args.view_range, config.maxOutputChars, config.requireAbsolutePath, exec) case 'create': - return createFile(ctx, args.path, args.file_text, config.requireAbsolutePath, exec) + return createFile(ctx, policy, args.path, args.file_text, config.requireAbsolutePath, exec) case 'str_replace': - return replaceInFile(ctx, args.path, args.old_str, args.new_str, config.requireAbsolutePath, exec) + return replaceInFile( + ctx, + policy, + args.path, + args.old_str, + args.new_str, + config.requireAbsolutePath, + config.expandTabsOnMutation, + exec, + ) case 'insert': - return insertInFile(ctx, args.path, args.insert_line, args.new_str, config.requireAbsolutePath, exec) + return insertInFile( + ctx, + policy, + args.path, + args.insert_line, + args.new_str, + config.requireAbsolutePath, + config.expandTabsOnMutation, + exec, + ) } }, - presentCall: args => ({ - card: 'generic', - title: `${args.command} ${args.path}`, - kind: args.command === 'view' ? 'read' : 'edit', - }), + presentCall: presentEditorCall, })) } @@ -377,6 +543,8 @@ export interface Config { description?: string /** Require local absolute paths like the canonical editor contract (default true). */ requireAbsolutePath?: boolean + /** Expand tabs across the full file before each mutation, matching the canonical editor (default true). */ + expandTabsOnMutation?: boolean } /** Runtime configuration schema for the string-replacement editor tool. */ @@ -384,6 +552,7 @@ export const Config: z = z.object({ maxOutputChars: z.number().default(16_000), description: z.string().default(DEFAULT_DESCRIPTION), requireAbsolutePath: z.boolean().default(true), + expandTabsOnMutation: z.boolean().default(true), }) /** Register one `str_replace_editor` tool over `ctx.fs`. */ @@ -392,6 +561,7 @@ export function apply(ctx: Context, config: Config): void { maxOutputChars: config.maxOutputChars ?? 16_000, description: config.description ?? DEFAULT_DESCRIPTION, requireAbsolutePath: config.requireAbsolutePath ?? true, + expandTabsOnMutation: config.expandTabsOnMutation ?? true, } if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) { throw new Error('tool-str-replace-editor: maxOutputChars must be a positive safe integer') diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 7cf9ba5212..213161e147 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -9,6 +9,9 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' +import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' +import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox' +import SandboxPolicy from '@deepseek-ai/dsh-sandbox-policy' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import * as ToolStrReplaceEditor from '@deepseek-ai/dsh-tool-str-replace-editor' @@ -57,7 +60,10 @@ function call(ctx: Context, owner: Agent | undefined, args: unknown) { }) } -async function setup(config: ToolStrReplaceEditor.Config = {}) { +async function setup( + config: ToolStrReplaceEditor.Config = {}, + options: { fsPolicy?: boolean; sandboxMode?: 'read-only' | 'workspace-write' | 'danger-full-access' } = {}, +) { const root = await mkdtemp(join(tmpdir(), 'dsh-tool-str-replace-editor-')) roots.push(root) const ctx = new Context() @@ -65,7 +71,13 @@ async function setup(config: ToolStrReplaceEditor.Config = {}) { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalFileSystem, { cwd: root }) + if (options.sandboxMode === undefined) { + await ctx.plugin(LocalFileSystem, { cwd: root }) + } else { + await ctx.plugin(SandboxPolicy, { mode: options.sandboxMode, workspaceRoot: root }) + await ctx.plugin(SandboxedFileSystem, { cwd: root }) + } + if (options.fsPolicy === true) await ctx.plugin(FsPolicy) await ctx.plugin(ToolStrReplaceEditor, config) return { ctx, root, owner: agent(ctx, root) } } @@ -85,13 +97,38 @@ describe('tool-str-replace-editor', () => { expect(ctx.tools.get('str_replace_editor')?.presentCall?.({ command: 'view', path: '/workspace/a.txt', - })).toMatchObject({ card: 'generic', kind: 'read' }) + })).toMatchObject({ + card: 'generic', + kind: 'read', + locations: [{ path: '/workspace/a.txt' }], + }) + expect(ctx.tools.get('str_replace_editor')?.presentCall?.({ + command: 'create', + path: '/workspace/a.txt', + file_text: 'hello', + })).toMatchObject({ + card: 'diff', + diffs: [{ path: '/workspace/a.txt', oldText: null, newText: 'hello' }], + }) + expect(ctx.tools.get('str_replace_editor')?.presentCall?.({ + command: 'str_replace', + path: '/workspace/a.txt', + old_str: 'old', + new_str: 'new', + })).toMatchObject({ + card: 'diff', + diffs: [{ path: '/workspace/a.txt', oldText: 'old', newText: 'new' }], + }) expect(ctx.tools.get('str_replace_editor')?.presentCall?.({ command: 'insert', path: '/workspace/a.txt', insert_line: 0, new_str: 'x', - })).toMatchObject({ card: 'generic', kind: 'edit' }) + })).toMatchObject({ + card: 'generic', + kind: 'edit', + locations: [{ path: '/workspace/a.txt', line: 1 }], + }) }) it('creates, views, replaces, and inserts with the canonical model-facing output', async () => { @@ -136,16 +173,20 @@ describe('tool-str-replace-editor', () => { }) it('lists visible entries to depth two and clips at the configured view limit', async () => { - const { ctx, root, owner } = await setup({ maxOutputChars: 10 }) + const { ctx, root, owner } = await setup({ maxOutputChars: 10_000 }) await mkdir(join(root, 'dir', 'nested', 'third'), { recursive: true }) await mkdir(join(root, 'dir', 'node_modules', 'pkg'), { recursive: true }) + await mkdir(join(root, 'dir', 'node_modules_old'), { recursive: true }) await mkdir(join(root, 'dir', '__pycache__'), { recursive: true }) + await mkdir(join(root, 'dir', '__pycache__backup'), { recursive: true }) await writeFile(join(root, 'dir', 'visible.txt'), 'ok') await writeFile(join(root, 'dir', '.hidden'), 'hidden') await writeFile(join(root, 'dir', 'nested', 'child.txt'), 'child') await writeFile(join(root, 'dir', 'nested', 'third', 'too-deep.txt'), 'deep') await writeFile(join(root, 'dir', 'node_modules', 'pkg', 'index.js'), 'hidden dependency') + await writeFile(join(root, 'dir', 'node_modules_old', 'kept.js'), 'visible source') await writeFile(join(root, 'dir', '__pycache__', 'module.pyc'), 'cache') + await writeFile(join(root, 'dir', '__pycache__backup', 'kept.py'), 'visible source') const listDir = ctx.fs.listDir.bind(ctx.fs) const otherTarget = await ctx.fs.resolve(join(root, 'dir', 'other')) ctx.fs.listDir = async (target, signal) => { @@ -156,14 +197,19 @@ describe('tool-str-replace-editor', () => { } const listing = text(await call(ctx, owner, { command: 'view', path: join(root, 'dir') })) - expect(listing).toContain('') expect(listing).not.toContain('.hidden') expect(listing).not.toContain('too-deep.txt') expect(listing).not.toContain('index.js') expect(listing).not.toContain('module.pyc') + expect(listing).toContain('node_modules_old/kept.js') + expect(listing).toContain('__pycache__backup/kept.py') - await writeFile(join(root, 'large.txt'), 'x'.repeat(100)) - expect(text(await call(ctx, owner, { command: 'view', path: join(root, 'large.txt') }))) + const clipped = await setup({ maxOutputChars: 10 }) + await writeFile(join(clipped.root, 'large.txt'), 'x'.repeat(100)) + expect(text(await call(clipped.ctx, clipped.owner, { + command: 'view', + path: join(clipped.root, 'large.txt'), + }))) .toContain('') }) @@ -233,10 +279,20 @@ describe('tool-str-replace-editor', () => { expect(text(repeated)).toContain('Multiple occurrences of old_str `same` in lines [1, 3]') expect(text(repeated)).not.toContain('replace_all') + await writeFile(ambiguous, 'alpha\nbeta\nmiddle\nalpha\nbeta') + const repeatedMultiline = await call(ctx, owner, { + command: 'str_replace', + path: ambiguous, + old_str: 'alpha\nbeta', + new_str: 'x', + }) + expect(text(repeatedMultiline)) + .toContain('Multiple occurrences of old_str `alpha\nbeta` in lines [1, 4]') + const relative = await call(ctx, owner, { command: 'view', path: 'ambiguous.txt' }) expect(relative.isError).toBe(true) expect(text(relative)).toContain('is not an absolute path') - expect(await readFile(ambiguous, 'utf8')).toBe('same\nother\nsame') + expect(await readFile(ambiguous, 'utf8')).toBe('alpha\nbeta\nmiddle\nalpha\nbeta') }) it('reports invalid commands or arguments without mutating files', async () => { @@ -302,6 +358,63 @@ describe('tool-str-replace-editor', () => { .toContain("Here's the content of") }) + it('delegates read-before-edit decisions to fs-policy', async () => { + const { ctx, root, owner } = await setup({}, { fsPolicy: true }) + const existing = join(root, 'existing.txt') + const created = join(root, 'created.txt') + await writeFile(existing, 'before') + + const blindEdit = await call(ctx, owner, { + command: 'str_replace', + path: existing, + old_str: 'before', + new_str: 'after', + }) + expect(blindEdit.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } }) + expect(await readFile(existing, 'utf8')).toBe('before') + + await call(ctx, owner, { command: 'view', path: existing }) + expect((await call(ctx, owner, { + command: 'str_replace', + path: existing, + old_str: 'before', + new_str: 'after', + })).isError).toBe(false) + expect(await readFile(existing, 'utf8')).toBe('after') + + expect((await call(ctx, owner, { + command: 'create', + path: created, + file_text: 'new', + })).isError).toBe(false) + expect(await readFile(created, 'utf8')).toBe('new') + }) + + it('passes the session sandbox policy to every mutation', async () => { + const { ctx, root, owner } = await setup({}, { sandboxMode: 'read-only' }) + const path = join(root, 'blocked.txt') + const result = await call(ctx, owner, { + command: 'create', + path, + file_text: 'blocked', + }) + expect(result.error).toMatchObject({ info: { code: 'FS_SANDBOX_DENIED' } }) + expect(text(result)).toContain('[sandbox: file access denied under read-only mode]') + }) + + it('can preserve tabs outside the edited region', async () => { + const { ctx, root, owner } = await setup({ expandTabsOnMutation: false }) + const path = join(root, 'Makefile') + await writeFile(path, 'target:\n\told\n') + await call(ctx, owner, { + command: 'str_replace', + path, + old_str: 'old', + new_str: 'new', + }) + expect(await readFile(path, 'utf8')).toBe('target:\n\tnew\n') + }) + it('rejects invalid plugin config', () => { expect(() => { ToolStrReplaceEditor.apply(new Context(), { maxOutputChars: 0 }) diff --git a/packages/fs/tool-str-replace-editor/tsconfig.json b/packages/fs/tool-str-replace-editor/tsconfig.json index 2c6eb3688c..6ee0dc19eb 100644 --- a/packages/fs/tool-str-replace-editor/tsconfig.json +++ b/packages/fs/tool-str-replace-editor/tsconfig.json @@ -9,6 +9,8 @@ { "path": "../../../vendor/cordis" }, { "path": "../../core/tools" }, { "path": "../fs" }, + { "path": "../../sandbox/sandbox" }, + { "path": "../../sandbox/sandbox-policy" }, { "path": "../../support/invariants" } ] } diff --git a/packages/pty/tool-bash-persistent/src/index.ts b/packages/pty/tool-bash-persistent/src/index.ts index b812fe8992..8ba51388cf 100644 --- a/packages/pty/tool-bash-persistent/src/index.ts +++ b/packages/pty/tool-bash-persistent/src/index.ts @@ -16,7 +16,10 @@ const LOST_PREFIX_MESSAGE = 'The beginning of this comma const SHELL_RESET_MESSAGE = 'The persistent bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment.' const SHELL_PROMPT = '__DSH_PERSISTENT_BASH_PROMPT__ ' const TIMEOUT_CODE = 'PERSISTENT_BASH_TIMEOUT' +// One page is enough to find a just-emitted completion marker; the full +// scrollback is assembled only when a command settles or needs partial output. const SCROLLBACK_PAGE_LINES = 1_000 +const POLL_INTERVAL_MS = 25 const DEFAULT_DESCRIPTION = 'Run commands in a persistent bash shell. State, including the current directory and exported environment variables, persists across calls for this agent.' @@ -101,7 +104,7 @@ function commandOutput( const start = startMarker < 0 ? 0 : startMarker + marker.start.length return { text: stripPrompt(text.slice(start, end).replace(/^\r?\n/, '')), - incomplete: startMarker < 0 || snapshot.truncated, + incomplete: startMarker < 0, } } @@ -115,22 +118,29 @@ function partialOutput( snapshot: RetainedOutput, marker: CommandMarkers, fallback: string, + fallbackTruncated = false, ): CapturedOutput { const startMarker = snapshot.text.lastIndexOf(marker.start) if (startMarker >= 0) { return { text: stripPrompt(snapshot.text.slice(startMarker + marker.start.length).replace(/^\r?\n/, '')), - incomplete: snapshot.truncated, + incomplete: false, } } + const fallbackStart = fallback.lastIndexOf(marker.start) + const afterStart = fallbackStart < 0 + ? fallback + : fallback.slice(fallbackStart + marker.start.length).replace(/^\r?\n/, '') + const fallbackEnd = afterStart.lastIndexOf(marker.end) + const beforeEnd = fallbackEnd < 0 ? afterStart : afterStart.slice(0, fallbackEnd) return { - text: stripPrompt(fallback), - incomplete: snapshot.truncated, + text: stripPrompt(beforeEnd.replaceAll(SHELL_PROMPT, '')), + incomplete: fallbackTruncated || fallbackStart < 0, } } async function pause(): Promise { - await new Promise(resolve => setTimeout(resolve, 25)) + await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)) } function nextScrollbackOffset(page: PtyReadResult, offset: number): number | undefined { @@ -142,11 +152,13 @@ function retainedScrollback( ctx: Context, owner: Agent, id: PtySessionId, + latest = ctx.pty.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }), ): RetainedOutput { - const pages: string[] = [] - let offset = 0 - let truncated = false + const pages: string[] = latest.text.length === 0 ? [] : [latest.text] + let offset = latest.lineEnd + let truncated = latest.truncated while (true) { + if (offset >= latest.totalLines) break const page = ctx.pty.read(owner, id, { offset, count: SCROLLBACK_PAGE_LINES }) truncated ||= page.truncated if (page.text.length > 0) pages.unshift(page.text) @@ -167,7 +179,10 @@ function renderCaptured(output: CapturedOutput, maxOutputChars: number): string function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells { const pending = new WeakMap>() const live = new Map() + const creating = new Set>() const ownerCleanupInstalled = new WeakSet() + const lifecycle = new AbortController() + let disposed = false const close = async (owner: Agent, id: PtySessionId, reason: string): Promise => { if (!ctx.pty.list(owner).some(snapshot => snapshot.sessionId === id)) return @@ -175,6 +190,9 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell } ctx.effect(() => async () => { + disposed = true + lifecycle.abort(new Error('tool-bash-persistent disposed during shell creation')) + await Promise.allSettled([...creating]) const closing = [...live].map(async ([owner, id]) => { await close(owner, id, 'tool-bash-persistent disposed') }) await Promise.all(closing) live.clear() @@ -188,15 +206,17 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell } const get = (owner: Agent, signal: AbortSignal): Promise => { + if (disposed) return Promise.reject(new Error('tool-bash-persistent is disposed')) const existing = pending.get(owner) if (existing !== undefined) return existing - const creating = (async () => { + const combinedSignal = AbortSignal.any([signal, lifecycle.signal]) + const creation = (async () => { try { const cwd = owner.session.header.cwd const spawned = await ctx.pty.spawn(owner, { type: config.backendType, ...cwd === undefined ? {} : { cwd }, - }, signal) + }, combinedSignal) live.set(owner, spawned.sessionId) if (!ownerCleanupInstalled.has(owner)) { ownerCleanupInstalled.add(owner) @@ -208,7 +228,7 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell const setup = ctx.pty.startSend(owner, spawned.sessionId, { text: `stty -echo; PS1=${quoteForBash(SHELL_PROMPT)}`, submit: true, - signal, + signal: combinedSignal, }) const result = await setup.done if (result.sessionStatus.kind === 'exited' || result.waitReason === 'timeout') { @@ -220,8 +240,12 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell throw error } })() - pending.set(owner, creating) - return creating + const tracked = creation.finally(() => { + creating.delete(tracked) + }) + creating.add(tracked) + pending.set(owner, tracked) + return tracked } return { get, reset } @@ -241,21 +265,32 @@ async function executeCommand( const wrapped = wrapCommand(command, marker) let first = true let fallback = '' + let fallbackTruncated = false while (true) { - const operation = ctx.pty.startSend(owner, id, { - text: first ? wrapped : '', - submit: first, - signal: commandDeadline.signal, - }) - first = false - const result = await operation.done - fallback += result.viewport - const snapshot = retainedScrollback(ctx, owner, id) + let operation + let result + try { + operation = ctx.pty.startSend(owner, id, { + text: first ? wrapped : '', + submit: first, + signal: commandDeadline.signal, + }) + first = false + result = await operation.done + } catch (error: unknown) { + await shells.reset(owner, 'persistent bash send failed') + throw error + } + const incremental = operation.readOutput() + fallback = incremental.delta.length > 0 ? fallback + incremental.delta : result.viewport + fallbackTruncated ||= incremental.truncated || result.truncated + const latest = ctx.pty.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }) const timedOut = timeoutOf(commandDeadline.signal, TIMEOUT_CODE) if (timedOut !== undefined) { + const snapshot = retainedScrollback(ctx, owner, id, latest) const partial = renderCaptured( - partialOutput(snapshot, marker, fallback), + partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars, ) await shells.reset(owner, 'persistent bash command timed out') @@ -265,12 +300,15 @@ async function executeCommand( SHELL_RESET_MESSAGE, ].join('\n') } - const complete = commandOutput(snapshot, marker) - if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars) + if (latest.text.includes(marker.end)) { + const complete = commandOutput(retainedScrollback(ctx, owner, id, latest), marker) + if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars) + } if (result.sessionStatus.kind === 'exited') { + const snapshot = retainedScrollback(ctx, owner, id, latest) await shells.reset(owner, 'persistent bash shell exited') return [ - renderCaptured(partialOutput(snapshot, marker, fallback), config.maxOutputChars), + renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars), SHELL_RESET_MESSAGE, ].filter(part => part.length > 0).join('\n') } @@ -279,7 +317,11 @@ async function executeCommand( commandDeadline.signal.throwIfAborted() } if (promptCompleted(result)) { - return maybeTruncate(stripPrompt(fallback), config.maxOutputChars, result.truncated) + const snapshot = retainedScrollback(ctx, owner, id, latest) + return renderCaptured( + partialOutput(snapshot, marker, fallback, fallbackTruncated), + config.maxOutputChars, + ) } await pause() } diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index f757d6d1f8..3863ec29ce 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -85,6 +85,8 @@ type StubMode = | 'init-exit' | 'init-timeout' | 'spawn-error' + | 'send-error' + | 'prompt-after-idle' class StubPtySession implements PtyBackendSession { readonly motd = '__DSH_PERSISTENT_BASH_PROMPT__ ' @@ -95,6 +97,7 @@ class StubPtySession implements PtyBackendSession { mode: StubMode sends = 0 pendingText = '' + historyTruncated = false constructor(mode: StubMode) { this.mode = mode @@ -112,6 +115,7 @@ class StubPtySession implements PtyBackendSession { } return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read'))) } + if (this.mode === 'send-error') throw new Error('stub send failed') if (this.mode === 'wait-for-abort') { const done = new Promise>((resolve) => { request.signal?.addEventListener('abort', () => { @@ -126,6 +130,17 @@ class StubPtySession implements PtyBackendSession { this.pendingText = request.text return this.operation(Promise.resolve(this.result('', 'inferred_idle'))) } + if (this.mode === 'prompt-after-idle') { + if (request.text.length > 0) { + const start = /__DSH_PERSISTENT_BASH_START_[^_]+(?:-[^_]+)*__/.exec(request.text)?.[0] + const output = `${start ?? ''}\npartial syntax output\n` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'inferred_idle'))) + } + const output = `bash: syntax error\n${this.motd}` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } if (this.mode === 'prompt-only' || this.mode === 'prompt-crlf') { const newline = this.mode === 'prompt-crlf' ? '\r\n' : '\n' const output = `bash: syntax error${newline}${this.motd}${newline}` @@ -166,7 +181,7 @@ class StubPtySession implements PtyBackendSession { totalLines: lines.length, lineBegin: 0, lineEnd: lines.length, - truncated: false, + truncated: this.historyTruncated, } } @@ -316,6 +331,29 @@ describe('tool-bash-persistent', () => { expect(text(await call(ctx, owner, 'stalled page'))).toContain('hello from stub') }) + it('sanitizes a prompt fallback reached after multiple polling rounds', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + session.mode = 'prompt-after-idle' + session.scrollback = '' + const result = text(await call(ctx, owner, 'bad {')) + expect(result).toContain('partial syntax output') + expect(result).toContain('bash: syntax error') + expect(result).not.toContain('DSH_PERSISTENT_BASH_PROMPT') + expect(result).not.toContain('DSH_PERSISTENT_BASH_START') + }) + + it('does not attribute old scrollback truncation to a complete current command', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.historyTruncated = true + const result = text(await call(ctx, owner, 'short command')) + expect(result).toBe('hello from stub') + expect(result).not.toContain('') + expect(result).not.toContain('beginning of this command output was dropped') + }) + it('closes a timed-out shell and reports bounded partial output', async () => { const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 10 }) await call(ctx, owner, 'warm up') @@ -359,6 +397,48 @@ describe('tool-bash-persistent', () => { expect(stub.sessions).toHaveLength(0) }) + it('resets a cached shell after startSend fails', async () => { + const { ctx, owner, stub } = await setup() + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = 'send-error' + expect((await call(ctx, owner, 'fails')).isError).toBe(true) + expect(stub.sessions[0]?.closed).toContain('persistent bash send failed') + expect(text(await call(ctx, owner, 'recovers'))).toBe('hello from stub') + expect(stub.sessions).toHaveLength(2) + }) + + it('cancels and awaits a pending shell spawn when the plugin is disposed', async () => { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(PtyService) + const spawnStarted = Promise.withResolvers() + const spawnAborted = Promise.withResolvers() + ctx.pty.registerBackend({ + type: 'slow', + spawn: spec => new Promise((_resolve, reject) => { + spawnStarted.resolve(undefined) + spec.signal?.addEventListener('abort', () => { + spawnAborted.resolve(undefined) + const reason: unknown = spec.signal?.reason + reject(reason instanceof Error + ? reason + : new Error('slow PTY spawn aborted', { cause: reason })) + }, { once: true }) + }), + }) + const fiber = await ctx.plugin(ToolBashPersistent, { backendType: 'slow' }) + const owner = agent(ctx, '/workspace') + const running = call(ctx, owner, 'pwd') + await spawnStarted.promise + await fiber.dispose() + await spawnAborted.promise + expect((await running).isError).toBe(true) + expect(ctx.pty.list(owner)).toEqual([]) + }) + it('rejects invalid config and invalid calls', async () => { const { ctx, owner, stub } = await setup() expect((await call(ctx, undefined, 'pwd')).isError).toBe(true) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c57d106a6..73092a292c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -593,6 +593,9 @@ importers: '@deepseek-ai/dsh-tool-ask-user': specifier: workspace:* version: link:../packages/ui/tool-ask-user + '@deepseek-ai/dsh-tool-bash-persistent': + specifier: workspace:* + version: link:../packages/pty/tool-bash-persistent '@deepseek-ai/dsh-tool-cordis': specifier: workspace:* version: link:../packages/cordis/tool-cordis @@ -617,6 +620,9 @@ importers: '@deepseek-ai/dsh-tool-session-query': specifier: workspace:* version: link:../packages/session-query/tool-session-query + '@deepseek-ai/dsh-tool-str-replace-editor': + specifier: workspace:* + version: link:../packages/fs/tool-str-replace-editor '@deepseek-ai/dsh-tool-subagent': specifier: workspace:* version: link:../packages/subagent/tool-subagent @@ -2593,12 +2599,24 @@ importers: '@deepseek-ai/dsh-fs-local': specifier: workspace:^ version: link:../fs-local + '@deepseek-ai/dsh-fs-policy': + specifier: workspace:^ + version: link:../fs-policy + '@deepseek-ai/dsh-fs-sandbox': + specifier: workspace:^ + version: link:../fs-sandbox '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index 44fcf94b60..4b104ec211 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -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 python/sdk-runtime/README.md -README.md: 977bce41191d6c7716548dafde81d2c7ec14dec7 -README.zh.md: ade8455c56c27fcbe3e43a68abeaad98421cf720 +README.md: ee3791eddf26b526316d4f3952793a03cc48841e +README.zh.md: 59d40ee56688cb377902ff126b7fa77606c7ad8b diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index 977bce4119..ee3791eddf 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -15,7 +15,7 @@ Both carriers hold the same content, defined once: the [package.json](package.js A missing exe raises `FileNotFoundError` naming both acquisition routes: build via `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness checkout, or install the matching platform runtime wheel produced by the `build-exe-for-python-sdk` CI workflow. A missing dev-only node carrier names its sole route, the build script. The workflow retains wheels rather than standalone executable archives. Acquisition strategy is deliberately separate from the lookup interface, so an on-demand download can replace it later without touching callers. -Each wheel contains exactly one runtime executable and its matching native spawn helper. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_11_0_arm64`; the build hook rejects `py3-none-any`, absent or multiple runtime files, non-executable files, and unsupported platform tags. The repository root `package.json` supplies the shared version for this package and the SDK, and a `python-vX.Y.Z` release tag must match it. +Each wheel contains exactly one runtime executable and its matching native spawn helper. A missing sidecar makes the runtime installation incomplete and is a hard startup error, even for a selected Cordis composition that does not use PTY tools; old exe-only wheels are intentionally unsupported. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_11_0_arm64`; the build hook rejects `py3-none-any`, absent or multiple runtime files, non-executable files, and unsupported platform tags. The repository root `package.json` supplies the shared version for this package and the SDK, and a `python-vX.Y.Z` release tag must match it. ## Resolution API diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index ade8455c56..59d40ee566 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -15,7 +15,7 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`, exe 缺失时抛出 `FileNotFoundError`,并写明两种获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或安装 `build-exe-for-python-sdk` CI 工作流生成的对应平台运行时 wheel 包。仅限开发的 `node` 载体缺失时只提示构建脚本这一条途径。该工作流只保留 wheel 包,不保留独立 exe 归档。获取策略与查找接口刻意分离,之后可以换成按需下载而不改动任何调用方。 -每个 wheel 包只包含一个运行时可执行文件及其匹配的原生 spawn helper。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;构建钩子会拒绝 `py3-none-any`、运行时文件缺失或重复、文件不可执行以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-vX.Y.Z` 发布标签必须与其匹配。 +每个 wheel 包只包含一个运行时可执行文件及其匹配的原生 spawn helper。缺少伴随文件意味着运行时安装不完整,并会在启动时硬失败,即使所选 Cordis 组合不使用 PTY 工具也是如此;旧的仅 exe wheel 有意不再兼容。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;构建钩子会拒绝 `py3-none-any`、运行时文件缺失或重复、文件不可执行以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-vX.Y.Z` 发布标签必须与其匹配。 ## 解析 API diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index 03c82b25b0..38400009ac 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -260,7 +260,9 @@ class SingleExeBuild { '--config.node-linker=hoisted', '--config.auto-install-peers=false', '--config.link-workspace-packages=true', - // The production closure intentionally omits patched dev-only packages. + // The production closure intentionally omits the patched dev-only + // @earendil-works/pi-tui package. The root frozen install still validates + // every patch; this exception is scoped only to the production deploy. '--config.allow-unused-patches=true', this.staging, ]) diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 11500b5ec7..346818e5fa 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -27,7 +27,7 @@ WORKFLOW_PROMPT = "Use workflow to compute the packaged worker smoke value witho WORKFLOW_WORKER_TEXT = "workflow worker smoke ok" PERSISTENT_TOOLS_PROMPT = "Exercise the packaged persistent Bash and string-replacement editor." PERSISTENT_TOOLS_TEXT = "persistent tools smoke ok" -PERSISTENT_EDITOR_PATH: str | None = None +PERSISTENT_EDITOR_PATH_PREFIX = "Editor path: " PERSISTENT_BASH_COMMAND = ( "counter=$(( ${counter:-0} + 1 )); export counter; " "printf 'COUNT=%s CWD=%s\\n' \"$counter\" \"$PWD\"; " @@ -198,7 +198,7 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: raise AssertionError(f"unexpected tool follow-up: {tool_name}") prompt = message_text(latest.get("content")) - if prompt == PERSISTENT_TOOLS_PROMPT: + if prompt.startswith(f"{PERSISTENT_TOOLS_PROMPT}\n{PERSISTENT_EDITOR_PATH_PREFIX}"): names = advertised_tool_names(body) if names != {"bash", "str_replace_editor"}: raise AssertionError(f"persistent tools smoke advertised unexpected tools: {names}") @@ -261,14 +261,27 @@ def persistent_tool_followup( if call_id == "persistent-bash-2" and tool_name == "bash": if "COUNT=2 CWD=/tmp" not in tool_text: raise AssertionError(f"persistent bash did not retain state: {tool_text}") - if PERSISTENT_EDITOR_PATH is None: - raise AssertionError("persistent editor smoke path was not initialized") + messages = body.get("messages") + if not isinstance(messages, list): + raise AssertionError("persistent editor smoke request has no messages") + editor_path = next( + ( + text.split(PERSISTENT_EDITOR_PATH_PREFIX, 1)[1].strip() + for message in messages + if isinstance(message, dict) and message.get("role") == "user" + for text in [message_text(message.get("content"))] + if PERSISTENT_EDITOR_PATH_PREFIX in text + ), + None, + ) + if editor_path is None: + raise AssertionError("persistent editor smoke prompt has no editor path") return tool_call_chunks( "persistent-editor", "str_replace_editor", { "command": "create", - "path": PERSISTENT_EDITOR_PATH, + "path": editor_path, "file_text": "created by packaged editor\n", }, ) @@ -545,12 +558,12 @@ def smoke_sdk_custom(base_url: str, executable: Path) -> None: def smoke_sdk_persistent_tools(base_url: str, executable: Path) -> None: """Exercise native PTY state and the editor through the packaged executable.""" - global PERSISTENT_EDITOR_PATH from deepseek_harness import DeepSeekHarness with tempfile.TemporaryDirectory(prefix="dsh-sdk-persistent-tools-") as temporary: root = Path(temporary).resolve() - PERSISTENT_EDITOR_PATH = str(root / "created.txt") + editor_path = root / "created.txt" + prompt = f"{PERSISTENT_TOOLS_PROMPT}\n{PERSISTENT_EDITOR_PATH_PREFIX}{editor_path}" sessions = root / "sessions" cordis = root / "cordis.yml" cordis.write_text(PERSISTENT_TOOLS_CORDIS) @@ -565,17 +578,15 @@ def smoke_sdk_persistent_tools(base_url: str, executable: Path) -> None: base_url=base_url, request_timeout_seconds=60, ) as harness: - result = harness.run(PERSISTENT_TOOLS_PROMPT, session_id="persistent-tools-smoke") + result = harness.run(prompt, session_id="persistent-tools-smoke") assert result.status == "ok", result event_text = json.dumps(result.events) if PERSISTENT_TOOLS_TEXT not in event_text: raise AssertionError(f"packaged tools run emitted no final response: {result.events}") - created = root / "created.txt" - if created.read_text() != "created by packaged editor\n": - raise AssertionError(f"packaged editor wrote unexpected content: {created.read_text()!r}") + if editor_path.read_text() != "created by packaged editor\n": + raise AssertionError(f"packaged editor wrote unexpected content: {editor_path.read_text()!r}") assert_session_log(sessions, root, PERSISTENT_TOOLS_TEXT, "COUNT=1", "COUNT=2 CWD=/tmp") - PERSISTENT_EDITOR_PATH = None def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) -> None: From 9c9c32ed59de8ee0762dcd2b04334a8e366538f6 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 15:26:00 +0800 Subject: [PATCH 13/72] =?UTF-8?q?fix(host):=20review=20round=204=20?= =?UTF-8?q?=E2=80=94=20post-commit=20refocus=20covers=20right-pane=20picks?= =?UTF-8?q?;=20combobox=20semantics=20recorded=20as=20deferred?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...directory-picker-capability-seam.i18n.yaml | 4 +-- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- .../src/client/DirectoryBrowser.module.css | 14 ++++---- .../src/client/DirectoryBrowser.tsx | 35 ++++++++++++++----- .../tests/directory-browser.spec.tsx | 19 ++++++++++ 6 files changed, 56 insertions(+), 20 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index dc22b8f807..e70ac895a0 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: f633cd60b32c10e63814bf06a06773d2dba396f2 -2026-07-28-directory-picker-capability-seam.zh.md: b708e6fffa46cf51fd35154bfc2b947d955e5e9c +2026-07-28-directory-picker-capability-seam.md: 550d51cf2e4e5b70b0844401d42bfea2342612e7 +2026-07-28-directory-picker-capability-seam.zh.md: afaf277e866349b20d370fe1fd638b0c71e1a3e7 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index f633cd60b3..550d51cf2e 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -19,7 +19,7 @@ Placement and policy rulings folded into this decision: - **Not the `ctx.fs` seam.** `packages/fs/` is the model/session-facing storage stack (policy events, sandbox-swappable backends). Riding it would couple GUI browsing to the model's confinement backend — swapping `fs-sandbox` for the model must never change GUI behavior — and OS facts (home anchoring, hidden conventions) are not storage primitives. The picker seam stays presentation-free and model-free; `packages/host/` is its consumer-domain home. - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. -- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. +- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. - **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index b708e6fffa..afaf277e86 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -19,7 +19,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **不用 `ctx.fs` seam。** `packages/fs/` 是面向模型/会话的存储栈(policy 事件、sandbox 可换后端)。骑上去会把 GUI 浏览耦合进模型的限制后端——为模型换 `fs-sandbox` 绝不能改变 GUI 行为——而 OS 事实(home 锚定、隐藏约定)也不是存储原语。picker seam 保持无展示、无模型;`packages/host/` 是它消费方域的家。 - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 -- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。 +- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index e6f1daba98..2c09dd2940 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -15,6 +15,13 @@ gap: 0; } +/* Card-scope wrapper hosting the path editor's Escape and focus-leave + * observers; display:contents keeps header/content/footer as direct flex + * children of the Modal card. */ +.editorScope { + display: contents; +} + /* Header block: pl24 pr14 pt16 pb8, 8px between title row and crumb row. */ .header { display: flex; @@ -284,13 +291,6 @@ color: var(--dsw-alias-label-primary); } -/* Card-scope wrapper hosting the path editor's Escape and focus-leave - * observers; display:contents keeps header/content/footer as direct flex - * children of the Modal card. */ -.editorScope { - display: contents; -} - .footerGap { flex: 1 1 0; } diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 37c3d33982..bb0c4c5d7c 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -132,13 +132,11 @@ function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPr // where the blur lands before our guards) drop this click. // Outside editing, rows keep native focus behavior. onMouseDown={pathEditing ? (event) => { event.preventDefault() } : undefined} - onClick={(event) => { - // A pick during editing is about to unmount the focused - // input; park focus on the picked row so keyboard traversal - // stays inside the dialog (the Modal has no focus trap). - if (pathEditing) event.currentTarget.focus() - onPick(entry) - }} + // Editing-time focus parking happens after commit (the + // DirectoryBrowser refocus effect): a right-pane pick replaces + // this very column, so focusing the clicked node here would + // still fall to body. + onClick={() => { onPick(entry) }} > {selected ? @@ -235,11 +233,18 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }) }, [launchListing]) + // An editing-time pick parks focus on the selection after commit; the + // flag is set by select() and consumed by the refocus effect below the + // miller-row ref. + const refocusPick = useRef(false) + /** Select a row of the listed level and preview its children on the right. */ const select = useCallback((entry: DirectoryEntry) => { const { seq, scan } = launchListing(entry.path) // A pick while the path editor is open adopts the (filtered) row and - // closes the editor — the draft served its purpose. + // closes the editor — the draft served its purpose. Focus re-parks on + // the selection after commit (see the refocus effect below). + if (pathDraft !== null) refocusPick.current = true setPathDraft(null) setSelected(entry) setChild(null) @@ -257,7 +262,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // breadcrumb still names the level: fall back to the single pane. setSelected(null) }) - }, [launchListing]) + }, [launchListing, pathDraft]) /** Abandon path editing (Escape or clicking away) and restore the crumb view. */ const cancelPathEdit = useCallback(() => { @@ -368,6 +373,18 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const row = millerRowRef.current if (row !== null && childPath !== undefined) row.scrollLeft = row.scrollWidth }, [childPath]) + // An editing-time pick unmounts the focused input, and a right-pane pick + // additionally replaces the picked button's whole column (advance swaps + // both panes): park focus on the selection's row — aria-current in the + // freshly rendered left pane — after commit, so keyboard traversal stays + // inside the dialog (the Modal has no focus trap). + useEffect(() => { + if (!refocusPick.current) return + refocusPick.current = false + /* v8 ignore next 2 -- narrowing guard: the pick that set the flag just rendered its aria-current row inside the miller row. */ + const row = millerRowRef.current?.querySelector('button[aria-current="true"]') + row?.focus() + }) if (!open) return null const twoPane = selected !== null diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index 550146df41..217ffa481e 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -369,6 +369,25 @@ describe('DirectoryBrowser', () => { expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() }) + it('a right-pane pick while editing parks focus on the advanced selection', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(rowButton(screen.getByRole('listitem'))) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: `${DOCS}/h` } }) + // The advance replaces BOTH panes (the picked button's own column + // unmounts), so focus is re-parked on the selection's aria-current row + // in the freshly rendered left pane rather than the clicked node. + const row = rowButton(within(columns()[1]!).getByRole('listitem')) + fireEvent.mouseDown(row) + fireEvent.click(row) + expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + await waitFor(() => { expect(document.activeElement?.textContent).toBe('harness') }) + expect(document.activeElement?.getAttribute('aria-current')).toBe('true') + }) + it('seeds and filters with backslashes on a Windows-rooted listing', async () => { const ROOT = 'C:\\' const windowsListing: DirectoryListing = { From df58af92cda9b7d7a61e648ec212c07e31707c1d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 15:33:46 +0800 Subject: [PATCH 14/72] test(tools): close persistent tool coverage gaps --- docs/config-catalog.md | 2 +- .../fs/tool-str-replace-editor/src/index.ts | 2 +- .../tests/tools.spec.ts | 101 +++++++++++++++++- .../pty/tool-bash-persistent/src/index.ts | 15 +-- .../tool-bash-persistent/tests/tools.spec.ts | 13 ++- 5 files changed, 113 insertions(+), 20 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 0e92e4e06e..2e6ffaf3d8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1593,7 +1593,7 @@ export interface Config { } ``` -Source: [`packages/pty/tool-bash-persistent/src/index.ts:382`](../packages/pty/tool-bash-persistent/src/index.ts) +Source: [`packages/pty/tool-bash-persistent/src/index.ts:373`](../packages/pty/tool-bash-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` diff --git a/packages/fs/tool-str-replace-editor/src/index.ts b/packages/fs/tool-str-replace-editor/src/index.ts index 5d9855c1da..7ff0773107 100644 --- a/packages/fs/tool-str-replace-editor/src/index.ts +++ b/packages/fs/tool-str-replace-editor/src/index.ts @@ -281,7 +281,7 @@ async function createFile( outcome = await ctx.fs.writeText( target, content, - intent ?? { kind: 'createIfAbsent' }, + intent, exec.signal, sandboxPolicy, ) diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 213161e147..fb1e0d4b58 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -129,6 +129,24 @@ describe('tool-str-replace-editor', () => { kind: 'edit', locations: [{ path: '/workspace/a.txt', line: 1 }], }) + expect(ctx.tools.get('str_replace_editor')?.presentCall?.({ + command: 'create', + path: '/workspace/empty.txt', + })).toMatchObject({ + diffs: [{ path: '/workspace/empty.txt', oldText: null, newText: '' }], + }) + expect(ctx.tools.get('str_replace_editor')?.presentCall?.({ + command: 'str_replace', + path: '/workspace/a.txt', + })).toMatchObject({ + diffs: [{ path: '/workspace/a.txt', oldText: null, newText: '' }], + }) + expect(ctx.tools.get('str_replace_editor')?.presentCall?.({ + command: 'insert', + path: '/workspace/a.txt', + })).toMatchObject({ + locations: [{ path: '/workspace/a.txt' }], + }) }) it('creates, views, replaces, and inserts with the canonical model-facing output', async () => { @@ -192,7 +210,11 @@ describe('tool-str-replace-editor', () => { ctx.fs.listDir = async (target, signal) => { const entries = await listDir(target, signal) return target.displayPath === join(root, 'dir') - ? [...entries, { name: 'other', type: 'other', target: otherTarget }] + ? [ + { name: 'same-target', type: 'other', target: otherTarget }, + { name: 'other', type: 'other', target: otherTarget }, + ...entries.toReversed(), + ] : entries } @@ -235,6 +257,11 @@ describe('tool-str-replace-editor', () => { command: 'view', path: plain, }))).toContain(' 1 one') + expect((await call(ctx, undefined, { + command: 'create', + path: join(root, 'ownerless.txt'), + file_text: 'ownerless', + })).isError).toBe(false) await call(ctx, owner, { command: 'insert', @@ -382,6 +409,14 @@ describe('tool-str-replace-editor', () => { })).isError).toBe(false) expect(await readFile(existing, 'utf8')).toBe('after') + expect((await call(ctx, owner, { + command: 'insert', + path: existing, + insert_line: 1, + new_str: 'tail', + })).isError).toBe(false) + expect(await readFile(existing, 'utf8')).toBe('after\ntail') + expect((await call(ctx, owner, { command: 'create', path: created, @@ -400,19 +435,79 @@ describe('tool-str-replace-editor', () => { }) expect(result.error).toMatchObject({ info: { code: 'FS_SANDBOX_DENIED' } }) expect(text(result)).toContain('[sandbox: file access denied under read-only mode]') + + const ownerless = await call(ctx, undefined, { + command: 'create', + path: join(root, 'ownerless-blocked.txt'), + file_text: 'blocked', + }) + expect(ownerless.error).toMatchObject({ info: { code: 'FS_SANDBOX_DENIED' } }) }) it('can preserve tabs outside the edited region', async () => { const { ctx, root, owner } = await setup({ expandTabsOnMutation: false }) const path = join(root, 'Makefile') - await writeFile(path, 'target:\n\told\n') + await writeFile(path, 'target:\n\told\nremove\n') await call(ctx, owner, { command: 'str_replace', path, old_str: 'old', new_str: 'new', }) - expect(await readFile(path, 'utf8')).toBe('target:\n\tnew\n') + await call(ctx, owner, { + command: 'str_replace', + path, + old_str: 'remove\n', + }) + await call(ctx, owner, { + command: 'insert', + path, + insert_line: 1, + new_str: '\tkept', + }) + expect(await readFile(path, 'utf8')).toBe('target:\n\tkept\n\tnew\n') + }) + + it('reports missing sandbox-policy composition during plugin startup', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-tool-str-replace-editor-missing-policy-')) + roots.push(root) + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalFileSystem, { cwd: root }) + Object.defineProperty(ctx.fs, 'sandboxMode', { value: 'read-only' }) + + await expect(ctx.plugin(ToolStrReplaceEditor)) + .rejects.toThrow('the mounted filesystem confines but ctx.sandboxPolicy is missing') + }) + + it('maps unexpected backend write failures for replace and insert', async () => { + const { ctx, root, owner } = await setup() + const path = join(root, 'backend-error.txt') + await writeFile(path, 'old\n') + ctx.fs.writeText = async () => { + throw new Error('backend write failed') + } + + const replace = await call(ctx, owner, { + command: 'str_replace', + path, + old_str: 'old', + new_str: 'new', + }) + expect(replace.isError).toBe(true) + expect(text(replace)).toContain('backend write failed') + + const insert = await call(ctx, owner, { + command: 'insert', + path, + insert_line: 1, + new_str: 'new', + }) + expect(insert.isError).toBe(true) + expect(text(insert)).toContain('backend write failed') }) it('rejects invalid plugin config', () => { diff --git a/packages/pty/tool-bash-persistent/src/index.ts b/packages/pty/tool-bash-persistent/src/index.ts index 8ba51388cf..136dcbaabd 100644 --- a/packages/pty/tool-bash-persistent/src/index.ts +++ b/packages/pty/tool-bash-persistent/src/index.ts @@ -81,12 +81,7 @@ function wrapCommand(command: string, marker: CommandMarkers): string { } function stripPrompt(text: string): string { - let result = text - while (result.endsWith(`${SHELL_PROMPT}\r\n`) || result.endsWith(`${SHELL_PROMPT}\n`)) { - result = result.slice(0, result.endsWith('\r\n') - ? -SHELL_PROMPT.length - 2 - : -SHELL_PROMPT.length - 1) - } + let result = text.replace(/\r?\n$/, '') while (result.endsWith(SHELL_PROMPT)) { result = result.slice(0, -SHELL_PROMPT.length) } @@ -96,10 +91,9 @@ function stripPrompt(text: string): string { function commandOutput( snapshot: RetainedOutput, marker: CommandMarkers, -): CapturedOutput | undefined { +): CapturedOutput { const text = snapshot.text const end = text.lastIndexOf(marker.end) - if (end < 0) return undefined const startMarker = text.lastIndexOf(marker.start, end) const start = startMarker < 0 ? 0 : startMarker + marker.start.length return { @@ -182,7 +176,6 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell const creating = new Set>() const ownerCleanupInstalled = new WeakSet() const lifecycle = new AbortController() - let disposed = false const close = async (owner: Agent, id: PtySessionId, reason: string): Promise => { if (!ctx.pty.list(owner).some(snapshot => snapshot.sessionId === id)) return @@ -190,7 +183,6 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell } ctx.effect(() => async () => { - disposed = true lifecycle.abort(new Error('tool-bash-persistent disposed during shell creation')) await Promise.allSettled([...creating]) const closing = [...live].map(async ([owner, id]) => { await close(owner, id, 'tool-bash-persistent disposed') }) @@ -206,7 +198,6 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell } const get = (owner: Agent, signal: AbortSignal): Promise => { - if (disposed) return Promise.reject(new Error('tool-bash-persistent is disposed')) const existing = pending.get(owner) if (existing !== undefined) return existing const combinedSignal = AbortSignal.any([signal, lifecycle.signal]) @@ -302,7 +293,7 @@ async function executeCommand( } if (latest.text.includes(marker.end)) { const complete = commandOutput(retainedScrollback(ctx, owner, id, latest), marker) - if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars) + return renderCaptured(complete, config.maxOutputChars) } if (result.sessionStatus.kind === 'exited') { const snapshot = retainedScrollback(ctx, owner, id, latest) diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index 3863ec29ce..811d733f2f 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -87,6 +87,7 @@ type StubMode = | 'spawn-error' | 'send-error' | 'prompt-after-idle' + | 'empty-page-after-latest' class StubPtySession implements PtyBackendSession { readonly motd = '__DSH_PERSISTENT_BASH_PROMPT__ ' @@ -168,19 +169,22 @@ class StubPtySession implements PtyBackendSession { return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) } - read(_request: PtyReadRequest) { + read(request: PtyReadRequest) { if (this.mode === 'empty-read') { return { text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false } } if (this.mode === 'stalled-read') { return { text: 'stalled', totalLines: 1, lineBegin: 0, lineEnd: 0, truncated: false } } + if (this.mode === 'empty-page-after-latest' && (request.offset ?? 0) > 0) { + return { text: '', totalLines: 2, lineBegin: 1, lineEnd: 1, truncated: false } + } const lines = this.scrollback.split('\n') return { text: this.scrollback, - totalLines: lines.length, + totalLines: this.mode === 'empty-page-after-latest' ? lines.length + 1 : lines.length, lineBegin: 0, - lineEnd: lines.length, + lineEnd: this.mode === 'empty-page-after-latest' ? 1 : lines.length, truncated: this.historyTruncated, } } @@ -329,6 +333,9 @@ describe('tool-bash-persistent', () => { session.mode = 'stalled-read' expect(text(await call(ctx, owner, 'stalled page'))).toContain('hello from stub') + + session.mode = 'empty-page-after-latest' + expect(text(await call(ctx, owner, 'empty continuation page'))).toContain('hello from stub') }) it('sanitizes a prompt fallback reached after multiple polling rounds', async () => { From 23b74c5d7daa1245fe1aae28a2ee7b2f0a5da362 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 15:38:52 +0800 Subject: [PATCH 15/72] =?UTF-8?q?fix(host):=20review=20round=205=20nits=20?= =?UTF-8?q?=E2=80=94=20refocus=20covers=20Enter/Escape=20exits;=20trailing?= =?UTF-8?q?=20pressed=20check;=20narrowed=20v8=20ignores?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/client/DirectoryBrowser.tsx | 69 +++++++++++++++---- .../tests/directory-browser.spec.tsx | 8 +++ 2 files changed, 62 insertions(+), 15 deletions(-) diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index bb0c4c5d7c..52211bed4a 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -233,10 +233,15 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }) }, [launchListing]) - // An editing-time pick parks focus on the selection after commit; the - // flag is set by select() and consumed by the refocus effect below the - // miller-row ref. + // Editor-close focus parking (consumed by the refocus effect below the + // miller-row ref): a pick parks on the selection's row, Enter and an + // input-focused Escape park on the crumb edit zone that replaces the + // input. Pointer-out cancels never set (or clear) these — yanking focus + // back from wherever the user clicked would be worse than the fall. const refocusPick = useRef(false) + const refocusEditZone = useRef(false) + const pathInputRef = useRef(null) + const editZoneRef = useRef(null) /** Select a row of the listed level and preview its children on the right. */ const select = useCallback((entry: DirectoryEntry) => { @@ -373,17 +378,33 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const row = millerRowRef.current if (row !== null && childPath !== undefined) row.scrollLeft = row.scrollWidth }, [childPath]) - // An editing-time pick unmounts the focused input, and a right-pane pick - // additionally replaces the picked button's whole column (advance swaps - // both panes): park focus on the selection's row — aria-current in the - // freshly rendered left pane — after commit, so keyboard traversal stays - // inside the dialog (the Modal has no focus trap). + // Every editor exit that would drop focus to body re-parks it after + // commit, so keyboard traversal stays inside the dialog (the Modal has no + // focus trap): a pick lands on the selection's row — aria-current in the + // freshly rendered left pane, which survives even a right-pane advance + // replacing the picked button's column — while Enter and an input-focused + // Escape land on the crumb edit zone that replaces the input. useEffect(() => { - if (!refocusPick.current) return - refocusPick.current = false - /* v8 ignore next 2 -- narrowing guard: the pick that set the flag just rendered its aria-current row inside the miller row. */ - const row = millerRowRef.current?.querySelector('button[aria-current="true"]') - row?.focus() + if (pathDraft !== null) return + if (refocusPick.current) { + refocusPick.current = false + refocusEditZone.current = false + const rowHost = millerRowRef.current + /* v8 ignore next -- narrowing guard: the miller row is mounted whenever a pick just committed. */ + if (rowHost === null) return + const row = rowHost.querySelector('button[aria-current="true"]') + /* v8 ignore next -- narrowing guard: the pick that set the flag just rendered its aria-current row. */ + if (row === null) return + row.focus() + return + } + if (refocusEditZone.current) { + refocusEditZone.current = false + const zone = editZoneRef.current + /* v8 ignore next -- narrowing guard: crumb mode renders the edit zone whenever the editor just closed. */ + if (zone === null) return + zone.focus() + } }) if (!open) return null @@ -424,6 +445,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // document listener — the same containment the input previously // provided for itself. event.stopPropagation() + // Escape while the input holds focus is about to unmount it; with + // focus already parked on a row, that row survives the cancel and + // keeps focus naturally. + if (document.activeElement === pathInputRef.current) refocusEditZone.current = true cancelPathEdit() }} // Focus leaving THIS dialog card while editing cancels like Escape. @@ -442,6 +467,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, /* v8 ignore next -- narrowing guard: this scope always renders inside the Modal card. */ if (card === null) return if (event.relatedTarget instanceof Node && card.contains(event.relatedTarget)) return + // The user moved focus out of the card themselves: cancel without + // re-parking (a lingering Enter-failure flag must not yank focus + // back either). + refocusEditZone.current = false cancelPathEdit() }} > @@ -475,6 +504,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // listing itself fails, typing an absolute path is the one // remaining way forward. disabled={parentInert} + ref={editZoneRef} onClick={() => { // Opening the editor supersedes any pending listing: a // settlement landing before the first keystroke would @@ -502,6 +532,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, value={pathDraft} aria-label={t('browser.editPath')} autoFocus + ref={pathInputRef} disabled={parentInert} onChange={(event) => { // Editing the draft supersedes any in-flight navigation: @@ -521,7 +552,13 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // Trim only detects a blank draft; the Host gets the // original text — a real directory name may end in // whitespace, and trimming would list its sibling. - if (pathDraft.trim() !== '') navigate(pathDraft) + if (pathDraft.trim() !== '') { + // Success will unmount the still-focused input; park + // focus on the returning crumb edit zone (a failure + // keeps the editor, so the flag waits until close). + refocusEditZone.current = true + navigate(pathDraft) + } } }} /> @@ -586,8 +623,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, onMouseDown={draftPending ? (event) => { event.preventDefault() } : undefined} onClick={() => { setShowHidden(prev => !prev) }} > - {showHidden && } {t('browser.showHidden')} + {/* Trailing check (Menu's selected vocabulary): the label never + * shifts when the pressed state toggles. */} + {showHidden && } diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index 217ffa481e..06f8b78a3f 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -227,6 +227,9 @@ describe('DirectoryBrowser', () => { fireEvent.keyDown(input, { key: 'Enter' }) await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) expect(columns()).toHaveLength(1) + // The submitted navigation unmounted the focused input; focus parks on + // the crumb edit zone that replaced it. + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' })) fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) const again = screen.getByLabelText('browser.editPath') fireEvent.change(again, { target: { value: ' ' } }) @@ -234,6 +237,8 @@ describe('DirectoryBrowser', () => { expect(b.listDirectory).toHaveBeenCalledTimes(2) fireEvent.keyDown(again, { key: 'Escape' }) expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + // Escape with focus in the input parks focus on the returning edit zone. + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' })) }) it('prefix-filters the listed level from the draft tail, dot revealing hidden matches', async () => { @@ -324,9 +329,12 @@ describe('DirectoryBrowser', () => { // Tab parked focus on the result row; Escape must still mean "leave // path editing", not "close the whole dialog". const row = rowButton(screen.getByRole('listitem')) + row.focus() fireEvent.keyDown(row, { key: 'Escape' }) expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() expect(b.onClose).not.toHaveBeenCalled() + // Focus was already on a surviving row, so nothing re-parks it. + expect(document.activeElement).toBe(row) // With no draft left, Escape falls through to the Modal and closes. fireEvent.keyDown(row, { key: 'Escape' }) expect(b.onClose).toHaveBeenCalledTimes(1) From b9bc1c8a612f4235cabcd7c1259cd1ecf24bfe0b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 16:11:30 +0800 Subject: [PATCH 16/72] fix(python-sdk): read nested assistant messages --- python/sdk/src/deepseek_harness/api.py | 4 +++- python/sdk/tests/test_client.py | 7 ++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/python/sdk/src/deepseek_harness/api.py b/python/sdk/src/deepseek_harness/api.py index 5986dc2cdc..29bb4223ac 100644 --- a/python/sdk/src/deepseek_harness/api.py +++ b/python/sdk/src/deepseek_harness/api.py @@ -191,7 +191,9 @@ def final_response(events: list[JsonObject]) -> str: data = event.get("data") if not isinstance(data, dict): continue - content = data.get("content") + message = data.get("message") + content_owner = message if isinstance(message, dict) else data + content = content_owner.get("content") if not isinstance(content, list): continue parts: list[str] = [] diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index de2927c598..52ceac9f4d 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -46,7 +46,12 @@ for line in sys.stdin: "sessionId": params["sessionId"], "event": { "type": "assistant/message", - "data": {"content": [{"type": "text", "text": "hello from runtime"}]}, + "data": { + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "hello from runtime"}], + }, + }, }, }, }), flush=True) From a1d752f79958d63e4d1ce3e28af37e04c2b790cb Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 21:12:04 +0800 Subject: [PATCH 17/72] =?UTF-8?q?feat(host):=20navigations=20land=20select?= =?UTF-8?q?ion-anchored=20=E2=80=94=20crumb=20jumps=20step=20back=20a=20pa?= =?UTF-8?q?ne=20instead=20of=20collapsing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 1 + ...-28-directory-picker-capability-seam.zh.md | 1 + apps/web/tests/workspace-flow.snapshot.ts | 6 + .../directory-picker-browse/README.i18n.yaml | 4 +- .../host/directory-picker-browse/README.md | 2 +- .../host/directory-picker-browse/README.zh.md | 2 +- .../src/client/DirectoryBrowser.tsx | 52 +++++++-- .../tests/directory-browser.spec.tsx | 108 +++++++++++++++++- 9 files changed, 160 insertions(+), 20 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index e70ac895a0..e270a79a11 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 550d51cf2e4e5b70b0844401d42bfea2342612e7 -2026-07-28-directory-picker-capability-seam.zh.md: afaf277e866349b20d370fe1fd638b0c71e1a3e7 +2026-07-28-directory-picker-capability-seam.md: 51d71d15cb5144d555a5b156d9b108d7a2ad41b8 +2026-07-28-directory-picker-capability-seam.zh.md: 60bc14c13d1e4655faefbd5eaa63469119bf0ca3 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 550d51cf2e..51d71d15cb 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -20,6 +20,7 @@ Placement and policy rulings folded into this decision: - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. - **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. +- **Navigation lands selection-anchored.** Away from the display root, the browse client's navigate (a crumb jump or a submitted path) lists the target's parent level with the target selected and its children on the right — two panes throughout, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The display root (home, or a rootward chain with no parent crumb) keeps the single wide level; the parent leg runs under the same supersession scope as the landing, and its failure falls back to the single-pane landing quietly — the target listed fine, and nobody asked to see the parent. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. - **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index afaf277e86..60bc14c13d 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -20,6 +20,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 - **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。 +- **导航以选中项为锚落地。** 在展示根之外,browse 客户端的导航(crumb 跳转或提交的路径)列出目标的父层级,选中目标并在右侧展示其子项——全程双栏,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。展示根(home,或没有父 crumb 的抵根链)保持单个宽层级;父层级这一程与落地共用同一 supersession 范围,其失败会静默回退到单栏落地——目标本身列举无误,本也没有人要求查看父层级。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts index 32d44b6fff..2ed00f7cbf 100644 --- a/apps/web/tests/workspace-flow.snapshot.ts +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -202,6 +202,12 @@ it('adopts a directory through the composed in-app browse flow and lands in its // row's name span is stable (clicks bubble to the row button). fireEvent.click(await within(dialog).findByText('Documents', {}, { timeout: 10_000 })) fireEvent.click(await within(dialog).findByText('project', {}, { timeout: 10_000 })) + // A crumb jump away from the root steps BACK one pane instead of + // collapsing: Documents lands selected in the home level (Downloads is + // the home-level marker) with its children still on the right. + fireEvent.click(within(dialog).getByRole('button', { name: 'Documents' })) + await within(dialog).findByText('Downloads', {}, { timeout: 10_000 }) + fireEvent.click(await within(dialog).findByText('project', {}, { timeout: 10_000 })) // Open disables while the selection's child listing is in flight; wait for // the enabled state or the click lands on a dead button on slow runners. await waitFor(() => { diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index e59cd22b1e..842e91481c 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/README.i18n.yaml @@ -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/directory-picker-browse/README.md -README.md: 7813e0e9c589d1f37471b08ebafcf08878cbf768 -README.zh.md: 46538ed6f1cf23357cc4f8e289a8117d0e5e017d +README.md: 51e27115b5179796810c19b618b08a3523de8d10 +README.zh.md: 4caeff1c2ebfea2c12e6bb598dae2c34f2e34d84 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 7813e0e9c5..51e27115b5 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -6,7 +6,7 @@ The **in-app browsing backend** of the [directory-picker seam](../directory-pick Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated); window insertion is binary with an O(1) full-window tail rejection, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). -**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view; breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing (case-insensitively, over the listed — possibly truncated — rows only; Enter still navigates by the exact text), and cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft); a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from both filters; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). +**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored: a crumb jump or a submitted path lists the target's parent level with the target selected, so stepping back keeps two panes while the display root keeps the single wide level; breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing (case-insensitively, over the listed — possibly truncated — rows only; Enter still navigates by the exact text), and cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft); a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from both filters; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). ## Model Experience diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 46538ed6f1..4caeff1c2e 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -6,7 +6,7 @@ 行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 -**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图;带点击即编辑路径区的面包屑,其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;Enter 仍按确切文本导航)、按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿);基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受这两种过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 +**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚落地:crumb 跳转或提交的路径会列出目标的父层级并选中目标,因此后退仍保持双栏,而展示根保持单个宽层级;带点击即编辑路径区的面包屑,其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;Enter 仍按确切文本导航)、按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿);基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受这两种过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 ## 模型体验 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 52211bed4a..ba0502fc65 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -214,24 +214,60 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, return { seq, scan: listDirectory(path, controller.signal) } }, [supersede, listDirectory]) - /** Replace the whole view with one freshly listed level (no selection). */ + /** + * Replace the whole view with a freshly navigated level. Away from the + * display root the landing keeps the navigated directory SELECTED inside + * its parent level (left pane = parent, right pane = its children), so a + * crumb jump or a submitted path reads as stepping back one pane instead + * of collapsing to a single column; the display root (the home level, or + * a chain with no parent) keeps the single wide level. + */ const navigate = useCallback((path?: string) => { const { seq, scan } = launchListing(path) setLoading(true) setError(null) - scan.then((next) => { + scan.then((target) => { if (seq !== requestSeq.current) return - setParent(next) - setSelected(null) - setChild(null) - setLoading(false) - setPathDraft(null) + const parentCrumb = target.crumbs.at(-2) + const anchor = target.crumbs.at(-1) + if (target.path === target.home || parentCrumb === undefined + /* v8 ignore next -- narrowing: the anchor crumb exists whenever a parent crumb does (root-to-target inclusive chain). */ + || anchor === undefined) { + setParent(target) + setSelected(null) + setChild(null) + setLoading(false) + setPathDraft(null) + return + } + // Two-pane landing: the parent leg runs under the same supersession + // scope (a newer intent aborts it like the first leg). + const controller = new AbortController() + scanController.current = controller + listDirectory(parentCrumb.path, controller.signal).then((parentLevel) => { + if (seq !== requestSeq.current) return + setParent(parentLevel) + setSelected(anchor) + setChild(target) + setLoading(false) + setPathDraft(null) + }, () => { + if (seq !== requestSeq.current) return + // The target listed fine and is what the user asked for; a parent + // leg failure quietly falls back to the single-pane landing rather + // than surfacing an error for a level nobody requested. + setParent(target) + setSelected(null) + setChild(null) + setLoading(false) + setPathDraft(null) + }) }, (reason: unknown) => { if (seq !== requestSeq.current) return setLoading(false) setError(failureText(reason)) }) - }, [launchListing]) + }, [launchListing, listDirectory]) // Editor-close focus parking (consumed by the refocus effect below the // miller-row ref): a pick parks on the selection's row, Enter and an diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index 06f8b78a3f..ab60efcc5d 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -29,6 +29,13 @@ function listingFor(path?: string): DirectoryListing { ], truncated: false, }, + '/': { + path: '/', + home: HOME, + crumbs: [{ name: '/', path: '/', hidden: false }], + entries: [{ name: 'home', path: '/home', hidden: false }], + truncated: false, + }, [`${HOME}/.config`]: { path: `${HOME}/.config`, home: HOME, @@ -198,6 +205,88 @@ describe('DirectoryBrowser', () => { expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull() }) + it('a crumb jump away from the root lands two-pane with the target selected', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(rowButton(screen.getByRole('listitem'))) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + fireEvent.click(rowButton(within(columns()[1]!).getByRole('listitem'))) + await waitFor(() => { expect(screen.getByRole('button', { name: 'harness' })).toBeTruthy() }) + // Jumping to the Documents crumb is a step BACK one pane, not a + // collapse: Documents stays selected in the home level, its children + // stay on the right. + fireEvent.click(screen.getByRole('button', { name: 'Documents' })) + await waitFor(() => { + expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true') + }) + expect(columns()).toHaveLength(2) + expect(within(columns()[0]!).getByText('Documents')).toBeTruthy() + expect(within(columns()[1]!).getByText('harness')).toBeTruthy() + }) + + it('a navigation to the filesystem root keeps the single wide level', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: '/' } }) + fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) + // A one-crumb chain has no parent level to show on the left. + await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('home') }) + expect(columns()).toHaveLength(1) + }) + + it('drops a parent leg that settles after a newer intent, resolving or rejecting', async () => { + const settlers: { resolve: (value: DirectoryListing) => void; reject: (reason: unknown) => void }[] = [] + const listDirectory = vi.fn(async (path?: string) => { + if (path === HOME) { + return new Promise((resolve, reject) => { settlers.push({ resolve, reject }) }) + } + return listingFor(path) + }) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + // Enter lands the target leg; the parent leg hangs. Escape supersedes + // the landing, and the late parent RESOLUTION must change nothing. + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: DOCS } }) + fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) + await waitFor(() => { expect(settlers).toHaveLength(1) }) + fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Escape' }) + await act(async () => { settlers[0]!.resolve(listingFor(HOME)) }) + expect(columns()).toHaveLength(1) + expect(screen.getByRole('listitem').textContent).toBe('Documents') + // Same shape, late parent REJECTION: equally silent. + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: DOCS } }) + fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) + await waitFor(() => { expect(settlers).toHaveLength(2) }) + fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Escape' }) + await act(async () => { settlers[1]!.reject(new Error('late')) }) + expect(columns()).toHaveLength(1) + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('falls back to the single-pane landing when the parent leg of a navigation fails', async () => { + const listDirectory = vi.fn(async (path?: string) => { + // The initial open lists home through the absent-path form; only the + // parent leg names HOME explicitly. + if (path === HOME) { + throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'parent gone', details: { path } }) + } + return listingFor(path) + }) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: DOCS } }) + fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) + // The target listed fine; the failed parent leg neither blocks the + // landing nor surfaces an error for a level nobody asked to see. + await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) + expect(columns()).toHaveLength(1) + expect(screen.queryByRole('alert')).toBeNull() + }) + it('opens the selection, else the listed level; Cancel closes; busy freezes Open', async () => { const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) @@ -225,8 +314,11 @@ describe('DirectoryBrowser', () => { expect(input.value).toBe(`${HOME}/`) fireEvent.change(input, { target: { value: DOCS } }) fireEvent.keyDown(input, { key: 'Enter' }) - await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) - expect(columns()).toHaveLength(1) + // Away from the root a navigation lands two-pane: the target selected + // in its parent level, its own children on the right. + await waitFor(() => { expect(columns()).toHaveLength(2) }) + expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true') + expect(within(columns()[1]!).getByText('harness')).toBeTruthy() // The submitted navigation unmounted the focused input; focus parks on // the crumb edit zone that replaced it. expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' })) @@ -234,7 +326,9 @@ describe('DirectoryBrowser', () => { const again = screen.getByLabelText('browser.editPath') fireEvent.change(again, { target: { value: ' ' } }) fireEvent.keyDown(again, { key: 'Enter' }) - expect(b.listDirectory).toHaveBeenCalledTimes(2) + // Initial home + the DOCS target leg + its parent leg; the blank draft + // added none. + expect(b.listDirectory).toHaveBeenCalledTimes(3) fireEvent.keyDown(again, { key: 'Escape' }) expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() // Escape with focus in the input parks focus on the returning edit zone. @@ -945,11 +1039,13 @@ describe('DirectoryBrowser', () => { b.listDirectory.mockReturnValueOnce(slow) fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) fireEvent.click(within(screen.getByRole('navigation')).getByRole('button', { name: 'Documents' })) - await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) + // The newer jump lands two-pane: Documents selected at home, children right. + await waitFor(() => { expect(within(columns()[1]!).getByText('harness')).toBeTruthy() }) resolveSlow(listingFor(undefined)) await new Promise(settle => setTimeout(settle, 0)) - // The stale home listing did not replace the newer Documents level. - expect(screen.getByRole('listitem').textContent).toBe('harness') + // The stale home listing did not replace the newer Documents landing. + expect(columns()).toHaveLength(2) + expect(within(columns()[1]!).getByText('harness')).toBeTruthy() }) it('names the create target by its path when the level reports no crumbs', async () => { From f8df313f218734a105fa547ef8df367047e299ca Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:24:33 +0800 Subject: [PATCH 18/72] fix(pty): preserve persistent bash exit status --- .../pty/tool-bash-persistent/src/index.ts | 26 ++++++++++++++++-- .../tool-bash-persistent/tests/tools.spec.ts | 27 +++++++++++++++---- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/packages/pty/tool-bash-persistent/src/index.ts b/packages/pty/tool-bash-persistent/src/index.ts index 136dcbaabd..6cadf75708 100644 --- a/packages/pty/tool-bash-persistent/src/index.ts +++ b/packages/pty/tool-bash-persistent/src/index.ts @@ -43,6 +43,7 @@ interface RetainedOutput { interface CapturedOutput { text: string incomplete: boolean + exitCode?: number } interface PersistentShells { @@ -94,11 +95,13 @@ function commandOutput( ): CapturedOutput { const text = snapshot.text const end = text.lastIndexOf(marker.end) + const exitCode = Number.parseInt(text.slice(end + marker.end.length), 10) const startMarker = text.lastIndexOf(marker.start, end) const start = startMarker < 0 ? 0 : startMarker + marker.start.length return { text: stripPrompt(text.slice(start, end).replace(/^\r?\n/, '')), incomplete: startMarker < 0, + exitCode, } } @@ -165,9 +168,24 @@ function retainedScrollback( function renderCaptured(output: CapturedOutput, maxOutputChars: number): string { const rendered = maybeTruncate(output.text, maxOutputChars, output.incomplete) - return output.incomplete && output.text.length > 0 + const withPrefix = output.incomplete && output.text.length > 0 ? LOST_PREFIX_MESSAGE + rendered : rendered + return renderExitStatus(withPrefix, output.exitCode ?? 0, null) +} + +function renderExitStatus( + content: string, + exitCode: number | null, + signal: NodeJS.Signals | null, +): string { + const marker = signal !== null + ? `[killed by signal: ${signal}]` + : exitCode !== null && exitCode !== 0 + ? `[exit code: ${exitCode}]` + : undefined + if (marker === undefined) return content + return content.length === 0 ? marker : `${content}\n${marker}` } function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells { @@ -299,7 +317,11 @@ async function executeCommand( const snapshot = retainedScrollback(ctx, owner, id, latest) await shells.reset(owner, 'persistent bash shell exited') return [ - renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars), + renderExitStatus( + renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars), + result.sessionStatus.exitCode, + result.sessionStatus.signal, + ), SHELL_RESET_MESSAGE, ].filter(part => part.length > 0).join('\n') } diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index 811d733f2f..990004c1c9 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -78,9 +78,11 @@ type StubMode = | 'empty-read' | 'stalled-read' | 'exit' + | 'signal-exit' | 'wait-for-abort' | 'idle-then-normal' | 'large' + | 'nonzero' | 'end-only' | 'init-exit' | 'init-timeout' @@ -157,13 +159,18 @@ class StubPtySession implements PtyBackendSession { this.scrollback += output return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) } - const commandOutput = this.mode === 'large' ? 'x'.repeat(100) : 'hello from stub' - const output = `${start ?? ''}\n${commandOutput}\n${end ?? ''}0\n${this.motd}` + const commandOutput = this.mode === 'large' + ? 'x'.repeat(100) + : this.mode === 'nonzero' ? '' : 'hello from stub' + const exitCode = this.mode === 'nonzero' ? 7 : 0 + const output = `${start ?? ''}\n${commandOutput}\n${end ?? ''}${exitCode}\n${this.motd}` this.scrollback += output - if (this.mode === 'exit') { + if (this.mode === 'exit' || this.mode === 'signal-exit') { const exitedOutput = `${start ?? ''}\nhello from stub\n` this.scrollback = this.scrollback.slice(0, -output.length) + exitedOutput - this.statusValue = { kind: 'exited', exitCode: 0, signal: null } + this.statusValue = this.mode === 'signal-exit' + ? { kind: 'exited', exitCode: null, signal: 'SIGTERM' } + : { kind: 'exited', exitCode: 9, signal: null } return this.operation(Promise.resolve(this.result(exitedOutput, 'session_exit'))) } return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) @@ -303,19 +310,29 @@ describe('tool-bash-persistent', () => { session.mode = 'large' expect(text(await call(ctx, owner, 'large'))).toContain('') + session.mode = 'nonzero' + expect(text(await call(ctx, owner, 'false'))).toBe('[exit code: 7]') + session.mode = 'exit' const exited = text(await call(ctx, owner, 'exit')) expect(exited).toContain('hello from') + expect(exited).toContain('[exit code: 9]') expect(exited).toContain('next bash call starts from the workspace') expect(session.closed).toContain('persistent bash shell exited') await call(ctx, owner, 'new shell') expect(stub.sessions).toHaveLength(2) + const replacement = stub.sessions[1]! + replacement.mode = 'signal-exit' + expect(text(await call(ctx, owner, 'kill shell'))).toContain('[killed by signal: SIGTERM]') + + await call(ctx, owner, 'another shell') + expect(stub.sessions).toHaveLength(3) const externallyClosed = ctx.pty.list(owner)[0]?.sessionId expect(externallyClosed).toBeDefined() await ctx.pty.kill(owner, externallyClosed!, 'external cleanup') await fiber.dispose() - expect(stub.sessions[1]?.closed).toEqual(['external cleanup']) + expect(stub.sessions[2]?.closed).toEqual(['external cleanup']) }) it('marks a short missing-prefix result and tolerates exhausted scrollback pages', async () => { From 6b26126b3a743e976d3326300021dc6c205fdfdf Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:25:56 +0800 Subject: [PATCH 19/72] fix(fs): preserve tabs during editor mutations --- .../fs/tool-str-replace-editor/src/index.ts | 47 +++++-------------- .../tests/tools.spec.ts | 8 ++-- 2 files changed, 17 insertions(+), 38 deletions(-) diff --git a/packages/fs/tool-str-replace-editor/src/index.ts b/packages/fs/tool-str-replace-editor/src/index.ts index 7ff0773107..98bfd6528d 100644 --- a/packages/fs/tool-str-replace-editor/src/index.ts +++ b/packages/fs/tool-str-replace-editor/src/index.ts @@ -299,21 +299,18 @@ async function replaceInFile( oldStr: string | undefined, newStr: string | undefined, requireAbsolutePath: boolean, - expandTabsOnMutation: boolean, exec: ToolRunContext, ): Promise { const sandboxPolicy = policy.resolve(exec) const target = await resolveTarget(ctx, path, requireAbsolutePath, exec, sandboxPolicy?.workspaceRoot) const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined) - const rawOldValue = requiredForCommand(oldStr, 'old_str', 'str_replace', false) - const oldValue = expandTabsOnMutation ? expandTabs(rawOldValue) : rawOldValue - const newValue = expandTabsOnMutation ? expandTabs(newStr ?? '') : newStr ?? '' + const oldValue = requiredForCommand(oldStr, 'old_str', 'str_replace', false) + const newValue = newStr ?? '' const info = await statExisting(ctx, target, 'str_replace', exec) if (info.type !== 'file') { throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') } - const rawBefore = await ctx.fs.readText(target, exec.signal) - const before = expandTabsOnMutation ? expandTabs(rawBefore) : rawBefore + const before = await ctx.fs.readText(target, exec.signal) const offsets = matchOffsets(before, oldValue) if (offsets.length === 0) { throw new FsError( @@ -330,23 +327,13 @@ async function replaceInFile( } let outcome try { - outcome = expandTabsOnMutation - ? await ctx.fs.writeText( - target, - before.replace(oldValue, newValue), - intent === undefined - ? { kind: 'replaceIfVersion', version: info.version } - : { kind: 'replaceIfVersion', version: intent.version }, - exec.signal, - sandboxPolicy, - ) - : await ctx.fs.editText( - target, - { oldString: oldValue, newString: newValue, replaceAll: false }, - intent ?? { version: info.version }, - exec.signal, - sandboxPolicy, - ) + outcome = await ctx.fs.editText( + target, + { oldString: oldValue, newString: newValue, replaceAll: false }, + intent ?? { version: info.version }, + exec.signal, + sandboxPolicy, + ) } catch (error: unknown) { throw policy.mapError(error, sandboxPolicy) } @@ -361,12 +348,10 @@ async function insertInFile( insertLine: number | undefined, newStr: string | undefined, requireAbsolutePath: boolean, - expandTabsOnMutation: boolean, exec: ToolRunContext, ): Promise { if (insertLine === undefined) throw new Error('Parameter `insert_line` is required for command: insert') - const rawValue = requiredForCommand(newStr, 'new_str', 'insert') - const value = expandTabsOnMutation ? expandTabs(rawValue) : rawValue + const value = requiredForCommand(newStr, 'new_str', 'insert') const sandboxPolicy = policy.resolve(exec) const target = await resolveTarget(ctx, path, requireAbsolutePath, exec, sandboxPolicy?.workspaceRoot) const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined) @@ -374,8 +359,7 @@ async function insertInFile( if (info.type !== 'file') { throw new FsError(`cannot insert into "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') } - const rawBefore = await ctx.fs.readText(target, exec.signal) - const before = expandTabsOnMutation ? expandTabs(rawBefore) : rawBefore + const before = await ctx.fs.readText(target, exec.signal) const lines = before.split('\n') if (!Number.isInteger(insertLine) || insertLine < 0 || insertLine > lines.length) { throw new Error( @@ -404,7 +388,6 @@ interface ResolvedConfig { maxOutputChars: number description: string requireAbsolutePath: boolean - expandTabsOnMutation: boolean } function presentEditorCall(args: { @@ -512,7 +495,6 @@ function registerStrReplaceEditor(ctx: Context, config: ResolvedConfig): void { args.old_str, args.new_str, config.requireAbsolutePath, - config.expandTabsOnMutation, exec, ) case 'insert': @@ -523,7 +505,6 @@ function registerStrReplaceEditor(ctx: Context, config: ResolvedConfig): void { args.insert_line, args.new_str, config.requireAbsolutePath, - config.expandTabsOnMutation, exec, ) } @@ -543,8 +524,6 @@ export interface Config { description?: string /** Require local absolute paths like the canonical editor contract (default true). */ requireAbsolutePath?: boolean - /** Expand tabs across the full file before each mutation, matching the canonical editor (default true). */ - expandTabsOnMutation?: boolean } /** Runtime configuration schema for the string-replacement editor tool. */ @@ -552,7 +531,6 @@ export const Config: z = z.object({ maxOutputChars: z.number().default(16_000), description: z.string().default(DEFAULT_DESCRIPTION), requireAbsolutePath: z.boolean().default(true), - expandTabsOnMutation: z.boolean().default(true), }) /** Register one `str_replace_editor` tool over `ctx.fs`. */ @@ -561,7 +539,6 @@ export function apply(ctx: Context, config: Config): void { maxOutputChars: config.maxOutputChars ?? 16_000, description: config.description ?? DEFAULT_DESCRIPTION, requireAbsolutePath: config.requireAbsolutePath ?? true, - expandTabsOnMutation: config.expandTabsOnMutation ?? true, } if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) { throw new Error('tool-str-replace-editor: maxOutputChars must be a positive safe integer') diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index fb1e0d4b58..9518057634 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -444,8 +444,8 @@ describe('tool-str-replace-editor', () => { expect(ownerless.error).toMatchObject({ info: { code: 'FS_SANDBOX_DENIED' } }) }) - it('can preserve tabs outside the edited region', async () => { - const { ctx, root, owner } = await setup({ expandTabsOnMutation: false }) + it('preserves tabs outside the edited region', async () => { + const { ctx, root, owner } = await setup() const path = join(root, 'Makefile') await writeFile(path, 'target:\n\told\nremove\n') await call(ctx, owner, { @@ -487,9 +487,10 @@ describe('tool-str-replace-editor', () => { const { ctx, root, owner } = await setup() const path = join(root, 'backend-error.txt') await writeFile(path, 'old\n') - ctx.fs.writeText = async () => { + const failWrite = async (): Promise => { throw new Error('backend write failed') } + ctx.fs.editText = failWrite const replace = await call(ctx, owner, { command: 'str_replace', @@ -500,6 +501,7 @@ describe('tool-str-replace-editor', () => { expect(replace.isError).toBe(true) expect(text(replace)).toContain('backend write failed') + ctx.fs.writeText = failWrite const insert = await call(ctx, owner, { command: 'insert', path, From fbad903dd012c6e3dc5baf8d9cbf564aa1eb41a8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:32:19 +0800 Subject: [PATCH 20/72] test(examples): move persistent tools into snapshot lane --- .../persistent-tools.snapshot.cordis.yml | 19 ++ .../tests/persistent-tools.snapshot.spec.ts | 214 ------------------ examples/jsonrpc-agent/tests/sdk.snapshot.ts | 38 +++- .../persistent-tools/behavior.expected.json | 57 ----- .../notifications.expected.jsonl | 54 +++++ .../persistent-tools/result.expected.json | 1 + .../snapshots/persistent-tools/session.jsonl | 54 +++++ 7 files changed, 162 insertions(+), 275 deletions(-) create mode 100644 examples/jsonrpc-agent/persistent-tools.snapshot.cordis.yml delete mode 100644 examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts delete mode 100644 examples/jsonrpc-agent/tests/snapshots/persistent-tools/behavior.expected.json create mode 100644 examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl create mode 100644 examples/jsonrpc-agent/tests/snapshots/persistent-tools/result.expected.json create mode 100644 examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl diff --git a/examples/jsonrpc-agent/persistent-tools.snapshot.cordis.yml b/examples/jsonrpc-agent/persistent-tools.snapshot.cordis.yml new file mode 100644 index 0000000000..5bda5ac6a5 --- /dev/null +++ b/examples/jsonrpc-agent/persistent-tools.snapshot.cordis.yml @@ -0,0 +1,19 @@ +# Keyless replay keeps the persistent-tool composition intact and replaces +# only its live DeepSeek adapter with the fixture-backed provider. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./persistent-tools.cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek + name: DeepSeek + models: + - id: deepseek-v4-flash diff --git a/examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts b/examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts deleted file mode 100644 index 18d1454837..0000000000 --- a/examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { createServer } from 'node:http' -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' -import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' -import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client' - -const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)) -const configPath = fileURLToPath(new URL('../persistent-tools.cordis.yml', import.meta.url)) -const runtimeBin = fileURLToPath(new URL('../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url)) -const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -const expectedPath = fileURLToPath(new URL('./snapshots/persistent-tools/behavior.expected.json', import.meta.url)) - -interface ModelRequest { - messages?: Array> - tools?: Array<{ function?: { name?: string; parameters?: { required?: string[] } } }> -} - -function sseToolCall(id: string, name: string, args: Record): string[] { - return [ - 'data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n', - `data: ${JSON.stringify({ - choices: [{ - delta: { - tool_calls: [{ - index: 0, - id, - type: 'function', - function: { name, arguments: JSON.stringify(args) }, - }], - }, - }], - })}\n\n`, - 'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":3,"completion_tokens":3}}\n\n', - 'data: [DONE]\n\n', - ] -} - -function sseText(text: string): string[] { - return [ - 'data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n', - `data: ${JSON.stringify({ choices: [{ delta: { content: text } }] })}\n\n`, - 'data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":3}}\n\n', - 'data: [DONE]\n\n', - ] -} - -function messageText(content: unknown): string { - if (typeof content === 'string') return content - if (!Array.isArray(content)) return '' - return content.flatMap((block) => { - if (typeof block !== 'object' || block === null) return [] - const text = (block as { text?: unknown }).text - return typeof text === 'string' ? [text] : [] - }).join('') -} - -function latestToolCall(messages: Array>): { id: string; name: string } { - for (const message of messages.toReversed()) { - const calls = message.tool_calls - if (!Array.isArray(calls)) continue - const call = (calls as unknown[]).at(-1) - if (typeof call !== 'object' || call === null) continue - const id = (call as { id?: unknown }).id - const fn = (call as { function?: { name?: unknown } }).function - if (typeof id === 'string' && typeof fn?.name === 'string') return { id, name: fn.name } - } - throw new Error('model request has no preceding tool call') -} - -function normalize(value: string, cwd: string): string { - return value.replaceAll(cwd, '{{cwd}}') -} - -describe('jsonrpc persistent tools snapshot', () => { - it('runs persistent shell state and editor mutations keylessly', async () => { - const cwd = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-persistent-tools-')) - const sessionRoot = join(cwd, '.sessions') - const target = join(cwd, 'note.txt') - const requests: ModelRequest[] = [] - const modelServer = createServer((request, response) => { - let body = '' - request.setEncoding('utf8') - request.on('data', (chunk: string) => { body += chunk }) - request.on('end', () => { - const parsed = JSON.parse(body) as ModelRequest - requests.push(parsed) - const messages = parsed.messages ?? [] - const latest = messages.at(-1) - if (latest === undefined) throw new Error('model request has no messages') - let chunks: string[] - if (latest.role !== 'tool') { - chunks = sseToolCall('bash-1', 'bash', { - command: 'cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf "COUNT=%s CWD=%s\\n" "$DSH_EXAMPLE_COUNT" "$PWD"', - }) - } else { - const call = latestToolCall(messages) - const toolText = messageText(latest.content) - if (call.id === 'bash-1') { - expect(toolText).toContain('COUNT=1 CWD=/tmp') - chunks = sseToolCall('bash-2', 'bash', { - command: 'DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf "COUNT=%s CWD=%s\\n" "$DSH_EXAMPLE_COUNT" "$PWD"', - }) - } else if (call.id === 'bash-2') { - expect(toolText).toContain('COUNT=2 CWD=/tmp') - chunks = sseToolCall('editor-create', 'str_replace_editor', { - command: 'create', - path: target, - file_text: 'alpha\n', - }) - } else if (call.id === 'editor-create') { - expect(toolText).toContain('New file created successfully') - chunks = sseToolCall('editor-replace', 'str_replace_editor', { - command: 'str_replace', - path: target, - old_str: 'alpha', - new_str: 'beta', - }) - } else if (call.id === 'editor-replace') { - expect(toolText).toContain('has been edited successfully') - chunks = sseText('PERSISTENT_TOOLS_OK') - } else { - throw new Error(`unexpected tool call ${call.id}`) - } - } - response.writeHead(200, { 'content-type': 'text/event-stream' }) - for (const chunk of chunks) response.write(chunk) - response.end() - }) - }) - await new Promise(resolve => modelServer.listen(0, '127.0.0.1', resolve)) - const address = modelServer.address() - if (address === null || typeof address === 'string') throw new Error('model server did not bind') - const launch = resolveExampleLaunch({ - srcBin: runtimeBin, - configArgs: [], - tsconfigPath: repoTsconfig, - }) - const harness = new DeepSeekHarness({ - launch: { - command: launch.command, - args: launch.args, - cwd: repoRoot, - env: { - ...Object.fromEntries(Object.entries(process.env).filter(([, value]) => value !== undefined)) as Record, - ...Object.fromEntries(Object.entries(launch.env).filter(([, value]) => value !== undefined)) as Record, - DSH_CORDIS_CONFIG: configPath, - DSH_CWD: cwd, - DSH_SESSION_ROOT: sessionRoot, - DEEPSEEK_API_KEY: 'keyless-local-mock', - DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, - NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), - }, - requestTimeoutMs: 60_000, - }, - cwd, - provider: 'deepseek', - model: 'deepseek-v4-flash', - }) - - try { - const result = await harness.run( - 'Prove that bash state persists, then create and edit note.txt.', - { sessionId: 'persistent-tools-snapshot' }, - ) - const calls = result.events.flatMap((event) => { - if (event.type !== 'tool/call') return [] - return [{ - name: event.data.name, - arguments: normalize(event.data.arguments, cwd), - }] - }) - const results = result.events.flatMap((event) => { - if (event.type !== 'tool/result') return [] - return event.data.message.content.flatMap((block) => { - if (block.type !== 'tool-result') return [] - return block.content.flatMap(content => - content.type === 'text' - ? [{ text: normalize(content.text, cwd) }] - : []) - }) - }) - const tools = (requests[0]?.tools ?? []).map(tool => ({ - name: tool.function?.name, - required: tool.function?.parameters?.required ?? [], - })).sort((left, right) => { - const leftName = String(left.name) - const rightName = String(right.name) - return leftName < rightName ? -1 : leftName > rightName ? 1 : 0 - }) - const behavior = { - tools, - calls, - results, - final: { - status: result.status, - reason: result.reason, - response: result.finalResponse, - file: await readFile(target, 'utf8'), - }, - } - if (process.env.DSH_SNAPSHOT === 'refresh') { - await writeFile(expectedPath, `${JSON.stringify(behavior, null, 2)}\n`) - } - expect(behavior).toEqual(JSON.parse(await readFile(expectedPath, 'utf8'))) - } finally { - await harness.close() - await new Promise(resolve => modelServer.close(() => { resolve() })) - await rm(cwd, { recursive: true, force: true }) - } - }, 75_000) -}) diff --git a/examples/jsonrpc-agent/tests/sdk.snapshot.ts b/examples/jsonrpc-agent/tests/sdk.snapshot.ts index c54812e3e5..26a6461586 100644 --- a/examples/jsonrpc-agent/tests/sdk.snapshot.ts +++ b/examples/jsonrpc-agent/tests/sdk.snapshot.ts @@ -11,7 +11,7 @@ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { delimiter, join } from 'node:path' +import { delimiter, isAbsolute, join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { @@ -31,6 +31,8 @@ const testsDir = dirOf(import.meta.url) const snapshotsDir = join(testsDir, 'snapshots') const liveConfig = join(testsDir, '..', 'cordis.yml') const replayConfig = join(testsDir, '..', 'cordis.snapshot.yml') +const persistentToolsLiveConfig = join(testsDir, '..', 'persistent-tools.cordis.yml') +const persistentToolsReplayConfig = join(testsDir, '..', 'persistent-tools.snapshot.cordis.yml') const runtimeBin = fileURLToPath(new URL('../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url)) const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) @@ -51,6 +53,10 @@ interface SdkScenario { sessionId: string /** How many child sessions the turn persists (subagent scenarios). */ children: number + /** Optional scenario-specific live and replay compositions. */ + configs?: { live: string; replay: string } + /** Files whose final contents are part of the scenario contract. */ + expectedFiles?: Readonly> } const SCENARIOS: SdkScenario[] = [ @@ -72,6 +78,16 @@ const SCENARIOS: SdkScenario[] = [ sessionId: 'sdk-snapshot-subagent', children: 1, }, + { + name: 'persistent-tools', + prompt: 'Prove that bash state persists, then create and edit note.txt.', + sessionId: 'persistent-tools-snapshot', + children: 0, + configs: { live: persistentToolsLiveConfig, replay: persistentToolsReplayConfig }, + // Replay returns recorded tool arguments verbatim, so this cross-platform + // POSIX fixture uses one stable absolute path and cleans it around the run. + expectedFiles: { '/tmp/dsh-persistent-tools-snapshot-note.txt': 'beta\n' }, + }, ] interface PersistedLog { @@ -147,11 +163,15 @@ async function runScenario(scenario: SdkScenario): Promise<{ result: TurnResult notifications: HarnessNotification[] logs: PersistedLog[] + observedFiles: Record cwd: string }> { const cwd = await mkdtemp(join(tmpdir(), `sdk-snapshot-${scenario.name}-`)) const sessionsRoot = join(cwd, '.sessions') const scenarioDir = join(snapshotsDir, scenario.name) + const expectedFilePaths = Object.keys(scenario.expectedFiles ?? {}).map(path => + isAbsolute(path) ? path : join(cwd, path)) + await Promise.all(expectedFilePaths.map(async path => rm(path, { force: true }))) const launch = resolveExampleLaunch({ srcBin: runtimeBin, configArgs: [], @@ -164,7 +184,9 @@ async function runScenario(scenario: SdkScenario): Promise<{ const env: Record = { ...Object.fromEntries(Object.entries(process.env).filter(([, value]) => value !== undefined)) as Record, ...Object.fromEntries(Object.entries(launch.env).filter(([, value]) => value !== undefined)) as Record, - DSH_CORDIS_CONFIG: recording ? liveConfig : replayConfig, + DSH_CORDIS_CONFIG: recording + ? scenario.configs?.live ?? liveConfig + : scenario.configs?.replay ?? replayConfig, DSH_SESSION_ROOT: sessionsRoot, DSH_CWD: cwd, DSH_SNAPSHOT: mode, @@ -195,9 +217,16 @@ async function runScenario(scenario: SdkScenario): Promise<{ }) await harness.close() const logs = await persistedLogs(sessionsRoot) - return { result, notifications, logs, cwd } + const observedFiles = Object.fromEntries(await Promise.all( + Object.keys(scenario.expectedFiles ?? {}).map(async (path): Promise<[string, string]> => [ + path, + await readFile(isAbsolute(path) ? path : join(cwd, path), 'utf8'), + ]), + )) + return { result, notifications, logs, observedFiles, cwd } } finally { await harness.close() + await Promise.all(expectedFilePaths.map(async path => rm(path, { force: true }))) await rm(cwd, { recursive: true, force: true }) } } @@ -227,7 +256,7 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { const notificationsExpectedPath = join(scenarioDir, 'notifications.expected.jsonl') const resultExpectedPath = join(scenarioDir, 'result.expected.json') - const { result, notifications, logs, cwd } = await runScenario(scenario) + const { result, notifications, logs, observedFiles, cwd } = await runScenario(scenario) const ordered = orderLogs(logs, scenario) const actualContext = contextOf(ordered, cwd) @@ -293,6 +322,7 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { // Wire-shape invariants that must hold in every mode. expect(result.status).toBe('ok') expect(notifications.at(-1)?.method).toBe('session.finished') + expect(observedFiles).toEqual(scenario.expectedFiles ?? {}) if (scenario.children > 0) { expect(notifications.some(n => n.method === 'subagent.started')).toBe(true) expect(notifications.some(n => n.method === 'subagent.finished')).toBe(true) diff --git a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/behavior.expected.json b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/behavior.expected.json deleted file mode 100644 index 18b54e9312..0000000000 --- a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/behavior.expected.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "tools": [ - { - "name": "bash", - "required": [ - "command" - ] - }, - { - "name": "str_replace_editor", - "required": [ - "command", - "path" - ] - } - ], - "calls": [ - { - "name": "bash", - "arguments": "{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}" - }, - { - "name": "bash", - "arguments": "{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}" - }, - { - "name": "str_replace_editor", - "arguments": "{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}" - }, - { - "name": "str_replace_editor", - "arguments": "{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}" - } - ], - "results": [ - { - "text": "COUNT=1 CWD=/tmp" - }, - { - "text": "COUNT=2 CWD=/tmp" - }, - { - "text": "New file created successfully at: {{cwd}}/note.txt" - }, - { - "text": "The file {{cwd}}/note.txt has been edited successfully." - } - ], - "final": { - "status": "ok", - "reason": { - "kind": "completed" - }, - "response": "PERSISTENT_TOOLS_OK", - "file": "beta\n" - } -} diff --git a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl new file mode 100644 index 0000000000..481e0a3d08 --- /dev/null +++ b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl @@ -0,0 +1,54 @@ +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Prove that bash state persists, then create and edit note.txt."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Prove that bash state persists,","messageSeqs":[1],"source":{"kind":"fallback"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"bash-1","name":"bash","argumentsDelta":"{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bash-1","name":"bash","arguments":"{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bash-1","name":"bash","arguments":"{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"bash-1","name":"bash","arguments":"{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"bash-1"},"content":[{"type":"tool-result","toolCallId":"bash-1","content":[{"type":"text","text":"COUNT=1 CWD=/tmp"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[11],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"bash-2","name":"bash","argumentsDelta":"{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bash-2","name":"bash","arguments":"{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bash-2","name":"bash","arguments":"{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"bash-2","name":"bash","arguments":"{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":22,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"bash-2"},"content":[{"type":"tool-result","toolCallId":"bash-2","content":[{"type":"text","text":"COUNT=2 CWD=/tmp"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[21],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":23,"time":0,"data":{"turn":1,"step":2}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":24,"time":0,"data":{"turn":1,"step":3}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-create","name":"str_replace_editor","argumentsDelta":"{\"command\":\"create\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"file_text\":\"alpha\\n\"}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"file_text\":\"alpha\\n\"}"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":30,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"file_text\":\"alpha\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":31,"time":0,"data":{"turn":1,"step":3,"callId":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"file_text\":\"alpha\\n\"}"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":32,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"editor-create"},"content":[{"type":"tool-result","toolCallId":"editor-create","content":[{"type":"text","text":"New file created successfully at: /tmp/dsh-{{sessionId}}-note.txt"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[31],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":33,"time":0,"data":{"turn":1,"step":3}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":34,"time":0,"data":{"turn":1,"step":4}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":35,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-replace","name":"str_replace_editor","argumentsDelta":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":40,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":41,"time":0,"data":{"turn":1,"step":4,"callId":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":42,"time":0,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"editor-replace"},"content":[{"type":"tool-result","toolCallId":"editor-replace","content":[{"type":"text","text":"The file /tmp/dsh-{{sessionId}}-note.txt has been edited successfully."}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[41],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":43,"time":0,"data":{"turn":1,"step":4}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":44,"time":0,"data":{"turn":1,"step":5}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":46,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"PERSISTENT_TOOLS_OK"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PERSISTENT_TOOLS_OK"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":50,"time":0,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"PERSISTENT_TOOLS_OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":51,"time":0,"data":{"turn":1,"step":5}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":52,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} +{"method":"session.finished","params":{"sessionId":"{{sessionId}}","status":"ok","reason":{"kind":"completed"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/result.expected.json b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/result.expected.json new file mode 100644 index 0000000000..989372e15b --- /dev/null +++ b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/result.expected.json @@ -0,0 +1 @@ +{"status":"ok","reason":{"kind":"completed"},"finalResponse":"PERSISTENT_TOOLS_OK"} diff --git a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl new file mode 100644 index 0000000000..d4c36a93ac --- /dev/null +++ b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl @@ -0,0 +1,54 @@ +{"type":"session","version":0,"id":"persistent-tools-snapshot","createdAt":1785331618309,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1785331618311,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1785331618311,"data":{"content":[{"type":"text","text":"Prove that bash state persists, then create and edit note.txt."}],"source":{"kind":"user"},"role":"user","id":"d0534fe8-a74b-4fcf-913f-d78e36f486bb"},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785331618312,"data":{"title":"Prove that bash state persists,","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785331618312,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785331618313,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1785331618325,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":6,"time":1785331618325,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"bash-1","name":"bash","argumentsDelta":"{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}} +{"type":"assistant/chunk","seq":7,"time":1785331618326,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bash-1","name":"bash","arguments":"{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}}} +{"type":"assistant/chunk","seq":8,"time":1785331618326,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":9,"time":1785331618326,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":10,"time":1785331618327,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bash-1","name":"bash","arguments":"{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"68f0912b-5e3a-417e-a324-00871206cdf7"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} +{"type":"tool/call","seq":11,"time":1785331618327,"data":{"turn":1,"step":1,"callId":"bash-1","name":"bash","arguments":"{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}} +{"type":"tool/result","seq":12,"time":1785331618649,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"bash-1"},"content":[{"type":"tool-result","toolCallId":"bash-1","content":[{"type":"text","text":"COUNT=1 CWD=/tmp"}],"isError":false}],"role":"user","id":"a83a469c-0321-4f8b-a40e-913c1b433b9d"}},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"step/end","seq":13,"time":1785331618649,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":14,"time":1785331618649,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":15,"time":1785331618652,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":16,"time":1785331618652,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"bash-2","name":"bash","argumentsDelta":"{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}} +{"type":"assistant/chunk","seq":17,"time":1785331618652,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bash-2","name":"bash","arguments":"{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}}} +{"type":"assistant/chunk","seq":18,"time":1785331618652,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":19,"time":1785331618652,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":20,"time":1785331618652,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bash-2","name":"bash","arguments":"{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"425c837c-b7e5-48ef-bc97-282bf5a10221"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} +{"type":"tool/call","seq":21,"time":1785331618652,"data":{"turn":1,"step":2,"callId":"bash-2","name":"bash","arguments":"{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}} +{"type":"tool/result","seq":22,"time":1785331618759,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"bash-2"},"content":[{"type":"tool-result","toolCallId":"bash-2","content":[{"type":"text","text":"COUNT=2 CWD=/tmp"}],"isError":false}],"role":"user","id":"1d3fcea8-51d9-47a1-8e8e-283c7b9cf53a"}},"sourceEventSeqs":[21],"surfaceOp":"append"} +{"type":"step/end","seq":23,"time":1785331618759,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":24,"time":1785331618759,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":25,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":26,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-create","name":"str_replace_editor","argumentsDelta":"{\"command\":\"create\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"file_text\":\"alpha\\n\"}"}}} +{"type":"assistant/chunk","seq":27,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"file_text\":\"alpha\\n\"}"}}}} +{"type":"assistant/chunk","seq":28,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":29,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":30,"time":1785331618762,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"file_text\":\"alpha\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"6407aec3-f75c-427a-8783-a61bd99327bb"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"} +{"type":"tool/call","seq":31,"time":1785331618762,"data":{"turn":1,"step":3,"callId":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"file_text\":\"alpha\\n\"}"}} +{"type":"tool/result","seq":32,"time":1785331618782,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"editor-create"},"content":[{"type":"tool-result","toolCallId":"editor-create","content":[{"type":"text","text":"New file created successfully at: /tmp/dsh-persistent-tools-snapshot-note.txt"}],"isError":false}],"role":"user","id":"121833da-381d-492e-9d6c-82eaa9694ef1"}},"sourceEventSeqs":[31],"surfaceOp":"append"} +{"type":"step/end","seq":33,"time":1785331618782,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":34,"time":1785331618782,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":35,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":36,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-replace","name":"str_replace_editor","argumentsDelta":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}} +{"type":"assistant/chunk","seq":37,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}}} +{"type":"assistant/chunk","seq":38,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":39,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":40,"time":1785331618784,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"1cf1d34c-faee-464d-bdd7-413ba7233e23"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"} +{"type":"tool/call","seq":41,"time":1785331618784,"data":{"turn":1,"step":4,"callId":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}} +{"type":"tool/result","seq":42,"time":1785331618799,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"editor-replace"},"content":[{"type":"tool-result","toolCallId":"editor-replace","content":[{"type":"text","text":"The file /tmp/dsh-persistent-tools-snapshot-note.txt has been edited successfully."}],"isError":false}],"role":"user","id":"c88746c2-208d-46aa-8c3d-79ccc88c7f6d"}},"sourceEventSeqs":[41],"surfaceOp":"append"} +{"type":"step/end","seq":43,"time":1785331618799,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":44,"time":1785331618799,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":45,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":46,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"PERSISTENT_TOOLS_OK"}}} +{"type":"assistant/chunk","seq":47,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PERSISTENT_TOOLS_OK"}}}} +{"type":"assistant/chunk","seq":48,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":49,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":50,"time":1785331618802,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"PERSISTENT_TOOLS_OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"b8832049-1795-4127-b0e0-e31528da0e99"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"} +{"type":"step/end","seq":51,"time":1785331618802,"data":{"turn":1,"step":5}} +{"type":"turn/end","seq":52,"time":1785331618802,"data":{"turn":1,"reason":{"kind":"completed"}}} From 205702adaf7b100a7e841f203471bfdaa5b1af2f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:33:39 +0800 Subject: [PATCH 21/72] fix(pty): reset persistent shell on cancellation --- .../pty/tool-bash-persistent/src/index.ts | 8 ++-- .../tool-bash-persistent/tests/tools.spec.ts | 45 +++++++++++-------- 2 files changed, 31 insertions(+), 22 deletions(-) diff --git a/packages/pty/tool-bash-persistent/src/index.ts b/packages/pty/tool-bash-persistent/src/index.ts index 6cadf75708..9c6f755434 100644 --- a/packages/pty/tool-bash-persistent/src/index.ts +++ b/packages/pty/tool-bash-persistent/src/index.ts @@ -309,6 +309,10 @@ async function executeCommand( SHELL_RESET_MESSAGE, ].join('\n') } + if (commandDeadline.signal.aborted) { + await shells.reset(owner, 'persistent bash command aborted') + commandDeadline.signal.throwIfAborted() + } if (latest.text.includes(marker.end)) { const complete = commandOutput(retainedScrollback(ctx, owner, id, latest), marker) return renderCaptured(complete, config.maxOutputChars) @@ -325,10 +329,6 @@ async function executeCommand( SHELL_RESET_MESSAGE, ].filter(part => part.length > 0).join('\n') } - if (commandDeadline.signal.aborted) { - await shells.reset(owner, 'persistent bash command aborted') - commandDeadline.signal.throwIfAborted() - } if (promptCompleted(result)) { const snapshot = retainedScrollback(ctx, owner, id, latest) return renderCaptured( diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index 990004c1c9..81a4f28b87 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -80,6 +80,7 @@ type StubMode = | 'exit' | 'signal-exit' | 'wait-for-abort' + | 'end-on-abort' | 'idle-then-normal' | 'large' | 'nonzero' @@ -119,11 +120,16 @@ class StubPtySession implements PtyBackendSession { return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read'))) } if (this.mode === 'send-error') throw new Error('stub send failed') - if (this.mode === 'wait-for-abort') { + if (this.mode === 'wait-for-abort' || this.mode === 'end-on-abort') { const done = new Promise>((resolve) => { request.signal?.addEventListener('abort', () => { - this.scrollback += 'partial output' - resolve(this.result('partial output', 'stdin_read')) + const start = /__DSH_PERSISTENT_BASH_START_[^_]+(?:-[^_]+)*__/.exec(request.text)?.[0] + const end = /__DSH_PERSISTENT_BASH_END_[^:]+:/.exec(request.text)?.[0] + const output = this.mode === 'end-on-abort' + ? `${start ?? ''}\ninterrupted\n${end ?? ''}130\n${this.motd}` + : 'partial output' + this.scrollback += output + resolve(this.result(output, 'stdin_read')) }, { once: true }) }) return this.operation(done) @@ -389,22 +395,25 @@ describe('tool-bash-persistent', () => { expect(stub.sessions[0]?.closed).toContain('persistent bash command timed out') }) - it('cancels in-flight work, resets the shell, and releases a queued call', async () => { - const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 5_000 }) - await call(ctx, owner, 'warm up') - stub.sessions[0]!.mode = 'wait-for-abort' - const controller = new AbortController() - const cancelled = call(ctx, owner, 'hang', controller.signal) - const queued = call(ctx, owner, 'after cancellation') - setTimeout(() => { - controller.abort(new Error('caller stopped')) - }, 5) + it.each(['wait-for-abort', 'end-on-abort'] as const)( + 'cancels %s work, resets the shell, and releases a queued call', + async (mode) => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 5_000 }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = mode + const controller = new AbortController() + const cancelled = call(ctx, owner, 'hang', controller.signal) + const queued = call(ctx, owner, 'after cancellation') + setTimeout(() => { + controller.abort(new Error('caller stopped')) + }, 5) - expect((await cancelled).isError).toBe(true) - expect(text(await queued)).toBe('hello from stub') - expect(stub.sessions[0]?.closed).toContain('persistent bash command aborted') - expect(stub.sessions).toHaveLength(2) - }) + expect((await cancelled).isError).toBe(true) + expect(text(await queued)).toBe('hello from stub') + expect(stub.sessions[0]?.closed).toContain('persistent bash command aborted') + expect(stub.sessions).toHaveLength(2) + }, + ) it.each(['init-exit', 'init-timeout'] as const)( 'fails initialization and closes the unusable shell for %s', From eac3b378fa8b8aac919403dd146e05ea8648dc60 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 21:34:30 +0800 Subject: [PATCH 22/72] =?UTF-8?q?fix(host):=20review=20round=206=20?= =?UTF-8?q?=E2=80=94=20progressive=20selection-anchored=20landing;=20ancho?= =?UTF-8?q?r=20on=20actual=20parent=20entries;=20focus-flag=20hygiene?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- .../directory-picker-browse/README.i18n.yaml | 4 +- .../host/directory-picker-browse/README.md | 2 +- .../host/directory-picker-browse/README.zh.md | 2 +- .../src/client/DirectoryBrowser.module.css | 5 +- .../src/client/DirectoryBrowser.tsx | 98 ++++++++------ .../tests/directory-browser.spec.tsx | 127 +++++++++++++++--- 9 files changed, 182 insertions(+), 64 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index e270a79a11..900e1d01b6 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 51d71d15cb5144d555a5b156d9b108d7a2ad41b8 -2026-07-28-directory-picker-capability-seam.zh.md: 60bc14c13d1e4655faefbd5eaa63469119bf0ca3 +2026-07-28-directory-picker-capability-seam.md: ad2aa904beddb2fe941883c3c1827702dbec9964 +2026-07-28-directory-picker-capability-seam.zh.md: 30e719ad9b4e8374496106b447e961a042c7d8b6 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 51d71d15cb..ad2aa904be 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -20,7 +20,7 @@ Placement and policy rulings folded into this decision: - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. - **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. -- **Navigation lands selection-anchored.** Away from the display root, the browse client's navigate (a crumb jump or a submitted path) lists the target's parent level with the target selected and its children on the right — two panes throughout, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The display root (home, or a rootward chain with no parent crumb) keeps the single wide level; the parent leg runs under the same supersession scope as the landing, and its failure falls back to the single-pane landing quietly — the target listed fine, and nobody asked to see the parent. +- **Navigation lands selection-anchored, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. - **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 60bc14c13d..30e719ad9b 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -20,7 +20,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 - **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。 -- **导航以选中项为锚落地。** 在展示根之外,browse 客户端的导航(crumb 跳转或提交的路径)列出目标的父层级,选中目标并在右侧展示其子项——全程双栏,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。展示根(home,或没有父 crumb 的抵根链)保持单个宽层级;父层级这一程与落地共用同一 supersession 范围,其失败会静默回退到单栏落地——目标本身列举无误,本也没有人要求查看父层级。 +- **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 842e91481c..d807bd737a 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/README.i18n.yaml @@ -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/directory-picker-browse/README.md -README.md: 51e27115b5179796810c19b618b08a3523de8d10 -README.zh.md: 4caeff1c2ebfea2c12e6bb598dae2c34f2e34d84 +README.md: 23153881b84dcb71dfb05d4f297a5818c410ca77 +README.zh.md: d7010e2941a801ba6358082824330eaae46e42b7 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 51e27115b5..23153881b8 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -6,7 +6,7 @@ The **in-app browsing backend** of the [directory-picker seam](../directory-pick Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated); window insertion is binary with an O(1) full-window tail rejection, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). -**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored: a crumb jump or a submitted path lists the target's parent level with the target selected, so stepping back keeps two panes while the display root keeps the single wide level; breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing (case-insensitively, over the listed — possibly truncated — rows only; Enter still navigates by the exact text), and cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft); a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from both filters; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). +**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored: a crumb jump or a submitted path commits the target immediately, then re-selects its actual entry in its parent level once that level arrives — two panes, so stepping back never collapses (a failed or truncated parent leg keeps the single-pane landing; the display root keeps the single wide level); breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing (case-insensitively, over the listed — possibly truncated — rows only; Enter still navigates by the exact text), and cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft); a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from both filters; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). ## Model Experience diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 4caeff1c2e..d7010e2941 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -6,7 +6,7 @@ 行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 -**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚落地:crumb 跳转或提交的路径会列出目标的父层级并选中目标,因此后退仍保持双栏,而展示根保持单个宽层级;带点击即编辑路径区的面包屑,其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;Enter 仍按确切文本导航)、按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿);基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受这两种过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 +**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚落地:crumb 跳转或提交的路径会立即提交目标,待父层级到达后再在其中重新选中目标的实际条目——双栏,因此后退绝不塌缩(父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;Enter 仍按确切文本导航)、按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿);基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受这两种过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 ## 模型体验 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index 2c09dd2940..85349962e5 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -261,8 +261,9 @@ } /* Show-hidden toggle: a subtle fixed-label text button left of the gap; - * the pressed state seats a check glyph before the label (Menu's selected - * vocabulary) instead of flipping the wording. */ + * the pressed state seats a check glyph after the label (Menu's selected + * vocabulary; trailing so the label never shifts) instead of flipping the + * wording. */ .showHiddenToggle { display: inline-flex; align-items: center; diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index ba0502fc65..859667d115 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -5,7 +5,10 @@ * breadcrumb, and a click-to-edit path zone; below it a Miller view — one * full-width level until a row is selected, then two columns splitting the * row evenly (256px floor; level | selected folder's children) around a - * hairline divider. Selecting in the + * hairline divider. Navigations land selection-anchored: a crumb jump or a + * submitted path commits the target immediately, then re-selects it in its + * parent level once that level arrives, so stepping back keeps two panes + * away from the display root. Selecting in the * right column shifts the view one level deeper. "New folder" opens a nested * create dialog targeting the selected folder (or the level itself) and * selects the created folder. Open adopts the selected folder, falling back @@ -215,12 +218,27 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }, [supersede, listDirectory]) /** - * Replace the whole view with a freshly navigated level. Away from the - * display root the landing keeps the navigated directory SELECTED inside - * its parent level (left pane = parent, right pane = its children), so a - * crumb jump or a submitted path reads as stepping back one pane instead - * of collapsing to a single column; the display root (the home level, or - * a chain with no parent) keeps the single wide level. + * Launch a follow-up listing under the CURRENT supersession seq: a newer + * intent aborts it like the leg it continues, and it supersedes nothing. + */ + const continueScan = useCallback((path: string): Promise => { + const controller = new AbortController() + scanController.current = controller + return listDirectory(path, controller.signal) + }, [listDirectory]) + + /** + * Replace the whole view with a freshly navigated level. The target level + * commits the moment it arrives (single wide level: the editor closes and + * loading ends on this first settlement, so an Enter-submitted navigation + * is never withdrawn waiting on anything further). Away from the display + * root — the same collapse the crumb header renders, so crumbs and pane + * shape never disagree — a parent leg then upgrades the landing in place: + * the target's ACTUAL parent-level entry re-selected (left pane = parent, + * right pane = the target), so a crumb jump reads as stepping back one + * pane. A failed parent leg, or a truncated parent window that lacks the + * target, leaves the committed single-pane landing — the upgrade must + * never orphan the selection it exists to anchor. */ const navigate = useCallback((path?: string) => { const { seq, scan } = launchListing(path) @@ -228,46 +246,38 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setError(null) scan.then((target) => { if (seq !== requestSeq.current) return + setParent(target) + setSelected(null) + setChild(null) + setLoading(false) + setPathDraft(null) + // Arity is label-independent: only the collapsed chain's depth decides. + if (displayCrumbs(target, '').length < 2) return const parentCrumb = target.crumbs.at(-2) - const anchor = target.crumbs.at(-1) - if (target.path === target.home || parentCrumb === undefined - /* v8 ignore next -- narrowing: the anchor crumb exists whenever a parent crumb does (root-to-target inclusive chain). */ - || anchor === undefined) { - setParent(target) - setSelected(null) - setChild(null) - setLoading(false) - setPathDraft(null) - return - } - // Two-pane landing: the parent leg runs under the same supersession - // scope (a newer intent aborts it like the first leg). - const controller = new AbortController() - scanController.current = controller - listDirectory(parentCrumb.path, controller.signal).then((parentLevel) => { + /* v8 ignore next -- narrowing: a two-deep display chain implies a parent crumb (root-to-target inclusive). */ + if (parentCrumb === undefined) return + continueScan(parentCrumb.path).then((parentLevel) => { if (seq !== requestSeq.current) return + // Windows resolves a typed path preserving its case; anchor on the + // parent level's actual entry so selection comparisons hold. + const sep = separatorOf(parentLevel) + const fold = (value: string): string => (sep === '\\' ? value.toLowerCase() : value) + const match = parentLevel.entries.find(entry => fold(entry.path) === fold(target.path)) + if (match === undefined) return setParent(parentLevel) - setSelected(anchor) + setSelected(match) setChild(target) - setLoading(false) - setPathDraft(null) }, () => { - if (seq !== requestSeq.current) return - // The target listed fine and is what the user asked for; a parent - // leg failure quietly falls back to the single-pane landing rather - // than surfacing an error for a level nobody requested. - setParent(target) - setSelected(null) - setChild(null) - setLoading(false) - setPathDraft(null) + // Swallows the parent-leg failure (its abort included): the + // committed single-pane landing stands, and nobody asked to see + // the parent level. }) }, (reason: unknown) => { if (seq !== requestSeq.current) return setLoading(false) setError(failureText(reason)) }) - }, [launchListing, listDirectory]) + }, [launchListing, continueScan]) // Editor-close focus parking (consumed by the refocus effect below the // miller-row ref): a pick parks on the selection's row, Enter and an @@ -302,6 +312,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // An unreadable selection cannot be the committing target while the // breadcrumb still names the level: fall back to the single pane. setSelected(null) + // Clearing the selection can unmount the very row the pick parked + // focus on (a dot-revealed hidden row re-hides); the refocus effect + // re-parks on the edit zone only if focus actually fell to body. + refocusEditZone.current = true }) }, [launchListing, pathDraft]) @@ -350,6 +364,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setPathDraft(null) setFolderDraft(null) setCreateError(null) + // A close mid-flight (failed Enter, then Cancel) may leave refocus + // flags armed; retire them so a later render cannot consume them. + refocusPick.current = false + refocusEditZone.current = false }, [open, navigate, supersede]) /** The folder a create or Open acts on: the selection, else the listed level. */ @@ -436,6 +454,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, } if (refocusEditZone.current) { refocusEditZone.current = false + // Re-park only when the close actually dropped focus to body; focus + // the user parked elsewhere (a surviving row) stays theirs. + if (document.activeElement !== document.body) return const zone = editZoneRef.current /* v8 ignore next -- narrowing guard: crumb mode renders the edit zone whenever the editor just closed. */ if (zone === null) return @@ -483,8 +504,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, event.stopPropagation() // Escape while the input holds focus is about to unmount it; with // focus already parked on a row, that row survives the cancel and - // keeps focus naturally. - if (document.activeElement === pathInputRef.current) refocusEditZone.current = true + // keeps focus naturally. Assignment (not a conditional set) also + // retires a stale flag a failed or still-upgrading Enter left. + refocusEditZone.current = document.activeElement === pathInputRef.current cancelPathEdit() }} // Focus leaving THIS dialog card while editing cancels like Escape. diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index ab60efcc5d..6fa9bb999f 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -194,7 +194,7 @@ describe('DirectoryBrowser', () => { expect(signals[2]?.aborted).toBe(true) }) - it('jumps back through a crumb into a fresh single-column level', async () => { + it('a crumb jump to the display root (home) lands the single wide level', async () => { mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) fireEvent.click(rowButton(screen.getByRole('listitem'))) @@ -235,35 +235,130 @@ describe('DirectoryBrowser', () => { expect(columns()).toHaveLength(1) }) - it('drops a parent leg that settles after a newer intent, resolving or rejecting', async () => { - const settlers: { resolve: (value: DirectoryListing) => void; reject: (reason: unknown) => void }[] = [] - const listDirectory = vi.fn(async (path?: string) => { - if (path === HOME) { - return new Promise((resolve, reject) => { settlers.push({ resolve, reject }) }) + it('commits the target immediately, aborts a superseded parent leg on the wire, and drops its late resolution', async () => { + const signals: (AbortSignal | undefined)[] = [] + const settlers: ((value: DirectoryListing) => void)[] = [] + // Only the FIRST explicit HOME request (the parent leg) hangs; the later + // home crumb jump lists normally. + let homeCalls = 0 + const listDirectory = vi.fn(async (path?: string, signal?: AbortSignal) => { + signals.push(signal) + if (path === HOME && ++homeCalls === 1) { + return new Promise((resolve) => { settlers.push(resolve) }) } return listingFor(path) }) mount({ listDirectory }) await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) - // Enter lands the target leg; the parent leg hangs. Escape supersedes - // the landing, and the late parent RESOLUTION must change nothing. fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: DOCS } }) fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) + // The target leg commits at once: editor closed, single-pane DOCS level, + // while the parent leg (upgrade) is still in flight. + await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) + expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + expect(columns()).toHaveLength(1) await waitFor(() => { expect(settlers).toHaveLength(1) }) - fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Escape' }) - await act(async () => { settlers[0]!.resolve(listingFor(HOME)) }) + // A newer jump aborts the pending parent leg ON THE WIRE, not merely + // dropping its settlement. + fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) + expect(signals[2]?.aborted).toBe(true) + await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') }) + // Its late resolution changes nothing either. + await act(async () => { settlers[0]!(listingFor(HOME)) }) expect(columns()).toHaveLength(1) - expect(screen.getByRole('listitem').textContent).toBe('Documents') - // Same shape, late parent REJECTION: equally silent. + expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull() + }) + + it('keeps the single-pane landing when the truncated parent level lacks the target', async () => { + const listDirectory = vi.fn(async (path?: string) => { + // The parent leg names HOME explicitly; serve it a truncated window + // that misses Documents (the initial open uses the absent-path form). + if (path === HOME) return { ...listingFor(HOME), entries: [], truncated: true } + return listingFor(path) + }) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: DOCS } }) fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) - await waitFor(() => { expect(settlers).toHaveLength(2) }) - fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Escape' }) - await act(async () => { settlers[1]!.reject(new Error('late')) }) + await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) + // The upgrade would orphan the selection (no source row): it stays off. + await act(async () => {}) expect(columns()).toHaveLength(1) - expect(screen.queryByRole('alert')).toBeNull() + expect(screen.queryByText('browser.truncated')).toBeNull() + }) + + it('anchors the upgrade on the parent level actual entry under Windows case folding', async () => { + const ROOT = 'C:\\' + const TYPED = 'c:\\users' + const winRoot: DirectoryListing = { + path: ROOT, + home: ROOT, + crumbs: [{ name: 'C:\\', path: ROOT, hidden: false }], + entries: [{ name: 'Users', path: 'C:\\Users', hidden: false }], + truncated: false, + } + const winUsers: DirectoryListing = { + path: TYPED, + home: ROOT, + crumbs: [{ name: 'C:\\', path: ROOT, hidden: false }, { name: 'users', path: TYPED, hidden: false }], + entries: [], + truncated: false, + } + mount({ listDirectory: vi.fn(async (path?: string) => (path === TYPED ? winUsers : winRoot)) }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: TYPED } }) + fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) + // The typed case differs from the real entry; the upgrade selects the + // parent level's ACTUAL entry so aria-current and exemptions hold. + await waitFor(() => { + expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true') + }) + expect(within(columns()[0]!).getByText('Users')).toBeTruthy() + }) + + it('re-parks focus on the edit zone when a failed pick unmounts a dot-revealed row', async () => { + const listDirectory = vi.fn(async (path?: string) => { + if (path === `${HOME}/.config`) { + throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'denied', details: { path } }) + } + return listingFor(path) + }) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: `${HOME}/.co` } }) + const row = rowButton(screen.getByRole('listitem')) + fireEvent.mouseDown(row) + fireEvent.click(row) + // The failed selection re-hides the picked row; focus fell to body and + // re-parks on the crumb edit zone. + await screen.findByRole('alert') + expect(screen.queryByText('.config')).toBeNull() + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' })) + }) + + it('leaves focus on a surviving row when its pick fails', async () => { + const listDirectory = vi.fn(async (path?: string) => { + if (path === DOCS) { + throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'denied', details: { path } }) + } + return listingFor(path) + }) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: `${HOME}/do` } }) + const row = rowButton(screen.getByRole('listitem')) + row.focus() + fireEvent.mouseDown(row) + fireEvent.click(row) + // Documents survives the cleared selection (it is not hidden): the + // user's focus on it is not yanked to the edit zone. + await screen.findByRole('alert') + expect(document.activeElement).toBe(row) }) it('falls back to the single-pane landing when the parent leg of a navigation fails', async () => { From 98e6a0573f09a328420194e8195f948339351527 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:37:12 +0800 Subject: [PATCH 23/72] fix(fs): keep one editor path contract --- ...rsistent-bash-str-replace-editor.i18n.yaml | 4 +-- ...7-29-persistent-bash-str-replace-editor.md | 2 +- ...9-persistent-bash-str-replace-editor.zh.md | 2 +- docs/config-catalog.md | 8 ++--- .../tool-str-replace-editor/README.i18n.yaml | 4 +-- packages/fs/tool-str-replace-editor/README.md | 5 +-- .../fs/tool-str-replace-editor/README.zh.md | 5 +-- .../fs/tool-str-replace-editor/src/index.ts | 34 +++++++------------ .../tests/tools.spec.ts | 20 ++++++----- 9 files changed, 33 insertions(+), 51 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml index e3b6121e19..df7df7c84c 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md -2026-07-29-persistent-bash-str-replace-editor.md: 286a53c1c686cc515b65119ed4b1a01a57b0614b -2026-07-29-persistent-bash-str-replace-editor.zh.md: d2417708c8a1334e9f8930481f4218cbefc5a87b +2026-07-29-persistent-bash-str-replace-editor.md: 26949e7435bcbf05132662c308f33f322920c7eb +2026-07-29-persistent-bash-str-replace-editor.zh.md: 6d5eadbfa68a59157e8f4bc148d03144bc665cb2 diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md index 286a53c1c6..26949e7435 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md @@ -12,7 +12,7 @@ Some deployments need a one-call Bash schema whose shell state survives across m `@deepseek-ai/dsh-tool-bash-persistent` consumes `ctx.pty` and registers one `bash(command)` tool. It lazily creates one interactive shell per exact Agent and serializes that owner's calls. Cwd, exported variables, activated environments, functions, and background jobs persist. Random private markers delimit command output. Retained scrollback is paged backward to recover the command's original prefix; a dropped prefix is reported explicitly. Timeout or cancellation closes the shell before another call can reuse uncertain state, and model-visible timeout/exit results disclose that reset. The configurable description defaults to persistence facts only, so network and package-mirror claims remain deployment-owned. -`@deepseek-ai/dsh-tool-str-replace-editor` independently consumes `ctx.fs` and registers `str_replace_editor` with `view`, `create`, `str_replace`, and `insert`. It provides numbered text views, filtered two-level directory listings, unique literal replacement, canonical insertion boundaries, and bounded output. The public schema and failures use only `old_str`; canonical mode requires absolute paths and expands tabs before mutations. Deployments with an intentional session-cwd contract can disable the absolute-path requirement. The plugin can compose with persistent Bash, one-shot Bash, sandboxed Bash, or no shell. +`@deepseek-ai/dsh-tool-str-replace-editor` independently consumes `ctx.fs` and registers `str_replace_editor` with `view`, `create`, `str_replace`, and `insert`. It provides numbered text views, filtered two-level directory listings, unique literal replacement, canonical insertion boundaries, and bounded output. Paths are absolute, mutations preserve tabs outside the requested edit, and the public schema and failures use only `old_str`. The plugin can compose with persistent Bash, one-shot Bash, sandboxed Bash, or no shell. `dsh-system-prompt` accepts `includeHarnessIdentity: false`, while `dsh-agent-spine-demo` forwards that setting and accepts `toolBash: false`. A deployment can therefore own an exact persona and replace the spine's native Bash without duplicate prompt or tool registrations. Existing defaults remain unchanged. diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md index d2417708c8..6d5eadbfa6 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md @@ -12,7 +12,7 @@ `@deepseek-ai/dsh-tool-bash-persistent` 消费 `ctx.pty` 并注册一个 `bash(command)` 工具。它为每个精确 Agent 惰性创建一个交互式 shell,并串行化该所有者的调用。Cwd、导出的变量、已激活环境、函数和后台任务会保留。随机私有标记划分命令输出;保留的 scrollback 会向前分页,以恢复命令真正的输出前缀,若前缀已被丢弃则明确告知。超时或取消会先关闭 shell,避免下一次调用复用状态不确定的会话,模型可见的超时/退出结果也会说明该重置。可配置描述默认只声明持久性事实,因此网络和软件包镜像等声明仍归部署所有。 -`@deepseek-ai/dsh-tool-str-replace-editor` 独立消费 `ctx.fs`,注册包含 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor`。它提供带行号文本查看、过滤后的两层目录列表、唯一字面量替换、规范插入边界和有界输出。公开 schema 与错误只使用 `old_str`;规范模式要求绝对路径,并在变更前展开制表符。有明确 session-cwd 契约的部署可以关闭绝对路径要求。它可以与持久 Bash、一次性 Bash、沙箱 Bash 或无 shell 组合。 +`@deepseek-ai/dsh-tool-str-replace-editor` 独立消费 `ctx.fs`,注册包含 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor`。它提供带行号文本查看、过滤后的两层目录列表、唯一字面量替换、规范插入边界和有界输出。路径必须为绝对路径,变更会保留请求编辑范围之外的制表符,且公开 schema 与错误只使用 `old_str`。它可以与持久 Bash、一次性 Bash、沙箱 Bash 或无 shell 组合。 `dsh-system-prompt` 接受 `includeHarnessIdentity: false`;`dsh-agent-spine-demo` 会转发该设置,并接受 `toolBash: false`。因此部署可以拥有精确 persona,并替换 spine 的原生 Bash,而不会重复注册提示词或工具。既有默认值不变。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2e6ffaf3d8..dc2a740349 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1593,7 +1593,7 @@ export interface Config { } ``` -Source: [`packages/pty/tool-bash-persistent/src/index.ts:373`](../packages/pty/tool-bash-persistent/src/index.ts) +Source: [`packages/pty/tool-bash-persistent/src/index.ts:395`](../packages/pty/tool-bash-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` @@ -1764,14 +1764,10 @@ export interface Config { maxOutputChars?: number /** Model-facing tool description. */ description?: string - /** Require local absolute paths like the canonical editor contract (default true). */ - requireAbsolutePath?: boolean - /** Expand tabs across the full file before each mutation, matching the canonical editor (default true). */ - expandTabsOnMutation?: boolean } ``` -Source: [`packages/fs/tool-str-replace-editor/src/index.ts:539`](../packages/fs/tool-str-replace-editor/src/index.ts) +Source: [`packages/fs/tool-str-replace-editor/src/index.ts:514`](../packages/fs/tool-str-replace-editor/src/index.ts) ## `@deepseek-ai/dsh-tool-subagent` diff --git a/packages/fs/tool-str-replace-editor/README.i18n.yaml b/packages/fs/tool-str-replace-editor/README.i18n.yaml index 10b1f19182..9c7a2190c4 100644 --- a/packages/fs/tool-str-replace-editor/README.i18n.yaml +++ b/packages/fs/tool-str-replace-editor/README.i18n.yaml @@ -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/fs/tool-str-replace-editor/README.md -README.md: 8ac6a22f24ddcdd3818b346e3426e58e718027e2 -README.zh.md: cf82b132b63730af209c159066a70f6a18b77f39 +README.md: 12224537ab2ca2d2ba97e93fe8dc2192fa9ac1aa +README.zh.md: 5481723f8a3077ee329ec202b12a67b678abc691 diff --git a/packages/fs/tool-str-replace-editor/README.md b/packages/fs/tool-str-replace-editor/README.md index 8ac6a22f24..12224537ab 100644 --- a/packages/fs/tool-str-replace-editor/README.md +++ b/packages/fs/tool-str-replace-editor/README.md @@ -10,12 +10,10 @@ Standalone model-facing `str_replace_editor` over `ctx.fs`. It can be composed w |---|---:|---| | `maxOutputChars` | `16000` | Prefix characters retained for file and directory views. | | `description` | Editor command guide | Model-facing tool description. | -| `requireAbsolutePath` | `true` | Reject relative paths; disable only for deployments with a deliberate session-cwd contract. | -| `expandTabsOnMutation` | `true` | Preserve the canonical Claude SWE behavior that expands tabs across the whole file before replace/insert. Set `false` for atomic literal replacement that preserves unrelated tabs. | ## Tool -The schema provides `view`, `create`, `str_replace`, and `insert`. File views use one-based line numbers; directory views omit hidden, dependency, and Python-cache entries and descend two levels. Replacement requires one unique literal match and reports errors only in the public `old_str` vocabulary. Insert follows the selected zero-based insertion boundary without adding an implicit trailing newline. +The schema provides `view`, `create`, `str_replace`, and `insert` over absolute paths. File views use one-based line numbers; directory views omit hidden, dependency, and Python-cache entries and descend two levels. Replacement requires one unique literal match and reports errors only in the public `old_str` vocabulary. Insert follows the selected zero-based insertion boundary without adding an implicit trailing newline. Mutations preserve tabs outside the requested edit. ## Model Experience @@ -51,5 +49,4 @@ Append-only tool results follow the reusable request prefix. - Operations target UTF-8 text; binary files are unsupported. - `str_replace` intentionally rejects zero or multiple matches and has no `replace_all` argument. -- Canonical mode (`expandTabsOnMutation: true`) expands tabs in the entire file before replacement or insertion, including lines outside the edited region. Set it to `false` for Makefiles and other tab-sensitive files. - Every mutation goes through `fs/write-intent` or `fs/edit-intent`, resolves the current session sandbox policy, and delegates enforcement to the mounted filesystem and policy plugins. diff --git a/packages/fs/tool-str-replace-editor/README.zh.md b/packages/fs/tool-str-replace-editor/README.zh.md index cf82b132b6..5481723f8a 100644 --- a/packages/fs/tool-str-replace-editor/README.zh.md +++ b/packages/fs/tool-str-replace-editor/README.zh.md @@ -10,12 +10,10 @@ |---|---:|---| | `maxOutputChars` | `16000` | 文件和目录查看结果保留的前缀字符数。 | | `description` | 编辑器命令指南 | 面向模型的工具描述。 | -| `requireAbsolutePath` | `true` | 拒绝相对路径;仅当部署明确约定 session cwd 时才应关闭。 | -| `expandTabsOnMutation` | `true` | 保留 Claude SWE 参考行为:替换/插入前展开整个文件的制表符。设为 `false` 时使用原子字面量替换,并保留未触及的制表符。 | ## 工具 -Schema 提供 `view`、`create`、`str_replace` 与 `insert`。文件查看使用从一开始的行号;目录查看忽略隐藏、依赖与 Python 缓存条目并下探两层。替换要求字面量唯一匹配,错误只使用公开的 `old_str` 词汇。插入遵循所选的零基插入边界,不会隐式补尾换行。 +Schema 提供针对绝对路径的 `view`、`create`、`str_replace` 与 `insert`。文件查看使用从一开始的行号;目录查看忽略隐藏、依赖与 Python 缓存条目并下探两层。替换要求字面量唯一匹配,错误只使用公开的 `old_str` 词汇。插入遵循所选的零基插入边界,不会隐式补尾换行。修改操作会保留请求编辑范围之外的制表符。 ## 模型体验 @@ -51,5 +49,4 @@ Schema 提供 `view`、`create`、`str_replace` 与 `insert`。文件查看使 - 操作面向 UTF-8 文本,不支持二进制文件。 - `str_replace` 刻意拒绝零匹配或多匹配,且没有 `replace_all` 参数。 -- 规范模式(`expandTabsOnMutation: true`)会在替换或插入前展开整个文件中的制表符,包括未编辑区域。Makefile 等依赖制表符的文件应设为 `false`。 - 每个修改操作都会经过 `fs/write-intent` 或 `fs/edit-intent`,解析当前 session 的沙箱策略,并交由挂载的文件系统与策略插件执行。 diff --git a/packages/fs/tool-str-replace-editor/src/index.ts b/packages/fs/tool-str-replace-editor/src/index.ts index 98bfd6528d..801e9200b4 100644 --- a/packages/fs/tool-str-replace-editor/src/index.ts +++ b/packages/fs/tool-str-replace-editor/src/index.ts @@ -105,12 +105,11 @@ class MutationPolicy { async function resolveTarget( ctx: Context, path: string, - requireAbsolutePath: boolean, exec: ToolRunContext, workspaceRoot?: string, ): Promise { if (path.trim().length === 0) throw new Error('path must be a non-empty string') - if (requireAbsolutePath && !isAbsolute(path)) { + if (!isAbsolute(path)) { throw new Error(`The path ${path} is not an absolute path, it should start with \`/\`. Maybe you meant /${path}?`) } const cwd = exec.agent?.session.header.cwd ?? workspaceRoot @@ -237,10 +236,9 @@ async function viewPath( path: string, viewRange: number[] | undefined, maxOutputChars: number, - requireAbsolutePath: boolean, exec: ToolRunContext, ): Promise { - const target = await resolveTarget(ctx, path, requireAbsolutePath, exec) + const target = await resolveTarget(ctx, path, exec) const info = await statExisting(ctx, target, 'view', exec) if (info.type === 'directory') { if (viewRange !== undefined) { @@ -261,12 +259,11 @@ async function createFile( policy: MutationPolicy, path: string, fileText: string | undefined, - requireAbsolutePath: boolean, exec: ToolRunContext, ): Promise { const content = requiredForCommand(fileText, 'file_text', 'create') const sandboxPolicy = policy.resolve(exec) - const target = await resolveTarget(ctx, path, requireAbsolutePath, exec, sandboxPolicy?.workspaceRoot) + const target = await resolveTarget(ctx, path, exec, sandboxPolicy?.workspaceRoot) if (await ctx.fs.stat(target, exec.signal) !== undefined) { throw new Error(`File already exists at: ${target.displayPath}. Cannot overwrite files using command \`create\`.`) } @@ -298,11 +295,10 @@ async function replaceInFile( path: string, oldStr: string | undefined, newStr: string | undefined, - requireAbsolutePath: boolean, exec: ToolRunContext, ): Promise { const sandboxPolicy = policy.resolve(exec) - const target = await resolveTarget(ctx, path, requireAbsolutePath, exec, sandboxPolicy?.workspaceRoot) + const target = await resolveTarget(ctx, path, exec, sandboxPolicy?.workspaceRoot) const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined) const oldValue = requiredForCommand(oldStr, 'old_str', 'str_replace', false) const newValue = newStr ?? '' @@ -327,10 +323,12 @@ async function replaceInFile( } let outcome try { - outcome = await ctx.fs.editText( + outcome = await ctx.fs.writeText( target, - { oldString: oldValue, newString: newValue, replaceAll: false }, - intent ?? { version: info.version }, + before.replace(oldValue, newValue), + intent === undefined + ? { kind: 'replaceIfVersion', version: info.version } + : { kind: 'replaceIfVersion', version: intent.version }, exec.signal, sandboxPolicy, ) @@ -347,13 +345,12 @@ async function insertInFile( path: string, insertLine: number | undefined, newStr: string | undefined, - requireAbsolutePath: boolean, exec: ToolRunContext, ): Promise { if (insertLine === undefined) throw new Error('Parameter `insert_line` is required for command: insert') const value = requiredForCommand(newStr, 'new_str', 'insert') const sandboxPolicy = policy.resolve(exec) - const target = await resolveTarget(ctx, path, requireAbsolutePath, exec, sandboxPolicy?.workspaceRoot) + const target = await resolveTarget(ctx, path, exec, sandboxPolicy?.workspaceRoot) const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined) const info = await statExisting(ctx, target, 'insert', exec) if (info.type !== 'file') { @@ -387,7 +384,6 @@ async function insertInFile( interface ResolvedConfig { maxOutputChars: number description: string - requireAbsolutePath: boolean } function presentEditorCall(args: { @@ -484,9 +480,9 @@ function registerStrReplaceEditor(ctx: Context, config: ResolvedConfig): void { async execute(args, exec) { switch (args.command) { case 'view': - return viewPath(ctx, args.path, args.view_range, config.maxOutputChars, config.requireAbsolutePath, exec) + return viewPath(ctx, args.path, args.view_range, config.maxOutputChars, exec) case 'create': - return createFile(ctx, policy, args.path, args.file_text, config.requireAbsolutePath, exec) + return createFile(ctx, policy, args.path, args.file_text, exec) case 'str_replace': return replaceInFile( ctx, @@ -494,7 +490,6 @@ function registerStrReplaceEditor(ctx: Context, config: ResolvedConfig): void { args.path, args.old_str, args.new_str, - config.requireAbsolutePath, exec, ) case 'insert': @@ -504,7 +499,6 @@ function registerStrReplaceEditor(ctx: Context, config: ResolvedConfig): void { args.path, args.insert_line, args.new_str, - config.requireAbsolutePath, exec, ) } @@ -522,15 +516,12 @@ export interface Config { maxOutputChars?: number /** Model-facing tool description. */ description?: string - /** Require local absolute paths like the canonical editor contract (default true). */ - requireAbsolutePath?: boolean } /** Runtime configuration schema for the string-replacement editor tool. */ export const Config: z = z.object({ maxOutputChars: z.number().default(16_000), description: z.string().default(DEFAULT_DESCRIPTION), - requireAbsolutePath: z.boolean().default(true), }) /** Register one `str_replace_editor` tool over `ctx.fs`. */ @@ -538,7 +529,6 @@ export function apply(ctx: Context, config: Config): void { const resolved: ResolvedConfig = { maxOutputChars: config.maxOutputChars ?? 16_000, description: config.description ?? DEFAULT_DESCRIPTION, - requireAbsolutePath: config.requireAbsolutePath ?? true, } if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) { throw new Error('tool-str-replace-editor: maxOutputChars must be a positive safe integer') diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 9518057634..64bdd481ee 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -316,6 +316,16 @@ describe('tool-str-replace-editor', () => { expect(text(repeatedMultiline)) .toContain('Multiple occurrences of old_str `alpha\nbeta` in lines [1, 4]') + const mixedEol = join(root, 'mixed-eol.txt') + await writeFile(mixedEol, 'alpha\r\nbeta\nmiddle\nalpha\nbeta') + expect((await call(ctx, owner, { + command: 'str_replace', + path: mixedEol, + old_str: 'alpha\r\nbeta', + new_str: 'replaced', + })).isError).toBe(false) + expect(await readFile(mixedEol, 'utf8')).toBe('replaced\nmiddle\nalpha\nbeta') + const relative = await call(ctx, owner, { command: 'view', path: 'ambiguous.txt' }) expect(relative.isError).toBe(true) expect(text(relative)).toContain('is not an absolute path') @@ -378,13 +388,6 @@ describe('tool-str-replace-editor', () => { })).error).toMatchObject({ info: { code: 'FS_NOT_REGULAR_FILE' } }) }) - it('can opt into session-relative paths for non-canonical deployments', async () => { - const { ctx, root, owner } = await setup({ requireAbsolutePath: false }) - await writeFile(join(root, 'relative.txt'), 'relative') - expect(text(await call(ctx, owner, { command: 'view', path: 'relative.txt' }))) - .toContain("Here's the content of") - }) - it('delegates read-before-edit decisions to fs-policy', async () => { const { ctx, root, owner } = await setup({}, { fsPolicy: true }) const existing = join(root, 'existing.txt') @@ -490,7 +493,7 @@ describe('tool-str-replace-editor', () => { const failWrite = async (): Promise => { throw new Error('backend write failed') } - ctx.fs.editText = failWrite + ctx.fs.writeText = failWrite const replace = await call(ctx, owner, { command: 'str_replace', @@ -501,7 +504,6 @@ describe('tool-str-replace-editor', () => { expect(replace.isError).toBe(true) expect(text(replace)).toContain('backend write failed') - ctx.fs.writeText = failWrite const insert = await call(ctx, owner, { command: 'insert', path, From 7b1e8978a989cd4406e5d8fa123c82590c75de2e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:49:21 +0800 Subject: [PATCH 24/72] fix(build): validate packaged spawn helpers --- ...rsistent-bash-str-replace-editor.i18n.yaml | 4 +- ...7-29-persistent-bash-str-replace-editor.md | 2 +- ...9-persistent-bash-str-replace-editor.zh.md | 2 +- patches/node-pty@1.1.0.patch | 6 +- pnpm-lock.yaml | 8 +-- python/sdk-runtime/README.i18n.yaml | 4 +- python/sdk-runtime/README.md | 2 +- python/sdk-runtime/README.zh.md | 2 +- python/sdk-runtime/hatch_build.py | 33 ++++++++++- python/sdk/tests/test_release_version.py | 55 ++++++++++++++++++- scripts/build-exe-for-python-sdk.ts | 32 ++++++++++- scripts/build-python-release.py | 37 +++++++++++++ 12 files changed, 167 insertions(+), 20 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml index df7df7c84c..cb1e7631fd 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md -2026-07-29-persistent-bash-str-replace-editor.md: 26949e7435bcbf05132662c308f33f322920c7eb -2026-07-29-persistent-bash-str-replace-editor.zh.md: 6d5eadbfa68a59157e8f4bc148d03144bc665cb2 +2026-07-29-persistent-bash-str-replace-editor.md: b1be9cc40e11b07b722877e666de0e0328636a05 +2026-07-29-persistent-bash-str-replace-editor.zh.md: 0bf100514e971dfa759f44dcca15a7ad6a2fdd8a diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md index 26949e7435..b1be9cc40e 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md @@ -16,7 +16,7 @@ Some deployments need a one-call Bash schema whose shell state survives across m `dsh-system-prompt` accepts `includeHarnessIdentity: false`, while `dsh-agent-spine-demo` forwards that setting and accepts `toolBash: false`. A deployment can therefore own an exact persona and replace the spine's native Bash without duplicate prompt or tool registrations. Existing defaults remain unchanged. -Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper`, each packaged runtime executable ships with an architecture-matched `-spawn-helper` sibling. A pinned `node-pty` patch resolves that sibling only when present (or when `DSH_NODE_PTY_SPAWN_HELPER` explicitly selects one), preserving upstream lookup in ordinary Node runs; the executable and runtime-wheel builders fail before publication when the helper is absent, mismatched, or not executable. +Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper`, each packaged runtime executable ships with an architecture-matched `-spawn-helper` sibling. A pinned `node-pty` patch resolves that sibling only when present, preserving upstream lookup in ordinary Node runs. The explicit `DSH_NODE_PTY_SPAWN_HELPER` override remains for a current external consumer that supplies a non-sibling helper. The executable and runtime-wheel builders inspect ELF or thin Mach-O headers and fail before publication when the helper is absent, mismatched, or not executable. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md index 6d5eadbfa6..0bf100514e 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md @@ -16,7 +16,7 @@ `dsh-system-prompt` 接受 `includeHarnessIdentity: false`;`dsh-agent-spine-demo` 会转发该设置,并接受 `toolBash: false`。因此部署可以拥有精确 persona,并替换 spine 的原生 Bash,而不会重复注册提示词或工具。既有默认值不变。 -两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 会执行原生 `spawn-helper`,每个打包后的运行时可执行文件都会携带一个架构匹配的 `-spawn-helper` 伴随文件。固定版本的 `node-pty` 补丁只在该伴随文件存在时解析它(也可由 `DSH_NODE_PTY_SPAWN_HELPER` 显式指定),普通 Node 运行仍保留上游查找方式;若 helper 缺失、架构不匹配或不可执行,可执行文件与 runtime wheel 构建会在发布前失败。 +两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 会执行原生 `spawn-helper`,每个打包后的运行时可执行文件都会携带一个架构匹配的 `-spawn-helper` 伴随文件。固定版本的 `node-pty` 补丁只在该伴随文件存在时解析它,普通 Node 运行仍保留上游查找方式。显式的 `DSH_NODE_PTY_SPAWN_HELPER` 覆盖仍予保留,供当前提供非伴随 helper 的外部消费方使用。可执行文件与运行时 wheel 包的构建器会检查 ELF 或 thin Mach-O 文件头;若 helper 缺失、架构不匹配或不可执行,构建会在发布前失败。 ## 考虑过的替代方案 diff --git a/patches/node-pty@1.1.0.patch b/patches/node-pty@1.1.0.patch index f0de7b9054..56892a3d58 100644 --- a/patches/node-pty@1.1.0.patch +++ b/patches/node-pty@1.1.0.patch @@ -2,7 +2,7 @@ diff --git a/lib/unixTerminal.js b/lib/unixTerminal.js index 1ec12f796a822c78fba9ad7f6448c3987e325c23..5cd6b7d635f4752be5a6c5ff9cf9edf988cf94c5 100644 --- a/lib/unixTerminal.js +++ b/lib/unixTerminal.js -@@ -26,10 +26,22 @@ var terminal_1 = require("./terminal"); +@@ -26,10 +26,23 @@ var terminal_1 = require("./terminal"); var utils_1 = require("./utils"); var native = utils_1.loadNativeModule('pty'); var pty = native.module; @@ -10,6 +10,7 @@ index 1ec12f796a822c78fba9ad7f6448c3987e325c23..5cd6b7d635f4752be5a6c5ff9cf9edf9 -helperPath = path.resolve(__dirname, helperPath); -helperPath = helperPath.replace('app.asar', 'app.asar.unpacked'); -helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked'); ++// A current external embedded-runtime consumer supplies a non-sibling helper. +var helperPath = process.env.DSH_NODE_PTY_SPAWN_HELPER; +if (helperPath) { + helperPath = path.resolve(helperPath); @@ -33,7 +34,7 @@ diff --git a/src/unixTerminal.ts b/src/unixTerminal.ts index 98733dc0cd752b554bd94e45904ca341ad141bba..fa234291206617ae5a6d8605abf9771220392d17 100644 --- a/src/unixTerminal.ts +++ b/src/unixTerminal.ts -@@ -14,10 +14,20 @@ import { assign, loadNativeModule } from './utils'; +@@ -14,10 +14,21 @@ import { assign, loadNativeModule } from './utils'; const native = loadNativeModule('pty'); const pty: IUnixNative = native.module; @@ -41,6 +42,7 @@ index 98733dc0cd752b554bd94e45904ca341ad141bba..fa234291206617ae5a6d8605abf97712 -helperPath = path.resolve(__dirname, helperPath); -helperPath = helperPath.replace('app.asar', 'app.asar.unpacked'); -helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked'); ++// A current external embedded-runtime consumer supplies a non-sibling helper. +let helperPath = process.env.DSH_NODE_PTY_SPAWN_HELPER; +if (helperPath) { + helperPath = path.resolve(helperPath); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73092a292c..82af864f54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: patchedDependencies: '@earendil-works/pi-tui@0.80.7': 6c30c5386c0159131e1361023cddf31377f5728962524841964373312c1ed946 - node-pty@1.1.0: 4a1568bc9ef77084629054d0736430818818155abcd0dce581ef8c782e974c15 + node-pty@1.1.0: 7a0c04f1f49d798a9ffe2f7f414c01064a44ca2489772d0c3e1235ab336755e6 importers: @@ -656,7 +656,7 @@ importers: devDependencies: node-pty: specifier: 1.1.0 - version: 1.1.0(patch_hash=4a1568bc9ef77084629054d0736430818818155abcd0dce581ef8c782e974c15) + version: 1.1.0(patch_hash=7a0c04f1f49d798a9ffe2f7f414c01064a44ca2489772d0c3e1235ab336755e6) packages/acp/acp: dependencies: @@ -3402,7 +3402,7 @@ importers: dependencies: node-pty: specifier: ^1.1.0 - version: 1.1.0(patch_hash=4a1568bc9ef77084629054d0736430818818155abcd0dce581ef8c782e974c15) + version: 1.1.0(patch_hash=7a0c04f1f49d798a9ffe2f7f414c01064a44ca2489772d0c3e1235ab336755e6) schemastery: specifier: ^3.18.0 version: 3.18.0 @@ -15287,7 +15287,7 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - node-pty@1.1.0(patch_hash=4a1568bc9ef77084629054d0736430818818155abcd0dce581ef8c782e974c15): + node-pty@1.1.0(patch_hash=7a0c04f1f49d798a9ffe2f7f414c01064a44ca2489772d0c3e1235ab336755e6): dependencies: node-addon-api: 7.1.1 diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index 4b104ec211..c25eecf5de 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -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 python/sdk-runtime/README.md -README.md: ee3791eddf26b526316d4f3952793a03cc48841e -README.zh.md: 59d40ee56688cb377902ff126b7fa77606c7ad8b +README.md: 0869b4a9dce0e261b168f21c90a80faf81ea4a64 +README.zh.md: 3f2342c4c66e24b8213c78d4e7530c3360497022 diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index ee3791eddf..0869b4a9dc 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -8,7 +8,7 @@ Runtime carrier package for the Python SDK (dist `deepseek-harness-runtime-bin`, Two carriers coexist under `src/deepseek_harness_runtime/runtime/`, both injected by the repo's `scripts/build-exe-for-python-sdk.ts` build and both gitignored: -- **exe (production)** — a single-file Node executable `dsh-jsonrpc-agent-pkg--` plus its native `-spawn-helper` sibling (platform: `linux`/`macos`; arch: `x64`/`arm64`). The helper is required by `node-pty`; both files are built and validated as one runtime product. No Node installation is needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists. +- **exe (production)** — a single-file Node executable `dsh-jsonrpc-agent-pkg--` plus its native `-spawn-helper` sibling (platform: `linux`/`macos`; arch: `x64`/`arm64`). The helper is required by `node-pty`; both files are built as one runtime product, and ELF or thin Mach-O headers must match the target. No Node installation is needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists. - **node (dev-only)** — the full deploy closure under `runtime/node/` (`package.json` + `node_modules/`), executed as `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` on a system Node >= 22.19. It is the current checkout's source build, meant for repo-local development and verification only; it is never selected automatically and is excluded from distributions. Both carriers hold the same content, defined once: the [package.json](package.json) at this package's root is the deploy root of the single-exe pipeline — a pure dependency manifest (no code of its own) whose dependency closure IS both the plugin set compiled into the exe and the tree materialized into `runtime/node/`. Adding a plugin to the distribution means adding one dependency line there and rebuilding. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 59d40ee566..3f2342c4c6 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -8,7 +8,7 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`, 两种载体并存于 `src/deepseek_harness_runtime/runtime/` 之下,均由仓库的 `scripts/build-exe-for-python-sdk.ts` 构建注入,且均被 git 忽略: -- **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg--` 及其原生 `-spawn-helper` 伴随文件(platform:`linux`/`macos`;arch:`x64`/`arm64`)。`node-pty` 需要该 helper;构建与校验会把两者视作同一个运行时产物。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 +- **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg--` 及其原生 `-spawn-helper` 伴随文件(platform:`linux`/`macos`;arch:`x64`/`arm64`)。`node-pty` 需要该 helper;两者作为一个运行时产物构建,且 ELF 或 thin Mach-O 文件头必须与目标匹配。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 - **`node`(仅限开发)**——`runtime/node/` 下的完整部署闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。 两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](package.json) 是 single-exe 流水线的部署根目录——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py index 108e77cf2c..f2df169cfc 100644 --- a/python/sdk-runtime/hatch_build.py +++ b/python/sdk-runtime/hatch_build.py @@ -16,6 +16,34 @@ _PLATFORMS = { _SPAWN_HELPER_SUFFIX = "-spawn-helper" +def _spawn_helper_binary_target(header: bytes) -> str | None: + if ( + len(header) >= 20 + and header[:4] == b"\x7fELF" + and header[4] == 2 + and header[5] == 1 + ): + machine = int.from_bytes(header[18:20], "little") + if machine == 62: + return "linux-x64" + if machine == 183: + return "linux-arm64" + if len(header) >= 8 and header[:4] == b"\xcf\xfa\xed\xfe": + if int.from_bytes(header[4:8], "little") == 0x0100000C: + return "macos-arm64" + return None + + +def _validate_spawn_helper(path: Path, expected_target: str) -> None: + with path.open("rb") as helper: + actual_target = _spawn_helper_binary_target(helper.read(20)) + if actual_target != expected_target: + raise RuntimeError( + f"runtime spawn helper binary mismatch: expected {expected_target}, " + f"found {actual_target or 'unsupported format or architecture'} at {path}" + ) + + def _host_platform_tag() -> str: machine = platform.machine().lower() arch = "arm64" if machine in {"arm64", "aarch64"} else "x64" if machine in {"x86_64", "amd64"} else machine @@ -39,13 +67,13 @@ class RuntimeBuildHook(BuildHookInterface): ) platform_tag = os.environ.get("DSH_RUNTIME_PLATFORM_TAG") or _host_platform_tag() - matches = [value for value in _PLATFORMS.values() if value[0] == platform_tag] + matches = [(key, value) for key, value in _PLATFORMS.items() if value[0] == platform_tag] if len(matches) != 1: supported = ", ".join(value[0] for value in _PLATFORMS.values()) raise RuntimeError( f"unsupported DSH_RUNTIME_PLATFORM_TAG {platform_tag!r}; expected one of {supported}" ) - expected_executable = matches[0][1] + expected_target, (_, expected_executable) = matches[0] runtime_dir = Path(self.root) / "src" / "deepseek_harness_runtime" / "runtime" runtime_files = sorted(runtime_dir.glob("dsh-jsonrpc-agent-pkg-*") if runtime_dir.is_dir() else []) executables = [path for path in runtime_files if not path.name.endswith(_SPAWN_HELPER_SUFFIX)] @@ -64,6 +92,7 @@ class RuntimeBuildHook(BuildHookInterface): for executable in [executables[0], helpers[0]]: if executable.stat().st_mode & stat.S_IXUSR == 0: raise RuntimeError(f"runtime executable is not executable: {executable}") + _validate_spawn_helper(helpers[0], expected_target) build_data["pure_python"] = False build_data["infer_tag"] = False diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index 7b7c8254b1..698676a636 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -16,6 +16,18 @@ SCRIPT = ROOT / "scripts" / "build-python-release.py" build_python_release = SimpleNamespace(**runpy.run_path(str(SCRIPT))) +def helper_header(target: str) -> bytes: + header = bytearray(20) + if target.startswith("linux-"): + header[:6] = b"\x7fELF\x02\x01" + machine = 62 if target == "linux-x64" else 183 + header[18:20] = machine.to_bytes(2, "little") + else: + header[:4] = b"\xcf\xfa\xed\xfe" + header[4:8] = (0x0100000C).to_bytes(4, "little") + return bytes(header) + + def test_repository_version_matches_root_package_json() -> None: expected = json.loads((ROOT / "package.json").read_text())["version"] @@ -45,7 +57,7 @@ def test_stage_runtime_copies_executable_and_spawn_helper(tmp_path: Path) -> Non executable.write_bytes(b"runtime") executable.chmod(0o755) spawn_helper = Path(f"{executable}-spawn-helper") - spawn_helper.write_bytes(b"helper") + spawn_helper.write_bytes(helper_header("macos-arm64")) spawn_helper.chmod(0o751) destination = tmp_path / "staging" @@ -59,7 +71,7 @@ def test_stage_runtime_copies_executable_and_spawn_helper(tmp_path: Path) -> Non runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" assert (runtime_dir / executable.name).read_bytes() == b"runtime" copied_helper = runtime_dir / spawn_helper.name - assert copied_helper.read_bytes() == b"helper" + assert copied_helper.read_bytes() == helper_header("macos-arm64") assert copied_helper.stat().st_mode & stat.S_IXUSR @@ -75,3 +87,42 @@ def test_stage_runtime_rejects_missing_spawn_helper(tmp_path: Path) -> None: executable, executable.name, ) + + +@pytest.mark.parametrize("target", ["linux-x64", "linux-arm64", "macos-arm64"]) +def test_spawn_helper_binary_target(target: str) -> None: + assert build_python_release.spawn_helper_binary_target(helper_header(target)) == target + + +def test_stage_runtime_rejects_mismatched_spawn_helper(tmp_path: Path) -> None: + executable = tmp_path / "dsh-jsonrpc-agent-pkg-linux-x64" + executable.write_bytes(b"runtime") + executable.chmod(0o755) + spawn_helper = Path(f"{executable}-spawn-helper") + spawn_helper.write_bytes(helper_header("linux-arm64")) + spawn_helper.chmod(0o755) + + with pytest.raises(ValueError, match="expected linux-x64, found linux-arm64"): + build_python_release.stage_runtime( + tmp_path / "staging", + "1.2.3", + executable, + executable.name, + ) + + +def test_stage_runtime_rejects_non_binary_spawn_helper(tmp_path: Path) -> None: + executable = tmp_path / "dsh-jsonrpc-agent-pkg-macos-arm64" + executable.write_bytes(b"runtime") + executable.chmod(0o755) + spawn_helper = Path(f"{executable}-spawn-helper") + spawn_helper.write_bytes(b"helper") + spawn_helper.chmod(0o755) + + with pytest.raises(ValueError, match="unsupported format or architecture"): + build_python_release.stage_runtime( + tmp_path / "staging", + "1.2.3", + executable, + executable.name, + ) diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index 38400009ac..24cd6c0383 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -7,7 +7,7 @@ */ import { spawn } from 'node:child_process' -import { existsSync, mkdirSync, statSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, statSync } from 'node:fs' import { chmod, copyFile, readFile, rm, writeFile } from 'node:fs/promises' import { basename, join, resolve, sep } from 'node:path' import { parseArgs } from 'node:util' @@ -58,6 +58,24 @@ interface RuntimeProduct { spawnHelper: string } +function spawnHelperBinaryTarget(path: string): string | undefined { + const header = readFileSync(path).subarray(0, 20) + if (header.length >= 20 + && header.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) + && header[4] === 2 + && header[5] === 1) { + const machine = header.readUInt16LE(18) + if (machine === 62) return 'linux-x64' + if (machine === 183) return 'linux-arm64' + } + if (header.length >= 8 && header.readUInt32LE(0) === 0xfeedfacf) { + const cpuType = header.readUInt32LE(4) + if (cpuType === 0x01000007) return 'macos-x64' + if (cpuType === 0x0100000c) return 'macos-arm64' + } + return undefined +} + function isPlatform(value: string): value is Platform { return (PLATFORMS as readonly string[]).includes(value) } @@ -347,7 +365,17 @@ class SingleExeBuild { + `checked ${candidates.join(', ')}. Build each runtime on its target platform and architecture.`, ) } - if (statSync(helper).mode & 0o111) return helper + if (statSync(helper).mode & 0o111) { + const expected = `${target.platform}-${target.arch}` + const actual = spawnHelperBinaryTarget(helper) + if (actual !== expected) { + throw new Error( + `build-exe-for-python-sdk: node-pty spawn-helper binary mismatch: expected ${expected}, ` + + `found ${actual ?? 'unsupported format or architecture'} at ${helper}`, + ) + } + return helper + } throw new Error(`build-exe-for-python-sdk: node-pty spawn-helper is not executable: ${helper}`) } diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index 915968b30d..3011120da1 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -23,6 +23,35 @@ PLATFORMS = { "macos-arm64": ("macosx_11_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"), } SPAWN_HELPER_SUFFIX = "-spawn-helper" +EXECUTABLE_TARGETS = {value[1]: key for key, value in PLATFORMS.items()} + + +def spawn_helper_binary_target(header: bytes) -> str | None: + if ( + len(header) >= 20 + and header[:4] == b"\x7fELF" + and header[4] == 2 + and header[5] == 1 + ): + machine = int.from_bytes(header[18:20], "little") + if machine == 62: + return "linux-x64" + if machine == 183: + return "linux-arm64" + if len(header) >= 8 and header[:4] == b"\xcf\xfa\xed\xfe": + if int.from_bytes(header[4:8], "little") == 0x0100000C: + return "macos-arm64" + return None + + +def validate_spawn_helper(path: Path, expected_target: str) -> None: + with path.open("rb") as helper: + actual_target = spawn_helper_binary_target(helper.read(20)) + if actual_target != expected_target: + raise ValueError( + f"runtime spawn helper binary mismatch: expected {expected_target}, " + f"found {actual_target or 'unsupported format or architecture'} at {path}" + ) def main() -> None: @@ -142,6 +171,7 @@ def stage_runtime(destination: Path, version: str, executable: Path, executable_ raise FileNotFoundError(f"runtime spawn helper does not exist: {spawn_helper}") if spawn_helper.stat().st_mode & stat.S_IXUSR == 0: raise PermissionError(f"runtime spawn helper is not executable: {spawn_helper}") + validate_spawn_helper(spawn_helper, EXECUTABLE_TARGETS[executable_name]) copy_package(ROOT / "python" / "sdk-runtime", destination) rewrite_version(destination / "pyproject.toml", version) runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" @@ -186,6 +216,13 @@ def verify_wheel( mode = archive.getinfo(executable).external_attr >> 16 if mode & stat.S_IXUSR == 0: raise RuntimeError(f"{wheel} runtime executable lost its executable bit: {executable}") + actual_target = spawn_helper_binary_target(archive.read(helpers[0])[:20]) + expected_target = EXECUTABLE_TARGETS[platform[1]] + if actual_target != expected_target: + raise RuntimeError( + f"{wheel} spawn helper binary mismatch: expected {expected_target}, " + f"found {actual_target or 'unsupported format or architecture'}" + ) elif runtime_files: raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {runtime_files}") if package == "sdk": From 9939236dcbc87e6b2fc3d0fa79ec2ff482dade3a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:50:25 +0800 Subject: [PATCH 25/72] test(tools): prove persistent tool disposal --- .../fs/tool-str-replace-editor/tests/tools.spec.ts | 10 +++++++--- packages/pty/tool-bash-persistent/src/invariant.ts | 5 +++-- packages/pty/tool-bash-persistent/tests/tools.spec.ts | 6 +++++- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 64bdd481ee..f1fafcfa04 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -78,13 +78,13 @@ async function setup( await ctx.plugin(SandboxedFileSystem, { cwd: root }) } if (options.fsPolicy === true) await ctx.plugin(FsPolicy) - await ctx.plugin(ToolStrReplaceEditor, config) - return { ctx, root, owner: agent(ctx, root) } + const fiber = await ctx.plugin(ToolStrReplaceEditor, config) + return { ctx, root, fiber, owner: agent(ctx, root) } } describe('tool-str-replace-editor', () => { it('registers the standalone schema and configurable description', async () => { - const { ctx } = await setup({ description: 'custom editor description' }) + const { ctx, fiber } = await setup({ description: 'custom editor description' }) const schema = ctx.tools.schemas()[0] expect(ctx.tools.schemas().map(item => item.name)).toEqual(['str_replace_editor']) expect(schema?.description).toBe('custom editor description') @@ -147,6 +147,10 @@ describe('tool-str-replace-editor', () => { })).toMatchObject({ locations: [{ path: '/workspace/a.txt' }], }) + + await fiber.dispose() + expect(ctx.tools.schemas()).toEqual([]) + expect(ctx.tools.get('str_replace_editor')).toBeUndefined() }) it('creates, views, replaces, and inserts with the canonical model-facing output', async () => { diff --git a/packages/pty/tool-bash-persistent/src/invariant.ts b/packages/pty/tool-bash-persistent/src/invariant.ts index f6b5acfbc7..5e276d4c45 100644 --- a/packages/pty/tool-bash-persistent/src/invariant.ts +++ b/packages/pty/tool-bash-persistent/src/invariant.ts @@ -15,8 +15,9 @@ export const name = 'tool-bash-persistent-invariant' export const inject = ['invariants'] /** - * No runtime invariant: the tool adapter owns no independent durable state; - * PTY ownership and filesystem mutation relations stay with their services. + * No runtime invariant: the adapter's private owner-to-shell cache has no + * observable event or data relation. Lifecycle tests prove its cleanup without + * adding a public surface solely for an invariant. */ const install: InvariantInstaller = () => {} diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index 81a4f28b87..1a9d15c476 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -260,7 +260,7 @@ async function setup( describe('tool-bash-persistent', () => { it('registers a configurable schema and reuses one owner shell', async () => { - const { ctx, owner, stub } = await setup({ + const { ctx, owner, stub, fiber } = await setup({ backendType: 'stub', description: 'deployment-specific persistent shell', }) @@ -282,6 +282,10 @@ describe('tool-bash-persistent', () => { const ownerWithoutCwd = agent(ctx, undefined) expect(text(await call(ctx, ownerWithoutCwd, 'pwd'))).toBe('hello from stub') expect(stub.sessions).toHaveLength(2) + + await fiber.dispose() + expect(ctx.tools.schemas()).toEqual([]) + expect(ctx.tools.get('bash')).toBeUndefined() }) it('handles inferred idle, prompt fallback, shell exit, clipping, and cleanup', async () => { From 7fdde06cf959476fa9f86747b41f5163493820dd Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:51:07 +0800 Subject: [PATCH 26/72] chore(pty): mark unsupported diagnostic claims --- docs/config-catalog.md | 2 +- packages/pty/tool-bash-persistent/src/index.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index dc2a740349..fcb2bdd2bd 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1593,7 +1593,7 @@ export interface Config { } ``` -Source: [`packages/pty/tool-bash-persistent/src/index.ts:395`](../packages/pty/tool-bash-persistent/src/index.ts) +Source: [`packages/pty/tool-bash-persistent/src/index.ts:397`](../packages/pty/tool-bash-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` diff --git a/packages/pty/tool-bash-persistent/src/index.ts b/packages/pty/tool-bash-persistent/src/index.ts index 9c6f755434..2ad2bd54b8 100644 --- a/packages/pty/tool-bash-persistent/src/index.ts +++ b/packages/pty/tool-bash-persistent/src/index.ts @@ -11,6 +11,7 @@ import type { PtyReadResult, PtySendResult, PtySessionId } from '@deepseek-ai/ds import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { defineTool } from '@deepseek-ai/dsh-tools' +// TODO: Replace the file-search advice; arbitrary command output need not come from a searchable file. const TRUNCATED_MESSAGE = 'To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.' const LOST_PREFIX_MESSAGE = 'The beginning of this command output was dropped by the terminal scrollback limit. The following text is the earliest retained output.\n' const SHELL_RESET_MESSAGE = 'The persistent bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment.' @@ -304,6 +305,7 @@ async function executeCommand( ) await shells.reset(owner, 'persistent bash command timed out') return [ + // TODO: Report a timeout only; this signal does not establish an OOM. `Your command timed out after ${Math.round(timedOut.timeoutMs / 1000)} seconds or experienced an OOM error. Below is partial output:`, partial, SHELL_RESET_MESSAGE, From 3224ad92af202d483f915f2b8cd875f3a3478768 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 21:57:06 +0800 Subject: [PATCH 27/72] =?UTF-8?q?fix(host):=20review=20round=207=20?= =?UTF-8?q?=E2=80=94=20upgrade=20re-parks=20displaced=20row=20focus;=20cas?= =?UTF-8?q?e-folded=20display=20root;=20boundary=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- .../src/client/DirectoryBrowser.tsx | 56 ++++++++++++++----- .../tests/directory-browser.spec.tsx | 53 ++++++++++++++++++ 5 files changed, 100 insertions(+), 17 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index 900e1d01b6..7d06152986 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: ad2aa904beddb2fe941883c3c1827702dbec9964 -2026-07-28-directory-picker-capability-seam.zh.md: 30e719ad9b4e8374496106b447e961a042c7d8b6 +2026-07-28-directory-picker-capability-seam.md: a9f9bc81a8aeff4f9592257b574a199063c681ae +2026-07-28-directory-picker-capability-seam.zh.md: 6331440b04cc8ee6994cb49ff3fd1e6b49403fa4 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index ad2aa904be..a9f9bc81a8 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -20,7 +20,7 @@ Placement and policy rulings folded into this decision: - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. - **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. -- **Navigation lands selection-anchored, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor. +- **Navigation lands selection-anchored, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor. Known boundaries of the progressive shape: a pointer press landing exactly inside the one-RTT upgrade window can lose its click when the pressed row node is replaced (keyboard focus is re-parked on the re-selected row; the pointer window is accepted); slash-platform typed-case drift (macOS) misses the parent-entry match and keeps the single-pane landing; and navigate always relists both legs even when the target is the currently shown level — a crumb tap doubles as the refresh gesture, so freshness wins over reusing possibly-stale in-hand listings at the cost of up to two host scans. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. - **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 30e719ad9b..6331440b04 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -20,7 +20,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 - **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。 -- **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。 +- **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。渐进形态的已知边界:指针按压若恰好落在单个 RTT 的升级窗口内,可能因所按行节点被替换而丢失点击(键盘焦点会重新停靠到重新选中的行上;指针的这段窗口则被接受);斜杠平台的键入大小写偏差(macOS)会错过父层级条目匹配,保留单栏落地;且导航总是重新列举两程,哪怕目标就是当前展示的层级——crumb 点按兼作刷新手势,因此宁要新鲜度也不复用手头可能已陈旧的列举,代价是至多两次宿主扫描。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 859667d115..5c44492b77 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -55,13 +55,27 @@ function failureText(error: unknown): string { return error instanceof Error ? error.message : String(error) } +/** + * Case-folds a path for comparisons under the listing's platform: backslash + * (Windows) paths compare case-insensitively — a typed path legally differs + * in case from the host's stamped one — while slash platforms compare + * exactly (the filesystem may be case-sensitive; macOS typed-case drift + * degrades to the single-pane landing instead of a wrong match). + */ +function foldPathFor(listing: DirectoryListing): (value: string) => string { + const sep = separatorOf(listing) + return value => (sep === '\\' ? value.toLowerCase() : value) +} + /** * Breadcrumb rows for display: inside the home subtree the chain starts at a * localized Home crumb; outside it the full ancestry shows, the root labeled - * by its own path. + * by its own path. The home comparison folds per platform so a typed-case + * Windows path still collapses to the Home crumb. */ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryEntry[] { - const homeIndex = listing.crumbs.findIndex(crumb => crumb.path === listing.home) + const fold = foldPathFor(listing) + const homeIndex = listing.crumbs.findIndex(crumb => fold(crumb.path) === fold(listing.home)) if (homeIndex === -1) return listing.crumbs const tail = listing.crumbs.slice(homeIndex + 1) return [{ name: homeLabel, path: listing.home, hidden: false }, ...tail] @@ -222,6 +236,12 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, * intent aborts it like the leg it continues, and it supersedes nothing. */ const continueScan = useCallback((path: string): Promise => { + // Abort whatever the slot last tracked before overwriting it (the + // caller's settled leg: a no-op) — the slot must never silently strand + // a live scan, the exact waste supersede() exists to prevent. + const displaced = scanController.current + /* v8 ignore next -- narrowing guard: the landing's target leg installed a controller before any follow-up runs. */ + if (displaced !== null) displaced.abort() const controller = new AbortController() scanController.current = controller return listDirectory(path, controller.signal) @@ -236,9 +256,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, * shape never disagree — a parent leg then upgrades the landing in place: * the target's ACTUAL parent-level entry re-selected (left pane = parent, * right pane = the target), so a crumb jump reads as stepping back one - * pane. A failed parent leg, or a truncated parent window that lacks the - * target, leaves the committed single-pane landing — the upgrade must - * never orphan the selection it exists to anchor. + * pane (Windows folds case; slash-platform typed-case drift degrades to + * the single-pane landing). A failed parent leg, or a truncated parent + * window that lacks the target, leaves the committed single-pane landing + * — the upgrade must never orphan the selection it exists to anchor. */ const navigate = useCallback((path?: string) => { const { seq, scan } = launchListing(path) @@ -259,11 +280,18 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, continueScan(parentCrumb.path).then((parentLevel) => { if (seq !== requestSeq.current) return // Windows resolves a typed path preserving its case; anchor on the - // parent level's actual entry so selection comparisons hold. - const sep = separatorOf(parentLevel) - const fold = (value: string): string => (sep === '\\' ? value.toLowerCase() : value) + // parent level's actual entry so selection comparisons hold (slash + // platforms compare exactly — see foldPathFor). + const fold = foldPathFor(parentLevel) const match = parentLevel.entries.find(entry => fold(entry.path) === fold(target.path)) if (match === undefined) return + // The upgrade replaces every committed row node; if focus lives + // among them (Tab reached the rows during the parent leg), arm the + // refocus effect so it re-parks on the re-selected row. + const rowHost = millerRowRef.current + /* v8 ignore next -- narrowing guard: the committed landing just rendered the miller row. */ + const focusInRows = rowHost !== null && rowHost.contains(document.activeElement) + if (focusInRows) refocusPick.current = true setParent(parentLevel) setSelected(match) setChild(target) @@ -279,11 +307,13 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }) }, [launchListing, continueScan]) - // Editor-close focus parking (consumed by the refocus effect below the - // miller-row ref): a pick parks on the selection's row, Enter and an - // input-focused Escape park on the crumb edit zone that replaces the - // input. Pointer-out cancels never set (or clear) these — yanking focus - // back from wherever the user clicked would be worse than the fall. + // Focus parking (consumed by the refocus effect below the miller-row + // ref): a pick — and a parent-leg upgrade that displaces focused rows — + // parks on the selection's row; Enter, an input-focused Escape, and a + // failed pick whose row unmounts park on the crumb edit zone (the latter + // only when focus actually fell to body). Pointer-out cancels never set + // (or clear) these — yanking focus back from wherever the user clicked + // would be worse than the fall. const refocusPick = useRef(false) const refocusEditZone = useRef(false) const pathInputRef = useRef(null) diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index 6fa9bb999f..fc2b6f564b 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -270,6 +270,59 @@ describe('DirectoryBrowser', () => { expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull() }) + it('re-parks focus on the re-selected row when the upgrade displaces focused rows', async () => { + const settlers: ((value: DirectoryListing) => void)[] = [] + const listDirectory = vi.fn(async (path?: string) => { + if (path === HOME) { + return new Promise((resolve) => { settlers.push(resolve) }) + } + return listingFor(path) + }) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: DOCS } }) + fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) + // The committed landing is interactive; Tab reaches its rows while the + // parent leg is still in flight. + await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) + rowButton(screen.getByRole('listitem')).focus() + await waitFor(() => { expect(settlers).toHaveLength(1) }) + // The upgrade replaces every committed row node; focus re-parks on the + // re-selected row instead of falling to body. + await act(async () => { settlers[0]!(listingFor(HOME)) }) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + expect(document.activeElement?.textContent).toBe('Documents') + expect(document.activeElement?.getAttribute('aria-current')).toBe('true') + }) + + it('collapses a typed-case Windows home to the display root (single pane, Home crumb)', async () => { + const CANON = 'C:\\Users\\Alice' + const TYPED = 'c:\\users\\alice' + const typedHome: DirectoryListing = { + path: TYPED, + home: CANON, + crumbs: [ + { name: 'C:\\', path: 'C:\\', hidden: false }, + { name: 'users', path: 'c:\\users', hidden: false }, + { name: 'alice', path: TYPED, hidden: false }, + ], + entries: [{ name: 'Desktop', path: `${CANON}\\Desktop`, hidden: false }], + truncated: false, + } + const canonHome: DirectoryListing = { ...typedHome, path: CANON, crumbs: typedHome.crumbs } + mount({ listDirectory: vi.fn(async (path?: string) => (path === TYPED ? typedHome : canonHome)) }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: TYPED } }) + fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) + // Case-folded home comparison: the typed-case home is still the display + // root — single pane, collapsed Home crumb, no parent leg. + await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Desktop') }) + expect(columns()).toHaveLength(1) + expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() + }) + it('keeps the single-pane landing when the truncated parent level lacks the target', async () => { const listDirectory = vi.fn(async (path?: string) => { // The parent leg names HOME explicitly; serve it a truncated window From 8b1a8d383aeabac28088fed0e6d96eb9a6d24cbe Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 22:20:48 +0800 Subject: [PATCH 28/72] =?UTF-8?q?fix(host):=20review=20round=208=20?= =?UTF-8?q?=E2=80=94=20platform-folded=20draft=20matching;=20honest=20slas?= =?UTF-8?q?h-platform=20boundary;=20coverage-visible=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- .../src/client/DirectoryBrowser.tsx | 75 +++++++++++-------- .../tests/directory-browser.spec.tsx | 12 ++- 5 files changed, 59 insertions(+), 36 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index 7d06152986..9fe765766f 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: a9f9bc81a8aeff4f9592257b574a199063c681ae -2026-07-28-directory-picker-capability-seam.zh.md: 6331440b04cc8ee6994cb49ff3fd1e6b49403fa4 +2026-07-28-directory-picker-capability-seam.md: 17131eaf390db03fb037216c2b72d3db0fab93bb +2026-07-28-directory-picker-capability-seam.zh.md: 41e9a89ddff05d9e659a93e306348480ea3db5dc diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index a9f9bc81a8..17131eaf39 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -20,7 +20,7 @@ Placement and policy rulings folded into this decision: - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. - **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. -- **Navigation lands selection-anchored, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor. Known boundaries of the progressive shape: a pointer press landing exactly inside the one-RTT upgrade window can lose its click when the pressed row node is replaced (keyboard focus is re-parked on the re-selected row; the pointer window is accepted); slash-platform typed-case drift (macOS) misses the parent-entry match and keeps the single-pane landing; and navigate always relists both legs even when the target is the currently shown level — a crumb tap doubles as the refresh gesture, so freshness wins over reusing possibly-stale in-hand listings at the cost of up to two host scans. +- **Navigation lands selection-anchored, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor. Known boundaries of the progressive shape: a pointer press landing exactly inside the one-RTT upgrade window can lose its click when the pressed row node is replaced (keyboard focus is re-parked on the re-selected row; the pointer window is accepted); on slash platforms (macOS) only a final-segment case drift misses the parent-entry match and keeps the single-pane landing, while ancestor-segment drift still matches — parent entry paths inherit the typed prefix — and lands two panes at the cost of the Home collapse; and navigate always relists both legs even when the target is the currently shown level — a crumb tap doubles as the refresh gesture, so freshness wins over reusing possibly-stale in-hand listings at the cost of up to two host scans. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. - **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 6331440b04..41e9a89ddf 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -20,7 +20,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 - **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。 -- **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。渐进形态的已知边界:指针按压若恰好落在单个 RTT 的升级窗口内,可能因所按行节点被替换而丢失点击(键盘焦点会重新停靠到重新选中的行上;指针的这段窗口则被接受);斜杠平台的键入大小写偏差(macOS)会错过父层级条目匹配,保留单栏落地;且导航总是重新列举两程,哪怕目标就是当前展示的层级——crumb 点按兼作刷新手势,因此宁要新鲜度也不复用手头可能已陈旧的列举,代价是至多两次宿主扫描。 +- **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。渐进形态的已知边界:指针按压若恰好落在单个 RTT 的升级窗口内,可能因所按行节点被替换而丢失点击(键盘焦点会重新停靠到重新选中的行上;指针的这段窗口则被接受);斜杠平台(macOS)上仅末段的大小写偏差会错过父层级条目匹配,保留单栏落地,而祖先段的偏差仍能匹配——父层级条目路径继承键入的前缀——并落地双栏,代价是 Home 塌缩;且导航总是重新列举两程,哪怕目标就是当前展示的层级——crumb 点按兼作刷新手势,因此宁要新鲜度也不复用手头可能已陈旧的列举,代价是至多两次宿主扫描。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 5c44492b77..ab6ecb731c 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -95,20 +95,23 @@ function separatorOf(listing: DirectoryListing): '\\' | '/' { } /** - * The path draft's final segment, when its directory part is exactly the - * level `listing` lists — the segment the level prefix-filters on while the - * user types. Any other draft (no separator yet, or naming some other - * directory) leaves the level unfiltered. The directory part compares - * exactly (it is the host's own path text, reached by seeding or erasing); - * only the name filter downstream is case-insensitive. + * The path draft's final segment, when its directory part names the level + * `listing` lists — the segment the level prefix-filters on while the user + * types. Any other draft (no separator yet, or naming some other directory) + * leaves the level unfiltered. The directory part compares under the + * platform fold (exact on slash platforms; Windows folds case, since an + * upgraded selection may carry the actual entry's case while the level + * below still carries the typed one); the name filter downstream is + * case-insensitive everywhere. */ function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null { if (draft === null) return null const sep = separatorOf(listing) const cut = draft.lastIndexOf(sep) if (cut === -1) return null + const fold = foldPathFor(listing) const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}` - return draft.slice(0, cut + 1) === level ? draft.slice(cut + 1) : null + return fold(draft.slice(0, cut + 1)) === fold(level) ? draft.slice(cut + 1) : null } /** One column of folder rows (the Miller view renders one or two of these). */ @@ -231,6 +234,21 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, return { seq, scan: listDirectory(path, controller.signal) } }, [supersede, listDirectory]) + // The miller row's scroll host, shared by the pin and refocus effects + // below and read by navigate's upgrade leg (declared ahead of both). + const millerRowRef = useRef(null) + // Focus parking (consumed by the refocus effect below): a pick — and a + // parent-leg upgrade that displaces focused rows — parks on the + // selection's row; Enter, an input-focused Escape, and a failed pick + // whose row unmounts park on the crumb edit zone (the latter only when + // focus actually fell to body). Pointer-out cancels never set (or clear) + // these — yanking focus back from wherever the user clicked would be + // worse than the fall. + const refocusPick = useRef(false) + const refocusEditZone = useRef(false) + const pathInputRef = useRef(null) + const editZoneRef = useRef(null) + /** * Launch a follow-up listing under the CURRENT supersession seq: a newer * intent aborts it like the leg it continues, and it supersedes nothing. @@ -240,8 +258,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // caller's settled leg: a no-op) — the slot must never silently strand // a live scan, the exact waste supersede() exists to prevent. const displaced = scanController.current - /* v8 ignore next -- narrowing guard: the landing's target leg installed a controller before any follow-up runs. */ - if (displaced !== null) displaced.abort() + // Inverted so the live abort below stays in coverage: a supersede + // would have bumped the seq before any follow-up could run. + /* v8 ignore next -- narrowing guard: the landing's target leg installed a controller first. */ + if (displaced === null) throw new Error('continueScan launched before any leg installed a controller') + displaced.abort() const controller = new AbortController() scanController.current = controller return listDirectory(path, controller.signal) @@ -256,10 +277,13 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, * shape never disagree — a parent leg then upgrades the landing in place: * the target's ACTUAL parent-level entry re-selected (left pane = parent, * right pane = the target), so a crumb jump reads as stepping back one - * pane (Windows folds case; slash-platform typed-case drift degrades to - * the single-pane landing). A failed parent leg, or a truncated parent - * window that lacks the target, leaves the committed single-pane landing - * — the upgrade must never orphan the selection it exists to anchor. + * pane (Windows folds case; on slash platforms only a FINAL-segment case + * drift misses the match and keeps the single-pane landing — parent + * entries inherit the typed prefix, so ancestor-segment drift still + * matches, at the cost of the Home collapse). A failed parent leg, or a + * truncated parent window that lacks the target, leaves the committed + * single-pane landing — the upgrade must never orphan the selection it + * exists to anchor. */ const navigate = useCallback((path?: string) => { const { seq, scan } = launchListing(path) @@ -289,9 +313,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // among them (Tab reached the rows during the parent leg), arm the // refocus effect so it re-parks on the re-selected row. const rowHost = millerRowRef.current + // Inverted so the live contains() probe below stays in coverage. /* v8 ignore next -- narrowing guard: the committed landing just rendered the miller row. */ - const focusInRows = rowHost !== null && rowHost.contains(document.activeElement) - if (focusInRows) refocusPick.current = true + if (rowHost === null) throw new Error('parent-leg upgrade before the miller row rendered') + if (rowHost.contains(document.activeElement)) refocusPick.current = true setParent(parentLevel) setSelected(match) setChild(target) @@ -307,18 +332,6 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }) }, [launchListing, continueScan]) - // Focus parking (consumed by the refocus effect below the miller-row - // ref): a pick — and a parent-leg upgrade that displaces focused rows — - // parks on the selection's row; Enter, an input-focused Escape, and a - // failed pick whose row unmounts park on the crumb edit zone (the latter - // only when focus actually fell to body). Pointer-out cancels never set - // (or clear) these — yanking focus back from wherever the user clicked - // would be worse than the fall. - const refocusPick = useRef(false) - const refocusEditZone = useRef(false) - const pathInputRef = useRef(null) - const editZoneRef = useRef(null) - /** Select a row of the listed level and preview its children on the right. */ const select = useCallback((entry: DirectoryEntry) => { const { seq, scan } = launchListing(entry.path) @@ -455,8 +468,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }, [crumbTail]) // On viewports too narrow for both fixed panes the Miller row scrolls; // whenever a child preview lands, pin it into view the way the crumb tail - // pins — otherwise descent is unreachable on a phone-width window. - const millerRowRef = useRef(null) + // pins — otherwise descent is unreachable on a phone-width window. On a + // parent-leg upgrade the refocus effect below runs after this pin and its + // row.focus() may scroll the selected LEFT row back into view: for that + // one landing, focus placement wins over the child pin by design. const childPath = child?.path useEffect(() => { const row = millerRowRef.current @@ -712,8 +727,6 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, onClick={() => { setShowHidden(prev => !prev) }} > {t('browser.showHidden')} - {/* Trailing check (Menu's selected vocabulary): the label never - * shifts when the pressed state toggles. */} {showHidden && } diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index fc2b6f564b..95b60ced2f 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -356,7 +356,7 @@ describe('DirectoryBrowser', () => { path: TYPED, home: ROOT, crumbs: [{ name: 'C:\\', path: ROOT, hidden: false }, { name: 'users', path: TYPED, hidden: false }], - entries: [], + entries: [{ name: 'Alpha', path: `${TYPED}\\Alpha`, hidden: false }], truncated: false, } mount({ listDirectory: vi.fn(async (path?: string) => (path === TYPED ? winUsers : winRoot)) }) @@ -370,6 +370,16 @@ describe('DirectoryBrowser', () => { expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true') }) expect(within(columns()[0]!).getByText('Users')).toBeTruthy() + // The editor seeds from the actual-cased selection while the child + // level still carries the typed case: the draft's directory part folds + // per platform, so the right pane keeps prefix-filtering. + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + expect(input.value).toBe('C:\\Users\\') + fireEvent.change(input, { target: { value: 'C:\\Users\\a' } }) + expect(within(columns()[1]!).getByText('Alpha')).toBeTruthy() + fireEvent.change(input, { target: { value: 'C:\\Users\\z' } }) + expect(within(columns()[1]!).queryAllByRole('listitem')).toHaveLength(0) }) it('re-parks focus on the edit zone when a failed pick unmounts a dot-revealed row', async () => { From df9c03fbc2c19046a4092f480656cea40429abae Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 22:42:11 +0800 Subject: [PATCH 29/72] =?UTF-8?q?fix(host):=20review=20round=209=20?= =?UTF-8?q?=E2=80=94=20universal=20pick=20refocus;=20always-armed=20scan?= =?UTF-8?q?=20controller;=20graceful=20close-race=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/client/DirectoryBrowser.tsx | 97 ++++++++++--------- .../tests/directory-browser.spec.tsx | 50 ++++++++++ 2 files changed, 103 insertions(+), 44 deletions(-) diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index ab6ecb731c..c4c4068d2e 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -2,13 +2,14 @@ * The in-app workspace-directory browser (figma Harness 813-23126 family): a * 680×500 dialog (clamped to short/narrow viewports — the Miller row scrolls * sideways, the columns scroll down) whose header carries the title, the selection-path - * breadcrumb, and a click-to-edit path zone; below it a Miller view — one - * full-width level until a row is selected, then two columns splitting the - * row evenly (256px floor; level | selected folder's children) around a - * hairline divider. Navigations land selection-anchored: a crumb jump or a - * submitted path commits the target immediately, then re-selects it in its - * parent level once that level arrives, so stepping back keeps two panes - * away from the display root. Selecting in the + * breadcrumb, and a click-to-edit path zone; below it a Miller view of one + * or two columns splitting the row evenly (256px floor; level | selected + * folder's children) around a hairline divider — the display root and + * degraded landings keep the single wide level, while any selection opens + * the second pane, including the one a navigation lands with: a crumb jump + * or a submitted path commits the target immediately, then re-selects it + * in its parent level once that level arrives, so stepping back keeps two + * panes away from the display root. Selecting in the * right column shifts the view one level deeper. "New folder" opens a nested * create dialog targeting the selected folder (or the level itself) and * selects the created folder. Open adopts the selected folder, falling back @@ -56,14 +57,15 @@ function failureText(error: unknown): string { } /** - * Case-folds a path for comparisons under the listing's platform: backslash - * (Windows) paths compare case-insensitively — a typed path legally differs - * in case from the host's stamped one — while slash platforms compare - * exactly (the filesystem may be case-sensitive; macOS typed-case drift - * degrades to the single-pane landing instead of a wrong match). + * Case-folds a path for comparisons under the given separator's platform: + * backslash (Windows) paths compare case-insensitively — a typed path + * legally differs in case from the host's stamped one — while slash + * platforms compare exactly (the filesystem may be case-sensitive; only a + * FINAL-segment macOS case drift misses parent-entry matching and keeps + * the single-pane landing, since parent entry paths inherit the typed + * prefix). */ -function foldPathFor(listing: DirectoryListing): (value: string) => string { - const sep = separatorOf(listing) +function foldPathFor(sep: '\\' | '/'): (value: string) => string { return value => (sep === '\\' ? value.toLowerCase() : value) } @@ -74,7 +76,7 @@ function foldPathFor(listing: DirectoryListing): (value: string) => string { * Windows path still collapses to the Home crumb. */ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryEntry[] { - const fold = foldPathFor(listing) + const fold = foldPathFor(separatorOf(listing)) const homeIndex = listing.crumbs.findIndex(crumb => fold(crumb.path) === fold(listing.home)) if (homeIndex === -1) return listing.crumbs const tail = listing.crumbs.slice(homeIndex + 1) @@ -109,7 +111,7 @@ function draftPrefixFor(listing: DirectoryListing, draft: string | null): string const sep = separatorOf(listing) const cut = draft.lastIndexOf(sep) if (cut === -1) return null - const fold = foldPathFor(listing) + const fold = foldPathFor(sep) const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}` return fold(draft.slice(0, cut + 1)) === fold(level) ? draft.slice(cut + 1) : null } @@ -195,8 +197,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const requestSeq = useRef(0) // The in-flight listing's controller: superseding intent aborts the wire // request too — the Host stops scanning — instead of only discarding the - // eventual result while the scan keeps consuming host resources. - const scanController = useRef(null) + // eventual result while the scan keeps consuming host resources. Always + // holds a controller (a settled or aborted one between scans) so no + // consumer needs a null guard. + const scanController = useRef(new AbortController()) // Bumped on every open/close edge: settlements from a previous open (a // pending creation included) must never mutate a reopened dialog. const openGeneration = useRef(0) @@ -212,7 +216,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, useEffect(() => () => { requestSeq.current += 1 openGeneration.current += 1 - scanController.current?.abort() + scanController.current.abort() }, []) const compositionGuard = { onCompositionStart: () => { composingRef.current = true }, @@ -221,8 +225,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, /** Newer intent wins: invalidate the pending listing's settlement AND abort its wire request. */ const supersede = useCallback((): number => { - scanController.current?.abort() - scanController.current = null + scanController.current.abort() return ++requestSeq.current }, []) @@ -257,12 +260,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // Abort whatever the slot last tracked before overwriting it (the // caller's settled leg: a no-op) — the slot must never silently strand // a live scan, the exact waste supersede() exists to prevent. - const displaced = scanController.current - // Inverted so the live abort below stays in coverage: a supersede - // would have bumped the seq before any follow-up could run. - /* v8 ignore next -- narrowing guard: the landing's target leg installed a controller first. */ - if (displaced === null) throw new Error('continueScan launched before any leg installed a controller') - displaced.abort() + scanController.current.abort() const controller = new AbortController() scanController.current = controller return listDirectory(path, controller.signal) @@ -306,16 +304,18 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // Windows resolves a typed path preserving its case; anchor on the // parent level's actual entry so selection comparisons hold (slash // platforms compare exactly — see foldPathFor). - const fold = foldPathFor(parentLevel) + const fold = foldPathFor(separatorOf(parentLevel)) const match = parentLevel.entries.find(entry => fold(entry.path) === fold(target.path)) if (match === undefined) return // The upgrade replaces every committed row node; if focus lives // among them (Tab reached the rows during the parent leg), arm the // refocus effect so it re-parks on the re-selected row. const rowHost = millerRowRef.current - // Inverted so the live contains() probe below stays in coverage. - /* v8 ignore next -- narrowing guard: the committed landing just rendered the miller row. */ - if (rowHost === null) throw new Error('parent-leg upgrade before the miller row rendered') + // A close race can clear the ref before the close effect's + // supersede runs (commit precedes passive effects): drop the + // upgrade, the dialog is going away. + /* v8 ignore next -- close-race guard: the commit-to-effect window is not deterministically reproducible. */ + if (rowHost === null) return if (rowHost.contains(document.activeElement)) refocusPick.current = true setParent(parentLevel) setSelected(match) @@ -336,9 +336,13 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const select = useCallback((entry: DirectoryEntry) => { const { seq, scan } = launchListing(entry.path) // A pick while the path editor is open adopts the (filtered) row and - // closes the editor — the draft served its purpose. Focus re-parks on - // the selection after commit (see the refocus effect below). - if (pathDraft !== null) refocusPick.current = true + // closes the editor — the draft served its purpose. EVERY pick re-parks + // focus on the selection after commit (see the refocus effect below): + // a left-pane pick lands on the very row that was clicked (a near + // no-op), while a right-pane advance and a create landing replace the + // picked button's column entirely and would otherwise drop focus to + // body. + refocusPick.current = true setPathDraft(null) setSelected(entry) setChild(null) @@ -477,12 +481,13 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const row = millerRowRef.current if (row !== null && childPath !== undefined) row.scrollLeft = row.scrollWidth }, [childPath]) - // Every editor exit that would drop focus to body re-parks it after - // commit, so keyboard traversal stays inside the dialog (the Modal has no - // focus trap): a pick lands on the selection's row — aria-current in the - // freshly rendered left pane, which survives even a right-pane advance - // replacing the picked button's column — while Enter and an input-focused - // Escape land on the crumb edit zone that replaces the input. + // Every pick and editor exit that would drop focus to body re-parks it + // after commit, so keyboard traversal stays inside the dialog (the Modal + // has no focus trap): a pick lands on the selection's row — aria-current + // in the freshly rendered left pane, which survives even a right-pane + // advance or a create landing replacing the picked button's column — + // while Enter and an input-focused Escape land on the crumb edit zone + // that replaces the input. useEffect(() => { if (pathDraft !== null) return if (refocusPick.current) { @@ -492,10 +497,14 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, /* v8 ignore next -- narrowing guard: the miller row is mounted whenever a pick just committed. */ if (rowHost === null) return const row = rowHost.querySelector('button[aria-current="true"]') - /* v8 ignore next -- narrowing guard: the pick that set the flag just rendered its aria-current row. */ - if (row === null) return - row.focus() - return + if (row !== null) { + row.focus() + return + } + // The pick lost its row (a truncated relist after Create can drop + // the created directory outside the window): fall through to the + // edit-zone parking below instead of leaving focus where it fell. + refocusEditZone.current = true } if (refocusEditZone.current) { refocusEditZone.current = false diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index 95b60ced2f..c5ed60fa5e 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -629,6 +629,56 @@ describe('DirectoryBrowser', () => { expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() }) + it('a plain right-pane advance parks focus on the new selection (no editor involved)', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(rowButton(screen.getByRole('listitem'))) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + // Keyboard reached the right pane; the advance replaces that whole + // column, so focus re-parks on the new left pane's selected row. + const row = rowButton(within(columns()[1]!).getByRole('listitem')) + row.focus() + fireEvent.click(row) + await waitFor(() => { expect(document.activeElement?.textContent).toBe('harness') }) + expect(document.activeElement?.getAttribute('aria-current')).toBe('true') + }) + + it('a create landing parks focus on the created row, or the edit zone when the relist lost it', async () => { + // First create: the relist contains the created directory. + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + b.listDirectory.mockImplementation(async (path?: string) => { + // The created directory is not in listingFor's fixed tree: serve its + // level before the fixture lookup can reject the unknown path. + if (path === `${HOME}/fresh`) return { ...listingFor(HOME), path: `${HOME}/fresh`, entries: [] } + const base = listingFor(path) + if (path === HOME) { + return { ...base, entries: [...base.entries, { name: 'fresh', path: `${HOME}/fresh`, hidden: false }] } + } + return base + }) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + fireEvent.change(screen.getByLabelText('browser.folderName'), { target: { value: 'fresh' } }) + fireEvent.click(screen.getByRole('button', { name: 'browser.create' })) + await waitFor(() => { expect(document.activeElement?.textContent).toBe('fresh') }) + expect(document.activeElement?.getAttribute('aria-current')).toBe('true') + }) + + it('a create landing whose truncated relist lost the created row parks on the edit zone', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + // The relist window misses the created directory (truncated tail). + b.listDirectory.mockImplementation(async (path?: string) => ({ ...listingFor(path), truncated: true })) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + fireEvent.change(screen.getByLabelText('browser.folderName'), { target: { value: 'zzz-tail' } }) + fireEvent.click(screen.getByRole('button', { name: 'browser.create' })) + // No aria-current row exists for the selection: focus falls back to the + // crumb edit zone instead of staying wherever it fell. + await waitFor(() => { + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' })) + }) + }) + it('a right-pane pick while editing parks focus on the advanced selection', async () => { mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) From c0bd88430ab065fe266024201693910afaa66b2b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:44:19 +0800 Subject: [PATCH 30/72] fix(build): stage platform-specific PTY artifacts --- ...cutable-sdk-runtime-distribution.i18n.yaml | 6 +- ...ile-executable-sdk-runtime-distribution.md | 6 +- ...-executable-sdk-runtime-distribution.zh.md | 6 +- ...rsistent-bash-str-replace-editor.i18n.yaml | 4 +- ...7-29-persistent-bash-str-replace-editor.md | 4 +- ...9-persistent-bash-str-replace-editor.zh.md | 4 +- pnpm-workspace.yaml | 2 +- python/sdk-runtime/README.i18n.yaml | 4 +- python/sdk-runtime/README.md | 2 +- python/sdk-runtime/README.zh.md | 2 +- python/sdk-runtime/hatch_build.py | 29 +++---- python/sdk/tests/test_release_version.py | 38 ++++++---- scripts/build-exe-for-python-sdk.ts | 75 +++++++++++++------ scripts/build-python-release.py | 64 ++++++++-------- 14 files changed, 141 insertions(+), 105 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 1ab141230b..ce64f9d035 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 39cfb2999dea7767a18702ad7d160c9e88d7bf20 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: e1a21c40647e1418d4afd02c0bc6b44ef0d4a8cf +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +2026-07-10-single-file-executable-sdk-runtime-distribution.md: fac3d9527b496adaaffdbd8e78401a17c6f0cc0b +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: ff6a8be16c5f8591efa9bf383cd47c8fe251fe39 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 39cfb2999d..fac3d9527b 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -40,15 +40,15 @@ The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-ru ### Build pipeline and artifacts -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` and are copied back into the runtime directory. CI treats them as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted yields a zero-symlink file tree (most stable for the pkg VFS, physically guaranteeing a single cordis instance); disabling automatic peer installation keeps unpublished package names from triggering registry resolution; link-workspace-packages points the closure at workspace/vendor sources. +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source, so the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory; macOS uses its target prebuild and emits the required `-spawn-helper` beside the executable. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted yields a zero-symlink file tree (most stable for the pkg VFS, physically guaranteeing a single cordis instance); disabling automatic peer installation keeps unpublished package names from triggering registry resolution; link-workspace-packages points the closure at workspace/vendor sources. CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), triggered explicitly only — `workflow_dispatch`, or the `build-exe` label on a pull request; native builds on the three platforms linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached; macOS ad-hoc signing is handled by pkg. Each leg drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects GLIBC requirements and runs in a manylinux 2.28 container. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts only `python-vX.Y.Z` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal. ### Python SDK distribution: two carriers, exe for production, node for development -The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds three kinds of content: the checked-in default `runtime/cordis.yml`, the build-injected platform exe, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel distributions. +The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds the checked-in default `runtime/cordis.yml`, the build-injected platform exe and optional helper, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel distributions. -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative stable `X.Y.Z` from the repository root `package.json` and stages both packages at that version, with the SDK depending exactly on `deepseek-harness-runtime-bin==X.Y.Z`. An optional `python-vX.Y.Z` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. The SDK is a `py3-none-any` wheel; the wheel-only runtime package contains exactly one exe and uses one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or `py3-none-macosx_11_0_arm64`. Its Hatch hook rejects sdists, universal tags, mixed executable payloads, and unsupported platforms. +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative stable `X.Y.Z` from the repository root `package.json` and stages both packages at that version, with the SDK depending exactly on `deepseek-harness-runtime-bin==X.Y.Z`. An optional `python-vX.Y.Z` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe, and the macOS wheel also contains its architecture-matched helper. Runtime wheels use one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or `py3-none-macosx_11_0_arm64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra helpers, and unsupported platforms. The exe's "must be explicitly configured" hard semantic is unchanged; the zero-config experience is restored by the wrapper: when the caller gave no `cordis`, named no explicit runtime, and the environment has no `DSH_CORDIS_CONFIG`, the client explicitly injects the checked-in default `cordis.yml` (agent-core + preloaded llm-deepseek + JSONL persistence + bash-local + the `dsh-jsonrpc` serving entry, with `!!js` environment-variable fallbacks) via `DSH_CORDIS_CONFIG`. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index e1a21c4064..ff6a8be16c 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -40,15 +40,15 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 ### 构建管线与产物 -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。CI 将这些文件作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 产出无符号链接的文件树(对 pkg VFS 最稳定,并从物理上保证只有一个 Cordis 实例);关闭对等依赖自动安装可避免未发布包名触发注册表解析;`link-workspace-packages` 让闭包指向工作区/vendor 源码。 +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`,而 `--legacy` 部署会省略该副作用目录,因此构建器会把它从根安装目录复制到暂存闭包;macOS 使用对应目标的预构建产物,并在可执行文件旁生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 产出无符号链接的文件树(对 pkg VFS 最稳定,并从物理上保证只有一个 Cordis 实例);关闭对等依赖自动安装可避免未发布包名触发注册表解析;`link-workspace-packages` 让闭包指向工作区/vendor 源码。 CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml),且只允许显式触发:手动派发 `workflow_dispatch`,或给 PR 添加 `build-exe` 标签。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用模拟 SSE 模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查 GLIBC 依赖,并在 manylinux 2.28 容器中运行。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-vX.Y.Z` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 ### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发 -Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含三类内容:检入的默认 `runtime/cordis.yml`、构建注入的平台 exe,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。 +Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含检入的默认 `runtime/cordis.yml`、构建注入的平台 exe 与可选 helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。 -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的稳定版本 `X.Y.Z`,以该版本暂存两个包,并让 SDK 精确依赖 `deepseek-harness-runtime-bin==X.Y.Z`。可选的 `python-vX.Y.Z` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。SDK 是 `py3-none-any` wheel 包;只提供 wheel 包的运行时包恰好包含一个 exe,标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 或 `py3-none-macosx_11_0_arm64`。其 Hatch 钩子拒绝 sdist、通用标签、混合可执行载荷以及不支持的平台。 +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的稳定版本 `X.Y.Z`,以该版本暂存两个包,并让 SDK 精确依赖 `deepseek-harness-runtime-bin==X.Y.Z`。可选的 `python-vX.Y.Z` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe,macOS wheel 包还包含与其架构匹配的 helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 或 `py3-none-macosx_11_0_arm64` 三种标签之一;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、helper 缺失或多余,以及不支持的平台。 exe“必须显式配置”的硬语义不变;零配置体验由包装层恢复:调用方没有提供 `cordis`、没有显式指定运行时,且环境中没有 `DSH_CORDIS_CONFIG` 时,客户端将检入的默认 `cordis.yml`(`agent-core` + 预载的 `llm-deepseek` + JSONL 持久化 + `bash-local` + `dsh-jsonrpc` 对外服务条目,并通过 `!!js` 使用环境变量兜底)显式注入 `DSH_CORDIS_CONFIG`。 diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml index cb1e7631fd..0e31711c21 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md -2026-07-29-persistent-bash-str-replace-editor.md: b1be9cc40e11b07b722877e666de0e0328636a05 -2026-07-29-persistent-bash-str-replace-editor.zh.md: 0bf100514e971dfa759f44dcca15a7ad6a2fdd8a +2026-07-29-persistent-bash-str-replace-editor.md: 6e8a1df7f04340f4a97c0b799aaace0a78526ba5 +2026-07-29-persistent-bash-str-replace-editor.zh.md: 256ffad7945cbb4367b9cc8bb92e9b2968ab4501 diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md index b1be9cc40e..6e8a1df7f0 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md @@ -16,7 +16,7 @@ Some deployments need a one-call Bash schema whose shell state survives across m `dsh-system-prompt` accepts `includeHarnessIdentity: false`, while `dsh-agent-spine-demo` forwards that setting and accepts `toolBash: false`. A deployment can therefore own an exact persona and replace the spine's native Bash without duplicate prompt or tool registrations. Existing defaults remain unchanged. -Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper`, each packaged runtime executable ships with an architecture-matched `-spawn-helper` sibling. A pinned `node-pty` patch resolves that sibling only when present, preserving upstream lookup in ordinary Node runs. The explicit `DSH_NODE_PTY_SPAWN_HELPER` override remains for a current external consumer that supplies a non-sibling helper. The executable and runtime-wheel builders inspect ELF or thin Mach-O headers and fail before publication when the helper is absent, mismatched, or not executable. +Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper` on macOS, each packaged macOS runtime executable ships with an architecture-matched `-spawn-helper` sibling; Linux uses `forkpty` directly. A pinned `node-pty` patch resolves the sibling only when present, preserving upstream lookup in ordinary Node runs. The explicit `DSH_NODE_PTY_SPAWN_HELPER` override remains for a current external consumer that supplies a non-sibling helper. The macOS executable and runtime-wheel builders inspect the thin Mach-O header and fail before publication when the helper is absent, mismatched, or not executable. ## Alternatives considered @@ -30,4 +30,4 @@ Both plugins are included in the Python runtime closure. The persistent Bash clo ## Consequences -Profiles can reproduce an external agent by configuring persona and descriptions while the underlying packages remain general. Persistent Bash requires an owning Agent and real PTY backend. Shell exit, timeout, or cancellation loses state. The editor delegates security and mutation policy to the mounted filesystem stack. Runtime-wheel consumers still need no Node installation, but the wheel now contains a main executable plus its private native helper rather than one physical file. +Profiles can reproduce an external agent by configuring persona and descriptions while the underlying packages remain general. Persistent Bash requires an owning Agent and real PTY backend. Shell exit, timeout, or cancellation loses state. The editor delegates security and mutation policy to the mounted filesystem stack. Runtime-wheel consumers still need no Node installation; Linux wheels contain one executable, while macOS wheels also contain its private native helper. diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md index 0bf100514e..256ffad794 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md @@ -16,7 +16,7 @@ `dsh-system-prompt` 接受 `includeHarnessIdentity: false`;`dsh-agent-spine-demo` 会转发该设置,并接受 `toolBash: false`。因此部署可以拥有精确 persona,并替换 spine 的原生 Bash,而不会重复注册提示词或工具。既有默认值不变。 -两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 会执行原生 `spawn-helper`,每个打包后的运行时可执行文件都会携带一个架构匹配的 `-spawn-helper` 伴随文件。固定版本的 `node-pty` 补丁只在该伴随文件存在时解析它,普通 Node 运行仍保留上游查找方式。显式的 `DSH_NODE_PTY_SPAWN_HELPER` 覆盖仍予保留,供当前提供非伴随 helper 的外部消费方使用。可执行文件与运行时 wheel 包的构建器会检查 ELF 或 thin Mach-O 文件头;若 helper 缺失、架构不匹配或不可执行,构建会在发布前失败。 +两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 在 macOS 上会执行原生 `spawn-helper`,每个打包后的 macOS 运行时可执行文件都会携带一个架构匹配的 `-spawn-helper` 伴随文件;Linux 直接使用 `forkpty`。固定版本的 `node-pty` 补丁只在该伴随文件存在时解析它,普通 Node 运行仍保留上游查找方式。显式的 `DSH_NODE_PTY_SPAWN_HELPER` 覆盖仍予保留,供当前提供非伴随 helper 的外部消费方使用。macOS 可执行文件与运行时 wheel 包的构建器会检查 thin Mach-O 文件头;若 helper 缺失、架构不匹配或不可执行,构建会在发布前失败。 ## 考虑过的替代方案 @@ -30,4 +30,4 @@ ## 后果 -Profile 可以通过配置 persona 和描述复现外部 Agent,而底层包保持通用。持久 Bash 需要拥有它的 Agent 与真实 PTY 后端;shell 退出、超时或取消会丢失状态。编辑器把安全与变更策略委托给挂载的文件系统栈。runtime wheel 的使用者仍不需要安装 Node,但 wheel 现在包含主可执行文件及其私有原生 helper,而不是单个物理文件。 +Profile 可以通过配置 persona 和描述复现外部 Agent,而底层包保持通用。持久 Bash 需要拥有它的 Agent 与真实 PTY 后端;shell 退出、超时或取消会丢失状态。编辑器把安全与变更策略委托给挂载的文件系统栈。运行时 wheel 包的消费方仍无需安装 Node;Linux wheel 包包含一个可执行文件,macOS wheel 包还包含其私有原生 helper。 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8aad3a1f3d..eb7a7c6322 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -40,7 +40,7 @@ allowBuilds: # JSONL durability calls MoveFileExW with write-through publication on Windows. koffi: true # The Python runtime deploy includes the reviewed workspace postinstall that - # places node-pty's spawn helper beside the compiled PTY backend. + # restores the executable bit on node-pty's macOS spawn helper. '@deepseek-ai/dsh-pty-local@file:packages/pty/pty-local': true # The Landlock launcher family is our own sibling-repo release, consumed diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index c25eecf5de..ba56be2639 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -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 python/sdk-runtime/README.md -README.md: 0869b4a9dce0e261b168f21c90a80faf81ea4a64 -README.zh.md: 3f2342c4c66e24b8213c78d4e7530c3360497022 +README.md: 29ffc1dcc3ec3b273ccaee64734739f3c4f34b9c +README.zh.md: d79c87090867c60e09c50016bad4797170b34400 diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index 0869b4a9dc..29ffc1dcc3 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -8,7 +8,7 @@ Runtime carrier package for the Python SDK (dist `deepseek-harness-runtime-bin`, Two carriers coexist under `src/deepseek_harness_runtime/runtime/`, both injected by the repo's `scripts/build-exe-for-python-sdk.ts` build and both gitignored: -- **exe (production)** — a single-file Node executable `dsh-jsonrpc-agent-pkg--` plus its native `-spawn-helper` sibling (platform: `linux`/`macos`; arch: `x64`/`arm64`). The helper is required by `node-pty`; both files are built as one runtime product, and ELF or thin Mach-O headers must match the target. No Node installation is needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists. +- **exe (production)** — a single-file Node executable `dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`). macOS builds also ship the native `-spawn-helper` sibling that `node-pty` uses there, and its thin Mach-O header must match the target. No Node installation is needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists. - **node (dev-only)** — the full deploy closure under `runtime/node/` (`package.json` + `node_modules/`), executed as `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` on a system Node >= 22.19. It is the current checkout's source build, meant for repo-local development and verification only; it is never selected automatically and is excluded from distributions. Both carriers hold the same content, defined once: the [package.json](package.json) at this package's root is the deploy root of the single-exe pipeline — a pure dependency manifest (no code of its own) whose dependency closure IS both the plugin set compiled into the exe and the tree materialized into `runtime/node/`. Adding a plugin to the distribution means adding one dependency line there and rebuilding. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 3f2342c4c6..d79c870908 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -8,7 +8,7 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`, 两种载体并存于 `src/deepseek_harness_runtime/runtime/` 之下,均由仓库的 `scripts/build-exe-for-python-sdk.ts` 构建注入,且均被 git 忽略: -- **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg--` 及其原生 `-spawn-helper` 伴随文件(platform:`linux`/`macos`;arch:`x64`/`arm64`)。`node-pty` 需要该 helper;两者作为一个运行时产物构建,且 ELF 或 thin Mach-O 文件头必须与目标匹配。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 +- **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`)。macOS 构建还会随附 `node-pty` 在该平台使用的原生 `-spawn-helper` 伴随文件,其 thin Mach-O 文件头必须与目标匹配。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 - **`node`(仅限开发)**——`runtime/node/` 下的完整部署闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。 两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](package.json) 是 single-exe 流水线的部署根目录——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py index f2df169cfc..9b54e0c5ed 100644 --- a/python/sdk-runtime/hatch_build.py +++ b/python/sdk-runtime/hatch_build.py @@ -17,26 +17,18 @@ _SPAWN_HELPER_SUFFIX = "-spawn-helper" def _spawn_helper_binary_target(header: bytes) -> str | None: - if ( - len(header) >= 20 - and header[:4] == b"\x7fELF" - and header[4] == 2 - and header[5] == 1 - ): - machine = int.from_bytes(header[18:20], "little") - if machine == 62: - return "linux-x64" - if machine == 183: - return "linux-arm64" if len(header) >= 8 and header[:4] == b"\xcf\xfa\xed\xfe": - if int.from_bytes(header[4:8], "little") == 0x0100000C: + cpu_type = int.from_bytes(header[4:8], "little") + if cpu_type == 0x01000007: + return "macos-x64" + if cpu_type == 0x0100000C: return "macos-arm64" return None def _validate_spawn_helper(path: Path, expected_target: str) -> None: with path.open("rb") as helper: - actual_target = _spawn_helper_binary_target(helper.read(20)) + actual_target = _spawn_helper_binary_target(helper.read(8)) if actual_target != expected_target: raise RuntimeError( f"runtime spawn helper binary mismatch: expected {expected_target}, " @@ -84,15 +76,18 @@ class RuntimeBuildHook(BuildHookInterface): f"runtime wheel {platform_tag} must contain only {expected_executable}; found {found}" ) expected_helper = f"{expected_executable}{_SPAWN_HELPER_SUFFIX}" - if [path.name for path in helpers] != [expected_helper]: + expected_helpers = [expected_helper] if expected_target.startswith("macos-") else [] + if [path.name for path in helpers] != expected_helpers: + expected = ", ".join(expected_helpers) or "none" found = ", ".join(path.name for path in helpers) or "none" raise RuntimeError( - f"runtime wheel {platform_tag} must contain only {expected_helper}; found {found}" + f"runtime wheel {platform_tag} helper payload mismatch: expected {expected}; found {found}" ) - for executable in [executables[0], helpers[0]]: + for executable in [executables[0], *helpers]: if executable.stat().st_mode & stat.S_IXUSR == 0: raise RuntimeError(f"runtime executable is not executable: {executable}") - _validate_spawn_helper(helpers[0], expected_target) + if helpers: + _validate_spawn_helper(helpers[0], expected_target) build_data["pure_python"] = False build_data["infer_tag"] = False diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index 698676a636..fad1d01cda 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -17,14 +17,10 @@ build_python_release = SimpleNamespace(**runpy.run_path(str(SCRIPT))) def helper_header(target: str) -> bytes: - header = bytearray(20) - if target.startswith("linux-"): - header[:6] = b"\x7fELF\x02\x01" - machine = 62 if target == "linux-x64" else 183 - header[18:20] = machine.to_bytes(2, "little") - else: - header[:4] = b"\xcf\xfa\xed\xfe" - header[4:8] = (0x0100000C).to_bytes(4, "little") + header = bytearray(8) + header[:4] = b"\xcf\xfa\xed\xfe" + cpu_type = 0x01000007 if target == "macos-x64" else 0x0100000C + header[4:8] = cpu_type.to_bytes(4, "little") return bytes(header) @@ -76,7 +72,7 @@ def test_stage_runtime_copies_executable_and_spawn_helper(tmp_path: Path) -> Non def test_stage_runtime_rejects_missing_spawn_helper(tmp_path: Path) -> None: - executable = tmp_path / "dsh-jsonrpc-agent-pkg-linux-x64" + executable = tmp_path / "dsh-jsonrpc-agent-pkg-macos-arm64" executable.write_bytes(b"runtime") executable.chmod(0o755) @@ -89,20 +85,36 @@ def test_stage_runtime_rejects_missing_spawn_helper(tmp_path: Path) -> None: ) -@pytest.mark.parametrize("target", ["linux-x64", "linux-arm64", "macos-arm64"]) +@pytest.mark.parametrize("target", ["linux-x64", "linux-arm64"]) +def test_stage_runtime_copies_linux_executable_without_spawn_helper( + tmp_path: Path, target: str +) -> None: + executable = tmp_path / f"dsh-jsonrpc-agent-pkg-{target}" + executable.write_bytes(b"runtime") + executable.chmod(0o755) + destination = tmp_path / "staging" + + build_python_release.stage_runtime(destination, "1.2.3", executable, executable.name) + + runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" + runtime_files = [path.name for path in runtime_dir.glob("dsh-jsonrpc-agent-pkg-*")] + assert runtime_files == [executable.name] + + +@pytest.mark.parametrize("target", ["macos-x64", "macos-arm64"]) def test_spawn_helper_binary_target(target: str) -> None: assert build_python_release.spawn_helper_binary_target(helper_header(target)) == target def test_stage_runtime_rejects_mismatched_spawn_helper(tmp_path: Path) -> None: - executable = tmp_path / "dsh-jsonrpc-agent-pkg-linux-x64" + executable = tmp_path / "dsh-jsonrpc-agent-pkg-macos-arm64" executable.write_bytes(b"runtime") executable.chmod(0o755) spawn_helper = Path(f"{executable}-spawn-helper") - spawn_helper.write_bytes(helper_header("linux-arm64")) + spawn_helper.write_bytes(helper_header("macos-x64")) spawn_helper.chmod(0o755) - with pytest.raises(ValueError, match="expected linux-x64, found linux-arm64"): + with pytest.raises(ValueError, match="expected macos-arm64, found macos-x64"): build_python_release.stage_runtime( tmp_path / "staging", "1.2.3", diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index 24cd6c0383..b4b4d23720 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -8,8 +8,8 @@ import { spawn } from 'node:child_process' import { existsSync, mkdirSync, readFileSync, statSync } from 'node:fs' -import { chmod, copyFile, readFile, rm, writeFile } from 'node:fs/promises' -import { basename, join, resolve, sep } from 'node:path' +import { chmod, copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { basename, dirname, join, resolve, sep } from 'node:path' import { parseArgs } from 'node:util' const root = resolve(import.meta.dirname, '..') @@ -55,19 +55,11 @@ type Arch = (typeof ARCHES)[number] interface RuntimeProduct { executable: string - spawnHelper: string + spawnHelper?: string } function spawnHelperBinaryTarget(path: string): string | undefined { - const header = readFileSync(path).subarray(0, 20) - if (header.length >= 20 - && header.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) - && header[4] === 2 - && header[5] === 1) { - const machine = header.readUInt16LE(18) - if (machine === 62) return 'linux-x64' - if (machine === 183) return 'linux-arm64' - } + const header = readFileSync(path).subarray(0, 8) if (header.length >= 8 && header.readUInt32LE(0) === 0xfeedfacf) { const cpuType = header.readUInt32LE(4) if (cpuType === 0x01000007) return 'macos-x64' @@ -76,6 +68,10 @@ function spawnHelperBinaryTarget(path: string): string | undefined { return undefined } +function runtimeProductFiles(product: RuntimeProduct): string[] { + return [product.executable, ...(product.spawnHelper === undefined ? [] : [product.spawnHelper])] +} + function isPlatform(value: string): value is Platform { return (PLATFORMS as readonly string[]).includes(value) } @@ -317,7 +313,7 @@ class SingleExeBuild { */ async pack(target: Target): Promise { const product = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`) - const spawnHelper = `${product}${SPAWN_HELPER_SUFFIX}` + await this.prepareNativePty(target) if (!this.cli.dryRun) mkdirSync(this.outDir, { recursive: true }) await this.run(`pkg ${target.spec}`, pnpmBin(), [ 'dlx', @@ -332,6 +328,8 @@ class SingleExeBuild { if (!this.cli.dryRun && !existsSync(product)) { throw new Error(`build-exe-for-python-sdk: product ${product} is missing after the pkg run; inspect ${this.outDir}.`) } + if (target.platform !== 'macos') return { executable: product } + const spawnHelper = `${product}${SPAWN_HELPER_SUFFIX}` if (this.cli.dryRun) { console.log(`build-exe-for-python-sdk: [dry-run] copy target node-pty spawn-helper to ${spawnHelper}`) } else { @@ -342,6 +340,38 @@ class SingleExeBuild { return { executable: product, spawnHelper } } + /** + * Put the target node-pty addon in the staged closure. Linux npm installs + * build it from source, but legacy deploy omits that side-effect directory. + * @param target - the pkg target whose native addon is being staged. + */ + private async prepareNativePty(target: Target): Promise { + const stagedRoot = join(this.staging, 'node_modules', 'node-pty') + const stagedBuild = join(stagedRoot, 'build') + if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] rm -rf ${stagedBuild}`) + else await rm(stagedBuild, { recursive: true, force: true }) + + const nativePlatform = target.platform === 'macos' ? 'darwin' : 'linux' + const prebuilt = join(stagedRoot, 'prebuilds', `${nativePlatform}-${target.arch}`, 'pty.node') + const source = join(root, 'packages', 'pty', 'pty-local', 'node_modules', 'node-pty', 'build', 'Release', 'pty.node') + const destination = join(stagedBuild, 'Release', 'pty.node') + if (this.cli.dryRun) { + if (target.platform === 'linux') console.log(`build-exe-for-python-sdk: [dry-run] cp ${source} ${destination}`) + return + } + if (existsSync(prebuilt)) return + + const host = Target.host() + if (target.platform !== host.platform || target.arch !== host.arch || !existsSync(source)) { + throw new Error( + `build-exe-for-python-sdk: node-pty native addon for ${target.platform}-${target.arch} is missing; ` + + `checked ${prebuilt}, ${source}. Build the Linux runtime on its target architecture.`, + ) + } + await mkdir(dirname(destination), { recursive: true }) + await copyFile(source, destination) + } + /** * Resolve the node-pty helper that matches a pkg target. * @param target - the pkg target whose helper must be shipped. @@ -349,14 +379,12 @@ class SingleExeBuild { */ private resolveSpawnHelper(target: Target): string { const nodePtyRoot = join(this.staging, 'node_modules', 'node-pty') - const nativePlatform = target.platform === 'macos' ? 'darwin' : 'linux' const candidates = [ - join(nodePtyRoot, 'prebuilds', `${nativePlatform}-${target.arch}`, 'spawn-helper'), + join(nodePtyRoot, 'prebuilds', `darwin-${target.arch}`, 'spawn-helper'), ] - const hostPlatform = process.platform === 'darwin' ? 'macos' : process.platform - const hostArch = process.arch === 'x64' || process.arch === 'arm64' ? process.arch : undefined - if (target.platform === hostPlatform && target.arch === hostArch) { - candidates.push(join(nodePtyRoot, 'build', 'Release', 'spawn-helper')) + const host = Target.host() + if (target.platform === host.platform && target.arch === host.arch) { + candidates.push(join(root, 'packages', 'pty', 'pty-local', 'node_modules', 'node-pty', 'build', 'Release', 'spawn-helper')) } const helper = candidates.find(candidate => existsSync(candidate)) if (helper === undefined) { @@ -387,11 +415,10 @@ class SingleExeBuild { console.log(this.cli.dryRun ? 'build-exe-for-python-sdk: [dry-run] would produce:' : 'build-exe-for-python-sdk: products:') for (const product of products) { if (this.cli.dryRun) { - console.log(` ${product.executable}`) - console.log(` ${product.spawnHelper}`) + for (const path of runtimeProductFiles(product)) console.log(` ${path}`) continue } - for (const path of [product.executable, product.spawnHelper]) { + for (const path of runtimeProductFiles(product)) { const megabytes = statSync(path).size / (1024 * 1024) console.log(` ${path} (${megabytes.toFixed(1)} MB)`) } @@ -407,7 +434,7 @@ class SingleExeBuild { const destDir = resolve(root, PYTHON_RUNTIME_DIR) if (this.cli.dryRun) { for (const product of products) { - for (const path of [product.executable, product.spawnHelper]) { + for (const path of runtimeProductFiles(product)) { console.log(`build-exe-for-python-sdk: [dry-run] cp ${path} ${join(destDir, basename(path))}`) } } @@ -415,7 +442,7 @@ class SingleExeBuild { } mkdirSync(destDir, { recursive: true }) for (const product of products) { - for (const path of [product.executable, product.spawnHelper]) { + for (const path of runtimeProductFiles(product)) { const destination = join(destDir, basename(path)) await copyFile(path, destination) await chmod(destination, statSync(path).mode & 0o777) diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index 3011120da1..6bbe8bf512 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -27,26 +27,18 @@ EXECUTABLE_TARGETS = {value[1]: key for key, value in PLATFORMS.items()} def spawn_helper_binary_target(header: bytes) -> str | None: - if ( - len(header) >= 20 - and header[:4] == b"\x7fELF" - and header[4] == 2 - and header[5] == 1 - ): - machine = int.from_bytes(header[18:20], "little") - if machine == 62: - return "linux-x64" - if machine == 183: - return "linux-arm64" if len(header) >= 8 and header[:4] == b"\xcf\xfa\xed\xfe": - if int.from_bytes(header[4:8], "little") == 0x0100000C: + cpu_type = int.from_bytes(header[4:8], "little") + if cpu_type == 0x01000007: + return "macos-x64" + if cpu_type == 0x0100000C: return "macos-arm64" return None def validate_spawn_helper(path: Path, expected_target: str) -> None: with path.open("rb") as helper: - actual_target = spawn_helper_binary_target(helper.read(20)) + actual_target = spawn_helper_binary_target(helper.read(8)) if actual_target != expected_target: raise ValueError( f"runtime spawn helper binary mismatch: expected {expected_target}, " @@ -166,12 +158,14 @@ def stage_runtime(destination: Path, version: str, executable: Path, executable_ raise FileNotFoundError(f"runtime executable does not exist: {executable}") if executable.stat().st_mode & stat.S_IXUSR == 0: raise PermissionError(f"runtime executable is not executable: {executable}") + expected_target = EXECUTABLE_TARGETS[executable_name] spawn_helper = Path(f"{executable}{SPAWN_HELPER_SUFFIX}") - if not spawn_helper.is_file(): - raise FileNotFoundError(f"runtime spawn helper does not exist: {spawn_helper}") - if spawn_helper.stat().st_mode & stat.S_IXUSR == 0: - raise PermissionError(f"runtime spawn helper is not executable: {spawn_helper}") - validate_spawn_helper(spawn_helper, EXECUTABLE_TARGETS[executable_name]) + if expected_target.startswith("macos-"): + if not spawn_helper.is_file(): + raise FileNotFoundError(f"runtime spawn helper does not exist: {spawn_helper}") + if spawn_helper.stat().st_mode & stat.S_IXUSR == 0: + raise PermissionError(f"runtime spawn helper is not executable: {spawn_helper}") + validate_spawn_helper(spawn_helper, expected_target) copy_package(ROOT / "python" / "sdk-runtime", destination) rewrite_version(destination / "pyproject.toml", version) runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" @@ -179,9 +173,10 @@ def stage_runtime(destination: Path, version: str, executable: Path, executable_ destination_executable = runtime_dir / executable_name shutil.copyfile(executable, destination_executable) destination_executable.chmod(executable.stat().st_mode & 0o777) - destination_helper = runtime_dir / f"{executable_name}{SPAWN_HELPER_SUFFIX}" - shutil.copyfile(spawn_helper, destination_helper) - destination_helper.chmod(spawn_helper.stat().st_mode & 0o777) + if expected_target.startswith("macos-"): + destination_helper = runtime_dir / f"{executable_name}{SPAWN_HELPER_SUFFIX}" + shutil.copyfile(spawn_helper, destination_helper) + destination_helper.chmod(spawn_helper.stat().st_mode & 0o777) def verify_wheel( @@ -209,20 +204,27 @@ def verify_wheel( assert platform is not None if len(executables) != 1 or not executables[0].endswith(f"/runtime/{platform[1]}"): raise RuntimeError(f"{wheel} must contain exactly {platform[1]}, found {executables}") + expected_target = EXECUTABLE_TARGETS[platform[1]] expected_helper = f"{platform[1]}{SPAWN_HELPER_SUFFIX}" - if len(helpers) != 1 or not helpers[0].endswith(f"/runtime/{expected_helper}"): - raise RuntimeError(f"{wheel} must contain exactly {expected_helper}, found {helpers}") - for executable in [executables[0], helpers[0]]: + expected_helpers = [expected_helper] if expected_target.startswith("macos-") else [] + found_helpers = [Path(helper).name for helper in helpers] + if found_helpers != expected_helpers: + expected = ", ".join(expected_helpers) or "none" + found = ", ".join(found_helpers) or "none" + raise RuntimeError( + f"{wheel} runtime helper payload mismatch: expected {expected}; found {found}" + ) + for executable in [executables[0], *helpers]: mode = archive.getinfo(executable).external_attr >> 16 if mode & stat.S_IXUSR == 0: raise RuntimeError(f"{wheel} runtime executable lost its executable bit: {executable}") - actual_target = spawn_helper_binary_target(archive.read(helpers[0])[:20]) - expected_target = EXECUTABLE_TARGETS[platform[1]] - if actual_target != expected_target: - raise RuntimeError( - f"{wheel} spawn helper binary mismatch: expected {expected_target}, " - f"found {actual_target or 'unsupported format or architecture'}" - ) + if helpers: + actual_target = spawn_helper_binary_target(archive.read(helpers[0])[:8]) + if actual_target != expected_target: + raise RuntimeError( + f"{wheel} spawn helper binary mismatch: expected {expected_target}, " + f"found {actual_target or 'unsupported format or architecture'}" + ) elif runtime_files: raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {runtime_files}") if package == "sdk": From 5de69975f018a2bc1fb972bef2f6aae5141443e4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:56:14 +0800 Subject: [PATCH 31/72] test(python): stabilize executable snapshot --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 2 +- ...-executable-sdk-runtime-distribution.zh.md | 2 +- scripts/smoke-python-runtime.py | 9 +- .../advanced/result.json | 708 +++++++++++------- .../advanced/session.1.jsonl | 6 +- .../advanced/session.2.jsonl | 6 +- .../advanced/session.jsonl | 30 +- 8 files changed, 468 insertions(+), 299 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index ce64f9d035..27e43ef4b2 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: fac3d9527b496adaaffdbd8e78401a17c6f0cc0b -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: ff6a8be16c5f8591efa9bf383cd47c8fe251fe39 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: f749d6a72b4c32a189a9f848595076457819d9b9 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 2b511573bc68e5378279cec8d22ce960af0966e9 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index fac3d9527b..f749d6a72b 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -62,7 +62,7 @@ The exe's "must be explicitly configured" hard semantic is unchanged; the zero-c ## Testing -The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build completes a turn against a mock endpoint through the default SDK path, a custom config, and the direct binary protocol, with final text and JSONL checked. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The same build leg runs a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The comparison normalizes the SDK result and notification stream plus the parent and two child JSONL logs. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. The platform wheel is then installed in a clean venv and run without `runtime_bin`. +The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build completes a turn against a mock endpoint through the default SDK path, a custom config, and the direct binary protocol, with final text and JSONL checked. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The same build leg runs a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message IDs in the SDK result and notification stream plus the parent and two child JSONL logs. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. The platform wheel is then installed in a clean venv and run without `runtime_bin`. Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index ff6a8be16c..2b511573bc 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -62,7 +62,7 @@ exe 内支持 `dsh-workflow-workerthread` 与 `dsh-code-runtime-worker`。两个 ## 测试 -验证面分三层。机制层:`--sea` 链路的实测结论内嵌在“决策”各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以假运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置和直接二进制协议,对模拟端点完成一个轮次,并校验最终文本与 JSONL。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。同一构建任务还会经 Python SDK 运行一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个由 spawn 提供方直接启动的 subagent(子 agent)和一个会通过 spawn 启动第二个子 agent 的工作流,随后卸载该插件。比较时会规范化 SDK 结果与通知流,以及父会话和两个子会话的 JSONL 日志。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。 +验证面分三层。机制层:`--sea` 链路的实测结论内嵌在“决策”各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以假运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置和直接二进制协议,对模拟端点完成一个轮次,并校验最终文本与 JSONL。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。同一构建任务还会经 Python SDK 运行一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个由 spawn 提供方直接启动的 subagent(子 agent)和一个会通过 spawn 启动第二个子 agent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化以下各处的不透明消息 ID:SDK 结果与通知流,以及父会话和两个子会话的 JSONL 日志。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。 手工驱动注意:`bin` 将 stdin EOF 视为“客户端已离开”并立即 dispose,短命管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。 diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 346818e5fa..3ce800932f 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -72,6 +72,9 @@ CUSTOM_CORDIS = """\ name: '@deepseek-ai/dsh-agent-spine-demo' config: workspaceContext: false + skills: + enabled: false + toolBash: false tools: mode: both - id: sessions @@ -79,10 +82,6 @@ CUSTOM_CORDIS = """\ config: root: !!js process.env.DSH_SESSION_ROOT compression: 'none' -- id: bash - name: '@deepseek-ai/dsh-bash-local' - config: - cwd: !!js process.env.DSH_CWD - id: code-runtime name: '@deepseek-ai/dsh-code-runtime-worker' - id: subagents @@ -874,6 +873,8 @@ def normalize_snapshot_value( normalized["createdAt"] = 0 if "seq" in normalized and "time" in normalized: normalized["time"] = 0 + if isinstance(normalized.get("id"), str) and normalized.get("role") in ("assistant", "user"): + normalized["id"] = "{{messageId}}" scrub_snapshot_header(normalized) return normalized diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/result.json b/scripts/snapshots/python-sdk-single-exe/advanced/result.json index 07393e7f25..79daa0570c 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/result.json +++ b/scripts/snapshots/python-sdk-single-exe/advanced/result.json @@ -30,7 +30,9 @@ ], "source": { "kind": "user" - } + }, + "role": "user", + "id": "{{messageId}}" }, "surfaceOp": "append" }, @@ -70,20 +72,15 @@ }, "system": "{{system}}", "tools": [ - "bash", "cordis_inspect", "cordis_mount", "cordis_unmount", "run_code", - "skill", "subagent", "task_kill", "task_list", "task_output", "workflow" - ], - "messagePrefix": [ - "{{messagePrefix}}" ] }, "reason": "initial" @@ -176,17 +173,22 @@ "data": { "turn": 1, "step": 1, - "content": [ - { - "type": "tool-call", - "id": "advanced-mount", - "name": "cordis_mount", - "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "id": "advanced-mount", + "name": "cordis_mount", + "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -221,14 +223,27 @@ "data": { "turn": 1, "step": 1, - "callId": "advanced-mount", - "content": [ - { - "type": "text", - "text": "Temporary Plugin dyn-1 is running (plugin \"\"; available until unmounted or DSH restarts)." - } - ], - "isError": false + "message": { + "source": { + "kind": "tool", + "callId": "advanced-mount" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "advanced-mount", + "content": [ + { + "type": "text", + "text": "Temporary Plugin dyn-1 is running (plugin \"\"; available until unmounted or DSH restarts)." + } + ], + "isError": false + } + ], + "role": "user", + "id": "{{messageId}}" + } }, "sourceEventSeqs": [ 11 @@ -266,21 +281,16 @@ }, "system": "{{system}}", "tools": [ - "bash", "cordis_inspect", "cordis_mount", "cordis_unmount", "run_code", - "skill", "snapshot_double", "subagent", "task_kill", "task_list", "task_output", "workflow" - ], - "messagePrefix": [ - "{{messagePrefix}}" ] }, "reason": "change" @@ -373,17 +383,22 @@ "data": { "turn": 1, "step": 2, - "content": [ - { - "type": "tool-call", - "id": "advanced-code", - "name": "run_code", - "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "id": "advanced-code", + "name": "run_code", + "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -451,14 +466,27 @@ "data": { "turn": 1, "step": 2, - "callId": "advanced-code", - "content": [ - { - "type": "text", - "text": "42" - } - ], - "isError": false + "message": { + "source": { + "kind": "tool", + "callId": "advanced-code" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "advanced-code", + "content": [ + { + "type": "text", + "text": "42" + } + ], + "isError": false + } + ], + "role": "user", + "id": "{{messageId}}" + } }, "sourceEventSeqs": [ 22 @@ -570,17 +598,22 @@ "data": { "turn": 1, "step": 3, - "content": [ - { - "type": "tool-call", - "id": "advanced-direct-child", - "name": "subagent", - "arguments": "{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "id": "advanced-direct-child", + "name": "subagent", + "arguments": "{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -615,14 +648,27 @@ "data": { "turn": 1, "step": 3, - "callId": "advanced-direct-child", - "content": [ - { - "type": "text", - "text": "DIRECT_CHILD_OK" - } - ], - "isError": false + "message": { + "source": { + "kind": "tool", + "callId": "advanced-direct-child" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "advanced-direct-child", + "content": [ + { + "type": "text", + "text": "DIRECT_CHILD_OK" + } + ], + "isError": false + } + ], + "role": "user", + "id": "{{messageId}}" + } }, "sourceEventSeqs": [ 34 @@ -734,17 +780,22 @@ "data": { "turn": 1, "step": 4, - "content": [ - { - "type": "tool-call", - "id": "advanced-workflow", - "name": "workflow", - "arguments": "{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "id": "advanced-workflow", + "name": "workflow", + "arguments": "{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -779,14 +830,27 @@ "data": { "turn": 1, "step": 4, - "callId": "advanced-workflow", - "content": [ - { - "type": "text", - "text": "workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}" - } - ], - "isError": false + "message": { + "source": { + "kind": "tool", + "callId": "advanced-workflow" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "advanced-workflow", + "content": [ + { + "type": "text", + "text": "workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}" + } + ], + "isError": false + } + ], + "role": "user", + "id": "{{messageId}}" + } }, "sourceEventSeqs": [ 44 @@ -898,17 +962,22 @@ "data": { "turn": 1, "step": 5, - "content": [ - { - "type": "tool-call", - "id": "advanced-unmount", - "name": "cordis_unmount", - "arguments": "{\"id\": \"dyn-1\"}" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "id": "advanced-unmount", + "name": "cordis_unmount", + "arguments": "{\"id\": \"dyn-1\"}" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -943,14 +1012,27 @@ "data": { "turn": 1, "step": 5, - "callId": "advanced-unmount", - "content": [ - { - "type": "text", - "text": "Temporary Plugin dyn-1 was unmounted and removed." - } - ], - "isError": false + "message": { + "source": { + "kind": "tool", + "callId": "advanced-unmount" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "advanced-unmount", + "content": [ + { + "type": "text", + "text": "Temporary Plugin dyn-1 was unmounted and removed." + } + ], + "isError": false + } + ], + "role": "user", + "id": "{{messageId}}" + } }, "sourceEventSeqs": [ 54 @@ -988,20 +1070,15 @@ }, "system": "{{system}}", "tools": [ - "bash", "cordis_inspect", "cordis_mount", "cordis_unmount", "run_code", - "skill", "subagent", "task_kill", "task_list", "task_output", "workflow" - ], - "messagePrefix": [ - "{{messagePrefix}}" ] }, "reason": "change" @@ -1090,15 +1167,20 @@ "data": { "turn": 1, "step": 6, - "content": [ - { - "type": "text", - "text": "ADVANCED_EXECUTABLE_OK" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "ADVANCED_EXECUTABLE_OK" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -1173,7 +1255,9 @@ ], "source": { "kind": "user" - } + }, + "role": "user", + "id": "{{messageId}}" }, "surfaceOp": "append" } @@ -1231,20 +1315,15 @@ }, "system": "{{system}}", "tools": [ - "bash", "cordis_inspect", "cordis_mount", "cordis_unmount", "run_code", - "skill", "subagent", "task_kill", "task_list", "task_output", "workflow" - ], - "messagePrefix": [ - "{{messagePrefix}}" ] }, "reason": "initial" @@ -1373,17 +1452,22 @@ "data": { "turn": 1, "step": 1, - "content": [ - { - "type": "tool-call", - "id": "advanced-mount", - "name": "cordis_mount", - "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "id": "advanced-mount", + "name": "cordis_mount", + "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -1430,14 +1514,27 @@ "data": { "turn": 1, "step": 1, - "callId": "advanced-mount", - "content": [ - { - "type": "text", - "text": "Temporary Plugin dyn-1 is running (plugin \"\"; available until unmounted or DSH restarts)." - } - ], - "isError": false + "message": { + "source": { + "kind": "tool", + "callId": "advanced-mount" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "advanced-mount", + "content": [ + { + "type": "text", + "text": "Temporary Plugin dyn-1 is running (plugin \"\"; available until unmounted or DSH restarts)." + } + ], + "isError": false + } + ], + "role": "user", + "id": "{{messageId}}" + } }, "sourceEventSeqs": [ 11 @@ -1493,21 +1590,16 @@ }, "system": "{{system}}", "tools": [ - "bash", "cordis_inspect", "cordis_mount", "cordis_unmount", "run_code", - "skill", "snapshot_double", "subagent", "task_kill", "task_list", "task_output", "workflow" - ], - "messagePrefix": [ - "{{messagePrefix}}" ] }, "reason": "change" @@ -1636,17 +1728,22 @@ "data": { "turn": 1, "step": 2, - "content": [ - { - "type": "tool-call", - "id": "advanced-code", - "name": "run_code", - "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "id": "advanced-code", + "name": "run_code", + "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -1738,14 +1835,27 @@ "data": { "turn": 1, "step": 2, - "callId": "advanced-code", - "content": [ - { - "type": "text", - "text": "42" - } - ], - "isError": false + "message": { + "source": { + "kind": "tool", + "callId": "advanced-code" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "advanced-code", + "content": [ + { + "type": "text", + "text": "42" + } + ], + "isError": false + } + ], + "role": "user", + "id": "{{messageId}}" + } }, "sourceEventSeqs": [ 22 @@ -1905,17 +2015,22 @@ "data": { "turn": 1, "step": 3, - "content": [ - { - "type": "tool-call", - "id": "advanced-direct-child", - "name": "subagent", - "arguments": "{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "id": "advanced-direct-child", + "name": "subagent", + "arguments": "{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -1995,7 +2110,9 @@ ], "source": { "kind": "user" - } + }, + "role": "user", + "id": "{{messageId}}" }, "surfaceOp": "append" } @@ -2053,21 +2170,16 @@ }, "system": "{{system}}", "tools": [ - "bash", "cordis_inspect", "cordis_mount", "cordis_unmount", "run_code", - "skill", "snapshot_double", "subagent", "task_kill", "task_list", "task_output", "workflow" - ], - "messagePrefix": [ - "{{messagePrefix}}" ] }, "reason": "initial" @@ -2192,15 +2304,20 @@ "data": { "turn": 1, "step": 1, - "content": [ - { - "type": "text", - "text": "DIRECT_CHILD_OK" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "DIRECT_CHILD_OK" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -2278,14 +2395,27 @@ "data": { "turn": 1, "step": 3, - "callId": "advanced-direct-child", - "content": [ - { - "type": "text", - "text": "DIRECT_CHILD_OK" - } - ], - "isError": false + "message": { + "source": { + "kind": "tool", + "callId": "advanced-direct-child" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "advanced-direct-child", + "content": [ + { + "type": "text", + "text": "DIRECT_CHILD_OK" + } + ], + "isError": false + } + ], + "role": "user", + "id": "{{messageId}}" + } }, "sourceEventSeqs": [ 34 @@ -2445,17 +2575,22 @@ "data": { "turn": 1, "step": 4, - "content": [ - { - "type": "tool-call", - "id": "advanced-workflow", - "name": "workflow", - "arguments": "{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "id": "advanced-workflow", + "name": "workflow", + "arguments": "{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -2535,7 +2670,9 @@ ], "source": { "kind": "user" - } + }, + "role": "user", + "id": "{{messageId}}" }, "surfaceOp": "append" } @@ -2593,21 +2730,16 @@ }, "system": "{{system}}", "tools": [ - "bash", "cordis_inspect", "cordis_mount", "cordis_unmount", "run_code", - "skill", "snapshot_double", "subagent", "task_kill", "task_list", "task_output", "workflow" - ], - "messagePrefix": [ - "{{messagePrefix}}" ] }, "reason": "initial" @@ -2732,15 +2864,20 @@ "data": { "turn": 1, "step": 1, - "content": [ - { - "type": "text", - "text": "WORKFLOW_CHILD_OK" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "WORKFLOW_CHILD_OK" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -2818,14 +2955,27 @@ "data": { "turn": 1, "step": 4, - "callId": "advanced-workflow", - "content": [ - { - "type": "text", - "text": "workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}" - } - ], - "isError": false + "message": { + "source": { + "kind": "tool", + "callId": "advanced-workflow" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "advanced-workflow", + "content": [ + { + "type": "text", + "text": "workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}" + } + ], + "isError": false + } + ], + "role": "user", + "id": "{{messageId}}" + } }, "sourceEventSeqs": [ 44 @@ -2985,17 +3135,22 @@ "data": { "turn": 1, "step": 5, - "content": [ - { - "type": "tool-call", - "id": "advanced-unmount", - "name": "cordis_unmount", - "arguments": "{\"id\": \"dyn-1\"}" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "id": "advanced-unmount", + "name": "cordis_unmount", + "arguments": "{\"id\": \"dyn-1\"}" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -3042,14 +3197,27 @@ "data": { "turn": 1, "step": 5, - "callId": "advanced-unmount", - "content": [ - { - "type": "text", - "text": "Temporary Plugin dyn-1 was unmounted and removed." - } - ], - "isError": false + "message": { + "source": { + "kind": "tool", + "callId": "advanced-unmount" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "advanced-unmount", + "content": [ + { + "type": "text", + "text": "Temporary Plugin dyn-1 was unmounted and removed." + } + ], + "isError": false + } + ], + "role": "user", + "id": "{{messageId}}" + } }, "sourceEventSeqs": [ 54 @@ -3105,20 +3273,15 @@ }, "system": "{{system}}", "tools": [ - "bash", "cordis_inspect", "cordis_mount", "cordis_unmount", "run_code", - "skill", "subagent", "task_kill", "task_list", "task_output", "workflow" - ], - "messagePrefix": [ - "{{messagePrefix}}" ] }, "reason": "change" @@ -3243,15 +3406,20 @@ "data": { "turn": 1, "step": 6, - "content": [ - { - "type": "text", - "text": "ADVANCED_EXECUTABLE_OK" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "ADVANCED_EXECUTABLE_OK" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl index 05998f8980..2929f8664c 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl @@ -1,14 +1,14 @@ {"type":"session","version":0,"id":"{{child-1}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","delegationDepth":1} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} {"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} +{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"step/end","seq":11,"time":0,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":12,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl index 778c200078..a5da33d006 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl @@ -1,14 +1,14 @@ {"type":"session","version":0,"id":"{{child-2}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","delegationDepth":1} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} {"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} +{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"step/end","seq":11,"time":0,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":12,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl index 1078fe4985..1f2f890b3c 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl @@ -1,30 +1,30 @@ {"type":"session","version":0,"id":"{{parent}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"Run the advanced packaged-runtime snapsh","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}} {"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}}} {"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} +{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}} -{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"\"; available until unmounted or DSH restarts)."}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}} -{"type":"request/header","seq":15,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"change"}} +{"type":"request/header","seq":15,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"change"}} {"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}} {"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}}} {"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":21,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"} +{"type":"assistant/message","seq":21,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"} {"type":"tool/call","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}} {"type":"tool/code-dispatch-start","seq":23,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21}}} {"type":"tool/code-dispatch","seq":24,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"content":[{"type":"text","text":"42"}]}} -{"type":"tool/result","seq":25,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false},"sourceEventSeqs":[22],"surfaceOp":"append"} +{"type":"tool/result","seq":25,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"} {"type":"step/end","seq":26,"time":0,"data":{"turn":1,"step":2}} {"type":"step/start","seq":27,"time":0,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -32,9 +32,9 @@ {"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} {"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":33,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[28,29,30,31,32],"surfaceOp":"append"} +{"type":"assistant/message","seq":33,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[28,29,30,31,32],"surfaceOp":"append"} {"type":"tool/call","seq":34,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} -{"type":"tool/result","seq":35,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false},"sourceEventSeqs":[34],"surfaceOp":"append"} +{"type":"tool/result","seq":35,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[34],"surfaceOp":"append"} {"type":"step/end","seq":36,"time":0,"data":{"turn":1,"step":3}} {"type":"step/start","seq":37,"time":0,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -42,9 +42,9 @@ {"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}}} {"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":43,"time":0,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[38,39,40,41,42],"surfaceOp":"append"} +{"type":"assistant/message","seq":43,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[38,39,40,41,42],"surfaceOp":"append"} {"type":"tool/call","seq":44,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}} -{"type":"tool/result","seq":45,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[44],"surfaceOp":"append"} +{"type":"tool/result","seq":45,"time":0,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[44],"surfaceOp":"append"} {"type":"step/end","seq":46,"time":0,"data":{"turn":1,"step":4}} {"type":"step/start","seq":47,"time":0,"data":{"turn":1,"step":5}} {"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -52,17 +52,17 @@ {"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}}} {"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":52,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":53,"time":0,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[48,49,50,51,52],"surfaceOp":"append"} +{"type":"assistant/message","seq":53,"time":0,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[48,49,50,51,52],"surfaceOp":"append"} {"type":"tool/call","seq":54,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}} -{"type":"tool/result","seq":55,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false},"sourceEventSeqs":[54],"surfaceOp":"append"} +{"type":"tool/result","seq":55,"time":0,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[54],"surfaceOp":"append"} {"type":"step/end","seq":56,"time":0,"data":{"turn":1,"step":5}} {"type":"step/start","seq":57,"time":0,"data":{"turn":1,"step":6}} -{"type":"request/header","seq":58,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"change"}} +{"type":"request/header","seq":58,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","subagent","task_kill","task_list","task_output","workflow"]},"reason":"change"}} {"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":60,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_EXECUTABLE_OK"}}} {"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}}} {"type":"assistant/chunk","seq":62,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":63,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":64,"time":0,"data":{"turn":1,"step":6,"content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"} +{"type":"assistant/message","seq":64,"time":0,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"} {"type":"step/end","seq":65,"time":0,"data":{"turn":1,"step":6}} {"type":"turn/end","seq":66,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} From a22d9aea0bd584ffbd86336868b9eaed8eebe291 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 23:00:49 +0800 Subject: [PATCH 32/72] =?UTF-8?q?fix(host):=20review=20round=2010=20?= =?UTF-8?q?=E2=80=94=20trailing-separator=20home=20root;=20dead=20dep;=20l?= =?UTF-8?q?azy=20controller;=20focus-invariant=20doc=20home?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- .../src/client/DirectoryBrowser.tsx | 50 ++++++++++++------- .../tests/directory-browser.spec.tsx | 15 +++++- 5 files changed, 50 insertions(+), 23 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index 9fe765766f..2a98ace12d 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 17131eaf390db03fb037216c2b72d3db0fab93bb -2026-07-28-directory-picker-capability-seam.zh.md: 41e9a89ddff05d9e659a93e306348480ea3db5dc +2026-07-28-directory-picker-capability-seam.md: b860feab1fecead8130b77218005ab950575d5c9 +2026-07-28-directory-picker-capability-seam.zh.md: fea181800243b27499fa0a97acc44253078ad9db diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 17131eaf39..b860feab1f 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -19,7 +19,7 @@ Placement and policy rulings folded into this decision: - **Not the `ctx.fs` seam.** `packages/fs/` is the model/session-facing storage stack (policy events, sandbox-swappable backends). Riding it would couple GUI browsing to the model's confinement backend — swapping `fs-sandbox` for the model must never change GUI behavior — and OS facts (home anchoring, hidden conventions) are not storage primitives. The picker seam stays presentation-free and model-free; `packages/host/` is its consumer-domain home. - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. -- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. +- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. Focus parking is a card-wide invariant, not an editor-only one: every pick — editing or not, including right-pane advances and create landings whose columns are replaced — re-parks focus on the selection's row after commit, while Enter, an input-focused Escape, and a pick whose row vanished park on the crumb edit zone; keyboard traversal never falls out of the card (the Modal has no focus trap). - **Navigation lands selection-anchored, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor. Known boundaries of the progressive shape: a pointer press landing exactly inside the one-RTT upgrade window can lose its click when the pressed row node is replaced (keyboard focus is re-parked on the re-selected row; the pointer window is accepted); on slash platforms (macOS) only a final-segment case drift misses the parent-entry match and keeps the single-pane landing, while ancestor-segment drift still matches — parent entry paths inherit the typed prefix — and lands two panes at the cost of the Home collapse; and navigate always relists both legs even when the target is the currently shown level — a crumb tap doubles as the refresh gesture, so freshness wins over reusing possibly-stale in-hand listings at the cost of up to two host scans. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 41e9a89ddf..fea1818002 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -19,7 +19,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **不用 `ctx.fs` seam。** `packages/fs/` 是面向模型/会话的存储栈(policy 事件、sandbox 可换后端)。骑上去会把 GUI 浏览耦合进模型的限制后端——为模型换 `fs-sandbox` 绝不能改变 GUI 行为——而 OS 事实(home 锚定、隐藏约定)也不是存储原语。picker seam 保持无展示、无模型;`packages/host/` 是它消费方域的家。 - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 -- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。 +- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。焦点停靠是卡片全域的不变量,而非编辑器独有:每次选取——无论是否处于编辑态,包括右栏推进与各列被替换的创建落地——提交后都把焦点重新停靠到选中项所在的行上,而 Enter、焦点在输入框上时的 Escape,以及所在行已消失的选取则停靠到 crumb 编辑区;键盘遍历绝不会落出卡片之外(Modal 没有焦点陷阱)。 - **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。渐进形态的已知边界:指针按压若恰好落在单个 RTT 的升级窗口内,可能因所按行节点被替换而丢失点击(键盘焦点会重新停靠到重新选中的行上;指针的这段窗口则被接受);斜杠平台(macOS)上仅末段的大小写偏差会错过父层级条目匹配,保留单栏落地,而祖先段的偏差仍能匹配——父层级条目路径继承键入的前缀——并落地双栏,代价是 Home 塌缩;且导航总是重新列举两程,哪怕目标就是当前展示的层级——crumb 点按兼作刷新手势,因此宁要新鲜度也不复用手头可能已陈旧的列举,代价是至多两次宿主扫描。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index c4c4068d2e..b2e007ec2f 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -69,15 +69,23 @@ function foldPathFor(sep: '\\' | '/'): (value: string) => string { return value => (sep === '\\' ? value.toLowerCase() : value) } +/** Drops one trailing separator (`HOME=/home/u/` ships verbatim while resolve() strips it) unless the path IS the bare root. */ +function trimTrailingSeparator(path: string, sep: '\\' | '/'): string { + return path.length > sep.length && path.endsWith(sep) ? path.slice(0, -sep.length) : path +} + /** * Breadcrumb rows for display: inside the home subtree the chain starts at a * localized Home crumb; outside it the full ancestry shows, the root labeled - * by its own path. The home comparison folds per platform so a typed-case - * Windows path still collapses to the Home crumb. + * by its own path. The home comparison folds per platform and normalizes a + * trailing separator, so a typed-case Windows path or a `HOME=/home/u/` + * shape still collapses to the Home crumb. */ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryEntry[] { - const fold = foldPathFor(separatorOf(listing)) - const homeIndex = listing.crumbs.findIndex(crumb => fold(crumb.path) === fold(listing.home)) + const sep = separatorOf(listing) + const fold = foldPathFor(sep) + const home = fold(trimTrailingSeparator(listing.home, sep)) + const homeIndex = listing.crumbs.findIndex(crumb => fold(trimTrailingSeparator(crumb.path, sep)) === home) if (homeIndex === -1) return listing.crumbs const tail = listing.crumbs.slice(homeIndex + 1) return [{ name: homeLabel, path: listing.home, hidden: false }, ...tail] @@ -154,10 +162,10 @@ function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPr // where the blur lands before our guards) drop this click. // Outside editing, rows keep native focus behavior. onMouseDown={pathEditing ? (event) => { event.preventDefault() } : undefined} - // Editing-time focus parking happens after commit (the - // DirectoryBrowser refocus effect): a right-pane pick replaces - // this very column, so focusing the clicked node here would - // still fall to body. + // Focus parking happens after commit (the DirectoryBrowser + // refocus effect): a right-pane pick replaces this very + // column, so focusing the clicked node here would still fall + // to body. onClick={() => { onPick(entry) }} > {selected @@ -198,9 +206,12 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // The in-flight listing's controller: superseding intent aborts the wire // request too — the Host stops scanning — instead of only discarding the // eventual result while the scan keeps consuming host resources. Always - // holds a controller (a settled or aborted one between scans) so no - // consumer needs a null guard. - const scanController = useRef(new AbortController()) + // holds a controller so no consumer needs a null guard: initially a + // placeholder that the first supersede aborts unused (minted lazily — + // useRef evaluates its argument every render), afterwards the latest + // scan's, settled or aborted between scans. + const [initialScanController] = useState(() => new AbortController()) + const scanController = useRef(initialScanController) // Bumped on every open/close edge: settlements from a previous open (a // pending creation included) must never mutate a reopened dialog. const openGeneration = useRef(0) @@ -364,7 +375,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // re-parks on the edit zone only if focus actually fell to body. refocusEditZone.current = true }) - }, [launchListing, pathDraft]) + }, [launchListing]) /** Abandon path editing (Escape or clicking away) and restore the crumb view. */ const cancelPathEdit = useCallback(() => { @@ -472,10 +483,13 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }, [crumbTail]) // On viewports too narrow for both fixed panes the Miller row scrolls; // whenever a child preview lands, pin it into view the way the crumb tail - // pins — otherwise descent is unreachable on a phone-width window. On a - // parent-leg upgrade the refocus effect below runs after this pin and its - // row.focus() may scroll the selected LEFT row back into view: for that - // one landing, focus placement wins over the child pin by design. + // pins — otherwise descent is unreachable on a phone-width window. The + // refocus effect's row.focus() and this pin can fight on such viewports, + // and whichever commit runs later wins by design: on a parent-leg + // upgrade (one commit) focus placement runs after the pin and keeps the + // selected LEFT row in view; on a plain advance or create landing the + // child arrives in a later commit, so the pin runs after the focus and + // descent reachability wins. const childPath = child?.path useEffect(() => { const row = millerRowRef.current @@ -512,7 +526,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // the user parked elsewhere (a surviving row) stays theirs. if (document.activeElement !== document.body) return const zone = editZoneRef.current - /* v8 ignore next -- narrowing guard: crumb mode renders the edit zone whenever the editor just closed. */ + // The effect already returned while a draft is open, and the close + // reset cleared both flags — so crumb mode's zone is always mounted. + /* v8 ignore next -- narrowing guard: crumb mode always renders the edit zone. */ if (zone === null) return zone.focus() } diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index c5ed60fa5e..909714ae7d 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -224,6 +224,18 @@ describe('DirectoryBrowser', () => { expect(within(columns()[1]!).getByText('harness')).toBeTruthy() }) + it('a trailing-separator home is still the display root (single pane on open)', async () => { + const listDirectory = vi.fn(async (path?: string) => ({ ...listingFor(path), home: `${HOME}/` })) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + // `HOME=/home/u/` ships verbatim while the listing path resolves without + // the trailing separator; the normalized comparison still collapses to + // the Home crumb and no parent leg launches. + expect(columns()).toHaveLength(1) + expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() + expect(listDirectory).toHaveBeenCalledTimes(1) + }) + it('a navigation to the filesystem root keeps the single wide level', async () => { mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) @@ -643,8 +655,7 @@ describe('DirectoryBrowser', () => { expect(document.activeElement?.getAttribute('aria-current')).toBe('true') }) - it('a create landing parks focus on the created row, or the edit zone when the relist lost it', async () => { - // First create: the relist contains the created directory. + it('a create landing parks focus on the created row', async () => { const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) b.listDirectory.mockImplementation(async (path?: string) => { From 525b3aa6c98113420983e344a6943087d9758156 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:20:08 +0800 Subject: [PATCH 33/72] fix(python): resolve Linux runtime without helper --- python/sdk-runtime/README.i18n.yaml | 4 +-- python/sdk-runtime/README.md | 4 +-- python/sdk-runtime/README.zh.md | 4 +-- .../src/deepseek_harness_runtime/__init__.py | 27 ++++++++++--------- python/sdk/tests/test_runtime_resolution.py | 27 +++++++++++++++++++ 5 files changed, 47 insertions(+), 19 deletions(-) diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index ba56be2639..129dac85d5 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -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 python/sdk-runtime/README.md -README.md: 29ffc1dcc3ec3b273ccaee64734739f3c4f34b9c -README.zh.md: d79c87090867c60e09c50016bad4797170b34400 +README.md: efdb5cf9f87e0831ef09a47e6ffdb99254a17f36 +README.zh.md: cafac7418608c416a9f291d62442c32b8787b1fc diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index 29ffc1dcc3..efdb5cf9f8 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -15,12 +15,12 @@ Both carriers hold the same content, defined once: the [package.json](package.js A missing exe raises `FileNotFoundError` naming both acquisition routes: build via `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness checkout, or install the matching platform runtime wheel produced by the `build-exe-for-python-sdk` CI workflow. A missing dev-only node carrier names its sole route, the build script. The workflow retains wheels rather than standalone executable archives. Acquisition strategy is deliberately separate from the lookup interface, so an on-demand download can replace it later without touching callers. -Each wheel contains exactly one runtime executable and its matching native spawn helper. A missing sidecar makes the runtime installation incomplete and is a hard startup error, even for a selected Cordis composition that does not use PTY tools; old exe-only wheels are intentionally unsupported. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_11_0_arm64`; the build hook rejects `py3-none-any`, absent or multiple runtime files, non-executable files, and unsupported platform tags. The repository root `package.json` supplies the shared version for this package and the SDK, and a `python-vX.Y.Z` release tag must match it. +Each wheel contains exactly one runtime executable. The macOS wheel also contains its matching native spawn helper; a missing sidecar makes that installation incomplete and is a hard startup error, even for a selected Cordis composition that does not use PTY tools. Linux wheels contain no spawn helper because `node-pty` uses the staged `pty.node` addon directly. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_11_0_arm64`; the build hook rejects `py3-none-any`, absent or multiple runtime files, non-executable files, and unsupported platform tags. The repository root `package.json` supplies the shared version for this package and the SDK, and a `python-vX.Y.Z` release tag must match it. ## Resolution API - `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]` — the argv tuple that launches the bundled runtime: `(exe_path,)` in exe mode, `(node_path, bin_js_path)` in node mode. Mode selection: explicit argument > `DSH_RUNTIME_MODE` env var (`exe` | `node`) > automatic. Automatic resolution finds the production exe ONLY — the dev-only node carrier must be opted into explicitly so a production deployment can never silently ride on a source build. -- `bundled_runtime_path() -> Path` — the platform exe path (exe carrier only; it validates that the required sibling `-spawn-helper` is also installed). The node carrier has no single-path equivalent and launches via the argv tuple above. +- `bundled_runtime_path() -> Path` — the platform exe path (exe carrier only; on macOS it validates that the required sibling `-spawn-helper` is also installed). The node carrier has no single-path equivalent and launches via the argv tuple above. - `bundled_default_config_path() -> Path` — the checked-in default config (see below). - `bundled_package_dir() -> Path` — the installed package data root. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index d79c870908..cafac74186 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -15,12 +15,12 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`, exe 缺失时抛出 `FileNotFoundError`,并写明两种获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或安装 `build-exe-for-python-sdk` CI 工作流生成的对应平台运行时 wheel 包。仅限开发的 `node` 载体缺失时只提示构建脚本这一条途径。该工作流只保留 wheel 包,不保留独立 exe 归档。获取策略与查找接口刻意分离,之后可以换成按需下载而不改动任何调用方。 -每个 wheel 包只包含一个运行时可执行文件及其匹配的原生 spawn helper。缺少伴随文件意味着运行时安装不完整,并会在启动时硬失败,即使所选 Cordis 组合不使用 PTY 工具也是如此;旧的仅 exe wheel 有意不再兼容。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;构建钩子会拒绝 `py3-none-any`、运行时文件缺失或重复、文件不可执行以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-vX.Y.Z` 发布标签必须与其匹配。 +每个 wheel 包只包含一个运行时可执行文件。macOS wheel 包还包含与其匹配的原生 spawn helper;缺少伴随文件意味着该安装不完整,并会在启动时硬失败,即使所选 Cordis 组合不使用 PTY 工具也是如此。Linux wheel 包不包含 spawn helper,因为 `node-pty` 直接使用暂存的 `pty.node` 原生插件。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;构建钩子会拒绝 `py3-none-any`、运行时文件缺失或重复、文件不可执行以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-vX.Y.Z` 发布标签必须与其匹配。 ## 解析 API - `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]`——启动内置运行时的 argv 元组:exe 模式下为 `(exe_path,)`,`node` 模式下为 `(node_path, bin_js_path)`。模式选择:显式参数 > `DSH_RUNTIME_MODE` 环境变量(`exe` | `node`)> 自动。自动解析只找生产 exe——仅限开发的 `node` 载体必须显式选用,从而生产部署绝不会悄悄跑在源码构建上。 -- `bundled_runtime_path() -> Path`——平台 exe 路径(仅 exe 载体,并会校验必要的 `-spawn-helper` 伴随文件也已安装)。`node` 载体没有单一路径的等价物,经由上面的 argv 元组启动。 +- `bundled_runtime_path() -> Path`——平台 exe 路径(仅 exe 载体,并会在 macOS 上校验必要的 `-spawn-helper` 伴随文件也已安装)。`node` 载体没有单一路径的等价物,经由上面的 argv 元组启动。 - `bundled_default_config_path() -> Path`——检入的默认配置(见下文)。 - `bundled_package_dir() -> Path`——已安装包的数据根目录。 diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py index 9228281ab2..d6f8c497b6 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -5,8 +5,8 @@ Two runtime carriers coexist under ``runtime/``, both injected by the repo's - **exe (production)**: single-file Node executables named ``dsh-jsonrpc-agent-pkg--`` (platform in {linux, macos}, arch in - {x64, arm64}) plus a sibling ``-spawn-helper`` used by ``node-pty``; the - target machine needs no Node installation. + {x64, arm64}); macOS also uses a sibling ``-spawn-helper``. The target machine + needs no Node installation. - **node (dev-only)**: the full deploy closure under ``runtime/node/`` (``package.json`` + ``node_modules/``), executed as ``node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`` on a @@ -71,11 +71,11 @@ def bundled_default_config_path() -> Path: def bundled_runtime_path() -> Path: """Absolute path of the bundled single-file runtime executable for the current platform. - Raises FileNotFoundError when the platform is unsupported or the executable - has not been placed into this package; the message names the acquisition - routes (acquisition strategy is deliberately separate from this lookup - interface, so an on-demand download can replace it without touching - callers). + Raises FileNotFoundError when the platform is unsupported, the executable + has not been placed into this package, or the required macOS spawn helper is + missing; the message names the acquisition routes (acquisition strategy is + deliberately separate from this lookup interface, so an on-demand download + can replace it without touching callers). """ tag = _current_platform_tag() path = bundled_package_dir() / "runtime" / f"dsh-jsonrpc-agent-pkg-{tag}" @@ -84,12 +84,13 @@ def bundled_runtime_path() -> Path: f"deepseek-harness-runtime-bin is missing the runtime executable at {path}. " + _EXE_ACQUISITION_HINT ) - helper = Path(f"{path}{SPAWN_HELPER_SUFFIX}") - if not helper.is_file(): - raise FileNotFoundError( - f"deepseek-harness-runtime-bin is missing the node-pty spawn helper at {helper}. " - + _EXE_ACQUISITION_HINT - ) + if tag.startswith("macos-"): + helper = Path(f"{path}{SPAWN_HELPER_SUFFIX}") + if not helper.is_file(): + raise FileNotFoundError( + f"deepseek-harness-runtime-bin is missing the node-pty spawn helper at {helper}. " + + _EXE_ACQUISITION_HINT + ) return path diff --git a/python/sdk/tests/test_runtime_resolution.py b/python/sdk/tests/test_runtime_resolution.py index 400394ae4e..e0411bb1fd 100644 --- a/python/sdk/tests/test_runtime_resolution.py +++ b/python/sdk/tests/test_runtime_resolution.py @@ -2,6 +2,9 @@ from __future__ import annotations +from pathlib import Path + +import deepseek_harness_runtime as runtime import pytest from deepseek_harness_runtime import ( @@ -39,3 +42,27 @@ def test_explicit_mode_wins_over_env_mode(monkeypatch: pytest.MonkeyPatch) -> No except FileNotFoundError: return # explicit 'exe' was honored; only the artifact is missing assert args[0].endswith(("-x64", "-arm64")) + + +@pytest.mark.parametrize( + ("platform_tag", "requires_helper"), + [("linux-x64", False), ("macos-arm64", True)], +) +def test_runtime_requires_spawn_helper_only_on_macos( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + platform_tag: str, + requires_helper: bool, +) -> None: + runtime_dir = tmp_path / "runtime" + runtime_dir.mkdir() + executable = runtime_dir / f"dsh-jsonrpc-agent-pkg-{platform_tag}" + executable.touch() + monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path) + monkeypatch.setattr(runtime, "_current_platform_tag", lambda: platform_tag) + + if requires_helper: + with pytest.raises(FileNotFoundError, match="node-pty spawn helper"): + runtime.bundled_runtime_path() + else: + assert runtime.bundled_runtime_path() == executable From 4171f8eaa95295edeedb480017839c58710fc627 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 23:21:35 +0800 Subject: [PATCH 34/72] =?UTF-8?q?fix(host):=20review=20round=2011=20?= =?UTF-8?q?=E2=80=94=20every=20displacing=20exit=20re-parks=20focus;=20ful?= =?UTF-8?q?l=20trailing-separator=20trim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- .../src/client/DirectoryBrowser.tsx | 62 +++++++++++++----- .../tests/directory-browser.spec.tsx | 65 +++++++++++++++++++ 5 files changed, 114 insertions(+), 21 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index 2a98ace12d..b35392209c 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: b860feab1fecead8130b77218005ab950575d5c9 -2026-07-28-directory-picker-capability-seam.zh.md: fea181800243b27499fa0a97acc44253078ad9db +2026-07-28-directory-picker-capability-seam.md: c6241ecca4abfafcee104a0c5d9ecc1150627e42 +2026-07-28-directory-picker-capability-seam.zh.md: 1e559a3c95352b08f8ad1aafdf8ce3efa0edc5cb diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index b860feab1f..c6241ecca4 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -19,7 +19,7 @@ Placement and policy rulings folded into this decision: - **Not the `ctx.fs` seam.** `packages/fs/` is the model/session-facing storage stack (policy events, sandbox-swappable backends). Riding it would couple GUI browsing to the model's confinement backend — swapping `fs-sandbox` for the model must never change GUI behavior — and OS facts (home anchoring, hidden conventions) are not storage primitives. The picker seam stays presentation-free and model-free; `packages/host/` is its consumer-domain home. - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. -- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. Focus parking is a card-wide invariant, not an editor-only one: every pick — editing or not, including right-pane advances and create landings whose columns are replaced — re-parks focus on the selection's row after commit, while Enter, an input-focused Escape, and a pick whose row vanished park on the crumb edit zone; keyboard traversal never falls out of the card (the Modal has no focus trap). +- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. Focus parking is a card-wide invariant, not an editor-only one: every pick — editing or not, including right-pane advances and create landings whose columns are replaced — re-parks focus on the selection's row after commit, while every other displacing exit (Enter, Escape, a navigation landing whose new level dropped the focused row, a failed pick or create relist, and the nested create dialog closing) falls back to the crumb edit zone whenever focus actually fell to body; keyboard traversal never falls out of the card (the Modal has no focus trap). - **Navigation lands selection-anchored, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor. Known boundaries of the progressive shape: a pointer press landing exactly inside the one-RTT upgrade window can lose its click when the pressed row node is replaced (keyboard focus is re-parked on the re-selected row; the pointer window is accepted); on slash platforms (macOS) only a final-segment case drift misses the parent-entry match and keeps the single-pane landing, while ancestor-segment drift still matches — parent entry paths inherit the typed prefix — and lands two panes at the cost of the Home collapse; and navigate always relists both legs even when the target is the currently shown level — a crumb tap doubles as the refresh gesture, so freshness wins over reusing possibly-stale in-hand listings at the cost of up to two host scans. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index fea1818002..1e559a3c95 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -19,7 +19,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **不用 `ctx.fs` seam。** `packages/fs/` 是面向模型/会话的存储栈(policy 事件、sandbox 可换后端)。骑上去会把 GUI 浏览耦合进模型的限制后端——为模型换 `fs-sandbox` 绝不能改变 GUI 行为——而 OS 事实(home 锚定、隐藏约定)也不是存储原语。picker seam 保持无展示、无模型;`packages/host/` 是它消费方域的家。 - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 -- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。焦点停靠是卡片全域的不变量,而非编辑器独有:每次选取——无论是否处于编辑态,包括右栏推进与各列被替换的创建落地——提交后都把焦点重新停靠到选中项所在的行上,而 Enter、焦点在输入框上时的 Escape,以及所在行已消失的选取则停靠到 crumb 编辑区;键盘遍历绝不会落出卡片之外(Modal 没有焦点陷阱)。 +- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。焦点停靠是卡片全域的不变量,而非编辑器独有:每次选取——无论是否处于编辑态,包括右栏推进与各列被替换的创建落地——提交后都把焦点重新停靠到选中项所在的行上,而其余所有会顶离焦点的退出(Enter、Escape、新层级已不含焦点所在行的导航落地、选取或创建失败后的重新列举,以及嵌套创建对话框的关闭)只要焦点确实落到了 body 上,就回落到 crumb 编辑区;键盘遍历绝不会落出卡片之外(Modal 没有焦点陷阱)。 - **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。渐进形态的已知边界:指针按压若恰好落在单个 RTT 的升级窗口内,可能因所按行节点被替换而丢失点击(键盘焦点会重新停靠到重新选中的行上;指针的这段窗口则被接受);斜杠平台(macOS)上仅末段的大小写偏差会错过父层级条目匹配,保留单栏落地,而祖先段的偏差仍能匹配——父层级条目路径继承键入的前缀——并落地双栏,代价是 Home 塌缩;且导航总是重新列举两程,哪怕目标就是当前展示的层级——crumb 点按兼作刷新手势,因此宁要新鲜度也不复用手头可能已陈旧的列举,代价是至多两次宿主扫描。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index b2e007ec2f..3ddf5ac540 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -69,9 +69,17 @@ function foldPathFor(sep: '\\' | '/'): (value: string) => string { return value => (sep === '\\' ? value.toLowerCase() : value) } -/** Drops one trailing separator (`HOME=/home/u/` ships verbatim while resolve() strips it) unless the path IS the bare root. */ +/** + * Drops every trailing separator (`HOME=/home/u//` ships verbatim while + * resolve() strips them) down to, but never past, one leading character — + * which keeps the POSIX root `/` intact. A backslash drive root (`C:\`) + * does lose its separator; that stays safe only because every comparison + * trims both sides symmetrically. + */ function trimTrailingSeparator(path: string, sep: '\\' | '/'): string { - return path.length > sep.length && path.endsWith(sep) ? path.slice(0, -sep.length) : path + let end = path.length + while (end > sep.length && path.endsWith(sep, end)) end -= sep.length + return path.slice(0, end) } /** @@ -253,14 +261,14 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const millerRowRef = useRef(null) // Focus parking (consumed by the refocus effect below): a pick — and a // parent-leg upgrade that displaces focused rows — parks on the - // selection's row; Enter, an input-focused Escape, and a failed pick - // whose row unmounts park on the crumb edit zone (the latter only when - // focus actually fell to body). Pointer-out cancels never set (or clear) - // these — yanking focus back from wherever the user clicked would be - // worse than the fall. + // selection's row; every other displacing exit (Enter, Escape, a landing + // whose new level dropped the focused row, a failed pick or relist, and + // the nested create dialog closing) parks on the crumb edit zone, each + // only when focus actually fell to body. Pointer-out cancels never set + // (or clear) these — yanking focus back from wherever the user clicked + // would be worse than the fall. const refocusPick = useRef(false) const refocusEditZone = useRef(false) - const pathInputRef = useRef(null) const editZoneRef = useRef(null) /** @@ -300,6 +308,14 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setError(null) scan.then((target) => { if (seq !== requestSeq.current) return + // The landing replaces every row key; a slow jump leaves the OLD + // rows tabbable meanwhile (parentInert excludes loading), so focus + // may live among them. With no selection yet the edit zone is the + // park target (body-guarded, like every other exit). + const rowHost = millerRowRef.current + /* v8 ignore next -- close-race guard: the commit-to-effect window is not deterministically reproducible. */ + if (rowHost === null) return + if (rowHost.contains(document.activeElement)) refocusEditZone.current = true setParent(target) setSelected(null) setChild(null) @@ -343,6 +359,17 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }) }, [launchListing, continueScan]) + /** + * Close the nested create dialog. Its unmount drops focus to body (the + * Modal has no focus trap), so every exit — Escape, mask, Cancel, and a + * successful create — arms the body-guarded edit-zone parking; a create + * landing's later select() re-parks on the created row instead. + */ + const closeCreateDialog = useCallback(() => { + setFolderDraft(null) + refocusEditZone.current = true + }, []) + /** Select a row of the listed level and preview its children on the right. */ const select = useCallback((entry: DirectoryEntry) => { const { seq, scan } = launchListing(entry.path) @@ -449,7 +476,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // the fresh dialog or issue a relist against the stale target. if (generation !== openGeneration.current) return setCreatingFolder(false) - setFolderDraft(null) + closeCreateDialog() // Land like a right-column pick (figma 802:57446 → 813:23278 flow): the // create target becomes the listed level and the new folder its selection. const { seq, scan } = launchListing(targetPath) @@ -572,11 +599,13 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // document listener — the same containment the input previously // provided for itself. event.stopPropagation() - // Escape while the input holds focus is about to unmount it; with - // focus already parked on a row, that row survives the cancel and - // keeps focus naturally. Assignment (not a conditional set) also + // The cancel may unmount whatever holds focus — the input, or a + // dot-revealed row the cleared draft re-hides. Arm the parking + // unconditionally: the refocus effect's body guard already + // distinguishes a surviving focused row (left alone) from focus + // that actually fell. Assignment (not a conditional set) also // retires a stale flag a failed or still-upgrading Enter left. - refocusEditZone.current = document.activeElement === pathInputRef.current + refocusEditZone.current = true cancelPathEdit() }} // Focus leaving THIS dialog card while editing cancels like Escape. @@ -660,7 +689,6 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, value={pathDraft} aria-label={t('browser.editPath')} autoFocus - ref={pathInputRef} disabled={parentInert} onChange={(event) => { // Editing the draft supersedes any in-flight navigation: @@ -770,7 +798,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, {/* Nested create dialog (figma 813:23278): names one folder inside the target. */} { if (!creatingFolder) setFolderDraft(null) }} + onClose={() => { if (!creatingFolder) closeCreateDialog() }} title={t('browser.newFolder')} className={clsx(css.createDialog)} headless @@ -794,13 +822,13 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, } if (event.key === 'Escape') { event.stopPropagation() - if (!creatingFolder) setFolderDraft(null) + if (!creatingFolder) closeCreateDialog() } }} /> {createError !== null &&
{createError}
}
- + diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index c27522a6cf..e2adc75881 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -349,6 +349,16 @@ describe('DirectoryBrowser', () => { expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' })) }) + it('a home carrying dot segments is still the display root', async () => { + // os.homedir() ships HOME verbatim; the backend resolves listing paths. + const listDirectory = vi.fn(async (path?: string) => ({ ...listingFor(path), home: `${HOME}/foo/../.` })) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + expect(columns()).toHaveLength(1) + expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() + expect(listDirectory).toHaveBeenCalledTimes(1) + }) + it('a navigation to the filesystem root keeps the single wide level', async () => { mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) @@ -1176,6 +1186,7 @@ describe('DirectoryBrowser', () => { expect(cancels.map(button => button.disabled).sort()).toEqual([false, true]) expect(screen.getByRole('button', { name: 'browser.open' }).disabled).toBe(true) expect(screen.getByRole('button', { name: 'browser.editPath' }).disabled).toBe(true) + expect(screen.getByRole('button', { name: 'browser.showHidden' }).disabled).toBe(true) for (const row of screen.getAllByRole('listitem')) { expect(rowButton(row).disabled).toBe(true) } From 4921b63fe353006c4319213e228c11a5ac1184ee Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 00:49:18 +0800 Subject: [PATCH 52/72] =?UTF-8?q?fix(host):=20review=20round=2016=20?= =?UTF-8?q?=E2=80=94=20UNC/forward-slash=20home=20forms;=20root-crumb=20se?= =?UTF-8?q?parator;=20scrollbar=20symmetry;=20test=20dedup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/client/DirectoryBrowser.module.css | 11 ++- .../src/client/DirectoryBrowser.tsx | 53 +++++++------ .../tests/directory-browser.spec.tsx | 75 +++++++++++++------ 3 files changed, 91 insertions(+), 48 deletions(-) diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index 8c8033d825..8bd044f610 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -62,6 +62,12 @@ scrollbar-width: none; } +/* Pseudo-element-path engines (see .millerRow's twin rule): the 20px crumb + * bar has no room for a bar at all. */ +.crumbTrail::-webkit-scrollbar { + display: none; +} + .crumbSeat { display: inline-flex; align-items: center; @@ -299,9 +305,8 @@ color: var(--dsw-alias-label-primary); } -/* Trailing pressed check (Menu's .check parallel): flex-none so wrap or - * narrow-viewport clamp pressure never squashes the glyph — the nowrap - * label refuses to shrink, leaving the icon as the only compressible item. */ +/* Flex-none: the nowrap label refuses to shrink, which would leave the + * glyph as the only compressible item under wrap or narrow-viewport clamp. */ .toggleCheck { flex: none; } diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 8507681150..d82b4f0abd 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -73,30 +73,37 @@ function foldPathFor(sep: '\\' | '/'): (value: string) => string { } /** - * Lexically normalizes an absolute host path for comparisons: collapses - * repeated and trailing separators, drops `.` segments, and applies `..` — - * mirroring the backend's resolve() for the shapes an environment-supplied - * HOME legally carries verbatim (`/home/u/`, `/home//u`, `/home/u/.`) - * while the backend's paths arrive already resolved. A lexical mirror - * only: symlinks are the backend's business, and the input always - * contains the separator (it is an absolute path). + * Lexically normalizes an absolute host path for comparisons: folds + * forward slashes on Windows (win32 accepts either), collapses repeated + * and trailing separators, drops `.` segments, and applies `..` without + * ever crossing the root — POSIX's `/`, a drive's `C:`, or UNC's + * `\\server\share` pair — mirroring the backend's resolve() for the + * shapes an environment-supplied HOME legally carries verbatim while the + * backend's own paths arrive already resolved. A lexical mirror only: + * symlinks are the backend's business. */ function normalizePathFor(sep: '\\' | '/'): (value: string) => string { - return (value) => { - const segments = value.split(sep) - const head = segments.shift() - /* v8 ignore next -- narrowing guard: split always yields at least one segment. */ - if (head === undefined) return value - const out: string[] = [] - for (const segment of segments) { + return (raw) => { + // win32 treats a forward slash as a separator too (resolve() folds + // them); POSIX must not — a backslash there is a name character. + const value = sep === '\\' ? raw.replaceAll('/', sep) : raw + const unc = sep === '\\' && value.startsWith(`${sep}${sep}`) + const segments = (unc ? value.slice(2) : value).split(sep) + // The unpoppable root: POSIX's leading empty segment / the drive + // segment, or UNC's server + share pair. + const rootLength = unc ? 2 : 1 + const out = segments.slice(0, rootLength) + for (const segment of segments.slice(rootLength)) { if (segment === '' || segment === '.') continue if (segment === '..') { - out.pop() + if (out.length > rootLength) out.pop() continue } out.push(segment) } - return `${head}${sep}${out.join(sep)}` + // A bare root keeps (or regains) the trailing separator resolve() + // emits for `/`, `C:\`, and `\\server\share\`. + return `${unc ? sep + sep : ''}${out.join(sep)}${out.length === rootLength ? sep : ''}` } } @@ -119,16 +126,20 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE } /** - * The listing's platform separator, inferred from the home path the host - * stamped — never from typed text or entry paths, where a backslash is a - * legal POSIX name character. Still a heuristic at the last step: a POSIX - * home directory whose own name contains a backslash would misread. + * The listing's platform separator, read from the host-resolved root crumb + * (`/`, `C:\`, `\\server\share\`) — exact for every root form the backend + * emits, immune both to a home delivered in the other slash flavor + * (`USERPROFILE=C:/Users/Alice`) and to backslashes inside POSIX names. * TODO: replace with a host-stamped `separator` field on the wire * DirectoryListing so the platform fact travels verbatim (the trade-off is * recorded in the directory-picker capability seam Agent Note). */ function separatorOf(listing: DirectoryListing): '\\' | '/' { - return listing.home.includes('\\') ? '\\' : '/' + const rootCrumb = listing.crumbs.at(0) + // Home is the honest fallback for an impossible empty chain. + /* v8 ignore next -- narrowing guard: the wire chain is root-to-target inclusive. */ + if (rootCrumb === undefined) return listing.home.includes('\\') ? '\\' : '/' + return rootCrumb.path.includes('\\') ? '\\' : '/' } /** diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index e2adc75881..fcc2b21f92 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -240,20 +240,12 @@ describe('DirectoryBrowser', () => { expect(within(columns()[1]!).getByText('harness')).toBeTruthy() }) - it('a trailing-separator home is still the display root (single pane on open)', async () => { - const listDirectory = vi.fn(async (path?: string) => ({ ...listingFor(path), home: `${HOME}/` })) - mount({ listDirectory }) - await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) - // `HOME=/home/u/` ships verbatim while the listing path resolves without - // the trailing separator; the normalized comparison still collapses to - // the Home crumb and no parent leg launches. - expect(columns()).toHaveLength(1) - expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() - expect(listDirectory).toHaveBeenCalledTimes(1) - }) - - it('a home with several trailing separators is still the display root', async () => { - const listDirectory = vi.fn(async (path?: string) => ({ ...listingFor(path), home: `${HOME}//` })) + // HOME ships verbatim from the environment while listing paths arrive + // resolved; each decoration (trailing, repeated, dot, dot-dot segments) + // must still normalize to the display root — single pane, Home crumb, + // and no parent leg launched. + it.each(['/', '//', '/foo/../.'])('a home decorated with "%s" is still the display root', async (decoration) => { + const listDirectory = vi.fn(async (path?: string) => ({ ...listingFor(path), home: `${HOME}${decoration}` })) mount({ listDirectory }) await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) expect(columns()).toHaveLength(1) @@ -349,16 +341,6 @@ describe('DirectoryBrowser', () => { expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' })) }) - it('a home carrying dot segments is still the display root', async () => { - // os.homedir() ships HOME verbatim; the backend resolves listing paths. - const listDirectory = vi.fn(async (path?: string) => ({ ...listingFor(path), home: `${HOME}/foo/../.` })) - mount({ listDirectory }) - await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) - expect(columns()).toHaveLength(1) - expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() - expect(listDirectory).toHaveBeenCalledTimes(1) - }) - it('a navigation to the filesystem root keeps the single wide level', async () => { mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) @@ -425,6 +407,51 @@ describe('DirectoryBrowser', () => { expect(document.activeElement?.getAttribute('aria-current')).toBe('true') }) + it('a UNC home with dot-dot never pops the share root and still collapses to Home', async () => { + const SHARE = '\\\\server\\share' + const listing: DirectoryListing = { + path: `${SHARE}\\x`, + // USERPROFILE ships verbatim; win32.resolve keeps \\server\share as + // the unpoppable root, so this normalizes to \\server\share\x. + home: `${SHARE}\\..\\x`, + crumbs: [ + { name: `${SHARE}\\`, path: `${SHARE}\\`, hidden: false }, + { name: 'x', path: `${SHARE}\\x`, hidden: false }, + ], + entries: [], + truncated: false, + } + const listDirectory = vi.fn(async () => listing) + mount({ listDirectory }) + await waitFor(() => { expect(listDirectory).toHaveBeenCalled() }) + await waitFor(() => { expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() }) + expect(columns()).toHaveLength(1) + expect(listDirectory).toHaveBeenCalledTimes(1) + }) + + it('a forward-slash Windows home still reads as the display root', async () => { + const listing: DirectoryListing = { + path: 'C:\\Users\\Alice', + // USERPROFILE may legally use forward slashes; the root crumb (not + // the home text) carries the platform, and normalization folds the + // slashes before comparing. + home: 'C:/Users/Alice', + crumbs: [ + { name: 'C:\\', path: 'C:\\', hidden: false }, + { name: 'Users', path: 'C:\\Users', hidden: false }, + { name: 'Alice', path: 'C:\\Users\\Alice', hidden: false }, + ], + entries: [{ name: 'Desktop', path: 'C:\\Users\\Alice\\Desktop', hidden: false }], + truncated: false, + } + const listDirectory = vi.fn(async () => listing) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + expect(columns()).toHaveLength(1) + expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() + expect(listDirectory).toHaveBeenCalledTimes(1) + }) + it('collapses a typed-case Windows home to the display root (single pane, Home crumb)', async () => { const CANON = 'C:\\Users\\Alice' const TYPED = 'c:\\users\\alice' From 0e53b7a8aabc2565238a76c52570a7399739bc98 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:07:47 +0800 Subject: [PATCH 53/72] cleanup(build): drop helper architecture parsing --- ...rsistent-bash-str-replace-editor.i18n.yaml | 4 +- ...7-29-persistent-bash-str-replace-editor.md | 2 +- ...9-persistent-bash-str-replace-editor.zh.md | 2 +- python/sdk-runtime/README.i18n.yaml | 4 +- python/sdk-runtime/README.md | 2 +- python/sdk-runtime/README.zh.md | 2 +- python/sdk-runtime/hatch_build.py | 23 --------- python/sdk/tests/test_release_version.py | 51 +------------------ scripts/build-exe-for-python-sdk.ts | 24 +-------- scripts/build-python-release.py | 28 ---------- 10 files changed, 12 insertions(+), 130 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml index 93776643da..a16f07a63c 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md -2026-07-29-persistent-bash-str-replace-editor.md: aee566f2adee94cea88b2ab802d95e405ee8b26c -2026-07-29-persistent-bash-str-replace-editor.zh.md: 86a4b11ff18ec8c4ef276565f242f60270c4bfb0 +2026-07-29-persistent-bash-str-replace-editor.md: a97af750bdd80ddf38dc2d126e70c245ed035f35 +2026-07-29-persistent-bash-str-replace-editor.zh.md: 0c2ab26693d90c91d5c41a128ebb77a3c6cc2e7f diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md index aee566f2ad..a97af750bd 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md @@ -16,7 +16,7 @@ Some deployments need a one-call Bash schema whose shell state survives across m `dsh-system-prompt` accepts `includeHarnessIdentity: false`, while `dsh-agent-spine-demo` forwards that setting and accepts `toolBash: false`. A deployment can therefore own an exact persona and replace the spine's native Bash without duplicate prompt or tool registrations. Existing defaults remain unchanged. -Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper` on macOS, each packaged macOS runtime executable ships with an architecture-matched `-spawn-helper` sibling; Linux uses `forkpty` directly. A pinned `node-pty` patch checks `DSH_NODE_PTY_SPAWN_HELPER` first, so it remains a true override for a current external consumer that supplies a non-sibling helper. When the override is unset, the patch resolves the packaged executable sibling if present and otherwise preserves upstream lookup in ordinary Node runs. The macOS executable and runtime-wheel builders inspect the thin Mach-O header and fail before publication when the helper is absent, mismatched, or not executable. +Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper` on macOS, each packaged macOS runtime executable ships with a `-spawn-helper` sibling; Linux uses `forkpty` directly. A pinned `node-pty` patch checks `DSH_NODE_PTY_SPAWN_HELPER` first, so it remains a true override for a current external consumer that supplies a non-sibling helper. When the override is unset, the patch resolves the packaged executable sibling if present and otherwise preserves upstream lookup in ordinary Node runs. The macOS builders fail before publication when the helper is absent or not executable. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md index 86a4b11ff1..0c2ab26693 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md @@ -16,7 +16,7 @@ `dsh-system-prompt` 接受 `includeHarnessIdentity: false`;`dsh-agent-spine-demo` 会转发该设置,并接受 `toolBash: false`。因此部署可以拥有精确 persona,并替换 spine 的原生 Bash,而不会重复注册提示词或工具。既有默认值不变。 -两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 在 macOS 上会执行原生 `spawn-helper`,每个打包后的 macOS 运行时可执行文件都会携带一个架构匹配的 `-spawn-helper` 伴随文件;Linux 直接使用 `forkpty`。固定版本的 `node-pty` 补丁会先检查 `DSH_NODE_PTY_SPAWN_HELPER`,因此对当前提供非伴随 helper 的外部消费方而言,该变量仍是真正的覆盖项。未设置该覆盖时,补丁会在打包可执行文件的伴随文件存在时解析它,否则在普通 Node 运行中保留上游查找方式。macOS 可执行文件与运行时 wheel 包的构建器会检查 thin Mach-O 文件头;若 helper 缺失、架构不匹配或不可执行,构建会在发布前失败。 +两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 在 macOS 上会执行原生 `spawn-helper`,每个打包后的 macOS 运行时可执行文件都会携带一个 `-spawn-helper` 伴随文件;Linux 直接使用 `forkpty`。固定版本的 `node-pty` 补丁会先检查 `DSH_NODE_PTY_SPAWN_HELPER`,因此对当前提供非伴随 helper 的外部消费方而言,该变量仍是真正的覆盖项。未设置该覆盖时,补丁会在打包可执行文件的伴随文件存在时解析它,否则在普通 Node 运行中保留上游查找方式。若 helper 缺失或不可执行,macOS 构建器会在发布前失败。 ## 考虑过的替代方案 diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index 129dac85d5..f39bfa8f13 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -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 python/sdk-runtime/README.md -README.md: efdb5cf9f87e0831ef09a47e6ffdb99254a17f36 -README.zh.md: cafac7418608c416a9f291d62442c32b8787b1fc +README.md: 07bb3c574b3cd49f1dc74f0e9d9bd1bb7ca9b216 +README.zh.md: 9eb03352505f7cad3e6bf6f253ed6564fdb06ca0 diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index efdb5cf9f8..07bb3c574b 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -8,7 +8,7 @@ Runtime carrier package for the Python SDK (dist `deepseek-harness-runtime-bin`, Two carriers coexist under `src/deepseek_harness_runtime/runtime/`, both injected by the repo's `scripts/build-exe-for-python-sdk.ts` build and both gitignored: -- **exe (production)** — a single-file Node executable `dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`). macOS builds also ship the native `-spawn-helper` sibling that `node-pty` uses there, and its thin Mach-O header must match the target. No Node installation is needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists. +- **exe (production)** — a single-file Node executable `dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`). macOS builds also ship the native `-spawn-helper` sibling that `node-pty` uses there. No Node installation is needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists. - **node (dev-only)** — the full deploy closure under `runtime/node/` (`package.json` + `node_modules/`), executed as `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` on a system Node >= 22.19. It is the current checkout's source build, meant for repo-local development and verification only; it is never selected automatically and is excluded from distributions. Both carriers hold the same content, defined once: the [package.json](package.json) at this package's root is the deploy root of the single-exe pipeline — a pure dependency manifest (no code of its own) whose dependency closure IS both the plugin set compiled into the exe and the tree materialized into `runtime/node/`. Adding a plugin to the distribution means adding one dependency line there and rebuilding. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index cafac74186..9eb0335250 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -8,7 +8,7 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`, 两种载体并存于 `src/deepseek_harness_runtime/runtime/` 之下,均由仓库的 `scripts/build-exe-for-python-sdk.ts` 构建注入,且均被 git 忽略: -- **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`)。macOS 构建还会随附 `node-pty` 在该平台使用的原生 `-spawn-helper` 伴随文件,其 thin Mach-O 文件头必须与目标匹配。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 +- **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`)。macOS 构建还会随附 `node-pty` 在该平台使用的原生 `-spawn-helper` 伴随文件。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 - **`node`(仅限开发)**——`runtime/node/` 下的完整部署闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。 两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](package.json) 是 single-exe 流水线的部署根目录——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py index 9b54e0c5ed..cf56dd4138 100644 --- a/python/sdk-runtime/hatch_build.py +++ b/python/sdk-runtime/hatch_build.py @@ -16,26 +16,6 @@ _PLATFORMS = { _SPAWN_HELPER_SUFFIX = "-spawn-helper" -def _spawn_helper_binary_target(header: bytes) -> str | None: - if len(header) >= 8 and header[:4] == b"\xcf\xfa\xed\xfe": - cpu_type = int.from_bytes(header[4:8], "little") - if cpu_type == 0x01000007: - return "macos-x64" - if cpu_type == 0x0100000C: - return "macos-arm64" - return None - - -def _validate_spawn_helper(path: Path, expected_target: str) -> None: - with path.open("rb") as helper: - actual_target = _spawn_helper_binary_target(helper.read(8)) - if actual_target != expected_target: - raise RuntimeError( - f"runtime spawn helper binary mismatch: expected {expected_target}, " - f"found {actual_target or 'unsupported format or architecture'} at {path}" - ) - - def _host_platform_tag() -> str: machine = platform.machine().lower() arch = "arm64" if machine in {"arm64", "aarch64"} else "x64" if machine in {"x86_64", "amd64"} else machine @@ -86,9 +66,6 @@ class RuntimeBuildHook(BuildHookInterface): for executable in [executables[0], *helpers]: if executable.stat().st_mode & stat.S_IXUSR == 0: raise RuntimeError(f"runtime executable is not executable: {executable}") - if helpers: - _validate_spawn_helper(helpers[0], expected_target) - build_data["pure_python"] = False build_data["infer_tag"] = False build_data["tag"] = f"py3-none-{platform_tag}" diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index ce54185ba5..b8fa5484c2 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -16,14 +16,6 @@ SCRIPT = ROOT / "scripts" / "build-python-release.py" build_python_release = SimpleNamespace(**runpy.run_path(str(SCRIPT))) -def helper_header(target: str) -> bytes: - header = bytearray(8) - header[:4] = b"\xcf\xfa\xed\xfe" - cpu_type = 0x01000007 if target == "macos-x64" else 0x0100000C - header[4:8] = cpu_type.to_bytes(4, "little") - return bytes(header) - - def test_repository_version_matches_root_package_json() -> None: expected = json.loads((ROOT / "package.json").read_text())["version"] @@ -53,7 +45,7 @@ def test_stage_runtime_copies_executable_and_spawn_helper(tmp_path: Path) -> Non executable.write_bytes(b"runtime") executable.chmod(0o755) spawn_helper = Path(f"{executable}-spawn-helper") - spawn_helper.write_bytes(helper_header("macos-arm64")) + spawn_helper.write_bytes(b"helper") spawn_helper.chmod(0o751) destination = tmp_path / "staging" @@ -67,7 +59,7 @@ def test_stage_runtime_copies_executable_and_spawn_helper(tmp_path: Path) -> Non runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" assert (runtime_dir / executable.name).read_bytes() == b"runtime" copied_helper = runtime_dir / spawn_helper.name - assert copied_helper.read_bytes() == helper_header("macos-arm64") + assert copied_helper.read_bytes() == b"helper" assert copied_helper.stat().st_mode & stat.S_IXUSR @@ -120,42 +112,3 @@ def test_stage_runtime_copies_linux_executable_without_spawn_helper( runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" runtime_files = [path.name for path in runtime_dir.glob("dsh-jsonrpc-agent-pkg-*")] assert runtime_files == [executable.name] - - -@pytest.mark.parametrize("target", ["macos-x64", "macos-arm64"]) -def test_spawn_helper_binary_target(target: str) -> None: - assert build_python_release.spawn_helper_binary_target(helper_header(target)) == target - - -def test_stage_runtime_rejects_mismatched_spawn_helper(tmp_path: Path) -> None: - executable = tmp_path / "dsh-jsonrpc-agent-pkg-macos-arm64" - executable.write_bytes(b"runtime") - executable.chmod(0o755) - spawn_helper = Path(f"{executable}-spawn-helper") - spawn_helper.write_bytes(helper_header("macos-x64")) - spawn_helper.chmod(0o755) - - with pytest.raises(ValueError, match="expected macos-arm64, found macos-x64"): - build_python_release.stage_runtime( - tmp_path / "staging", - "1.2.3", - executable, - executable.name, - ) - - -def test_stage_runtime_rejects_non_binary_spawn_helper(tmp_path: Path) -> None: - executable = tmp_path / "dsh-jsonrpc-agent-pkg-macos-arm64" - executable.write_bytes(b"runtime") - executable.chmod(0o755) - spawn_helper = Path(f"{executable}-spawn-helper") - spawn_helper.write_bytes(b"helper") - spawn_helper.chmod(0o755) - - with pytest.raises(ValueError, match="unsupported format or architecture"): - build_python_release.stage_runtime( - tmp_path / "staging", - "1.2.3", - executable, - executable.name, - ) diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index b4b4d23720..e9ae59edbc 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -7,7 +7,7 @@ */ import { spawn } from 'node:child_process' -import { existsSync, mkdirSync, readFileSync, statSync } from 'node:fs' +import { existsSync, mkdirSync, statSync } from 'node:fs' import { chmod, copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { basename, dirname, join, resolve, sep } from 'node:path' import { parseArgs } from 'node:util' @@ -58,16 +58,6 @@ interface RuntimeProduct { spawnHelper?: string } -function spawnHelperBinaryTarget(path: string): string | undefined { - const header = readFileSync(path).subarray(0, 8) - if (header.length >= 8 && header.readUInt32LE(0) === 0xfeedfacf) { - const cpuType = header.readUInt32LE(4) - if (cpuType === 0x01000007) return 'macos-x64' - if (cpuType === 0x0100000c) return 'macos-arm64' - } - return undefined -} - function runtimeProductFiles(product: RuntimeProduct): string[] { return [product.executable, ...(product.spawnHelper === undefined ? [] : [product.spawnHelper])] } @@ -393,17 +383,7 @@ class SingleExeBuild { + `checked ${candidates.join(', ')}. Build each runtime on its target platform and architecture.`, ) } - if (statSync(helper).mode & 0o111) { - const expected = `${target.platform}-${target.arch}` - const actual = spawnHelperBinaryTarget(helper) - if (actual !== expected) { - throw new Error( - `build-exe-for-python-sdk: node-pty spawn-helper binary mismatch: expected ${expected}, ` - + `found ${actual ?? 'unsupported format or architecture'} at ${helper}`, - ) - } - return helper - } + if (statSync(helper).mode & 0o111) return helper throw new Error(`build-exe-for-python-sdk: node-pty spawn-helper is not executable: ${helper}`) } diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index dc53853b39..be5125cbcd 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -36,26 +36,6 @@ def executable_target(executable_name: str) -> str: ) from error -def spawn_helper_binary_target(header: bytes) -> str | None: - if len(header) >= 8 and header[:4] == b"\xcf\xfa\xed\xfe": - cpu_type = int.from_bytes(header[4:8], "little") - if cpu_type == 0x01000007: - return "macos-x64" - if cpu_type == 0x0100000C: - return "macos-arm64" - return None - - -def validate_spawn_helper(path: Path, expected_target: str) -> None: - with path.open("rb") as helper: - actual_target = spawn_helper_binary_target(helper.read(8)) - if actual_target != expected_target: - raise ValueError( - f"runtime spawn helper binary mismatch: expected {expected_target}, " - f"found {actual_target or 'unsupported format or architecture'} at {path}" - ) - - def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--package", choices=("sdk", "runtime"), required=True) @@ -175,7 +155,6 @@ def stage_runtime(destination: Path, version: str, executable: Path, executable_ raise FileNotFoundError(f"runtime spawn helper does not exist: {spawn_helper}") if spawn_helper.stat().st_mode & stat.S_IXUSR == 0: raise PermissionError(f"runtime spawn helper is not executable: {spawn_helper}") - validate_spawn_helper(spawn_helper, expected_target) copy_package(ROOT / "python" / "sdk-runtime", destination) rewrite_version(destination / "pyproject.toml", version) runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" @@ -228,13 +207,6 @@ def verify_wheel( mode = archive.getinfo(executable).external_attr >> 16 if mode & stat.S_IXUSR == 0: raise RuntimeError(f"{wheel} runtime executable lost its executable bit: {executable}") - if helpers: - actual_target = spawn_helper_binary_target(archive.read(helpers[0])[:8]) - if actual_target != expected_target: - raise RuntimeError( - f"{wheel} spawn helper binary mismatch: expected {expected_target}, " - f"found {actual_target or 'unsupported format or architecture'}" - ) elif runtime_files: raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {runtime_files}") if package == "sdk": From 7118b4cfe136d3a92427ddb47315de332cae3456 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:09:54 +0800 Subject: [PATCH 54/72] cleanup(build): collapse native runtime payloads --- python/sdk-runtime/hatch_build.py | 25 +++----- python/sdk/tests/test_release_version.py | 30 +--------- scripts/build-exe-for-python-sdk.ts | 75 ++++++++---------------- scripts/build-python-release.py | 69 ++++++++-------------- 4 files changed, 60 insertions(+), 139 deletions(-) diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py index cf56dd4138..19ec962257 100644 --- a/python/sdk-runtime/hatch_build.py +++ b/python/sdk-runtime/hatch_build.py @@ -39,31 +39,24 @@ class RuntimeBuildHook(BuildHookInterface): ) platform_tag = os.environ.get("DSH_RUNTIME_PLATFORM_TAG") or _host_platform_tag() - matches = [(key, value) for key, value in _PLATFORMS.items() if value[0] == platform_tag] + matches = [value for value in _PLATFORMS.values() if value[0] == platform_tag] if len(matches) != 1: supported = ", ".join(value[0] for value in _PLATFORMS.values()) raise RuntimeError( f"unsupported DSH_RUNTIME_PLATFORM_TAG {platform_tag!r}; expected one of {supported}" ) - expected_target, (_, expected_executable) = matches[0] + expected_executable = matches[0][1] runtime_dir = Path(self.root) / "src" / "deepseek_harness_runtime" / "runtime" runtime_files = sorted(runtime_dir.glob("dsh-jsonrpc-agent-pkg-*") if runtime_dir.is_dir() else []) - executables = [path for path in runtime_files if not path.name.endswith(_SPAWN_HELPER_SUFFIX)] - helpers = [path for path in runtime_files if path.name.endswith(_SPAWN_HELPER_SUFFIX)] - if [path.name for path in executables] != [expected_executable]: - found = ", ".join(path.name for path in executables) or "none" + expected_files = [expected_executable] + if "-macos-" in expected_executable: + expected_files.append(f"{expected_executable}{_SPAWN_HELPER_SUFFIX}") + found_files = [path.name for path in runtime_files] + if found_files != expected_files: raise RuntimeError( - f"runtime wheel {platform_tag} must contain only {expected_executable}; found {found}" + f"runtime wheel {platform_tag} payload must be {expected_files}; found {found_files}" ) - expected_helper = f"{expected_executable}{_SPAWN_HELPER_SUFFIX}" - expected_helpers = [expected_helper] if expected_target.startswith("macos-") else [] - if [path.name for path in helpers] != expected_helpers: - expected = ", ".join(expected_helpers) or "none" - found = ", ".join(path.name for path in helpers) or "none" - raise RuntimeError( - f"runtime wheel {platform_tag} helper payload mismatch: expected {expected}; found {found}" - ) - for executable in [executables[0], *helpers]: + for executable in runtime_files: if executable.stat().st_mode & stat.S_IXUSR == 0: raise RuntimeError(f"runtime executable is not executable: {executable}") build_data["pure_python"] = False diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index b8fa5484c2..68a9aac993 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -68,7 +68,7 @@ def test_stage_runtime_rejects_missing_spawn_helper(tmp_path: Path) -> None: executable.write_bytes(b"runtime") executable.chmod(0o755) - with pytest.raises(FileNotFoundError, match="spawn helper"): + with pytest.raises(FileNotFoundError, match="spawn-helper"): build_python_release.stage_runtime( tmp_path / "staging", "1.2.3", @@ -77,32 +77,8 @@ def test_stage_runtime_rejects_missing_spawn_helper(tmp_path: Path) -> None: ) -def test_stage_runtime_rejects_unsupported_executable_name(tmp_path: Path) -> None: - executable = tmp_path / "custom-runtime" - executable.write_bytes(b"runtime") - executable.chmod(0o755) - - with pytest.raises( - ValueError, - match=( - "unsupported runtime executable 'custom-runtime'; expected one of: " - "dsh-jsonrpc-agent-pkg-linux-arm64, dsh-jsonrpc-agent-pkg-linux-x64, " - "dsh-jsonrpc-agent-pkg-macos-arm64" - ), - ): - build_python_release.stage_runtime( - tmp_path / "staging", - "1.2.3", - executable, - executable.name, - ) - - -@pytest.mark.parametrize("target", ["linux-x64", "linux-arm64"]) -def test_stage_runtime_copies_linux_executable_without_spawn_helper( - tmp_path: Path, target: str -) -> None: - executable = tmp_path / f"dsh-jsonrpc-agent-pkg-{target}" +def test_stage_runtime_copies_linux_executable_without_spawn_helper(tmp_path: Path) -> None: + executable = tmp_path / "dsh-jsonrpc-agent-pkg-linux-x64" executable.write_bytes(b"runtime") executable.chmod(0o755) destination = tmp_path / "staging" diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index e9ae59edbc..bbfa2402d0 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -53,15 +53,6 @@ const ARCHES = ['x64', 'arm64'] as const type Platform = (typeof PLATFORMS)[number] type Arch = (typeof ARCHES)[number] -interface RuntimeProduct { - executable: string - spawnHelper?: string -} - -function runtimeProductFiles(product: RuntimeProduct): string[] { - return [product.executable, ...(product.spawnHelper === undefined ? [] : [product.spawnHelper])] -} - function isPlatform(value: string): value is Platform { return (PLATFORMS as readonly string[]).includes(value) } @@ -299,9 +290,9 @@ class SingleExeBuild { /** * Package one target; SEA mode accepts one target per invocation. * @param target - the pkg target triple to build. - * @returns the canonical product path `/dsh-jsonrpc-agent-pkg--`. + * @returns the executable path and, on macOS, its helper path. */ - async pack(target: Target): Promise { + async pack(target: Target): Promise { const product = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`) await this.prepareNativePty(target) if (!this.cli.dryRun) mkdirSync(this.outDir, { recursive: true }) @@ -318,7 +309,7 @@ class SingleExeBuild { if (!this.cli.dryRun && !existsSync(product)) { throw new Error(`build-exe-for-python-sdk: product ${product} is missing after the pkg run; inspect ${this.outDir}.`) } - if (target.platform !== 'macos') return { executable: product } + if (target.platform !== 'macos') return [product] const spawnHelper = `${product}${SPAWN_HELPER_SUFFIX}` if (this.cli.dryRun) { console.log(`build-exe-for-python-sdk: [dry-run] copy target node-pty spawn-helper to ${spawnHelper}`) @@ -327,7 +318,7 @@ class SingleExeBuild { await copyFile(source, spawnHelper) await chmod(spawnHelper, statSync(source).mode & 0o777) } - return { executable: product, spawnHelper } + return [product, spawnHelper] } /** @@ -341,21 +332,19 @@ class SingleExeBuild { if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] rm -rf ${stagedBuild}`) else await rm(stagedBuild, { recursive: true, force: true }) - const nativePlatform = target.platform === 'macos' ? 'darwin' : 'linux' - const prebuilt = join(stagedRoot, 'prebuilds', `${nativePlatform}-${target.arch}`, 'pty.node') const source = join(root, 'packages', 'pty', 'pty-local', 'node_modules', 'node-pty', 'build', 'Release', 'pty.node') const destination = join(stagedBuild, 'Release', 'pty.node') if (this.cli.dryRun) { if (target.platform === 'linux') console.log(`build-exe-for-python-sdk: [dry-run] cp ${source} ${destination}`) return } - if (existsSync(prebuilt)) return + if (target.platform === 'macos') return const host = Target.host() if (target.platform !== host.platform || target.arch !== host.arch || !existsSync(source)) { throw new Error( `build-exe-for-python-sdk: node-pty native addon for ${target.platform}-${target.arch} is missing; ` - + `checked ${prebuilt}, ${source}. Build the Linux runtime on its target architecture.`, + + `checked ${source}. Build the Linux runtime on its target architecture.`, ) } await mkdir(dirname(destination), { recursive: true }) @@ -368,19 +357,11 @@ class SingleExeBuild { * @returns a physical executable outside pkg's virtual snapshot. */ private resolveSpawnHelper(target: Target): string { - const nodePtyRoot = join(this.staging, 'node_modules', 'node-pty') - const candidates = [ - join(nodePtyRoot, 'prebuilds', `darwin-${target.arch}`, 'spawn-helper'), - ] - const host = Target.host() - if (target.platform === host.platform && target.arch === host.arch) { - candidates.push(join(root, 'packages', 'pty', 'pty-local', 'node_modules', 'node-pty', 'build', 'Release', 'spawn-helper')) - } - const helper = candidates.find(candidate => existsSync(candidate)) - if (helper === undefined) { + const helper = join(this.staging, 'node_modules', 'node-pty', 'prebuilds', `darwin-${target.arch}`, 'spawn-helper') + if (!existsSync(helper)) { throw new Error( `build-exe-for-python-sdk: node-pty spawn-helper for ${target.platform}-${target.arch} is missing; ` - + `checked ${candidates.join(', ')}. Build each runtime on its target platform and architecture.`, + + `checked ${helper}. Build each runtime on its target platform and architecture.`, ) } if (statSync(helper).mode & 0o111) return helper @@ -391,43 +372,37 @@ class SingleExeBuild { * Print each product path and, outside dry-run mode, its size. * @param products - the product paths returned by {@link pack}. */ - printProducts(products: RuntimeProduct[]): void { + printProducts(products: string[]): void { console.log(this.cli.dryRun ? 'build-exe-for-python-sdk: [dry-run] would produce:' : 'build-exe-for-python-sdk: products:') - for (const product of products) { + for (const path of products) { if (this.cli.dryRun) { - for (const path of runtimeProductFiles(product)) console.log(` ${path}`) + console.log(` ${path}`) continue } - for (const path of runtimeProductFiles(product)) { - const megabytes = statSync(path).size / (1024 * 1024) - console.log(` ${path} (${megabytes.toFixed(1)} MB)`) - } + const megabytes = statSync(path).size / (1024 * 1024) + console.log(` ${path} (${megabytes.toFixed(1)} MB)`) } } /** - * Copy each executable into the Python runtime package. The deployed node + * Copy each product into the Python runtime package. The deployed node * carrier is already in place, and `dist-exe/` retains upload copies. * @param products - the product paths returned by {@link pack}. */ - async syncToPythonRuntime(products: RuntimeProduct[]): Promise { + async syncToPythonRuntime(products: string[]): Promise { const destDir = resolve(root, PYTHON_RUNTIME_DIR) if (this.cli.dryRun) { - for (const product of products) { - for (const path of runtimeProductFiles(product)) { - console.log(`build-exe-for-python-sdk: [dry-run] cp ${path} ${join(destDir, basename(path))}`) - } + for (const path of products) { + console.log(`build-exe-for-python-sdk: [dry-run] cp ${path} ${join(destDir, basename(path))}`) } return } mkdirSync(destDir, { recursive: true }) - for (const product of products) { - for (const path of runtimeProductFiles(product)) { - const destination = join(destDir, basename(path)) - await copyFile(path, destination) - await chmod(destination, statSync(path).mode & 0o777) - console.log(`build-exe-for-python-sdk: synced ${destination}`) - } + for (const path of products) { + const destination = join(destDir, basename(path)) + await copyFile(path, destination) + await chmod(destination, statSync(path).mode & 0o777) + console.log(`build-exe-for-python-sdk: synced ${destination}`) } } @@ -476,8 +451,8 @@ async function main(): Promise { await pipeline.build() await pipeline.deployStaging() await pipeline.injectPkgConfig() - const products: RuntimeProduct[] = [] - for (const target of cli.targets) products.push(await pipeline.pack(target)) + const products: string[] = [] + for (const target of cli.targets) products.push(...await pipeline.pack(target)) pipeline.printProducts(products) await pipeline.syncToPythonRuntime(products) } diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index be5125cbcd..4fe7d62980 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -23,17 +23,6 @@ PLATFORMS = { "macos-arm64": ("macosx_11_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"), } SPAWN_HELPER_SUFFIX = "-spawn-helper" -EXECUTABLE_TARGETS = {value[1]: key for key, value in PLATFORMS.items()} - - -def executable_target(executable_name: str) -> str: - try: - return EXECUTABLE_TARGETS[executable_name] - except KeyError as error: - supported = ", ".join(sorted(EXECUTABLE_TARGETS)) - raise ValueError( - f"unsupported runtime executable {executable_name!r}; expected one of: {supported}" - ) from error def main() -> None: @@ -144,28 +133,24 @@ def stage_sdk(destination: Path, version: str) -> None: def stage_runtime(destination: Path, version: str, executable: Path, executable_name: str) -> None: - if not executable.is_file(): - raise FileNotFoundError(f"runtime executable does not exist: {executable}") - if executable.stat().st_mode & stat.S_IXUSR == 0: - raise PermissionError(f"runtime executable is not executable: {executable}") - expected_target = executable_target(executable_name) - spawn_helper = Path(f"{executable}{SPAWN_HELPER_SUFFIX}") - if expected_target.startswith("macos-"): - if not spawn_helper.is_file(): - raise FileNotFoundError(f"runtime spawn helper does not exist: {spawn_helper}") - if spawn_helper.stat().st_mode & stat.S_IXUSR == 0: - raise PermissionError(f"runtime spawn helper is not executable: {spawn_helper}") + payload = [(executable, executable_name)] + if "-macos-" in executable_name: + payload.append( + (Path(f"{executable}{SPAWN_HELPER_SUFFIX}"), f"{executable_name}{SPAWN_HELPER_SUFFIX}") + ) + for source, _ in payload: + if not source.is_file(): + raise FileNotFoundError(f"runtime file does not exist: {source}") + if source.stat().st_mode & stat.S_IXUSR == 0: + raise PermissionError(f"runtime file is not executable: {source}") copy_package(ROOT / "python" / "sdk-runtime", destination) rewrite_version(destination / "pyproject.toml", version) runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" runtime_dir.mkdir(parents=True, exist_ok=True) - destination_executable = runtime_dir / executable_name - shutil.copyfile(executable, destination_executable) - destination_executable.chmod(executable.stat().st_mode & 0o777) - if expected_target.startswith("macos-"): - destination_helper = runtime_dir / f"{executable_name}{SPAWN_HELPER_SUFFIX}" - shutil.copyfile(spawn_helper, destination_helper) - destination_helper.chmod(spawn_helper.stat().st_mode & 0o777) + for source, name in payload: + target = runtime_dir / name + shutil.copyfile(source, target) + target.chmod(source.stat().st_mode & 0o777) def verify_wheel( @@ -187,26 +172,18 @@ def verify_wheel( runtime_files = [ name for name in archive.namelist() if "/runtime/dsh-jsonrpc-agent-pkg-" in name ] - helpers = [name for name in runtime_files if name.endswith(SPAWN_HELPER_SUFFIX)] - executables = [name for name in runtime_files if not name.endswith(SPAWN_HELPER_SUFFIX)] if package == "runtime": assert platform is not None - if len(executables) != 1 or not executables[0].endswith(f"/runtime/{platform[1]}"): - raise RuntimeError(f"{wheel} must contain exactly {platform[1]}, found {executables}") - expected_target = executable_target(platform[1]) - expected_helper = f"{platform[1]}{SPAWN_HELPER_SUFFIX}" - expected_helpers = [expected_helper] if expected_target.startswith("macos-") else [] - found_helpers = [Path(helper).name for helper in helpers] - if found_helpers != expected_helpers: - expected = ", ".join(expected_helpers) or "none" - found = ", ".join(found_helpers) or "none" - raise RuntimeError( - f"{wheel} runtime helper payload mismatch: expected {expected}; found {found}" - ) - for executable in [executables[0], *helpers]: - mode = archive.getinfo(executable).external_attr >> 16 + expected_files = [platform[1]] + if "-macos-" in platform[1]: + expected_files.append(f"{platform[1]}{SPAWN_HELPER_SUFFIX}") + found_files = sorted(Path(name).name for name in runtime_files) + if found_files != expected_files: + raise RuntimeError(f"{wheel} runtime payload must be {expected_files}, found {found_files}") + for runtime_file in runtime_files: + mode = archive.getinfo(runtime_file).external_attr >> 16 if mode & stat.S_IXUSR == 0: - raise RuntimeError(f"{wheel} runtime executable lost its executable bit: {executable}") + raise RuntimeError(f"{wheel} runtime executable lost its executable bit: {runtime_file}") elif runtime_files: raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {runtime_files}") if package == "sdk": From 12d302ea591bc4439fc9c6102f495033b3b4cb18 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 01:10:37 +0800 Subject: [PATCH 55/72] =?UTF-8?q?fix(host):=20review=20round=2017=20?= =?UTF-8?q?=E2=80=94=20separator-fold=20drafts;=20UNC=20noise=20scrub;=20h?= =?UTF-8?q?onest=20empty-chain=20fallback;=20toggle=20focus=20keep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 4 +- ...-28-directory-picker-capability-seam.zh.md | 4 +- .../src/client/DirectoryBrowser.tsx | 47 ++++++++++---- .../tests/directory-browser.spec.tsx | 62 ++++++++++++++++++- 5 files changed, 102 insertions(+), 19 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index 0b17e08ce7..81c065227f 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 75fda86788ec2ab1966f7919cdca7d503238da91 -2026-07-28-directory-picker-capability-seam.zh.md: 1e2c7819133366d5b9a0b26db195b46ac5ea62f4 +2026-07-28-directory-picker-capability-seam.md: 9008ba928c16a19fae601db0b8a83d44eaeeaae0 +2026-07-28-directory-picker-capability-seam.zh.md: 95ba66062aafb4ebe1ed37219efd5ad94f13f842 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 75fda86788..9008ba928c 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -19,7 +19,7 @@ Placement and policy rulings folded into this decision: - **Not the `ctx.fs` seam.** `packages/fs/` is the model/session-facing storage stack (policy events, sandbox-swappable backends). Riding it would couple GUI browsing to the model's confinement backend — swapping `fs-sandbox` for the model must never change GUI behavior — and OS facts (home anchoring, hidden conventions) are not storage primitives. The picker seam stays presentation-free and model-free; `packages/host/` is its consumer-domain home. - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. -- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. Focus parking is a card-wide invariant, not an editor-only one: every pick — editing or not, including right-pane advances and create landings whose columns are replaced — re-parks focus on the selection's row after commit, while every other displacing exit (Enter, Escape, a navigation landing whose new level dropped the focused row, a failed pick or create relist, and the nested create dialog closing) falls back to the crumb edit zone whenever focus actually fell to body; keyboard traversal never falls out of the card (the Modal has no focus trap), except during the owner's adopt window, where `busy` inerts every control and the dialog is closing either way. +- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are read from the host-resolved root crumb (exact for every root form this backend emits: `/`, `C:\`, `\\server\share\`); the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. Focus parking is a card-wide invariant, not an editor-only one: every pick — editing or not, including right-pane advances and create landings whose columns are replaced — re-parks focus on the selection's row after commit, while every other displacing exit (Enter, Escape, a navigation landing whose new level dropped the focused row, a failed pick or create relist, and the nested create dialog closing) falls back to the crumb edit zone whenever focus actually fell to body; keyboard traversal never falls out of the card (the Modal has no focus trap), except during the owner's adopt window, where `busy` inerts every control and the dialog is closing either way. - **Navigation lands selection-anchored, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor. Known boundaries of the progressive shape: a pointer press landing exactly inside the one-RTT upgrade window can lose its click when the pressed row node is replaced (keyboard focus is re-parked on the re-selected row; the pointer window is accepted); on slash platforms (macOS) only a final-segment case drift misses the parent-entry match and keeps the single-pane landing, while ancestor-segment drift still matches — parent entry paths inherit the typed prefix — and lands two panes at the cost of the Home collapse; and navigate always relists both legs even when the target is the currently shown level — a crumb tap doubles as the refresh gesture, so freshness wins over reusing possibly-stale in-hand listings at the cost of up to two host scans. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. @@ -34,7 +34,7 @@ Placement and policy rulings folded into this decision: - **Adopting a file-manager/drive-enumeration dependency.** Rejected per the survey above; recorded here as the dependency policy requires. - **A flip-label show-hidden toggle ("Hide hidden files").** Rejected: a flipping action label is ambiguous between state and action and doubles the negative; the fixed label with a pressed presentation states both at once. - **Pure relatedTarget blur cancellation (no mousedown suppression).** Rejected: Safari does not focus buttons on pointer down, so a click's focusout carries a null `relatedTarget` and would cancel the editor before the click lands; editing-scoped mousedown suppression plus the card-anchored relatedTarget guard covers pointer and keyboard paths together. -- **A wire `separator` field on `DirectoryListing` (host stamps `path.sep`).** Deferred, not rejected: it is the authoritative form — a POSIX home directory containing a backslash defeats the `listing.home` heuristic — but it touches the seam type and every backend; the browse client's `separatorOf` carries a TODO pointing at this alternative until a wire change is next scheduled. +- **A wire `separator` field on `DirectoryListing` (host stamps `path.sep`).** Deferred, not rejected: it is the authoritative form. The root-crumb read the client uses today is exact for every root shape this backend emits, but it still infers a platform fact from path text and promotes "the chain starts at the root" from backend behavior into a client-relied invariant (the `crumbs` JSDoc does promise it), and it degrades to the old home-text heuristic on an empty chain; a wire field would travel verbatim and survive empty chains and future backends. It touches the seam type and every backend, so the browse client's `separatorOf` carries a TODO pointing at this alternative until a wire change is next scheduled. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 1e2c781913..95ba66062a 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -19,7 +19,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **不用 `ctx.fs` seam。** `packages/fs/` 是面向模型/会话的存储栈(policy 事件、sandbox 可换后端)。骑上去会把 GUI 浏览耦合进模型的限制后端——为模型换 `fs-sandbox` 绝不能改变 GUI 行为——而 OS 事实(home 锚定、隐藏约定)也不是存储原语。picker seam 保持无展示、无模型;`packages/host/` 是它消费方域的家。 - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 -- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。焦点停靠是卡片全域的不变量,而非编辑器独有:每次选取——无论是否处于编辑态,包括右栏推进与各列被替换的创建落地——提交后都把焦点重新停靠到选中项所在的行上,而其余所有会顶离焦点的退出(Enter、Escape、新层级已不含焦点所在行的导航落地、选取或创建失败后的重新列举,以及嵌套创建对话框的关闭)只要焦点确实落到了 body 上,就回落到 crumb 编辑区;键盘遍历绝不会落出卡片之外(Modal 没有焦点陷阱),仅 owner 的接纳窗口期间例外——此时 `busy` 把每个控件置为惰性,且对话框反正正在关闭。 +- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从宿主解析的根 crumb 读取(对该后端发出的每种根形态都精确:`/`、`C:\`、`\\server\share\`);下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。焦点停靠是卡片全域的不变量,而非编辑器独有:每次选取——无论是否处于编辑态,包括右栏推进与各列被替换的创建落地——提交后都把焦点重新停靠到选中项所在的行上,而其余所有会顶离焦点的退出(Enter、Escape、新层级已不含焦点所在行的导航落地、选取或创建失败后的重新列举,以及嵌套创建对话框的关闭)只要焦点确实落到了 body 上,就回落到 crumb 编辑区;键盘遍历绝不会落出卡片之外(Modal 没有焦点陷阱),仅 owner 的接纳窗口期间例外——此时 `busy` 把每个控件置为惰性,且对话框反正正在关闭。 - **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。渐进形态的已知边界:指针按压若恰好落在单个 RTT 的升级窗口内,可能因所按行节点被替换而丢失点击(键盘焦点会重新停靠到重新选中的行上;指针的这段窗口则被接受);斜杠平台(macOS)上仅末段的大小写偏差会错过父层级条目匹配,保留单栏落地,而祖先段的偏差仍能匹配——父层级条目路径继承键入的前缀——并落地双栏,代价是 Home 塌缩;且导航总是重新列举两程,哪怕目标就是当前展示的层级——crumb 点按兼作刷新手势,因此宁要新鲜度也不复用手头可能已陈旧的列举,代价是至多两次宿主扫描。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 @@ -34,7 +34,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **引入文件管理器/盘符枚举依赖。** 按上文调研否决;依赖政策要求记录于此。 - **动作标签随状态翻转的"显示隐藏"开关("隐藏隐藏文件")。** 否决:会翻转的动作标签在状态与动作之间有歧义,还把否定叠了两层;固定标签加按下态呈现一次说清两者。 - **纯 relatedTarget 失焦取消(不做 mousedown 抑制)。** 否决:Safari 在指针按下时不给按钮聚焦,点击触发的 focusout 因而携带空 `relatedTarget`,会在点击落地前就取消编辑器;编辑期作用的 mousedown 抑制加上锚定卡片的 relatedTarget 守卫才能同时覆盖指针与键盘路径。 -- **在 `DirectoryListing` 上增设线上 `separator` 字段(宿主标注 `path.sep`)。** 延期而非否决:它才是权威形态——含反斜杠的 POSIX 家目录会击穿 `listing.home` 启发式——但它触及 seam 类型与每个后端;browse 客户端的 `separatorOf` 挂着指向本方案的 TODO,直到下次安排线上变更。 +- **在 `DirectoryListing` 上增设线上 `separator` 字段(宿主标注 `path.sep`)。** 延期而非否决:它才是权威形态。客户端今天所用的根 crumb 读取对该后端发出的每种根形态都精确,但它仍是从路径文本推断平台事实,还把"链从根开始"从后端行为提升为客户端所依赖的不变量(`crumbs` 的 JSDoc 确实承诺了这一点),并在链为空时退化回旧的 home 文本启发式;线上字段则会原样随线传输,经得住空链与未来的后端。它触及 seam 类型与每个后端,因此 browse 客户端的 `separatorOf` 挂着指向本方案的 TODO,直到下次安排线上变更。 ## 后果 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index d82b4f0abd..2e5e732548 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -82,13 +82,26 @@ function foldPathFor(sep: '\\' | '/'): (value: string) => string { * backend's own paths arrive already resolved. A lexical mirror only: * symlinks are the backend's business. */ +/** + * Folds separators to the platform's canonical one: win32 treats a forward + * slash as a separator too (resolve() folds them the same way), while + * POSIX must not — a backslash there is a name character. + */ +function foldSeparatorsFor(sep: '\\' | '/'): (value: string) => string { + return value => (sep === '\\' ? value.replaceAll('/', sep) : value) +} + function normalizePathFor(sep: '\\' | '/'): (value: string) => string { + const foldSeparators = foldSeparatorsFor(sep) return (raw) => { - // win32 treats a forward slash as a separator too (resolve() folds - // them); POSIX must not — a backslash there is a name character. - const value = sep === '\\' ? raw.replaceAll('/', sep) : raw + const value = foldSeparators(raw) const unc = sep === '\\' && value.startsWith(`${sep}${sep}`) - const segments = (unc ? value.slice(2) : value).split(sep) + const rawSegments = (unc ? value.slice(2) : value).split(sep) + // Empty segments are separator noise everywhere except POSIX's leading + // root marker, which must survive as the first segment; scrubbing them + // up front keeps a doubled separator from being locked into the UNC + // server + share root below. + const segments = unc ? rawSegments.filter(segment => segment !== '') : rawSegments // The unpoppable root: POSIX's leading empty segment / the drive // segment, or UNC's server + share pair. const rootLength = unc ? 2 : 1 @@ -136,8 +149,10 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE */ function separatorOf(listing: DirectoryListing): '\\' | '/' { const rootCrumb = listing.crumbs.at(0) - // Home is the honest fallback for an impossible empty chain. - /* v8 ignore next -- narrowing guard: the wire chain is root-to-target inclusive. */ + // The seam type allows an empty chain (this backend never emits one, but + // create-target naming supports it, see targetName): degrade to a + // best-effort read of the home text — the pre-root-crumb heuristic, with + // its backslash-in-a-POSIX-name blind spot. if (rootCrumb === undefined) return listing.home.includes('\\') ? '\\' : '/' return rootCrumb.path.includes('\\') ? '\\' : '/' } @@ -149,17 +164,19 @@ function separatorOf(listing: DirectoryListing): '\\' | '/' { * leaves the level unfiltered. The directory part compares under the * platform fold (exact on slash platforms; Windows folds case, since an * upgraded selection may carry the actual entry's case while the level - * below still carries the typed one); the name filter downstream is - * case-insensitive everywhere. + * below still carries the typed one — and folds forward slashes, which + * win32 and the backend both accept in typed paths); the name filter + * downstream is case-insensitive everywhere. */ function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null { if (draft === null) return null const sep = separatorOf(listing) - const cut = draft.lastIndexOf(sep) + const folded = foldSeparatorsFor(sep)(draft) + const cut = folded.lastIndexOf(sep) if (cut === -1) return null const fold = foldPathFor(sep) const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}` - return fold(draft.slice(0, cut + 1)) === fold(level) ? draft.slice(cut + 1) : null + return fold(folded.slice(0, cut + 1)) === fold(level) ? folded.slice(cut + 1) : null } /** One column of folder rows (the Miller view renders one or two of these). */ @@ -824,7 +841,15 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // toggling never blur-cancels a draft mid-thought. Outside editing // it keeps native focus behavior. onMouseDown={draftPending ? (event) => { event.preventDefault() } : undefined} - onClick={() => { setShowHidden(prev => !prev) }} + onClick={(event) => { + // The suppression above exists to protect the INPUT's focus; + // when focus is instead on a row that this very toggle may + // re-hide, restore the native outcome — the clicked toggle + // keeps focus in the card (the editing-time refocus effect + // deliberately stays out of the way). + if (focusInMillerRows()) event.currentTarget.focus() + setShowHidden(prev => !prev) + }} > {t('browser.showHidden')} {showHidden && } diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index fcc2b21f92..4434e31eaa 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -412,8 +412,9 @@ describe('DirectoryBrowser', () => { const listing: DirectoryListing = { path: `${SHARE}\\x`, // USERPROFILE ships verbatim; win32.resolve keeps \\server\share as - // the unpoppable root, so this normalizes to \\server\share\x. - home: `${SHARE}\\..\\x`, + // the unpoppable root and folds the doubled separator, so this + // normalizes to \\server\share\x. + home: '\\\\server\\\\share\\..\\x', crumbs: [ { name: `${SHARE}\\`, path: `${SHARE}\\`, hidden: false }, { name: 'x', path: `${SHARE}\\x`, hidden: false }, @@ -536,6 +537,10 @@ describe('DirectoryBrowser', () => { expect(within(columns()[1]!).getByText('Alpha')).toBeTruthy() fireEvent.change(input, { target: { value: 'C:\\Users\\z' } }) expect(within(columns()[1]!).queryAllByRole('listitem')).toHaveLength(0) + // Forward-slash drafts are equally legal on win32 (Enter navigates + // them); the filter folds them instead of going silent. + fireEvent.change(input, { target: { value: 'C:/Users/a' } }) + expect(within(columns()[1]!).getByText('Alpha')).toBeTruthy() }) it('re-parks focus on the edit zone when a failed pick unmounts a dot-revealed row', async () => { @@ -1423,6 +1428,59 @@ describe('DirectoryBrowser', () => { expect(screen.getByText('browser.createIn:/srv/data')).toBeTruthy() }) + it('a crumb-less Windows level still seeds the editor with a backslash', async () => { + // The empty chain degrades separatorOf to the home-text read; the + // backslash side of that fallback is the Windows shape. + const bare: DirectoryListing = { path: 'C:\\srv', home: 'C:\\Users\\u', crumbs: [], entries: [], truncated: false } + mount({ listDirectory: vi.fn(async () => bare) }) + await waitFor(() => { + expect(screen.getByRole('button', { name: 'browser.editPath' }).disabled).toBe(false) + }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + expect(screen.getByLabelText('browser.editPath').value).toBe('C:\\srv\\') + }) + + it('a POSIX home whose name contains a backslash still reads as the display root', async () => { + const WEIRD = '/home/we\\ird' + const listing: DirectoryListing = { + path: WEIRD, + home: WEIRD, + crumbs: [ + { name: '/', path: '/', hidden: false }, + { name: 'home', path: '/home', hidden: false }, + { name: 'we\\ird', path: WEIRD, hidden: false }, + ], + entries: [{ name: 'notes', path: `${WEIRD}/notes`, hidden: false }], + truncated: false, + } + const listDirectory = vi.fn(async () => listing) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + // The root crumb ('/') decides the platform: the backslash in the name + // neither flips the fold nor breaks the Home collapse. + expect(columns()).toHaveLength(1) + expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() + expect(listDirectory).toHaveBeenCalledTimes(1) + }) + + it('pointer-toggling hidden off keeps focus on the toggle as the focused row re-hides', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + const toggle = screen.getByRole('button', { name: 'browser.showHidden' }) + fireEvent.click(toggle) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + // Tab parked focus on the revealed hidden row; the pointer click below + // would unmount it (toggle off + empty seeded prefix hides it again). + const hiddenRow = within(columns()[0]!).getByText('.config').closest('button')! + hiddenRow.focus() + fireEvent.mouseDown(toggle) + fireEvent.click(toggle) + expect(screen.queryByText('.config')).toBeNull() + expect(document.activeElement).toBe(toggle) + // The editor survives the whole exchange. + expect(screen.getByLabelText('browser.editPath', { selector: 'input' })).toBeTruthy() + }) + it('refuses to close the nested dialog while the creation is in flight', async () => { const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) From 9545914c10bce92630dc89688114cc73a90f1517 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 01:28:55 +0800 Subject: [PATCH 56/72] =?UTF-8?q?fix(host):=20review=20round=2018=20?= =?UTF-8?q?=E2=80=94=20draft=20dirs=20fully=20normalized;=20JSDoc=20reatta?= =?UTF-8?q?ched;=20third=20parking=20target=20documented?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- .../src/client/DirectoryBrowser.tsx | 70 +++++++++++-------- .../tests/directory-browser.spec.tsx | 9 +++ 5 files changed, 53 insertions(+), 34 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index 81c065227f..f5b5312f73 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 9008ba928c16a19fae601db0b8a83d44eaeeaae0 -2026-07-28-directory-picker-capability-seam.zh.md: 95ba66062aafb4ebe1ed37219efd5ad94f13f842 +2026-07-28-directory-picker-capability-seam.md: a22b313649a9efb1784f88a30fff3f82b9828347 +2026-07-28-directory-picker-capability-seam.zh.md: fb800fe75bda07198f9b3dc67ac03fb385df919a diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 9008ba928c..a22b313649 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -19,7 +19,7 @@ Placement and policy rulings folded into this decision: - **Not the `ctx.fs` seam.** `packages/fs/` is the model/session-facing storage stack (policy events, sandbox-swappable backends). Riding it would couple GUI browsing to the model's confinement backend — swapping `fs-sandbox` for the model must never change GUI behavior — and OS facts (home anchoring, hidden conventions) are not storage primitives. The picker seam stays presentation-free and model-free; `packages/host/` is its consumer-domain home. - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. -- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are read from the host-resolved root crumb (exact for every root form this backend emits: `/`, `C:\`, `\\server\share\`); the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. Focus parking is a card-wide invariant, not an editor-only one: every pick — editing or not, including right-pane advances and create landings whose columns are replaced — re-parks focus on the selection's row after commit, while every other displacing exit (Enter, Escape, a navigation landing whose new level dropped the focused row, a failed pick or create relist, and the nested create dialog closing) falls back to the crumb edit zone whenever focus actually fell to body; keyboard traversal never falls out of the card (the Modal has no focus trap), except during the owner's adopt window, where `busy` inerts every control and the dialog is closing either way. +- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are read from the host-resolved root crumb (exact for every root form this backend emits: `/`, `C:\`, `\\server\share\`); the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. Focus parking is a card-wide invariant, not an editor-only one: every pick — editing or not, including right-pane advances and create landings whose columns are replaced — re-parks focus on the selection's row after commit, while every other displacing exit (Enter, Escape, a navigation landing whose new level dropped the focused row, a failed pick or create relist, and the nested create dialog closing) falls back to the crumb edit zone whenever focus actually fell to body, and a show-hidden toggle click that finds focus among the rows parks synchronously on the toggle itself; keyboard traversal never falls out of the card (the Modal has no focus trap), except during the owner's adopt window, where `busy` inerts every control and the dialog is closing either way. - **Navigation lands selection-anchored, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor. Known boundaries of the progressive shape: a pointer press landing exactly inside the one-RTT upgrade window can lose its click when the pressed row node is replaced (keyboard focus is re-parked on the re-selected row; the pointer window is accepted); on slash platforms (macOS) only a final-segment case drift misses the parent-entry match and keeps the single-pane landing, while ancestor-segment drift still matches — parent entry paths inherit the typed prefix — and lands two panes at the cost of the Home collapse; and navigate always relists both legs even when the target is the currently shown level — a crumb tap doubles as the refresh gesture, so freshness wins over reusing possibly-stale in-hand listings at the cost of up to two host scans. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 95ba66062a..fb800fe75b 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -19,7 +19,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **不用 `ctx.fs` seam。** `packages/fs/` 是面向模型/会话的存储栈(policy 事件、sandbox 可换后端)。骑上去会把 GUI 浏览耦合进模型的限制后端——为模型换 `fs-sandbox` 绝不能改变 GUI 行为——而 OS 事实(home 锚定、隐藏约定)也不是存储原语。picker seam 保持无展示、无模型;`packages/host/` 是它消费方域的家。 - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 -- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从宿主解析的根 crumb 读取(对该后端发出的每种根形态都精确:`/`、`C:\`、`\\server\share\`);下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。焦点停靠是卡片全域的不变量,而非编辑器独有:每次选取——无论是否处于编辑态,包括右栏推进与各列被替换的创建落地——提交后都把焦点重新停靠到选中项所在的行上,而其余所有会顶离焦点的退出(Enter、Escape、新层级已不含焦点所在行的导航落地、选取或创建失败后的重新列举,以及嵌套创建对话框的关闭)只要焦点确实落到了 body 上,就回落到 crumb 编辑区;键盘遍历绝不会落出卡片之外(Modal 没有焦点陷阱),仅 owner 的接纳窗口期间例外——此时 `busy` 把每个控件置为惰性,且对话框反正正在关闭。 +- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从宿主解析的根 crumb 读取(对该后端发出的每种根形态都精确:`/`、`C:\`、`\\server\share\`);下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。焦点停靠是卡片全域的不变量,而非编辑器独有:每次选取——无论是否处于编辑态,包括右栏推进与各列被替换的创建落地——提交后都把焦点重新停靠到选中项所在的行上,而其余所有会顶离焦点的退出(Enter、Escape、新层级已不含焦点所在行的导航落地、选取或创建失败后的重新列举,以及嵌套创建对话框的关闭)只要焦点确实落到了 body 上,就回落到 crumb 编辑区,而点击"显示隐藏"开关时若发现焦点落在行间,则把焦点同步停靠到开关自身;键盘遍历绝不会落出卡片之外(Modal 没有焦点陷阱),仅 owner 的接纳窗口期间例外——此时 `busy` 把每个控件置为惰性,且对话框反正正在关闭。 - **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。渐进形态的已知边界:指针按压若恰好落在单个 RTT 的升级窗口内,可能因所按行节点被替换而丢失点击(键盘焦点会重新停靠到重新选中的行上;指针的这段窗口则被接受);斜杠平台(macOS)上仅末段的大小写偏差会错过父层级条目匹配,保留单栏落地,而祖先段的偏差仍能匹配——父层级条目路径继承键入的前缀——并落地双栏,代价是 Home 塌缩;且导航总是重新列举两程,哪怕目标就是当前展示的层级——crumb 点按兼作刷新手势,因此宁要新鲜度也不复用手头可能已陈旧的列举,代价是至多两次宿主扫描。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 2e5e732548..bdab232457 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -72,6 +72,15 @@ function foldPathFor(sep: '\\' | '/'): (value: string) => string { return value => (sep === '\\' ? value.toLowerCase() : value) } +/** + * Folds separators to the platform's canonical one: win32 treats a forward + * slash as a separator too (resolve() folds them the same way), while + * POSIX must not — a backslash there is a name character. + */ +function foldSeparatorsFor(sep: '\\' | '/'): (value: string) => string { + return value => (sep === '\\' ? value.replaceAll('/', sep) : value) +} + /** * Lexically normalizes an absolute host path for comparisons: folds * forward slashes on Windows (win32 accepts either), collapses repeated @@ -82,15 +91,6 @@ function foldPathFor(sep: '\\' | '/'): (value: string) => string { * backend's own paths arrive already resolved. A lexical mirror only: * symlinks are the backend's business. */ -/** - * Folds separators to the platform's canonical one: win32 treats a forward - * slash as a separator too (resolve() folds them the same way), while - * POSIX must not — a backslash there is a name character. - */ -function foldSeparatorsFor(sep: '\\' | '/'): (value: string) => string { - return value => (sep === '\\' ? value.replaceAll('/', sep) : value) -} - function normalizePathFor(sep: '\\' | '/'): (value: string) => string { const foldSeparators = foldSeparatorsFor(sep) return (raw) => { @@ -161,12 +161,13 @@ function separatorOf(listing: DirectoryListing): '\\' | '/' { * The path draft's final segment, when its directory part names the level * `listing` lists — the segment the level prefix-filters on while the user * types. Any other draft (no separator yet, or naming some other directory) - * leaves the level unfiltered. The directory part compares under the - * platform fold (exact on slash platforms; Windows folds case, since an - * upgraded selection may carry the actual entry's case while the level - * below still carries the typed one — and folds forward slashes, which - * win32 and the backend both accept in typed paths); the name filter - * downstream is case-insensitive everywhere. + * leaves the level unfiltered. The directory part compares lexically + * normalized (dot segments, repeated separators, and win32 forward + * slashes all match what Enter would navigate to) and under the platform + * case fold (exact on slash platforms; Windows folds, since an upgraded + * selection may carry the actual entry's case while the level below still + * carries the typed one); the name filter downstream is case-insensitive + * everywhere. */ function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null { if (draft === null) return null @@ -175,8 +176,10 @@ function draftPrefixFor(listing: DirectoryListing, draft: string | null): string const cut = folded.lastIndexOf(sep) if (cut === -1) return null const fold = foldPathFor(sep) - const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}` - return fold(folded.slice(0, cut + 1)) === fold(level) ? folded.slice(cut + 1) : null + const normalize = normalizePathFor(sep) + return fold(normalize(folded.slice(0, cut + 1))) === fold(normalize(listing.path)) + ? folded.slice(cut + 1) + : null } /** One column of folder rows (the Miller view renders one or two of these). */ @@ -312,25 +315,31 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // whose new level dropped the focused row, a failed pick, and every // create-dialog exit, whose close-time parking is also what a failed // relist inherits) parks on the crumb edit zone, each only when focus - // actually fell to body. Pointer-out cancels never set (or clear) these - // — yanking focus back from wherever the user clicked would be worse - // than the fall. + // actually fell to body. One parking bypasses both flags: the + // show-hidden toggle's click reclaims focus onto itself, synchronously, + // when the click finds focus among the rows. Pointer-out cancels never + // set (or clear) these — yanking focus back from wherever the user + // clicked would be worse than the fall. const refocusPick = useRef(false) const refocusEditZone = useRef(false) const editZoneRef = useRef(null) /** * Whether the focused element sits among the miller rows — probed before - * a landing replaces the row nodes, to decide focus parking. A probe - * only: it never gates the landing itself — a torn-down ref in a close - * race merely skips the parking, and committing the landing into a - * closing dialog is safe (the component already renders null, and the - * open effect resets parent/selected/child on the next open). + * a landing replaces the row nodes to decide focus parking, and by the + * show-hidden toggle's click to decide whether to reclaim the native + * focus outcome. A probe only: it never gates its caller — a torn-down + * ref in a landing's close race merely skips the parking, and + * committing the landing into a closing dialog is safe (the component + * already renders null, and the open effect resets parent/selected/child + * on the next open). * @returns true when `document.activeElement` is inside the miller row. */ const focusInMillerRows = useCallback((): boolean => { const rowHost = millerRowRef.current - /* v8 ignore next -- close-race guard: the commit-to-effect window is not deterministically reproducible. */ + // Only the landing callers can race a close (commit precedes the reset + // effect); the toggle's click caller always finds the host mounted. + /* v8 ignore next -- close-race guard: not deterministically reproducible. */ if (rowHost === null) return false return rowHost.contains(document.activeElement) }, []) @@ -843,10 +852,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, onMouseDown={draftPending ? (event) => { event.preventDefault() } : undefined} onClick={(event) => { // The suppression above exists to protect the INPUT's focus; - // when focus is instead on a row that this very toggle may - // re-hide, restore the native outcome — the clicked toggle - // keeps focus in the card (the editing-time refocus effect - // deliberately stays out of the way). + // with focus among the rows instead, hand back the native + // click outcome wholesale — the clicked toggle takes focus + // and stays in the card. Accepted cost: this also moves + // focus off a row the toggle would NOT have hidden; tracking + // which rows a direction change unmounts is not worth it. if (focusInMillerRows()) event.currentTarget.focus() setShowHidden(prev => !prev) }} diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index 4434e31eaa..af40e08fe7 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -541,6 +541,9 @@ describe('DirectoryBrowser', () => { // them); the filter folds them instead of going silent. fireEvent.change(input, { target: { value: 'C:/Users/a' } }) expect(within(columns()[1]!).getByText('Alpha')).toBeTruthy() + // Dot segments normalize on win32 too. + fireEvent.change(input, { target: { value: 'C:\\Users\\.\\a' } }) + expect(within(columns()[1]!).getByText('Alpha')).toBeTruthy() }) it('re-parks focus on the edit zone when a failed pick unmounts a dot-revealed row', async () => { @@ -673,6 +676,12 @@ describe('DirectoryBrowser', () => { // A draft naming some other directory (or none) leaves the level whole. fireEvent.change(input, { target: { value: 'no-separator' } }) expect(screen.getByRole('listitem').textContent).toBe('Documents') + // Dot segments and repeated separators are legal for Enter, so the + // filter's directory comparison normalizes them the same way. + fireEvent.change(input, { target: { value: `${HOME}/./do` } }) + expect(screen.getByRole('listitem').textContent).toBe('Documents') + fireEvent.change(input, { target: { value: `${HOME}//do` } }) + expect(screen.getByRole('listitem').textContent).toBe('Documents') }) it('filters the child pane in two-pane mode and follows the draft back up a level', async () => { From 8a9d882a11930a1348f039a5e8f783eecb6616fa Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:29:18 +0800 Subject: [PATCH 57/72] cleanup(build): minimize native payload handling --- python/README.i18n.yaml | 4 +- python/README.md | 2 +- python/README.zh.md | 2 +- python/sdk-runtime/hatch_build.py | 3 +- .../src/deepseek_harness_runtime/__init__.py | 4 +- python/sdk/tests/test_release_version.py | 54 +++++-------------- python/sdk/tests/test_runtime_resolution.py | 25 ++++----- scripts/build-exe-for-python-sdk.ts | 47 +++++----------- scripts/build-python-release.py | 25 +++------ 9 files changed, 47 insertions(+), 119 deletions(-) diff --git a/python/README.i18n.yaml b/python/README.i18n.yaml index f0d6c67967..59f3e836cf 100644 --- a/python/README.i18n.yaml +++ b/python/README.i18n.yaml @@ -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 python/README.md -README.md: aee682e25fc33287c49131d0f5b92b136ed16bae -README.zh.md: 4404114fcdab78468991769a4657370f85997a88 +README.md: dfd9d909122f9245a19fe91d8a394156795b13ae +README.zh.md: 151b2cdab4f28384a20d16c86d398f669fbdc818 diff --git a/python/README.md b/python/README.md index aee682e25f..dfd9d90912 100644 --- a/python/README.md +++ b/python/README.md @@ -22,7 +22,7 @@ pnpm exec tsx scripts/build-exe-for-python-sdk.ts --skip-build # lib/ artifac pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64 ``` -Products land in `dist-exe/` and are synced into this package as `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--` plus the matching `-spawn-helper` required by `node-pty` (platform: `linux`/`macos`; arch: `x64`/`arm64`) — after a local build the SDK finds the runtime with no further setup. The `build-exe-for-python-sdk` CI workflow (manual dispatch, or the `build-exe` PR label) exercises the same products. A full three-target run retains four release wheels; a subset dispatch retains the SDK wheel and selected platform wheels. Which plugins the exe bundles and how the carriers are organized: [sdk-runtime README](sdk-runtime/README.md); the build also refreshes the dev-only node carrier (see "against the Node source" below). +Products land in `dist-exe/` and are synced into this package as `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`); macOS builds also sync the matching `-spawn-helper` required by `node-pty`. After a local build the SDK finds the runtime with no further setup. The `build-exe-for-python-sdk` CI workflow (manual dispatch, or the `build-exe` PR label) exercises the same products. A full three-target run retains four release wheels; a subset dispatch retains the SDK wheel and selected platform wheels. Which plugins the exe bundles and how the carriers are organized: [sdk-runtime README](sdk-runtime/README.md); the build also refreshes the dev-only node carrier (see "against the Node source" below). ## Validating the SDK against the executable diff --git a/python/README.zh.md b/python/README.zh.md index 4404114fcd..151b2cdab4 100644 --- a/python/README.zh.md +++ b/python/README.zh.md @@ -22,7 +22,7 @@ pnpm exec tsx scripts/build-exe-for-python-sdk.ts --skip-build # lib/ artifac pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64 ``` -产物落入 `dist-exe/`,并同步进本包的 `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--` 及 `node-pty` 所需的同名 `-spawn-helper` 伴随文件(platform:`linux`/`macos`;arch:`x64`/`arm64`),本地构建完成后 SDK 不需要额外设置就能找到运行时。`build-exe-for-python-sdk` CI 工作流(手动触发,或给 PR 打 `build-exe` 标签)会测试同样的产物。完整构建三个目标时保留 4 个发布用 wheel 包;手动选择部分目标时保留 SDK wheel 与所选平台的 wheel。exe 内置哪些插件、载体如何组织,见 [sdk-runtime README](sdk-runtime/README.md);构建还会顺带刷新仅供开发使用的 `node` 载体(见下文「对着 Node 源码运行」)。 +产物落入 `dist-exe/`,并同步进本包的 `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`);macOS 构建还会同步 `node-pty` 所需的同名 `-spawn-helper` 伴随文件。本地构建完成后 SDK 不需要额外设置就能找到运行时。`build-exe-for-python-sdk` CI 工作流(手动触发,或给 PR 打 `build-exe` 标签)会测试同样的产物。完整构建三个目标时保留 4 个发布用 wheel 包;手动选择部分目标时保留 SDK wheel 与所选平台的 wheel。exe 内置哪些插件、载体如何组织,见 [sdk-runtime README](sdk-runtime/README.md);构建还会顺带刷新仅供开发使用的 `node` 载体(见下文「对着 Node 源码运行」)。 ## 用可执行文件验证 SDK diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py index 19ec962257..b0f9a79550 100644 --- a/python/sdk-runtime/hatch_build.py +++ b/python/sdk-runtime/hatch_build.py @@ -13,7 +13,6 @@ _PLATFORMS = { "linux-arm64": ("manylinux_2_28_aarch64", "dsh-jsonrpc-agent-pkg-linux-arm64"), "macos-arm64": ("macosx_11_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"), } -_SPAWN_HELPER_SUFFIX = "-spawn-helper" def _host_platform_tag() -> str: @@ -50,7 +49,7 @@ class RuntimeBuildHook(BuildHookInterface): runtime_files = sorted(runtime_dir.glob("dsh-jsonrpc-agent-pkg-*") if runtime_dir.is_dir() else []) expected_files = [expected_executable] if "-macos-" in expected_executable: - expected_files.append(f"{expected_executable}{_SPAWN_HELPER_SUFFIX}") + expected_files.append(f"{expected_executable}-spawn-helper") found_files = [path.name for path in runtime_files] if found_files != expected_files: raise RuntimeError( diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py index d6f8c497b6..a3aa53ae80 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -28,7 +28,6 @@ import sys from pathlib import Path PACKAGE_METADATA_FILENAME = "deepseek-harness-runtime.json" -SPAWN_HELPER_SUFFIX = "-spawn-helper" RUNTIME_MODE_ENV_VAR = "DSH_RUNTIME_MODE" @@ -85,7 +84,7 @@ def bundled_runtime_path() -> Path: + _EXE_ACQUISITION_HINT ) if tag.startswith("macos-"): - helper = Path(f"{path}{SPAWN_HELPER_SUFFIX}") + helper = Path(f"{path}-spawn-helper") if not helper.is_file(): raise FileNotFoundError( f"deepseek-harness-runtime-bin is missing the node-pty spawn helper at {helper}. " @@ -153,7 +152,6 @@ def _node_launch_args() -> tuple[str, str]: __all__ = [ "PACKAGE_METADATA_FILENAME", "RUNTIME_MODE_ENV_VAR", - "SPAWN_HELPER_SUFFIX", "bundled_default_config_path", "bundled_package_dir", "bundled_runtime_path", diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index 68a9aac993..7e5f660070 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -4,7 +4,6 @@ from __future__ import annotations import json import runpy -import stat from pathlib import Path from types import SimpleNamespace @@ -40,51 +39,22 @@ def test_repository_version_rejects_non_stable_versions(tmp_path: Path) -> None: build_python_release.repository_version(tmp_path) -def test_stage_runtime_copies_executable_and_spawn_helper(tmp_path: Path) -> None: - executable = tmp_path / "dsh-jsonrpc-agent-pkg-macos-arm64" - executable.write_bytes(b"runtime") - executable.chmod(0o755) - spawn_helper = Path(f"{executable}-spawn-helper") - spawn_helper.write_bytes(b"helper") - spawn_helper.chmod(0o751) - destination = tmp_path / "staging" - - build_python_release.stage_runtime( - destination, - "1.2.3", - executable, - executable.name, - ) - - runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" - assert (runtime_dir / executable.name).read_bytes() == b"runtime" - copied_helper = runtime_dir / spawn_helper.name - assert copied_helper.read_bytes() == b"helper" - assert copied_helper.stat().st_mode & stat.S_IXUSR - - -def test_stage_runtime_rejects_missing_spawn_helper(tmp_path: Path) -> None: - executable = tmp_path / "dsh-jsonrpc-agent-pkg-macos-arm64" - executable.write_bytes(b"runtime") - executable.chmod(0o755) - - with pytest.raises(FileNotFoundError, match="spawn-helper"): - build_python_release.stage_runtime( - tmp_path / "staging", - "1.2.3", - executable, - executable.name, - ) - - -def test_stage_runtime_copies_linux_executable_without_spawn_helper(tmp_path: Path) -> None: - executable = tmp_path / "dsh-jsonrpc-agent-pkg-linux-x64" +@pytest.mark.parametrize(("target", "with_helper"), [("linux-x64", False), ("macos-arm64", True)]) +def test_stage_runtime_copies_platform_payload( + tmp_path: Path, target: str, with_helper: bool +) -> None: + executable = tmp_path / f"dsh-jsonrpc-agent-pkg-{target}" executable.write_bytes(b"runtime") executable.chmod(0o755) + expected = {executable.name: b"runtime"} + if with_helper: + spawn_helper = Path(f"{executable}-spawn-helper") + spawn_helper.write_bytes(b"helper") + spawn_helper.chmod(0o755) + expected[spawn_helper.name] = b"helper" destination = tmp_path / "staging" build_python_release.stage_runtime(destination, "1.2.3", executable, executable.name) runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" - runtime_files = [path.name for path in runtime_dir.glob("dsh-jsonrpc-agent-pkg-*")] - assert runtime_files == [executable.name] + assert {path.name: path.read_bytes() for path in runtime_dir.glob("dsh-jsonrpc-agent-pkg-*")} == expected diff --git a/python/sdk/tests/test_runtime_resolution.py b/python/sdk/tests/test_runtime_resolution.py index e0411bb1fd..778203f4d1 100644 --- a/python/sdk/tests/test_runtime_resolution.py +++ b/python/sdk/tests/test_runtime_resolution.py @@ -44,25 +44,18 @@ def test_explicit_mode_wins_over_env_mode(monkeypatch: pytest.MonkeyPatch) -> No assert args[0].endswith(("-x64", "-arm64")) -@pytest.mark.parametrize( - ("platform_tag", "requires_helper"), - [("linux-x64", False), ("macos-arm64", True)], -) def test_runtime_requires_spawn_helper_only_on_macos( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - platform_tag: str, - requires_helper: bool, + tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: runtime_dir = tmp_path / "runtime" runtime_dir.mkdir() - executable = runtime_dir / f"dsh-jsonrpc-agent-pkg-{platform_tag}" - executable.touch() + linux = runtime_dir / "dsh-jsonrpc-agent-pkg-linux-x64" + linux.touch() + (runtime_dir / "dsh-jsonrpc-agent-pkg-macos-arm64").touch() monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path) - monkeypatch.setattr(runtime, "_current_platform_tag", lambda: platform_tag) - if requires_helper: - with pytest.raises(FileNotFoundError, match="node-pty spawn helper"): - runtime.bundled_runtime_path() - else: - assert runtime.bundled_runtime_path() == executable + monkeypatch.setattr(runtime, "_current_platform_tag", lambda: "macos-arm64") + with pytest.raises(FileNotFoundError, match="node-pty spawn helper"): + runtime.bundled_runtime_path() + monkeypatch.setattr(runtime, "_current_platform_tag", lambda: "linux-x64") + assert runtime.bundled_runtime_path() == linux diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index bbfa2402d0..d2cdbeec4d 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -7,7 +7,7 @@ */ import { spawn } from 'node:child_process' -import { existsSync, mkdirSync, statSync } from 'node:fs' +import { existsSync, statSync } from 'node:fs' import { chmod, copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { basename, dirname, join, resolve, sep } from 'node:path' import { parseArgs } from 'node:util' @@ -19,7 +19,6 @@ const DEPLOY_ROOT_PACKAGE = 'dsh-jsonrpc-agent-pkg' /** The app entry inside the deployed closure. */ const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js' const OUTPUT_BASENAME = 'dsh-jsonrpc-agent-pkg' -const SPAWN_HELPER_SUFFIX = '-spawn-helper' /** Default Node major; SEA mode requires at least Node 22. */ const DEFAULT_NODE_RANGE = 'node24' /** Pinned for reproducible builds. */ @@ -295,7 +294,7 @@ class SingleExeBuild { async pack(target: Target): Promise { const product = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`) await this.prepareNativePty(target) - if (!this.cli.dryRun) mkdirSync(this.outDir, { recursive: true }) + if (!this.cli.dryRun) await mkdir(this.outDir, { recursive: true }) await this.run(`pkg ${target.spec}`, pnpmBin(), [ 'dlx', PKG_SPEC, @@ -310,13 +309,13 @@ class SingleExeBuild { throw new Error(`build-exe-for-python-sdk: product ${product} is missing after the pkg run; inspect ${this.outDir}.`) } if (target.platform !== 'macos') return [product] - const spawnHelper = `${product}${SPAWN_HELPER_SUFFIX}` + const spawnHelper = `${product}-spawn-helper` + const source = join(this.staging, 'node_modules', 'node-pty', 'prebuilds', `darwin-${target.arch}`, 'spawn-helper') if (this.cli.dryRun) { - console.log(`build-exe-for-python-sdk: [dry-run] copy target node-pty spawn-helper to ${spawnHelper}`) + console.log(`build-exe-for-python-sdk: [dry-run] cp ${source} ${spawnHelper}`) } else { - const source = this.resolveSpawnHelper(target) await copyFile(source, spawnHelper) - await chmod(spawnHelper, statSync(source).mode & 0o777) + await chmod(spawnHelper, 0o755) } return [product, spawnHelper] } @@ -327,47 +326,27 @@ class SingleExeBuild { * @param target - the pkg target whose native addon is being staged. */ private async prepareNativePty(target: Target): Promise { - const stagedRoot = join(this.staging, 'node_modules', 'node-pty') - const stagedBuild = join(stagedRoot, 'build') + const stagedBuild = join(this.staging, 'node_modules', 'node-pty', 'build') if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] rm -rf ${stagedBuild}`) else await rm(stagedBuild, { recursive: true, force: true }) - + if (target.platform !== 'linux') return const source = join(root, 'packages', 'pty', 'pty-local', 'node_modules', 'node-pty', 'build', 'Release', 'pty.node') const destination = join(stagedBuild, 'Release', 'pty.node') if (this.cli.dryRun) { - if (target.platform === 'linux') console.log(`build-exe-for-python-sdk: [dry-run] cp ${source} ${destination}`) + console.log(`build-exe-for-python-sdk: [dry-run] cp ${source} ${destination}`) return } - if (target.platform === 'macos') return - const host = Target.host() - if (target.platform !== host.platform || target.arch !== host.arch || !existsSync(source)) { + if (target.platform !== host.platform || target.arch !== host.arch) { throw new Error( - `build-exe-for-python-sdk: node-pty native addon for ${target.platform}-${target.arch} is missing; ` - + `checked ${source}. Build the Linux runtime on its target architecture.`, + 'build-exe-for-python-sdk: build the Linux runtime on its target architecture; ' + + `target ${target.platform}-${target.arch} does not match host ${host.platform}-${host.arch}.`, ) } await mkdir(dirname(destination), { recursive: true }) await copyFile(source, destination) } - /** - * Resolve the node-pty helper that matches a pkg target. - * @param target - the pkg target whose helper must be shipped. - * @returns a physical executable outside pkg's virtual snapshot. - */ - private resolveSpawnHelper(target: Target): string { - const helper = join(this.staging, 'node_modules', 'node-pty', 'prebuilds', `darwin-${target.arch}`, 'spawn-helper') - if (!existsSync(helper)) { - throw new Error( - `build-exe-for-python-sdk: node-pty spawn-helper for ${target.platform}-${target.arch} is missing; ` - + `checked ${helper}. Build each runtime on its target platform and architecture.`, - ) - } - if (statSync(helper).mode & 0o111) return helper - throw new Error(`build-exe-for-python-sdk: node-pty spawn-helper is not executable: ${helper}`) - } - /** * Print each product path and, outside dry-run mode, its size. * @param products - the product paths returned by {@link pack}. @@ -397,7 +376,7 @@ class SingleExeBuild { } return } - mkdirSync(destDir, { recursive: true }) + await mkdir(destDir, { recursive: true }) for (const path of products) { const destination = join(destDir, basename(path)) await copyFile(path, destination) diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index 4fe7d62980..ec049cdd4f 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -22,7 +22,10 @@ PLATFORMS = { "linux-arm64": ("manylinux_2_28_aarch64", "dsh-jsonrpc-agent-pkg-linux-arm64"), "macos-arm64": ("macosx_11_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"), } -SPAWN_HELPER_SUFFIX = "-spawn-helper" + + +def runtime_suffixes(executable_name: str) -> tuple[str, ...]: + return ("", "-spawn-helper") if "-macos-" in executable_name else ("",) def main() -> None: @@ -133,24 +136,12 @@ def stage_sdk(destination: Path, version: str) -> None: def stage_runtime(destination: Path, version: str, executable: Path, executable_name: str) -> None: - payload = [(executable, executable_name)] - if "-macos-" in executable_name: - payload.append( - (Path(f"{executable}{SPAWN_HELPER_SUFFIX}"), f"{executable_name}{SPAWN_HELPER_SUFFIX}") - ) - for source, _ in payload: - if not source.is_file(): - raise FileNotFoundError(f"runtime file does not exist: {source}") - if source.stat().st_mode & stat.S_IXUSR == 0: - raise PermissionError(f"runtime file is not executable: {source}") copy_package(ROOT / "python" / "sdk-runtime", destination) rewrite_version(destination / "pyproject.toml", version) runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" runtime_dir.mkdir(parents=True, exist_ok=True) - for source, name in payload: - target = runtime_dir / name - shutil.copyfile(source, target) - target.chmod(source.stat().st_mode & 0o777) + for suffix in runtime_suffixes(executable_name): + shutil.copy2(Path(f"{executable}{suffix}"), runtime_dir / f"{executable_name}{suffix}") def verify_wheel( @@ -174,9 +165,7 @@ def verify_wheel( ] if package == "runtime": assert platform is not None - expected_files = [platform[1]] - if "-macos-" in platform[1]: - expected_files.append(f"{platform[1]}{SPAWN_HELPER_SUFFIX}") + expected_files = [f"{platform[1]}{suffix}" for suffix in runtime_suffixes(platform[1])] found_files = sorted(Path(name).name for name in runtime_files) if found_files != expected_files: raise RuntimeError(f"{wheel} runtime payload must be {expected_files}, found {found_files}") From cc92b6b578d930505c57123896ef2f3e39b59793 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 01:50:45 +0800 Subject: [PATCH 58/72] =?UTF-8?q?fix(host,client):=20review=20round=2019?= =?UTF-8?q?=20=E2=80=94=20home=20ships=20resolved=20on=20the=20wire;=20cli?= =?UTF-8?q?ent=20mirror=20shrinks=20to=20the=20draft=20side;=20scoped=20fo?= =?UTF-8?q?cus=20guarantee?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- packages/host/apiproxy/src/api/host.ts | 2 +- .../src/client/DirectoryBrowser.tsx | 66 +++++++++---------- .../host/directory-picker-browse/src/index.ts | 7 +- .../tests/directory-browser.spec.tsx | 57 ++++------------ .../tests/home-shape.spec.ts | 28 ++++++++ .../tests/service.spec.ts | 6 +- packages/host/directory-picker/src/index.ts | 2 +- 10 files changed, 90 insertions(+), 86 deletions(-) create mode 100644 packages/host/directory-picker-browse/tests/home-shape.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index f5b5312f73..0502f5cd0d 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: a22b313649a9efb1784f88a30fff3f82b9828347 -2026-07-28-directory-picker-capability-seam.zh.md: fb800fe75bda07198f9b3dc67ac03fb385df919a +2026-07-28-directory-picker-capability-seam.md: 6cba49f9a5817d05e4933e397918a0b9a17ce845 +2026-07-28-directory-picker-capability-seam.zh.md: bfa9f80db32b1e73a198442826c7aea9972c7412 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index a22b313649..6cba49f9a5 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -19,7 +19,7 @@ Placement and policy rulings folded into this decision: - **Not the `ctx.fs` seam.** `packages/fs/` is the model/session-facing storage stack (policy events, sandbox-swappable backends). Riding it would couple GUI browsing to the model's confinement backend — swapping `fs-sandbox` for the model must never change GUI behavior — and OS facts (home anchoring, hidden conventions) are not storage primitives. The picker seam stays presentation-free and model-free; `packages/host/` is its consumer-domain home. - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. -- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are read from the host-resolved root crumb (exact for every root form this backend emits: `/`, `C:\`, `\\server\share\`); the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. Focus parking is a card-wide invariant, not an editor-only one: every pick — editing or not, including right-pane advances and create landings whose columns are replaced — re-parks focus on the selection's row after commit, while every other displacing exit (Enter, Escape, a navigation landing whose new level dropped the focused row, a failed pick or create relist, and the nested create dialog closing) falls back to the crumb edit zone whenever focus actually fell to body, and a show-hidden toggle click that finds focus among the rows parks synchronously on the toggle itself; keyboard traversal never falls out of the card (the Modal has no focus trap), except during the owner's adopt window, where `busy` inerts every control and the dialog is closing either way. +- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are read from the host-resolved root crumb (exact for every root form this backend emits: `/`, `C:\`, `\\server\share\`); the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. Focus parking is a card-wide invariant, not an editor-only one: every pick — editing or not, including right-pane advances and create landings whose columns are replaced — re-parks focus on the selection's row after commit, while every other displacing exit (Enter, Escape, a navigation landing whose new level dropped the focused row, a failed pick or create relist, and the nested create dialog closing) falls back to the crumb edit zone whenever focus actually fell to body, and a show-hidden toggle click that finds focus among the rows parks synchronously on the toggle itself. The guarantee is scoped to the dialog's own node replacements — the Modal has no focus trap, so tabbing past the card's edge legitimately leaves, and the owner's adopt window (where `busy` inerts every control and the dialog is closing either way) is likewise outside it. - **Navigation lands selection-anchored, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor. Known boundaries of the progressive shape: a pointer press landing exactly inside the one-RTT upgrade window can lose its click when the pressed row node is replaced (keyboard focus is re-parked on the re-selected row; the pointer window is accepted); on slash platforms (macOS) only a final-segment case drift misses the parent-entry match and keeps the single-pane landing, while ancestor-segment drift still matches — parent entry paths inherit the typed prefix — and lands two panes at the cost of the Home collapse; and navigate always relists both legs even when the target is the currently shown level — a crumb tap doubles as the refresh gesture, so freshness wins over reusing possibly-stale in-hand listings at the cost of up to two host scans. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index fb800fe75b..bfa9f80db3 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -19,7 +19,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **不用 `ctx.fs` seam。** `packages/fs/` 是面向模型/会话的存储栈(policy 事件、sandbox 可换后端)。骑上去会把 GUI 浏览耦合进模型的限制后端——为模型换 `fs-sandbox` 绝不能改变 GUI 行为——而 OS 事实(home 锚定、隐藏约定)也不是存储原语。picker seam 保持无展示、无模型;`packages/host/` 是它消费方域的家。 - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 -- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从宿主解析的根 crumb 读取(对该后端发出的每种根形态都精确:`/`、`C:\`、`\\server\share\`);下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。焦点停靠是卡片全域的不变量,而非编辑器独有:每次选取——无论是否处于编辑态,包括右栏推进与各列被替换的创建落地——提交后都把焦点重新停靠到选中项所在的行上,而其余所有会顶离焦点的退出(Enter、Escape、新层级已不含焦点所在行的导航落地、选取或创建失败后的重新列举,以及嵌套创建对话框的关闭)只要焦点确实落到了 body 上,就回落到 crumb 编辑区,而点击"显示隐藏"开关时若发现焦点落在行间,则把焦点同步停靠到开关自身;键盘遍历绝不会落出卡片之外(Modal 没有焦点陷阱),仅 owner 的接纳窗口期间例外——此时 `busy` 把每个控件置为惰性,且对话框反正正在关闭。 +- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从宿主解析的根 crumb 读取(对该后端发出的每种根形态都精确:`/`、`C:\`、`\\server\share\`);下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。焦点停靠是卡片全域的不变量,而非编辑器独有:每次选取——无论是否处于编辑态,包括右栏推进与各列被替换的创建落地——提交后都把焦点重新停靠到选中项所在的行上,而其余所有会顶离焦点的退出(Enter、Escape、新层级已不含焦点所在行的导航落地、选取或创建失败后的重新列举,以及嵌套创建对话框的关闭)只要焦点确实落到了 body 上,就回落到 crumb 编辑区,而点击"显示隐藏"开关时若发现焦点落在行间,则把焦点同步停靠到开关自身。该保证的范围仅限对话框自身的节点替换——Modal 没有焦点陷阱,所以 Tab 越过卡片边缘属于正当离开,而 owner 的接纳窗口(其间 `busy` 把每个控件置为惰性,且对话框反正正在关闭)同样在此范围之外。 - **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。渐进形态的已知边界:指针按压若恰好落在单个 RTT 的升级窗口内,可能因所按行节点被替换而丢失点击(键盘焦点会重新停靠到重新选中的行上;指针的这段窗口则被接受);斜杠平台(macOS)上仅末段的大小写偏差会错过父层级条目匹配,保留单栏落地,而祖先段的偏差仍能匹配——父层级条目路径继承键入的前缀——并落地双栏,代价是 Home 塌缩;且导航总是重新列举两程,哪怕目标就是当前展示的层级——crumb 点按兼作刷新手势,因此宁要新鲜度也不复用手头可能已陈旧的列举,代价是至多两次宿主扫描。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index 3d0713e523..25a0698234 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -19,7 +19,7 @@ export interface DirectoryEntry { export interface DirectoryListing { /** Absolute path of the listed directory. */ path: string - /** The host account's home directory (breadcrumb "Home" rooting). */ + /** The host account's home directory (breadcrumb "Home" rooting), in the same resolved shape as `path` and `crumbs[].path`. */ home: string /** * Ancestor chain from the filesystem root to the listed directory diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index bdab232457..0ed8cf3eaa 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -82,19 +82,17 @@ function foldSeparatorsFor(sep: '\\' | '/'): (value: string) => string { } /** - * Lexically normalizes an absolute host path for comparisons: folds - * forward slashes on Windows (win32 accepts either), collapses repeated - * and trailing separators, drops `.` segments, and applies `..` without - * ever crossing the root — POSIX's `/`, a drive's `C:`, or UNC's - * `\\server\share` pair — mirroring the backend's resolve() for the - * shapes an environment-supplied HOME legally carries verbatim while the - * backend's own paths arrive already resolved. A lexical mirror only: - * symlinks are the backend's business. + * Lexically normalizes a typed absolute path for comparisons against the + * backend's resolved ones (the wire contract keeps `path`, `crumbs[].path`, + * and `home` in resolved shape; only the DRAFT side needs this): collapses + * repeated and trailing separators, drops `.` segments, and applies `..` + * without ever crossing the root — POSIX's `/`, a drive's `C:`, or UNC's + * `\\server\share` pair — mirroring resolve()'s lexical behavior. Expects + * separators already folded to `sep` (foldSeparatorsFor); a lexical mirror + * only, symlinks are the backend's business. */ function normalizePathFor(sep: '\\' | '/'): (value: string) => string { - const foldSeparators = foldSeparatorsFor(sep) - return (raw) => { - const value = foldSeparators(raw) + return (value) => { const unc = sep === '\\' && value.startsWith(`${sep}${sep}`) const rawSegments = (unc ? value.slice(2) : value).split(sep) // Empty segments are separator noise everywhere except POSIX's leading @@ -123,16 +121,14 @@ function normalizePathFor(sep: '\\' | '/'): (value: string) => string { /** * Breadcrumb rows for display: inside the home subtree the chain starts at a * localized Home crumb; outside it the full ancestry shows, the root labeled - * by its own path. The home comparison folds per platform and lexically - * normalizes both sides, so a typed-case Windows path or a `HOME=/home/u/.` - * shape still collapses to the Home crumb. + * by its own path. `home` and every crumb path arrive in the same resolved + * shape (the wire contract), so only the platform case fold remains — a + * typed-case Windows chain still collapses to the Home crumb. */ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryEntry[] { - const sep = separatorOf(listing) - const fold = foldPathFor(sep) - const normalize = normalizePathFor(sep) - const home = fold(normalize(listing.home)) - const homeIndex = listing.crumbs.findIndex(crumb => fold(normalize(crumb.path)) === home) + const fold = foldPathFor(separatorOf(listing)) + const home = fold(listing.home) + const homeIndex = listing.crumbs.findIndex(crumb => fold(crumb.path) === home) if (homeIndex === -1) return listing.crumbs const tail = listing.crumbs.slice(homeIndex + 1) return [{ name: homeLabel, path: listing.home, hidden: false }, ...tail] @@ -161,13 +157,14 @@ function separatorOf(listing: DirectoryListing): '\\' | '/' { * The path draft's final segment, when its directory part names the level * `listing` lists — the segment the level prefix-filters on while the user * types. Any other draft (no separator yet, or naming some other directory) - * leaves the level unfiltered. The directory part compares lexically - * normalized (dot segments, repeated separators, and win32 forward - * slashes all match what Enter would navigate to) and under the platform - * case fold (exact on slash platforms; Windows folds, since an upgraded - * selection may carry the actual entry's case while the level below still - * carries the typed one); the name filter downstream is case-insensitive - * everywhere. + * leaves the level unfiltered. Only the directory part is lexically + * normalized (dot segments, repeated separators, and win32 forward slashes + * all match what Enter would navigate to) and platform-case-folded (exact + * on slash platforms; Windows folds, since an upgraded selection may carry + * the actual entry's case while the level below still carries the typed + * one); the FINAL segment stays a literal name prefix — a lone `.` reads + * as the dot-reveal, `..` matches no entry (Enter still navigates it) — + * and the name filter downstream is case-insensitive everywhere. */ function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null { if (draft === null) return null @@ -177,7 +174,7 @@ function draftPrefixFor(listing: DirectoryListing, draft: string | null): string if (cut === -1) return null const fold = foldPathFor(sep) const normalize = normalizePathFor(sep) - return fold(normalize(folded.slice(0, cut + 1))) === fold(normalize(listing.path)) + return fold(normalize(folded.slice(0, cut + 1))) === fold(listing.path) ? folded.slice(cut + 1) : null } @@ -593,15 +590,16 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, if (row !== null && childPath !== undefined) row.scrollLeft = row.scrollWidth }, [childPath]) // Every pick and editor exit that would drop focus to body re-parks it - // after commit, so keyboard traversal stays inside the dialog (the Modal - // has no focus trap): a pick lands on the selection's row — aria-current - // in the freshly rendered left pane, which survives even a right-pane + // after commit, so THIS DIALOG'S OWN node replacements never leak focus + // out of the card: a pick lands on the selection's row — aria-current in + // the freshly rendered left pane, which survives even a right-pane // advance or a create landing replacing the picked button's column — // while the edit-zone exits enumerated at the flag declarations fall - // back to the crumb edit zone. The one window outside this invariant is - // the owner's adopt: busy inerts every control in the card (browsers - // blur disabled elements to body) and no parking applies — the owner - // closes the dialog either way. + // back to the crumb edit zone. Outside the guarantee: the Modal has no + // focus trap, so tabbing past the card's edge legitimately leaves, and + // the owner's adopt window (busy inerts every control; browsers blur + // disabled elements to body) gets no parking — the owner closes the + // dialog either way. useEffect(() => { if (pathDraft !== null) return if (refocusPick.current) { diff --git a/packages/host/directory-picker-browse/src/index.ts b/packages/host/directory-picker-browse/src/index.ts index 6fa157ea87..3a2b3d4cdf 100644 --- a/packages/host/directory-picker-browse/src/index.ts +++ b/packages/host/directory-picker-browse/src/index.ts @@ -215,7 +215,12 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { } private async list(path?: string, signal?: AbortSignal): Promise { - const home = homedir() + // Resolved like every other path in the listing: the environment may + // decorate HOME (trailing or repeated separators, dot segments, win32 + // forward slashes) and homedir() ships it verbatim, while clients + // compare home against the resolved `path`/`crumbs` — the wire contract + // promises one canonical shape for all three. + const home = resolve(homedir()) // The seam contract takes fully qualified paths only; resolve() would // silently rebase a relative or empty wire value under the host process // cwd (or, for rooted drive-less Windows forms, its current drive). diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index af40e08fe7..38fe2a5cba 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -240,19 +240,6 @@ describe('DirectoryBrowser', () => { expect(within(columns()[1]!).getByText('harness')).toBeTruthy() }) - // HOME ships verbatim from the environment while listing paths arrive - // resolved; each decoration (trailing, repeated, dot, dot-dot segments) - // must still normalize to the display root — single pane, Home crumb, - // and no parent leg launched. - it.each(['/', '//', '/foo/../.'])('a home decorated with "%s" is still the display root', async (decoration) => { - const listDirectory = vi.fn(async (path?: string) => ({ ...listingFor(path), home: `${HOME}${decoration}` })) - mount({ listDirectory }) - await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) - expect(columns()).toHaveLength(1) - expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() - expect(listDirectory).toHaveBeenCalledTimes(1) - }) - it('a landing whose new level dropped the focused row parks on the edit zone', async () => { mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) @@ -407,42 +394,16 @@ describe('DirectoryBrowser', () => { expect(document.activeElement?.getAttribute('aria-current')).toBe('true') }) - it('a UNC home with dot-dot never pops the share root and still collapses to Home', async () => { + it('a UNC level is home-collapsed and filters decorated UNC drafts without popping the share root', async () => { const SHARE = '\\\\server\\share' const listing: DirectoryListing = { path: `${SHARE}\\x`, - // USERPROFILE ships verbatim; win32.resolve keeps \\server\share as - // the unpoppable root and folds the doubled separator, so this - // normalizes to \\server\share\x. - home: '\\\\server\\\\share\\..\\x', + home: `${SHARE}\\x`, crumbs: [ { name: `${SHARE}\\`, path: `${SHARE}\\`, hidden: false }, { name: 'x', path: `${SHARE}\\x`, hidden: false }, ], - entries: [], - truncated: false, - } - const listDirectory = vi.fn(async () => listing) - mount({ listDirectory }) - await waitFor(() => { expect(listDirectory).toHaveBeenCalled() }) - await waitFor(() => { expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() }) - expect(columns()).toHaveLength(1) - expect(listDirectory).toHaveBeenCalledTimes(1) - }) - - it('a forward-slash Windows home still reads as the display root', async () => { - const listing: DirectoryListing = { - path: 'C:\\Users\\Alice', - // USERPROFILE may legally use forward slashes; the root crumb (not - // the home text) carries the platform, and normalization folds the - // slashes before comparing. - home: 'C:/Users/Alice', - crumbs: [ - { name: 'C:\\', path: 'C:\\', hidden: false }, - { name: 'Users', path: 'C:\\Users', hidden: false }, - { name: 'Alice', path: 'C:\\Users\\Alice', hidden: false }, - ], - entries: [{ name: 'Desktop', path: 'C:\\Users\\Alice\\Desktop', hidden: false }], + entries: [{ name: 'Alpha', path: `${SHARE}\\x\\Alpha`, hidden: false }], truncated: false, } const listDirectory = vi.fn(async () => listing) @@ -450,7 +411,15 @@ describe('DirectoryBrowser', () => { await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) expect(columns()).toHaveLength(1) expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() - expect(listDirectory).toHaveBeenCalledTimes(1) + // A decorated UNC draft (doubled separator, share-root-crossing dot-dot) + // still normalizes to the listed level: the filter matches what Enter + // would navigate to, and \\server\share stays unpoppable. + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: '\\\\server\\\\share\\..\\x\\a' } }) + expect(screen.getByText('Alpha')).toBeTruthy() + fireEvent.change(input, { target: { value: `${SHARE}\\x\\z` } }) + expect(screen.queryByRole('listitem')).toBeNull() }) it('collapses a typed-case Windows home to the display root (single pane, Home crumb)', async () => { @@ -682,6 +651,8 @@ describe('DirectoryBrowser', () => { expect(screen.getByRole('listitem').textContent).toBe('Documents') fireEvent.change(input, { target: { value: `${HOME}//do` } }) expect(screen.getByRole('listitem').textContent).toBe('Documents') + fireEvent.change(input, { target: { value: `${HOME}/foo/../do` } }) + expect(screen.getByRole('listitem').textContent).toBe('Documents') }) it('filters the child pane in two-pane mode and follows the draft back up a level', async () => { diff --git a/packages/host/directory-picker-browse/tests/home-shape.spec.ts b/packages/host/directory-picker-browse/tests/home-shape.spec.ts new file mode 100644 index 0000000000..153551f7f9 --- /dev/null +++ b/packages/host/directory-picker-browse/tests/home-shape.spec.ts @@ -0,0 +1,28 @@ +/** + * The wire contract's home shape: a decorated HOME (trailing/repeated + * separators, dot segments — homedir() ships it verbatim) still leaves the + * listing carrying the resolved form, matching `path` and `crumbs[].path`. + */ + +import { resolve } from 'node:path' +import { expect, it, vi } from 'vitest' +import { Context } from 'cordis' + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, homedir: () => `${actual.homedir()}/.//.` } +}) + +it('resolves a decorated homedir before stamping listing.home', async () => { + const { homedir } = await vi.importActual('node:os') + const { default: BrowseDirectoryPicker } = await import('../src/index.ts') + const ctx = new Context() + const fiber = ctx.plugin(BrowseDirectoryPicker) + await fiber.await() + const picked = ctx.get('directoryPicker')!.capability() + if (picked.kind !== 'browse') throw new Error('browse backend must advertise the browse capability') + const listing = await picked.list() + expect(listing.home).toBe(resolve(homedir())) + expect(listing.path).toBe(listing.home) + await fiber.dispose() +}) diff --git a/packages/host/directory-picker-browse/tests/service.spec.ts b/packages/host/directory-picker-browse/tests/service.spec.ts index 002d42e516..98adab3c5d 100644 --- a/packages/host/directory-picker-browse/tests/service.spec.ts +++ b/packages/host/directory-picker-browse/tests/service.spec.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' -import { basename, join } from 'node:path' +import { basename, join, resolve } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { Context } from 'cordis' import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' @@ -48,7 +48,9 @@ describe('BrowseDirectoryPicker', () => { it('lists directories only, flags hidden rows, follows symlinks, skips broken links, sorts by name', async () => { const listing = await capability.list(root) expect(listing.path).toBe(root) - expect(listing.home).toBe(homedir()) + // Resolved like path and crumbs — the environment may decorate HOME, + // and the wire contract promises one canonical shape for all three. + expect(listing.home).toBe(resolve(homedir())) expect(listing.entries.map(entry => entry.name)).toEqual(['.hidden-dir', 'linked', 'projects']) expect(listing.entries.map(entry => entry.hidden)).toEqual([true, false, false]) // Every entry path is absolute and host-joined — clients never join segments. diff --git a/packages/host/directory-picker/src/index.ts b/packages/host/directory-picker/src/index.ts index 4dba9c9c22..e6aa5791aa 100644 --- a/packages/host/directory-picker/src/index.ts +++ b/packages/host/directory-picker/src/index.ts @@ -38,7 +38,7 @@ export interface DirectoryEntry { export interface DirectoryListing { /** Absolute path of the listed directory. */ path: string - /** The host account's home directory (breadcrumb "Home" rooting). */ + /** The host account's home directory (breadcrumb "Home" rooting), in the same resolved shape as `path` and `crumbs[].path`. */ home: string /** * Ancestor chain from the filesystem root to the listed directory From 983e6d07a45f5e7c2912ed610d66e0ef3968e5ed Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 02:10:07 +0800 Subject: [PATCH 59/72] =?UTF-8?q?fix(host):=20review=20round=2020=20?= =?UTF-8?q?=E2=80=94=20canonical=20shape=20declared=20at=20the=20interface?= =?UTF-8?q?;=20hermetic=20home-shape=20spec;=20single=20resolve?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...directory-picker-capability-seam.i18n.yaml | 4 ++-- ...-07-28-directory-picker-capability-seam.md | 1 + ...-28-directory-picker-capability-seam.zh.md | 1 + .../client/connection/src/client/fixture.ts | 4 ++++ packages/host/apiproxy/src/api/host.ts | 11 +++++++-- .../src/client/DirectoryBrowser.tsx | 5 ++-- .../host/directory-picker-browse/src/index.ts | 11 +++++---- .../tests/home-shape.spec.ts | 24 +++++++++++++++---- packages/host/directory-picker/src/index.ts | 11 +++++++-- 9 files changed, 55 insertions(+), 17 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index 0502f5cd0d..cee7a5f948 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 6cba49f9a5817d05e4933e397918a0b9a17ce845 -2026-07-28-directory-picker-capability-seam.zh.md: bfa9f80db32b1e73a198442826c7aea9972c7412 +2026-07-28-directory-picker-capability-seam.md: ebd40b9dbc89630c5cbbfacdcfb8be2b6a65fd49 +2026-07-28-directory-picker-capability-seam.zh.md: 6ba8eb347bf906f05ac6192cf941e76d3bc4f44a diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 6cba49f9a5..ebd40b9dbc 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -22,6 +22,7 @@ Placement and policy rulings folded into this decision: - **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are read from the host-resolved root crumb (exact for every root form this backend emits: `/`, `C:\`, `\\server\share\`); the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. Focus parking is a card-wide invariant, not an editor-only one: every pick — editing or not, including right-pane advances and create landings whose columns are replaced — re-parks focus on the selection's row after commit, while every other displacing exit (Enter, Escape, a navigation landing whose new level dropped the focused row, a failed pick or create relist, and the nested create dialog closing) falls back to the crumb edit zone whenever focus actually fell to body, and a show-hidden toggle click that finds focus among the rows parks synchronously on the toggle itself. The guarantee is scoped to the dialog's own node replacements — the Modal has no focus trap, so tabbing past the card's edge legitimately leaves, and the owner's adopt window (where `busy` inerts every control and the dialog is closing either way) is likewise outside it. - **Navigation lands selection-anchored, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor. Known boundaries of the progressive shape: a pointer press landing exactly inside the one-RTT upgrade window can lose its click when the pressed row node is replaced (keyboard focus is re-parked on the re-selected row; the pointer window is accepted); on slash platforms (macOS) only a final-segment case drift misses the parent-entry match and keeps the single-pane landing, while ancestor-segment drift still matches — parent entry paths inherit the typed prefix — and lands two panes at the cost of the Home collapse; and navigate always relists both legs even when the target is the currently shown level — a crumb tap doubles as the refresh gesture, so freshness wins over reusing possibly-stale in-hand listings at the cost of up to two host scans. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. +- **One canonical path shape on the wire.** A listing's `path`, `crumbs[].path`, `entries[].path`, and `home` all ship host-resolved — homedir() output included, since the environment may decorate HOME and the backend resolves it before stamping. Clients compare listing paths verbatim on that promise; the only lexical mirror left in the browse client serves the draft side, the one path a user types and hence naturally non-canonical. Normalizing at the source replaces a client-side mirror of resolve() that had to anticipate every decoration (trailing and repeated separators, dot segments, UNC roots, forward slashes), and the promise binds every browse backend. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. - **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it. - **The native backend stays.** Plugin-form was the point: multiple providers can serve the seam (an Electron shell would provide the `native` interaction through its own dialog API). Kind naming: `dialog` was the first pick and was dropped — the browse interaction also presents a dialog (the in-app modal), so the word failed to discriminate; `native` names where the chooser runs. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index bfa9f80db3..6ba8eb347b 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -22,6 +22,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从宿主解析的根 crumb 读取(对该后端发出的每种根形态都精确:`/`、`C:\`、`\\server\share\`);下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。焦点停靠是卡片全域的不变量,而非编辑器独有:每次选取——无论是否处于编辑态,包括右栏推进与各列被替换的创建落地——提交后都把焦点重新停靠到选中项所在的行上,而其余所有会顶离焦点的退出(Enter、Escape、新层级已不含焦点所在行的导航落地、选取或创建失败后的重新列举,以及嵌套创建对话框的关闭)只要焦点确实落到了 body 上,就回落到 crumb 编辑区,而点击"显示隐藏"开关时若发现焦点落在行间,则把焦点同步停靠到开关自身。该保证的范围仅限对话框自身的节点替换——Modal 没有焦点陷阱,所以 Tab 越过卡片边缘属于正当离开,而 owner 的接纳窗口(其间 `busy` 把每个控件置为惰性,且对话框反正正在关闭)同样在此范围之外。 - **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。渐进形态的已知边界:指针按压若恰好落在单个 RTT 的升级窗口内,可能因所按行节点被替换而丢失点击(键盘焦点会重新停靠到重新选中的行上;指针的这段窗口则被接受);斜杠平台(macOS)上仅末段的大小写偏差会错过父层级条目匹配,保留单栏落地,而祖先段的偏差仍能匹配——父层级条目路径继承键入的前缀——并落地双栏,代价是 Home 塌缩;且导航总是重新列举两程,哪怕目标就是当前展示的层级——crumb 点按兼作刷新手势,因此宁要新鲜度也不复用手头可能已陈旧的列举,代价是至多两次宿主扫描。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 +- **线上只有一种规范路径形态。** 列举的 `path`、`crumbs[].path`、`entries[].path` 与 `home` 一律以宿主解析后的形态发出——homedir() 的输出也不例外,因为环境可能修饰 HOME,后端在标注前先行解析。客户端凭这一承诺逐字比较列举路径;browse 客户端仅剩的词法镜像服务于草稿一侧——用户键入的那一条路径,因而天然非规范。在源头做规范化,取代了客户端侧那份必须预判每种修饰(末尾与重复的分隔符、点段、UNC 根、正斜杠)的 resolve() 镜像,且这一承诺约束每一个 browse 后端。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 - **native 后端保留。** 插件化正是目的:多方都能提供该 seam(Electron 壳可以经自己的对话框 API 提供 `native` 交互)。kind 命名:最初选了 `dialog` 后被放弃——browse 交互同样以对话框呈现(应用内弹窗),这个词起不到判别作用;`native` 命名的是选择器运行的位置。 diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index be9ba79347..22113e7fdb 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1016,6 +1016,10 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { // same tree the browse primitives serve). pickDirectory: request => ok(request, { path: `${FIXTURE_HOME}/Documents/project` }), listDirectory: (request) => { + // The fixture accepts CANONICAL paths only: a decorated input + // (./, //, ..) misses the tree map and reads as unreadable, where + // the real backend resolve()s it first. The keyless lanes drive + // canonical paths, so the divergence stays out of transcripts. const target = request.payload.path ?? FIXTURE_HOME const children = childrenOf(target) if (children === undefined) { diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index 25a0698234..033ca2f112 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -15,11 +15,18 @@ export interface DirectoryEntry { hidden: boolean } -/** host.listDirectory response value: one directory level plus its ancestry. */ +/** + * host.listDirectory response value: one directory level plus its ancestry. + * Every path in one listing — `path`, `crumbs[].path`, `entries[].path`, + * and `home` — is host-resolved canonical form: no `.`/`..` segments, no + * repeated or trailing separators (bare roots `/`, `C:\`, `\\server\share\` + * excepted), one platform separator. Clients compare paths on this promise + * without re-normalizing. + */ export interface DirectoryListing { /** Absolute path of the listed directory. */ path: string - /** The host account's home directory (breadcrumb "Home" rooting), in the same resolved shape as `path` and `crumbs[].path`. */ + /** The host account's home directory (breadcrumb "Home" rooting), in the interface's canonical shape like every other path here. */ home: string /** * Ancestor chain from the filesystem root to the listed directory diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 0ed8cf3eaa..109f69ff2d 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -137,8 +137,9 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE /** * The listing's platform separator, read from the host-resolved root crumb * (`/`, `C:\`, `\\server\share\`) — exact for every root form the backend - * emits, immune both to a home delivered in the other slash flavor - * (`USERPROFILE=C:/Users/Alice`) and to backslashes inside POSIX names. + * emits, and immune to backslashes inside POSIX names (which the home text + * may legally carry; the wire contract already excludes non-canonical + * shapes elsewhere). * TODO: replace with a host-stamped `separator` field on the wire * DirectoryListing so the platform fact travels verbatim (the trade-off is * recorded in the directory-picker capability seam Agent Note). diff --git a/packages/host/directory-picker-browse/src/index.ts b/packages/host/directory-picker-browse/src/index.ts index 3a2b3d4cdf..042794a769 100644 --- a/packages/host/directory-picker-browse/src/index.ts +++ b/packages/host/directory-picker-browse/src/index.ts @@ -217,9 +217,12 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { private async list(path?: string, signal?: AbortSignal): Promise { // Resolved like every other path in the listing: the environment may // decorate HOME (trailing or repeated separators, dot segments, win32 - // forward slashes) and homedir() ships it verbatim, while clients - // compare home against the resolved `path`/`crumbs` — the wire contract - // promises one canonical shape for all three. + // forward slashes) and homedir() ships it verbatim, while the wire + // contract promises one canonical shape for every listing path. A + // relative or drive-less HOME rebases under the process cwd / current + // drive here — the behavior the fullyQualified fence refuses for wire + // values — accepted for the host's own environment, since the listed + // target derives from home and stays consistent with it. const home = resolve(homedir()) // The seam contract takes fully qualified paths only; resolve() would // silently rebase a relative or empty wire value under the host process @@ -227,7 +230,7 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { if (path !== undefined && !fullyQualified(path)) { throw new DirectoryPickerError('directory-unreadable', path, `cannot list "${path}": not a fully qualified path`) } - const target = resolve(path ?? home) + const target = path === undefined ? home : resolve(path) // Stream the level (opendir, one dirent at a time) into a name-sorted // window of maxEntries + 1 candidates: memory stays bounded no matter how // many children the directory holds, the window keeps the name-sorted diff --git a/packages/host/directory-picker-browse/tests/home-shape.spec.ts b/packages/host/directory-picker-browse/tests/home-shape.spec.ts index 153551f7f9..569a631388 100644 --- a/packages/host/directory-picker-browse/tests/home-shape.spec.ts +++ b/packages/host/directory-picker-browse/tests/home-shape.spec.ts @@ -2,19 +2,33 @@ * The wire contract's home shape: a decorated HOME (trailing/repeated * separators, dot segments — homedir() ships it verbatim) still leaves the * listing carrying the resolved form, matching `path` and `crumbs[].path`. + * The mock points homedir at a scratch tree so the probe never scans the + * running machine's real home (same hermetic reasoning as service.spec's + * temporary tree); the mock spreads the actual module, so tmpdir stays real. */ -import { resolve } from 'node:path' -import { expect, it, vi } from 'vitest' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterAll, beforeAll, expect, it, vi } from 'vitest' import { Context } from 'cordis' +let scratch: string + vi.mock('node:os', async (importOriginal) => { const actual = await importOriginal() - return { ...actual, homedir: () => `${actual.homedir()}/.//.` } + return { ...actual, homedir: () => `${scratch}/.//.` } +}) + +beforeAll(async () => { + scratch = await mkdtemp(join(tmpdir(), 'dsh-home-shape-')) +}) + +afterAll(async () => { + await rm(scratch, { recursive: true, force: true }) }) it('resolves a decorated homedir before stamping listing.home', async () => { - const { homedir } = await vi.importActual('node:os') const { default: BrowseDirectoryPicker } = await import('../src/index.ts') const ctx = new Context() const fiber = ctx.plugin(BrowseDirectoryPicker) @@ -22,7 +36,7 @@ it('resolves a decorated homedir before stamping listing.home', async () => { const picked = ctx.get('directoryPicker')!.capability() if (picked.kind !== 'browse') throw new Error('browse backend must advertise the browse capability') const listing = await picked.list() - expect(listing.home).toBe(resolve(homedir())) + expect(listing.home).toBe(resolve(scratch)) expect(listing.path).toBe(listing.home) await fiber.dispose() }) diff --git a/packages/host/directory-picker/src/index.ts b/packages/host/directory-picker/src/index.ts index e6aa5791aa..1dd15fefa4 100644 --- a/packages/host/directory-picker/src/index.ts +++ b/packages/host/directory-picker/src/index.ts @@ -34,11 +34,18 @@ export interface DirectoryEntry { hidden: boolean } -/** One directory level plus its ancestry, as a browse backend reports it. */ +/** + * One directory level plus its ancestry, as a browse backend reports it. + * Every path in one listing — `path`, `crumbs[].path`, `entries[].path`, + * and `home` — is host-resolved canonical form: no `.`/`..` segments, no + * repeated or trailing separators (bare roots `/`, `C:\`, `\\server\share\` + * excepted), one platform separator. Clients compare paths on this promise + * without re-normalizing; every backend must resolve before stamping. + */ export interface DirectoryListing { /** Absolute path of the listed directory. */ path: string - /** The host account's home directory (breadcrumb "Home" rooting), in the same resolved shape as `path` and `crumbs[].path`. */ + /** The host account's home directory (breadcrumb "Home" rooting), in the interface's canonical shape like every other path here. */ home: string /** * Ancestor chain from the filesystem root to the listed directory From f36bb1e882a053808258537b084199c550315704 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 02:14:58 +0800 Subject: [PATCH 60/72] doc: regenerate cordis catalog for the canonical-shape seam JSDoc --- docs/cordis-catalog/services.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index e0dd17fa02..576b63e4d1 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -500,7 +500,7 @@ Abstract directory-picking service. Subclass, implement `capability()`, and load abstract capability(): DirectoryPickerCapability ``` -Source: [`packages/host/directory-picker/src/index.ts:131`](../../packages/host/directory-picker/src/index.ts) +Source: [`packages/host/directory-picker/src/index.ts:138`](../../packages/host/directory-picker/src/index.ts) ## `ctx.fs` — `FileSystem` (abstract seam) From 56ac56bb26029ff943ac532f95bed54b331ace13 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 02:30:05 +0800 Subject: [PATCH 61/72] =?UTF-8?q?fix(host):=20review=20round=2021=20?= =?UTF-8?q?=E2=80=94=20create-path=20shape=20promised;=20lexical-not-realp?= =?UTF-8?q?ath=20named;=20hermetic-only=20home=20probes;=20enumerations=20?= =?UTF-8?q?point=20at=20the=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/host/apiproxy/src/api/host.ts | 9 ++++++--- .../src/client/DirectoryBrowser.tsx | 5 +++-- .../directory-picker-browse/tests/service.spec.ts | 9 ++------- packages/host/directory-picker/README.i18n.yaml | 4 ++-- packages/host/directory-picker/README.md | 2 +- packages/host/directory-picker/README.zh.md | 2 +- packages/host/directory-picker/src/index.ts | 12 +++++++++--- 7 files changed, 24 insertions(+), 19 deletions(-) diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index 033ca2f112..19e16bf628 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -20,8 +20,9 @@ export interface DirectoryEntry { * Every path in one listing — `path`, `crumbs[].path`, `entries[].path`, * and `home` — is host-resolved canonical form: no `.`/`..` segments, no * repeated or trailing separators (bare roots `/`, `C:\`, `\\server\share\` - * excepted), one platform separator. Clients compare paths on this promise - * without re-normalizing. + * excepted), one platform separator. Resolution is lexical (`resolve()`), + * never realpath: a symlinked ancestry keeps the logical path the operator + * navigated. Clients compare paths on this promise without re-normalizing. */ export interface DirectoryListing { /** Absolute path of the listed directory. */ @@ -82,7 +83,9 @@ export interface HostApi { * Create one child directory under an existing parent (the browser's * "New folder"). Only served under the `browse` capability; an existing * child fails with `directory-exists`, every other filesystem failure with - * `directory-create-failed`. + * `directory-create-failed`. The returned path is in the listing + * contract's canonical shape — verbatim equal to the child's + * `entries[].path` in the parent's next listing. */ createDirectory( request: RpcRequest<{ path: string; name: string }>, diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 109f69ff2d..7614d77f50 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -83,8 +83,9 @@ function foldSeparatorsFor(sep: '\\' | '/'): (value: string) => string { /** * Lexically normalizes a typed absolute path for comparisons against the - * backend's resolved ones (the wire contract keeps `path`, `crumbs[].path`, - * and `home` in resolved shape; only the DRAFT side needs this): collapses + * backend's resolved ones (every listing path arrives in the + * DirectoryListing contract's canonical shape; only the DRAFT side, the + * one path a user types, needs this): collapses * repeated and trailing separators, drops `.` segments, and applies `..` * without ever crossing the root — POSIX's `/`, a drive's `C:`, or UNC's * `\\server\share` pair — mirroring resolve()'s lexical behavior. Expects diff --git a/packages/host/directory-picker-browse/tests/service.spec.ts b/packages/host/directory-picker-browse/tests/service.spec.ts index 98adab3c5d..0ace7811f1 100644 --- a/packages/host/directory-picker-browse/tests/service.spec.ts +++ b/packages/host/directory-picker-browse/tests/service.spec.ts @@ -48,8 +48,8 @@ describe('BrowseDirectoryPicker', () => { it('lists directories only, flags hidden rows, follows symlinks, skips broken links, sorts by name', async () => { const listing = await capability.list(root) expect(listing.path).toBe(root) - // Resolved like path and crumbs — the environment may decorate HOME, - // and the wire contract promises one canonical shape for all three. + // The environment may decorate HOME; every listing path ships in the + // DirectoryListing contract's canonical shape, home included. expect(listing.home).toBe(resolve(homedir())) expect(listing.entries.map(entry => entry.name)).toEqual(['.hidden-dir', 'linked', 'projects']) expect(listing.entries.map(entry => entry.hidden)).toEqual([true, false, false]) @@ -163,11 +163,6 @@ describe('BrowseDirectoryPicker', () => { expect(listing.crumbs[0]!.name).toBe(listing.crumbs[0]!.path) }) - it('lists the home directory when no path is given', async () => { - const listing = await capability.list() - expect(listing.path).toBe(homedir()) - }) - it('throws directory-unreadable for a missing target', async () => { const missing = join(root, 'no-such-dir') const failure = await capability.list(missing).catch((error: unknown) => error) diff --git a/packages/host/directory-picker/README.i18n.yaml b/packages/host/directory-picker/README.i18n.yaml index 3e5bae41b5..959608c1b5 100644 --- a/packages/host/directory-picker/README.i18n.yaml +++ b/packages/host/directory-picker/README.i18n.yaml @@ -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/directory-picker/README.md -README.md: 8ef8889c875f5b1d07c015ddef819591041c8d7f -README.zh.md: 8aefffa7b29a47205ea42d0d1df742d1e1b2502d +README.md: 4445af3071d268c5919078278b26f369d9f3ba07 +README.zh.md: 16a90eb698b48438d04154d47afda937c71a4985 diff --git a/packages/host/directory-picker/README.md b/packages/host/directory-picker/README.md index 8ef8889c87..4445af3071 100644 --- a/packages/host/directory-picker/README.md +++ b/packages/host/directory-picker/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'native', pick(signal) }` opens one native OS chooser on the host display ([`-native`](../directory-picker-native/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS chooser can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map (a new backend declaration-merges its shape there), and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime. The client side mirrors the seam without a wire advertisement: each backend package is dual-face, its browser half registering the matching picking interaction into ui-workspace's directory-flow slots — so one composition row swaps both the host capability and the client flow together. -Browse primitives fail with the typed `DirectoryPickerError` (`directory-unreadable` / `directory-exists` / `directory-create-failed`, each carrying the subject `path`), which the consuming gateway maps 1:1 onto wire error codes. `DirectoryEntry` rows carry a host-owned `hidden` flag (POSIX dot convention) so display policy stays client-side; `DirectoryListing.crumbs` is the ancestor chain from the filesystem root, every crumb a jump target. Design rationale, the `ctx.fs` separation, and the policy decisions live in [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). +Browse primitives fail with the typed `DirectoryPickerError` (`directory-unreadable` / `directory-exists` / `directory-create-failed`, each carrying the subject `path`), which the consuming gateway maps 1:1 onto wire error codes. `DirectoryEntry` rows carry a host-owned `hidden` flag (POSIX dot convention) so display policy stays client-side; `DirectoryListing.crumbs` is the ancestor chain from the filesystem root, every crumb a jump target. Every path in one listing — and `createDirectory`'s returned path — ships in host-resolved canonical shape (lexical `resolve()`, never realpath): clients compare listing paths verbatim, so every backend must resolve before stamping. Design rationale, the `ctx.fs` separation, and the policy decisions live in [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). ## Model Experience diff --git a/packages/host/directory-picker/README.zh.md b/packages/host/directory-picker/README.zh.md index 8aefffa7b2..16a90eb698 100644 --- a/packages/host/directory-picker/README.zh.md +++ b/packages/host/directory-picker/README.zh.md @@ -4,7 +4,7 @@ web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker`(`ctx.directoryPicker`),唯一契约方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'native', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-native`](../directory-picker-native/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,可服务任何 OS 对话框都触及不到的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生(新后端在其中声明合并自己的形态),未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。client 侧以镜像方式承接该 seam 而不经 wire 广播:每个后端包都是双面包,其 browser half 把匹配的选取交互注册进 ui-workspace 的目录流 slot——因此一行组合同时切换宿主能力与 client 流程。 -浏览原语以带类型的 `DirectoryPickerError` 失败(`directory-unreadable`/`directory-exists`/`directory-create-failed`,各自携带主体 `path`),消费网关将其 1:1 映射为协议错误码。`DirectoryEntry` 行携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示策略留在客户端;`DirectoryListing.crumbs` 是从文件系统根开始的祖先链,每个 crumb 都是跳转目标。设计依据、与 `ctx.fs` 的切分、策略裁决见[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 +浏览原语以带类型的 `DirectoryPickerError` 失败(`directory-unreadable`/`directory-exists`/`directory-create-failed`,各自携带主体 `path`),消费网关将其 1:1 映射为协议错误码。`DirectoryEntry` 行携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示策略留在客户端;`DirectoryListing.crumbs` 是从文件系统根开始的祖先链,每个 crumb 都是跳转目标。单次列举中的每个路径——连同 `createDirectory` 返回的路径——都以宿主解析的规范形态交付(词法 `resolve()`,从不 realpath):客户端逐字比较列举路径,因此每个后端都必须先解析再标注。设计依据、与 `ctx.fs` 的切分、策略裁决见[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 ## 模型体验 diff --git a/packages/host/directory-picker/src/index.ts b/packages/host/directory-picker/src/index.ts index 1dd15fefa4..3f1b523f7e 100644 --- a/packages/host/directory-picker/src/index.ts +++ b/packages/host/directory-picker/src/index.ts @@ -39,8 +39,11 @@ export interface DirectoryEntry { * Every path in one listing — `path`, `crumbs[].path`, `entries[].path`, * and `home` — is host-resolved canonical form: no `.`/`..` segments, no * repeated or trailing separators (bare roots `/`, `C:\`, `\\server\share\` - * excepted), one platform separator. Clients compare paths on this promise - * without re-normalizing; every backend must resolve before stamping. + * excepted), one platform separator. Resolution is lexical (`resolve()`), + * never realpath: a symlinked ancestry keeps the logical path the operator + * navigated (the seam Agent Note's symlink ruling). Clients compare paths + * on this promise without re-normalizing; every backend must resolve + * before stamping. */ export interface DirectoryListing { /** Absolute path of the listed directory. */ @@ -86,7 +89,10 @@ export interface DirectoryPickerBrowseCapability { * Create one child directory under an existing parent. * @param path - absolute existing parent directory. * @param name - single non-blank path segment (no separators, not `.`/`..`). - * @returns the created directory's absolute path. + * @returns the created directory's absolute path, in the listing + * contract's canonical shape — verbatim equal to the child's + * `entries[].path` in the parent's next listing (clients anchor the + * create landing's selection and focus on that equality). * @throws {DirectoryPickerError} `directory-exists` for an existing child, * `directory-create-failed` for a parent that is not fully qualified or any other failure. */ From 8016591ebe757dd207b06e85c479fcefe3c8b1d2 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 02:33:06 +0800 Subject: [PATCH 62/72] doc: regenerate cordis catalog for the create-path and lexical-shape seam JSDoc --- docs/cordis-catalog/services.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 576b63e4d1..ea109431ea 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -500,7 +500,7 @@ Abstract directory-picking service. Subclass, implement `capability()`, and load abstract capability(): DirectoryPickerCapability ``` -Source: [`packages/host/directory-picker/src/index.ts:138`](../../packages/host/directory-picker/src/index.ts) +Source: [`packages/host/directory-picker/src/index.ts:144`](../../packages/host/directory-picker/src/index.ts) ## `ctx.fs` — `FileSystem` (abstract seam) From de24461b9147b0b40e701ca9d93b71094a471412 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 02:53:45 +0800 Subject: [PATCH 63/72] =?UTF-8?q?fix(host):=20review=20round=2022=20?= =?UTF-8?q?=E2=80=94=20note=20enumeration=20includes=20the=20create=20path?= =?UTF-8?q?;=20cross-method=20equality=20pinned;=20NFD-volume=20boundary?= =?UTF-8?q?=20recorded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...-07-28-directory-picker-capability-seam.i18n.yaml | 4 ++-- .../2026-07-28-directory-picker-capability-seam.md | 2 +- ...2026-07-28-directory-picker-capability-seam.zh.md | 2 +- packages/client/test-runtime/src/workspaces.ts | 4 +++- .../host/directory-picker-browse/README.i18n.yaml | 4 ++-- packages/host/directory-picker-browse/README.md | 1 + packages/host/directory-picker-browse/README.zh.md | 1 + .../src/client/DirectoryBrowser.tsx | 12 ++++++------ .../directory-picker-browse/tests/home-shape.spec.ts | 1 + .../directory-picker-browse/tests/service.spec.ts | 4 ++++ 10 files changed, 22 insertions(+), 13 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index cee7a5f948..bc62880e58 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: ebd40b9dbc89630c5cbbfacdcfb8be2b6a65fd49 -2026-07-28-directory-picker-capability-seam.zh.md: 6ba8eb347bf906f05ac6192cf941e76d3bc4f44a +2026-07-28-directory-picker-capability-seam.md: 6768ed8f898765396d6a4549a661674b5336c1f3 +2026-07-28-directory-picker-capability-seam.zh.md: f20ea0af278151cb6f9ebd963185701cc3f3f2fd diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index ebd40b9dbc..6768ed8f89 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -22,7 +22,7 @@ Placement and policy rulings folded into this decision: - **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are read from the host-resolved root crumb (exact for every root form this backend emits: `/`, `C:\`, `\\server\share\`); the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. Focus parking is a card-wide invariant, not an editor-only one: every pick — editing or not, including right-pane advances and create landings whose columns are replaced — re-parks focus on the selection's row after commit, while every other displacing exit (Enter, Escape, a navigation landing whose new level dropped the focused row, a failed pick or create relist, and the nested create dialog closing) falls back to the crumb edit zone whenever focus actually fell to body, and a show-hidden toggle click that finds focus among the rows parks synchronously on the toggle itself. The guarantee is scoped to the dialog's own node replacements — the Modal has no focus trap, so tabbing past the card's edge legitimately leaves, and the owner's adopt window (where `busy` inerts every control and the dialog is closing either way) is likewise outside it. - **Navigation lands selection-anchored, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor. Known boundaries of the progressive shape: a pointer press landing exactly inside the one-RTT upgrade window can lose its click when the pressed row node is replaced (keyboard focus is re-parked on the re-selected row; the pointer window is accepted); on slash platforms (macOS) only a final-segment case drift misses the parent-entry match and keeps the single-pane landing, while ancestor-segment drift still matches — parent entry paths inherit the typed prefix — and lands two panes at the cost of the Home collapse; and navigate always relists both legs even when the target is the currently shown level — a crumb tap doubles as the refresh gesture, so freshness wins over reusing possibly-stale in-hand listings at the cost of up to two host scans. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. -- **One canonical path shape on the wire.** A listing's `path`, `crumbs[].path`, `entries[].path`, and `home` all ship host-resolved — homedir() output included, since the environment may decorate HOME and the backend resolves it before stamping. Clients compare listing paths verbatim on that promise; the only lexical mirror left in the browse client serves the draft side, the one path a user types and hence naturally non-canonical. Normalizing at the source replaces a client-side mirror of resolve() that had to anticipate every decoration (trailing and repeated separators, dot segments, UNC roots, forward slashes), and the promise binds every browse backend. +- **One canonical path shape on the wire.** A listing's `path`, `crumbs[].path`, `entries[].path`, and `home` — and `createDirectory`'s returned path, which clients compare verbatim against the child's next `entries[].path` to anchor the create landing — all ship host-resolved: lexical `resolve()`, never realpath (the symlink ruling above keeps ancestries logical), with homedir() output included since the environment may decorate HOME and the backend resolves it before stamping. Clients compare listing paths verbatim on that promise; the only lexical mirror left in the browse client serves the draft side, the one path a user types and hence naturally non-canonical. Normalizing at the source replaces a client-side mirror of resolve() that had to anticipate every decoration (trailing and repeated separators, dot segments, UNC roots, forward slashes), and the promise binds every browse backend. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. - **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it. - **The native backend stays.** Plugin-form was the point: multiple providers can serve the seam (an Electron shell would provide the `native` interaction through its own dialog API). Kind naming: `dialog` was the first pick and was dropped — the browse interaction also presents a dialog (the in-app modal), so the word failed to discriminate; `native` names where the chooser runs. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 6ba8eb347b..f20ea0af27 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -22,7 +22,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从宿主解析的根 crumb 读取(对该后端发出的每种根形态都精确:`/`、`C:\`、`\\server\share\`);下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。焦点停靠是卡片全域的不变量,而非编辑器独有:每次选取——无论是否处于编辑态,包括右栏推进与各列被替换的创建落地——提交后都把焦点重新停靠到选中项所在的行上,而其余所有会顶离焦点的退出(Enter、Escape、新层级已不含焦点所在行的导航落地、选取或创建失败后的重新列举,以及嵌套创建对话框的关闭)只要焦点确实落到了 body 上,就回落到 crumb 编辑区,而点击"显示隐藏"开关时若发现焦点落在行间,则把焦点同步停靠到开关自身。该保证的范围仅限对话框自身的节点替换——Modal 没有焦点陷阱,所以 Tab 越过卡片边缘属于正当离开,而 owner 的接纳窗口(其间 `busy` 把每个控件置为惰性,且对话框反正正在关闭)同样在此范围之外。 - **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。渐进形态的已知边界:指针按压若恰好落在单个 RTT 的升级窗口内,可能因所按行节点被替换而丢失点击(键盘焦点会重新停靠到重新选中的行上;指针的这段窗口则被接受);斜杠平台(macOS)上仅末段的大小写偏差会错过父层级条目匹配,保留单栏落地,而祖先段的偏差仍能匹配——父层级条目路径继承键入的前缀——并落地双栏,代价是 Home 塌缩;且导航总是重新列举两程,哪怕目标就是当前展示的层级——crumb 点按兼作刷新手势,因此宁要新鲜度也不复用手头可能已陈旧的列举,代价是至多两次宿主扫描。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 -- **线上只有一种规范路径形态。** 列举的 `path`、`crumbs[].path`、`entries[].path` 与 `home` 一律以宿主解析后的形态发出——homedir() 的输出也不例外,因为环境可能修饰 HOME,后端在标注前先行解析。客户端凭这一承诺逐字比较列举路径;browse 客户端仅剩的词法镜像服务于草稿一侧——用户键入的那一条路径,因而天然非规范。在源头做规范化,取代了客户端侧那份必须预判每种修饰(末尾与重复的分隔符、点段、UNC 根、正斜杠)的 resolve() 镜像,且这一承诺约束每一个 browse 后端。 +- **线上只有一种规范路径形态。** 列举的 `path`、`crumbs[].path`、`entries[].path` 与 `home`——连同 `createDirectory` 返回的路径,客户端拿它与该子项下一次的 `entries[].path` 逐字比较以锚定创建落地——一律以宿主解析后的形态发出:词法 `resolve()`,绝不做 realpath(上文的符号链接裁决保持祖先链为逻辑路径);homedir() 的输出也不例外,因为环境可能修饰 HOME,后端在标注前先行解析。客户端凭这一承诺逐字比较列举路径;browse 客户端仅剩的词法镜像服务于草稿一侧——用户键入的那一条路径,因而天然非规范。在源头做规范化,取代了客户端侧那份必须预判每种修饰(末尾与重复的分隔符、点段、UNC 根、正斜杠)的 resolve() 镜像,且这一承诺约束每一个 browse 后端。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 - **native 后端保留。** 插件化正是目的:多方都能提供该 seam(Electron 壳可以经自己的对话框 API 提供 `native` 交互)。kind 命名:最初选了 `dialog` 后被放弃——browse 交互同样以对话框呈现(应用内弹窗),这个词起不到判别作用;`native` 命名的是选择器运行的位置。 diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index 6c1a9d0aad..d24e7b8c8a 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -142,7 +142,9 @@ export class TestWorkspaces implements IWorkspaces { * Browse child creation (recorded). The default joins parent and name. * @param path - absolute existing parent directory. * @param name - single path segment. - * @returns the created directory's absolute path. + * @returns the created directory's absolute path, in the shape + * `DirectoryPickerBrowseCapability.createDirectory` contracts (verbatim + * equal to the child's `entries[].path` in the parent's next listing). */ async createDirectory(path: string, name: string): Promise { this.calls.push({ method: 'createDirectory', args: [path, name] }) diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index d807bd737a..7d37891821 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/README.i18n.yaml @@ -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/directory-picker-browse/README.md -README.md: 23153881b84dcb71dfb05d4f297a5818c410ca77 -README.zh.md: d7010e2941a801ba6358082824330eaae46e42b7 +README.md: 01e2b9e5afcfd7c47a3f42a76cc1388a25477334 +README.zh.md: 8cb63048713dc964f92762546de783d2ce6ce5a7 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 23153881b8..01e2b9e5af 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -19,5 +19,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Windows hidden attribute is not read** — Node dirents do not expose `FILE_ATTRIBUTE_HIDDEN`, so `hidden` means dot-prefixed on every platform until a native probe is worth its cost. +- **Name-normalizing volumes void the create-path equality** — `createDirectory` promises its return verbatim-equal to the child's next `entries[].path`; Node's namespaced Win32 paths store even trailing-dot/space segments literally, but a volume that rewrites names on storage (NFD normalization on HFS+-style volumes) breaks the match, and the create landing degrades to the documented single-pane / edit-zone fallback. - **No drive-root enumeration** — on Windows the ancestry stops at the drive root; crossing drives waits for the browser UI's path-entry affordance rather than an enumeration primitive here. - **Whole-filesystem scope** — no per-deployment browse-root restriction; `workspace.create` accepts arbitrary paths today, so a root here would be UX scoping, not a boundary — deferred until a deployment needs it. diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index d7010e2941..8cb6304871 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -19,5 +19,6 @@ ## 已知限制与延期工作 - **不读取 Windows 隐藏属性**——Node 的 dirent 不暴露 `FILE_ATTRIBUTE_HIDDEN`,因此在所有平台上 `hidden` 都意味着点前缀,直到原生探测值回其成本为止。 +- **名称规范化的卷会使创建路径等式失效**——`createDirectory` 承诺其返回值与该子项下一次的 `entries[].path` 逐字相等;Node 带命名空间的 Win32 路径连末尾点/空格段都按字面存储,但在存储时改写名称的卷(HFS+ 风格卷上的 NFD 规范化)会破坏这一匹配,创建落地随之退化为文档所述的单栏/编辑区回退。 - **不枚举盘符根**——Windows 上祖先链止于盘符根;跨盘依赖浏览器 UI 的路径输入入口,而不是这里的枚举原语。 - **全盘可浏览**——没有按部署限定的浏览根;`workspace.create` 今天就接受任意路径,这里的根只会是 UX 范围而非边界——等到有部署需要时再做。 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 7614d77f50..3fff931685 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -85,12 +85,12 @@ function foldSeparatorsFor(sep: '\\' | '/'): (value: string) => string { * Lexically normalizes a typed absolute path for comparisons against the * backend's resolved ones (every listing path arrives in the * DirectoryListing contract's canonical shape; only the DRAFT side, the - * one path a user types, needs this): collapses - * repeated and trailing separators, drops `.` segments, and applies `..` - * without ever crossing the root — POSIX's `/`, a drive's `C:`, or UNC's - * `\\server\share` pair — mirroring resolve()'s lexical behavior. Expects - * separators already folded to `sep` (foldSeparatorsFor); a lexical mirror - * only, symlinks are the backend's business. + * one path a user types, needs this): collapses repeated and trailing + * separators, drops `.` segments, and applies `..` without ever crossing + * the root — POSIX's `/`, a drive's `C:`, or UNC's `\\server\share` pair — + * mirroring resolve()'s lexical behavior. Expects separators already + * folded to `sep` (foldSeparatorsFor); a lexical mirror only, symlinks are + * the backend's business. */ function normalizePathFor(sep: '\\' | '/'): (value: string) => string { return (value) => { diff --git a/packages/host/directory-picker-browse/tests/home-shape.spec.ts b/packages/host/directory-picker-browse/tests/home-shape.spec.ts index 569a631388..b0ae26a921 100644 --- a/packages/host/directory-picker-browse/tests/home-shape.spec.ts +++ b/packages/host/directory-picker-browse/tests/home-shape.spec.ts @@ -38,5 +38,6 @@ it('resolves a decorated homedir before stamping listing.home', async () => { const listing = await picked.list() expect(listing.home).toBe(resolve(scratch)) expect(listing.path).toBe(listing.home) + expect(listing.crumbs.at(-1)!.path).toBe(listing.home) await fiber.dispose() }) diff --git a/packages/host/directory-picker-browse/tests/service.spec.ts b/packages/host/directory-picker-browse/tests/service.spec.ts index 0ace7811f1..cfb65af627 100644 --- a/packages/host/directory-picker-browse/tests/service.spec.ts +++ b/packages/host/directory-picker-browse/tests/service.spec.ts @@ -207,6 +207,10 @@ describe('BrowseDirectoryPicker', () => { expect(created).toBe(join(root, 'fresh')) const listing = await capability.list(root) expect(listing.entries.map(entry => entry.name)).toContain('fresh') + // The contract's cross-method equality: the returned path is verbatim + // the child's entries[].path (clients anchor the create landing's + // selection and focus on it). + expect(listing.entries.find(entry => entry.name === 'fresh')!.path).toBe(created) }) it('refuses an existing child with directory-exists', async () => { From df313b753110e71c41f733c834bae80b482424f3 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 03:17:30 +0800 Subject: [PATCH 64/72] =?UTF-8?q?fix(host,client):=20review=20round=2023?= =?UTF-8?q?=20=E2=80=94=20create-path=20contract=20at=20every=20client=20d?= =?UTF-8?q?eclaration;=20close-edge=20resets;=20NFD=20tripwire;=20canonica?= =?UTF-8?q?l=20double=20join?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../runtime/src/client/contract/workspaces.ts | 5 +++- .../runtime/src/client/workspaces/service.ts | 3 ++- .../client/test-runtime/src/workspaces.ts | 6 ++--- .../test-runtime/tests/runtime.spec.tsx | 4 ++++ .../directory-picker-browse/README.i18n.yaml | 4 ++-- .../host/directory-picker-browse/README.md | 2 +- .../host/directory-picker-browse/README.zh.md | 2 +- .../src/client/DirectoryBrowser.tsx | 23 +++++++++++++------ .../src/client/flow.ts | 6 ++++- .../tests/service.spec.ts | 12 ++++++---- 10 files changed, 46 insertions(+), 21 deletions(-) diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts index 9238ea5fd0..ea8dfcf57f 100644 --- a/packages/client/runtime/src/client/contract/workspaces.ts +++ b/packages/client/runtime/src/client/contract/workspaces.ts @@ -48,7 +48,10 @@ export interface IWorkspaces { * Create one child directory through the Host's `browse` capability. * @param path - absolute existing parent directory. * @param name - single non-blank path segment. - * @returns the created directory's absolute path. + * @returns the created directory's absolute path, in the shape + * `DirectoryPickerBrowseCapability.createDirectory` contracts: verbatim + * equal to the child's `entries[].path` in the parent's next listing + * (the browser anchors a create landing's selection on that equality). */ createDirectory(path: string, name: string): Promise /** diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 1dd3319e79..df7f69a19b 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -208,7 +208,8 @@ export class WorkspacesService implements IWorkspaces { * Create one child directory through the Host's `browse` capability. * @param path - absolute existing parent directory. * @param name - single non-blank path segment. - * @returns the created directory's absolute path. + * @returns the created directory's absolute path, in the shape + * `IWorkspaces.createDirectory` contracts. */ async createDirectory(path: string, name: string): Promise { const response = await this.api.host.createDirectory({ path, name }) diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index d24e7b8c8a..bfaa414e9f 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -143,14 +143,14 @@ export class TestWorkspaces implements IWorkspaces { * @param path - absolute existing parent directory. * @param name - single path segment. * @returns the created directory's absolute path, in the shape - * `DirectoryPickerBrowseCapability.createDirectory` contracts (verbatim - * equal to the child's `entries[].path` in the parent's next listing). + * `IWorkspaces.createDirectory` contracts. */ async createDirectory(path: string, name: string): Promise { this.calls.push({ method: 'createDirectory', args: [path, name] }) const stub = this.stubs.get('createDirectory') if (stub !== undefined) return await (stub(path, name) as Promise) - return `${path}/${name}` + // Canonical join: a bare-root parent must not double the separator. + return path.endsWith('/') ? `${path}${name}` : `${path}/${name}` } /** diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index b170f69ba2..826a5e4326 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -329,12 +329,16 @@ describe('workspaces', () => { await expect(runtime.workspaces.listDirectory()).resolves.toMatchObject({ path: '/home/test', entries: [] }) await expect(runtime.workspaces.listDirectory('/home/test')).resolves.toMatchObject({ path: '/home/test' }) await expect(runtime.workspaces.createDirectory('/home/test', 'fresh')).resolves.toBe('/home/test/fresh') + // Canonical join: a bare-root parent yields /top, not //top (the + // IWorkspaces contract's verbatim entries[].path equality). + await expect(runtime.workspaces.createDirectory('/', 'top')).resolves.toBe('/top') // The recorded signal seat mirrors the production face (undefined here; // cancellation tests pass and observe a real one). expect(runtime.workspaces.calls).toEqual([ { method: 'listDirectory', args: [undefined, undefined] }, { method: 'listDirectory', args: ['/home/test', undefined] }, { method: 'createDirectory', args: ['/home/test', 'fresh'] }, + { method: 'createDirectory', args: ['/', 'top'] }, ]) // Stubs replace the defaults like every sibling method. const listing = { path: '/x', home: '/x', crumbs: [], entries: [] } diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 7d37891821..2ab8b5a667 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/README.i18n.yaml @@ -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/directory-picker-browse/README.md -README.md: 01e2b9e5afcfd7c47a3f42a76cc1388a25477334 -README.zh.md: 8cb63048713dc964f92762546de783d2ce6ce5a7 +README.md: d6ed7181ffbec85d11e0abf2aa8d0053173ba4e9 +README.zh.md: 67f5f2bc40297d96bc3fcd3cfba0a3fd26855adc diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 01e2b9e5af..d6ed7181ff 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -19,6 +19,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Windows hidden attribute is not read** — Node dirents do not expose `FILE_ATTRIBUTE_HIDDEN`, so `hidden` means dot-prefixed on every platform until a native probe is worth its cost. -- **Name-normalizing volumes void the create-path equality** — `createDirectory` promises its return verbatim-equal to the child's next `entries[].path`; Node's namespaced Win32 paths store even trailing-dot/space segments literally, but a volume that rewrites names on storage (NFD normalization on HFS+-style volumes) breaks the match, and the create landing degrades to the documented single-pane / edit-zone fallback. +- **Name-normalizing volumes void the create-path equality** — `createDirectory` promises its return verbatim-equal to the child's next `entries[].path`; Node's namespaced Win32 paths store even trailing-dot/space segments literally, but a volume that rewrites names on storage (NFD normalization on HFS+-style volumes) breaks the match, and the create landing degrades to a two-pane view whose left pane lacks the aria-current row while focus falls back to the crumb edit zone. - **No drive-root enumeration** — on Windows the ancestry stops at the drive root; crossing drives waits for the browser UI's path-entry affordance rather than an enumeration primitive here. - **Whole-filesystem scope** — no per-deployment browse-root restriction; `workspace.create` accepts arbitrary paths today, so a root here would be UX scoping, not a boundary — deferred until a deployment needs it. diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 8cb6304871..67f5f2bc40 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -19,6 +19,6 @@ ## 已知限制与延期工作 - **不读取 Windows 隐藏属性**——Node 的 dirent 不暴露 `FILE_ATTRIBUTE_HIDDEN`,因此在所有平台上 `hidden` 都意味着点前缀,直到原生探测值回其成本为止。 -- **名称规范化的卷会使创建路径等式失效**——`createDirectory` 承诺其返回值与该子项下一次的 `entries[].path` 逐字相等;Node 带命名空间的 Win32 路径连末尾点/空格段都按字面存储,但在存储时改写名称的卷(HFS+ 风格卷上的 NFD 规范化)会破坏这一匹配,创建落地随之退化为文档所述的单栏/编辑区回退。 +- **名称规范化的卷会使创建路径等式失效**——`createDirectory` 承诺其返回值与该子项下一次的 `entries[].path` 逐字相等;Node 带命名空间的 Win32 路径连末尾点/空格段都按字面存储,但在存储时改写名称的卷(HFS+ 风格卷上的 NFD 规范化)会破坏这一匹配,创建落地随之退化为左栏缺少 aria-current 行的双栏视图,同时焦点回落至 crumb 编辑区。 - **不枚举盘符根**——Windows 上祖先链止于盘符根;跨盘依赖浏览器 UI 的路径输入入口,而不是这里的枚举原语。 - **全盘可浏览**——没有按部署限定的浏览根;`workspace.create` 今天就接受任意路径,这里的根只会是 UX 范围而非边界——等到有部署需要时再做。 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 3fff931685..7414e08031 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -41,7 +41,12 @@ export interface DirectoryBrowserProps { open: boolean /** List one directory level (absent path = the Host home directory); the signal aborts a superseded scan on the wire. */ listDirectory: (path?: string, signal?: AbortSignal) => Promise - /** Create one child directory under an existing parent. */ + /** + * Create one child directory under an existing parent; the returned path + * is verbatim the child's `entries[].path` in the parent's next listing + * (`IWorkspaces.createDirectory`'s contract) — the create landing anchors + * its selection and focus on that equality. + */ createDirectory: (path: string, name: string) => Promise /** The operator confirmed a directory (the selection, else the listed level). */ onOpen: (path: string) => void @@ -500,19 +505,23 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }, [child, select]) // Every open starts fresh at the Host home directory; closing invalidates - // any in-flight response so a late arrival cannot repopulate a closed dialog. + // any in-flight response so a late arrival cannot repopulate a closed + // dialog. The per-open state resets live on the CLOSE edge: resetting on + // open would let the reopen's first commit paint one frame of the stale + // view (revealed hidden rows, a pressed toggle) before this passive + // effect runs. useEffect(() => { openGeneration.current += 1 if (open) { - setParent(null) - setSelected(null) - setChild(null) - setCreatingFolder(false) - setShowHidden(false) navigate() return } supersede() + setParent(null) + setSelected(null) + setChild(null) + setCreatingFolder(false) + setShowHidden(false) setError(null) setPathDraft(null) setFolderDraft(null) diff --git a/packages/host/directory-picker-browse/src/client/flow.ts b/packages/host/directory-picker-browse/src/client/flow.ts index 84e49b2c98..878bf7e4db 100644 --- a/packages/host/directory-picker-browse/src/client/flow.ts +++ b/packages/host/directory-picker-browse/src/client/flow.ts @@ -15,7 +15,11 @@ import { DirectoryBrowser } from './DirectoryBrowser.tsx' export interface BrowseFlowInjected { /** List one directory level (absent path = the Host home directory); the signal aborts a superseded scan. */ listDirectory: (path?: string, signal?: AbortSignal) => Promise - /** Create one child directory under an existing parent. */ + /** + * Create one child directory under an existing parent; returns the + * created path in the shape `IWorkspaces.createDirectory` contracts + * (verbatim equal to the child's next `entries[].path`). + */ createDirectory: (path: string, name: string) => Promise /** Localized dialog copy (this package's namespace). */ t: Translate diff --git a/packages/host/directory-picker-browse/tests/service.spec.ts b/packages/host/directory-picker-browse/tests/service.spec.ts index cfb65af627..09b888f5cc 100644 --- a/packages/host/directory-picker-browse/tests/service.spec.ts +++ b/packages/host/directory-picker-browse/tests/service.spec.ts @@ -203,14 +203,18 @@ describe('BrowseDirectoryPicker', () => { }) it('creates one child directory and surfaces it in the next listing', async () => { - const created = await capability.createDirectory(root, 'fresh') - expect(created).toBe(join(root, 'fresh')) + // The composed-form name (U+00E9) doubles as the name-rewriting + // tripwire: a volume that stores names NFD-decomposed hands back a + // different dirent.name and the equality below goes red — the README's + // documented boundary. + const created = await capability.createDirectory(root, 'café') + expect(created).toBe(join(root, 'café')) const listing = await capability.list(root) - expect(listing.entries.map(entry => entry.name)).toContain('fresh') + expect(listing.entries.map(entry => entry.name)).toContain('café') // The contract's cross-method equality: the returned path is verbatim // the child's entries[].path (clients anchor the create landing's // selection and focus on it). - expect(listing.entries.find(entry => entry.name === 'fresh')!.path).toBe(created) + expect(listing.entries.find(entry => entry.name === 'café')!.path).toBe(created) }) it('refuses an existing child with directory-exists', async () => { From 53e85101b924beedacd9501e0fa839d97cf668ff Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 03:38:43 +0800 Subject: [PATCH 65/72] =?UTF-8?q?fix(host,client):=20review=20round=2024?= =?UTF-8?q?=20=E2=80=94=20close-edge=20facts=20synced;=20wire-hop=20pointe?= =?UTF-8?q?r;=20platform-flavored=20double=20join;=20loading=20reset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../runtime/src/client/contract/workspaces.ts | 8 ++++---- packages/client/test-runtime/src/workspaces.ts | 7 +++++-- .../client/test-runtime/tests/runtime.spec.tsx | 7 ++++++- .../src/client/DirectoryBrowser.tsx | 16 ++++++++++------ .../directory-picker-browse/src/client/flow.ts | 6 +----- 5 files changed, 26 insertions(+), 18 deletions(-) diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts index ea8dfcf57f..2b6f5f4d34 100644 --- a/packages/client/runtime/src/client/contract/workspaces.ts +++ b/packages/client/runtime/src/client/contract/workspaces.ts @@ -48,10 +48,10 @@ export interface IWorkspaces { * Create one child directory through the Host's `browse` capability. * @param path - absolute existing parent directory. * @param name - single non-blank path segment. - * @returns the created directory's absolute path, in the shape - * `DirectoryPickerBrowseCapability.createDirectory` contracts: verbatim - * equal to the child's `entries[].path` in the parent's next listing - * (the browser anchors a create landing's selection on that equality). + * @returns the created directory's absolute path, in the shape the wire + * `HostApi.createDirectory` contracts: verbatim equal to the child's + * `entries[].path` in the parent's next listing (the browser anchors a + * create landing's selection on that equality). */ createDirectory(path: string, name: string): Promise /** diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index bfaa414e9f..75779f266b 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -149,8 +149,11 @@ export class TestWorkspaces implements IWorkspaces { this.calls.push({ method: 'createDirectory', args: [path, name] }) const stub = this.stubs.get('createDirectory') if (stub !== undefined) return await (stub(path, name) as Promise) - // Canonical join: a bare-root parent must not double the separator. - return path.endsWith('/') ? `${path}${name}` : `${path}/${name}` + // Join in the parent's own separator flavor (a canonical parent ends + // with one only when it is a bare root), so the contract's verbatim + // equality holds for POSIX and Windows fixture trees alike. + const sep = path.includes('\\') ? '\\' : '/' + return path.endsWith(sep) ? `${path}${name}` : `${path}${sep}${name}` } /** diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index 826a5e4326..dcd4bfaadf 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -329,9 +329,12 @@ describe('workspaces', () => { await expect(runtime.workspaces.listDirectory()).resolves.toMatchObject({ path: '/home/test', entries: [] }) await expect(runtime.workspaces.listDirectory('/home/test')).resolves.toMatchObject({ path: '/home/test' }) await expect(runtime.workspaces.createDirectory('/home/test', 'fresh')).resolves.toBe('/home/test/fresh') - // Canonical join: a bare-root parent yields /top, not //top (the + // Canonical join in the parent's own separator flavor: bare roots do + // not double the separator, Windows parents keep backslashes (the // IWorkspaces contract's verbatim entries[].path equality). await expect(runtime.workspaces.createDirectory('/', 'top')).resolves.toBe('/top') + await expect(runtime.workspaces.createDirectory('C:\\', 'top')).resolves.toBe('C:\\top') + await expect(runtime.workspaces.createDirectory('C:\\Users', 'Alice')).resolves.toBe('C:\\Users\\Alice') // The recorded signal seat mirrors the production face (undefined here; // cancellation tests pass and observe a real one). expect(runtime.workspaces.calls).toEqual([ @@ -339,6 +342,8 @@ describe('workspaces', () => { { method: 'listDirectory', args: ['/home/test', undefined] }, { method: 'createDirectory', args: ['/home/test', 'fresh'] }, { method: 'createDirectory', args: ['/', 'top'] }, + { method: 'createDirectory', args: ['C:\\', 'top'] }, + { method: 'createDirectory', args: ['C:\\Users', 'Alice'] }, ]) // Stubs replace the defaults like every sibling method. const listing = { path: '/x', home: '/x', crumbs: [], entries: [] } diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 7414e08031..f0f153ecc6 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -37,7 +37,7 @@ import css from './DirectoryBrowser.module.css' /** Owner-supplied browser props: browse calls, pick semantics, and copy. */ export interface DirectoryBrowserProps { - /** Dialog visibility (owner-local; closed unmounts nothing but resets on reopen). */ + /** Dialog visibility (owner-local; closing resets the per-open state, so a reopen starts clean on its first frame). */ open: boolean /** List one directory level (absent path = the Host home directory); the signal aborts a superseded scan on the wire. */ listDirectory: (path?: string, signal?: AbortSignal) => Promise @@ -258,7 +258,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const [error, setError] = useState(null) // Path-edit state: null = breadcrumb mode; a string = the draft being typed. const [pathDraft, setPathDraft] = useState(null) - // Show-hidden toggle state (pure client-side filter, reset on each open). + // Show-hidden toggle state (pure client-side filter, reset on close). const [showHidden, setShowHidden] = useState(false) // Create-folder state: null = closed; a string = the nested dialog's draft. const [folderDraft, setFolderDraft] = useState(null) @@ -334,9 +334,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, * show-hidden toggle's click to decide whether to reclaim the native * focus outcome. A probe only: it never gates its caller — a torn-down * ref in a landing's close race merely skips the parking, and - * committing the landing into a closing dialog is safe (the component - * already renders null, and the open effect resets parent/selected/child - * on the next open). + * committing the landing into a closing dialog is safe: the close edge's + * supersede() fences every later settlement, and the same close effect + * zeroes parent/selected/child for the one frame that can slip between + * the close render and its effect. * @returns true when `document.activeElement` is inside the miller row. */ const focusInMillerRows = useCallback((): boolean => { @@ -509,7 +510,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // dialog. The per-open state resets live on the CLOSE edge: resetting on // open would let the reopen's first commit paint one frame of the stale // view (revealed hidden rows, a pressed toggle) before this passive - // effect runs. + // effect runs. No automated gate observes that ordering (act() hides the + // frame in tests) — this comment is the guard; read it before moving + // these back. useEffect(() => { openGeneration.current += 1 if (open) { @@ -522,6 +525,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setChild(null) setCreatingFolder(false) setShowHidden(false) + setLoading(false) setError(null) setPathDraft(null) setFolderDraft(null) diff --git a/packages/host/directory-picker-browse/src/client/flow.ts b/packages/host/directory-picker-browse/src/client/flow.ts index 878bf7e4db..831eee9476 100644 --- a/packages/host/directory-picker-browse/src/client/flow.ts +++ b/packages/host/directory-picker-browse/src/client/flow.ts @@ -15,11 +15,7 @@ import { DirectoryBrowser } from './DirectoryBrowser.tsx' export interface BrowseFlowInjected { /** List one directory level (absent path = the Host home directory); the signal aborts a superseded scan. */ listDirectory: (path?: string, signal?: AbortSignal) => Promise - /** - * Create one child directory under an existing parent; returns the - * created path in the shape `IWorkspaces.createDirectory` contracts - * (verbatim equal to the child's next `entries[].path`). - */ + /** Create one child directory under an existing parent; returns the created path in the shape `IWorkspaces.createDirectory` contracts. */ createDirectory: (path: string, name: string) => Promise /** Localized dialog copy (this package's namespace). */ t: Translate From b46dcb16cbb570c7ff984cdb292481903f04de09 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 09:22:16 +0800 Subject: [PATCH 66/72] revert: restore the reviewer-approved tree (97a192d7b) ZiyaZhang approved 97a192d7b; the subsequent bot-review rounds (7-24) landed after that approval and were not requested by a human reviewer. This commit restores the approved tree verbatim as a forward commit (pushed history stays intact). git diff 97a192d7b is empty. --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 7 +- ...-28-directory-picker-capability-seam.zh.md | 7 +- docs/cordis-catalog/services.md | 2 +- .../client/connection/src/client/fixture.ts | 4 - .../runtime/src/client/contract/workspaces.ts | 5 +- .../runtime/src/client/workspaces/service.ts | 3 +- .../client/test-runtime/src/workspaces.ts | 9 +- .../test-runtime/tests/runtime.spec.tsx | 9 - packages/host/apiproxy/src/api/host.ts | 16 +- .../directory-picker-browse/README.i18n.yaml | 4 +- .../host/directory-picker-browse/README.md | 1 - .../host/directory-picker-browse/README.zh.md | 1 - .../src/client/DirectoryBrowser.module.css | 59 +-- .../src/client/DirectoryBrowser.tsx | 371 +++++------------- .../src/client/flow.ts | 2 +- .../host/directory-picker-browse/src/index.ts | 12 +- .../tests/directory-browser.spec.tsx | 309 +-------------- .../tests/home-shape.spec.ts | 43 -- .../tests/service.spec.ts | 25 +- .../host/directory-picker/README.i18n.yaml | 4 +- packages/host/directory-picker/README.md | 2 +- packages/host/directory-picker/README.zh.md | 2 +- packages/host/directory-picker/src/index.ts | 19 +- 24 files changed, 149 insertions(+), 771 deletions(-) delete mode 100644 packages/host/directory-picker-browse/tests/home-shape.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index bc62880e58..900e1d01b6 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 6768ed8f898765396d6a4549a661674b5336c1f3 -2026-07-28-directory-picker-capability-seam.zh.md: f20ea0af278151cb6f9ebd963185701cc3f3f2fd +2026-07-28-directory-picker-capability-seam.md: ad2aa904beddb2fe941883c3c1827702dbec9964 +2026-07-28-directory-picker-capability-seam.zh.md: 30e719ad9b4e8374496106b447e961a042c7d8b6 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 6768ed8f89..ad2aa904be 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -19,10 +19,9 @@ Placement and policy rulings folded into this decision: - **Not the `ctx.fs` seam.** `packages/fs/` is the model/session-facing storage stack (policy events, sandbox-swappable backends). Riding it would couple GUI browsing to the model's confinement backend — swapping `fs-sandbox` for the model must never change GUI behavior — and OS facts (home anchoring, hidden conventions) are not storage primitives. The picker seam stays presentation-free and model-free; `packages/host/` is its consumer-domain home. - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. -- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are read from the host-resolved root crumb (exact for every root form this backend emits: `/`, `C:\`, `\\server\share\`); the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. Focus parking is a card-wide invariant, not an editor-only one: every pick — editing or not, including right-pane advances and create landings whose columns are replaced — re-parks focus on the selection's row after commit, while every other displacing exit (Enter, Escape, a navigation landing whose new level dropped the focused row, a failed pick or create relist, and the nested create dialog closing) falls back to the crumb edit zone whenever focus actually fell to body, and a show-hidden toggle click that finds focus among the rows parks synchronously on the toggle itself. The guarantee is scoped to the dialog's own node replacements — the Modal has no focus trap, so tabbing past the card's edge legitimately leaves, and the owner's adopt window (where `busy` inerts every control and the dialog is closing either way) is likewise outside it. -- **Navigation lands selection-anchored, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor. Known boundaries of the progressive shape: a pointer press landing exactly inside the one-RTT upgrade window can lose its click when the pressed row node is replaced (keyboard focus is re-parked on the re-selected row; the pointer window is accepted); on slash platforms (macOS) only a final-segment case drift misses the parent-entry match and keeps the single-pane landing, while ancestor-segment drift still matches — parent entry paths inherit the typed prefix — and lands two panes at the cost of the Home collapse; and navigate always relists both legs even when the target is the currently shown level — a crumb tap doubles as the refresh gesture, so freshness wins over reusing possibly-stale in-hand listings at the cost of up to two host scans. +- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. +- **Navigation lands selection-anchored, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. -- **One canonical path shape on the wire.** A listing's `path`, `crumbs[].path`, `entries[].path`, and `home` — and `createDirectory`'s returned path, which clients compare verbatim against the child's next `entries[].path` to anchor the create landing — all ship host-resolved: lexical `resolve()`, never realpath (the symlink ruling above keeps ancestries logical), with homedir() output included since the environment may decorate HOME and the backend resolves it before stamping. Clients compare listing paths verbatim on that promise; the only lexical mirror left in the browse client serves the draft side, the one path a user types and hence naturally non-canonical. Normalizing at the source replaces a client-side mirror of resolve() that had to anticipate every decoration (trailing and repeated separators, dot segments, UNC roots, forward slashes), and the promise binds every browse backend. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. - **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it. - **The native backend stays.** Plugin-form was the point: multiple providers can serve the seam (an Electron shell would provide the `native` interaction through its own dialog API). Kind naming: `dialog` was the first pick and was dropped — the browse interaction also presents a dialog (the in-app modal), so the word failed to discriminate; `native` names where the chooser runs. @@ -35,7 +34,7 @@ Placement and policy rulings folded into this decision: - **Adopting a file-manager/drive-enumeration dependency.** Rejected per the survey above; recorded here as the dependency policy requires. - **A flip-label show-hidden toggle ("Hide hidden files").** Rejected: a flipping action label is ambiguous between state and action and doubles the negative; the fixed label with a pressed presentation states both at once. - **Pure relatedTarget blur cancellation (no mousedown suppression).** Rejected: Safari does not focus buttons on pointer down, so a click's focusout carries a null `relatedTarget` and would cancel the editor before the click lands; editing-scoped mousedown suppression plus the card-anchored relatedTarget guard covers pointer and keyboard paths together. -- **A wire `separator` field on `DirectoryListing` (host stamps `path.sep`).** Deferred, not rejected: it is the authoritative form. The root-crumb read the client uses today is exact for every root shape this backend emits, but it still infers a platform fact from path text and promotes "the chain starts at the root" from backend behavior into a client-relied invariant (the `crumbs` JSDoc does promise it), and it degrades to the old home-text heuristic on an empty chain; a wire field would travel verbatim and survive empty chains and future backends. It touches the seam type and every backend, so the browse client's `separatorOf` carries a TODO pointing at this alternative until a wire change is next scheduled. +- **A wire `separator` field on `DirectoryListing` (host stamps `path.sep`).** Deferred, not rejected: it is the authoritative form — a POSIX home directory containing a backslash defeats the `listing.home` heuristic — but it touches the seam type and every backend; the browse client's `separatorOf` carries a TODO pointing at this alternative until a wire change is next scheduled. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index f20ea0af27..30e719ad9b 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -19,10 +19,9 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **不用 `ctx.fs` seam。** `packages/fs/` 是面向模型/会话的存储栈(policy 事件、sandbox 可换后端)。骑上去会把 GUI 浏览耦合进模型的限制后端——为模型换 `fs-sandbox` 绝不能改变 GUI 行为——而 OS 事实(home 锚定、隐藏约定)也不是存储原语。picker seam 保持无展示、无模型;`packages/host/` 是它消费方域的家。 - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 -- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从宿主解析的根 crumb 读取(对该后端发出的每种根形态都精确:`/`、`C:\`、`\\server\share\`);下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。焦点停靠是卡片全域的不变量,而非编辑器独有:每次选取——无论是否处于编辑态,包括右栏推进与各列被替换的创建落地——提交后都把焦点重新停靠到选中项所在的行上,而其余所有会顶离焦点的退出(Enter、Escape、新层级已不含焦点所在行的导航落地、选取或创建失败后的重新列举,以及嵌套创建对话框的关闭)只要焦点确实落到了 body 上,就回落到 crumb 编辑区,而点击"显示隐藏"开关时若发现焦点落在行间,则把焦点同步停靠到开关自身。该保证的范围仅限对话框自身的节点替换——Modal 没有焦点陷阱,所以 Tab 越过卡片边缘属于正当离开,而 owner 的接纳窗口(其间 `busy` 把每个控件置为惰性,且对话框反正正在关闭)同样在此范围之外。 -- **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。渐进形态的已知边界:指针按压若恰好落在单个 RTT 的升级窗口内,可能因所按行节点被替换而丢失点击(键盘焦点会重新停靠到重新选中的行上;指针的这段窗口则被接受);斜杠平台(macOS)上仅末段的大小写偏差会错过父层级条目匹配,保留单栏落地,而祖先段的偏差仍能匹配——父层级条目路径继承键入的前缀——并落地双栏,代价是 Home 塌缩;且导航总是重新列举两程,哪怕目标就是当前展示的层级——crumb 点按兼作刷新手势,因此宁要新鲜度也不复用手头可能已陈旧的列举,代价是至多两次宿主扫描。 +- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。 +- **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 -- **线上只有一种规范路径形态。** 列举的 `path`、`crumbs[].path`、`entries[].path` 与 `home`——连同 `createDirectory` 返回的路径,客户端拿它与该子项下一次的 `entries[].path` 逐字比较以锚定创建落地——一律以宿主解析后的形态发出:词法 `resolve()`,绝不做 realpath(上文的符号链接裁决保持祖先链为逻辑路径);homedir() 的输出也不例外,因为环境可能修饰 HOME,后端在标注前先行解析。客户端凭这一承诺逐字比较列举路径;browse 客户端仅剩的词法镜像服务于草稿一侧——用户键入的那一条路径,因而天然非规范。在源头做规范化,取代了客户端侧那份必须预判每种修饰(末尾与重复的分隔符、点段、UNC 根、正斜杠)的 resolve() 镜像,且这一承诺约束每一个 browse 后端。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 - **native 后端保留。** 插件化正是目的:多方都能提供该 seam(Electron 壳可以经自己的对话框 API 提供 `native` 交互)。kind 命名:最初选了 `dialog` 后被放弃——browse 交互同样以对话框呈现(应用内弹窗),这个词起不到判别作用;`native` 命名的是选择器运行的位置。 @@ -35,7 +34,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **引入文件管理器/盘符枚举依赖。** 按上文调研否决;依赖政策要求记录于此。 - **动作标签随状态翻转的"显示隐藏"开关("隐藏隐藏文件")。** 否决:会翻转的动作标签在状态与动作之间有歧义,还把否定叠了两层;固定标签加按下态呈现一次说清两者。 - **纯 relatedTarget 失焦取消(不做 mousedown 抑制)。** 否决:Safari 在指针按下时不给按钮聚焦,点击触发的 focusout 因而携带空 `relatedTarget`,会在点击落地前就取消编辑器;编辑期作用的 mousedown 抑制加上锚定卡片的 relatedTarget 守卫才能同时覆盖指针与键盘路径。 -- **在 `DirectoryListing` 上增设线上 `separator` 字段(宿主标注 `path.sep`)。** 延期而非否决:它才是权威形态。客户端今天所用的根 crumb 读取对该后端发出的每种根形态都精确,但它仍是从路径文本推断平台事实,还把"链从根开始"从后端行为提升为客户端所依赖的不变量(`crumbs` 的 JSDoc 确实承诺了这一点),并在链为空时退化回旧的 home 文本启发式;线上字段则会原样随线传输,经得住空链与未来的后端。它触及 seam 类型与每个后端,因此 browse 客户端的 `separatorOf` 挂着指向本方案的 TODO,直到下次安排线上变更。 +- **在 `DirectoryListing` 上增设线上 `separator` 字段(宿主标注 `path.sep`)。** 延期而非否决:它才是权威形态——含反斜杠的 POSIX 家目录会击穿 `listing.home` 启发式——但它触及 seam 类型与每个后端;browse 客户端的 `separatorOf` 挂着指向本方案的 TODO,直到下次安排线上变更。 ## 后果 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index ea109431ea..e0dd17fa02 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -500,7 +500,7 @@ Abstract directory-picking service. Subclass, implement `capability()`, and load abstract capability(): DirectoryPickerCapability ``` -Source: [`packages/host/directory-picker/src/index.ts:144`](../../packages/host/directory-picker/src/index.ts) +Source: [`packages/host/directory-picker/src/index.ts:131`](../../packages/host/directory-picker/src/index.ts) ## `ctx.fs` — `FileSystem` (abstract seam) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 22113e7fdb..be9ba79347 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1016,10 +1016,6 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { // same tree the browse primitives serve). pickDirectory: request => ok(request, { path: `${FIXTURE_HOME}/Documents/project` }), listDirectory: (request) => { - // The fixture accepts CANONICAL paths only: a decorated input - // (./, //, ..) misses the tree map and reads as unreadable, where - // the real backend resolve()s it first. The keyless lanes drive - // canonical paths, so the divergence stays out of transcripts. const target = request.payload.path ?? FIXTURE_HOME const children = childrenOf(target) if (children === undefined) { diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts index 2b6f5f4d34..9238ea5fd0 100644 --- a/packages/client/runtime/src/client/contract/workspaces.ts +++ b/packages/client/runtime/src/client/contract/workspaces.ts @@ -48,10 +48,7 @@ export interface IWorkspaces { * Create one child directory through the Host's `browse` capability. * @param path - absolute existing parent directory. * @param name - single non-blank path segment. - * @returns the created directory's absolute path, in the shape the wire - * `HostApi.createDirectory` contracts: verbatim equal to the child's - * `entries[].path` in the parent's next listing (the browser anchors a - * create landing's selection on that equality). + * @returns the created directory's absolute path. */ createDirectory(path: string, name: string): Promise /** diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index df7f69a19b..1dd3319e79 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -208,8 +208,7 @@ export class WorkspacesService implements IWorkspaces { * Create one child directory through the Host's `browse` capability. * @param path - absolute existing parent directory. * @param name - single non-blank path segment. - * @returns the created directory's absolute path, in the shape - * `IWorkspaces.createDirectory` contracts. + * @returns the created directory's absolute path. */ async createDirectory(path: string, name: string): Promise { const response = await this.api.host.createDirectory({ path, name }) diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index 75779f266b..6c1a9d0aad 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -142,18 +142,13 @@ export class TestWorkspaces implements IWorkspaces { * Browse child creation (recorded). The default joins parent and name. * @param path - absolute existing parent directory. * @param name - single path segment. - * @returns the created directory's absolute path, in the shape - * `IWorkspaces.createDirectory` contracts. + * @returns the created directory's absolute path. */ async createDirectory(path: string, name: string): Promise { this.calls.push({ method: 'createDirectory', args: [path, name] }) const stub = this.stubs.get('createDirectory') if (stub !== undefined) return await (stub(path, name) as Promise) - // Join in the parent's own separator flavor (a canonical parent ends - // with one only when it is a bare root), so the contract's verbatim - // equality holds for POSIX and Windows fixture trees alike. - const sep = path.includes('\\') ? '\\' : '/' - return path.endsWith(sep) ? `${path}${name}` : `${path}${sep}${name}` + return `${path}/${name}` } /** diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index dcd4bfaadf..b170f69ba2 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -329,21 +329,12 @@ describe('workspaces', () => { await expect(runtime.workspaces.listDirectory()).resolves.toMatchObject({ path: '/home/test', entries: [] }) await expect(runtime.workspaces.listDirectory('/home/test')).resolves.toMatchObject({ path: '/home/test' }) await expect(runtime.workspaces.createDirectory('/home/test', 'fresh')).resolves.toBe('/home/test/fresh') - // Canonical join in the parent's own separator flavor: bare roots do - // not double the separator, Windows parents keep backslashes (the - // IWorkspaces contract's verbatim entries[].path equality). - await expect(runtime.workspaces.createDirectory('/', 'top')).resolves.toBe('/top') - await expect(runtime.workspaces.createDirectory('C:\\', 'top')).resolves.toBe('C:\\top') - await expect(runtime.workspaces.createDirectory('C:\\Users', 'Alice')).resolves.toBe('C:\\Users\\Alice') // The recorded signal seat mirrors the production face (undefined here; // cancellation tests pass and observe a real one). expect(runtime.workspaces.calls).toEqual([ { method: 'listDirectory', args: [undefined, undefined] }, { method: 'listDirectory', args: ['/home/test', undefined] }, { method: 'createDirectory', args: ['/home/test', 'fresh'] }, - { method: 'createDirectory', args: ['/', 'top'] }, - { method: 'createDirectory', args: ['C:\\', 'top'] }, - { method: 'createDirectory', args: ['C:\\Users', 'Alice'] }, ]) // Stubs replace the defaults like every sibling method. const listing = { path: '/x', home: '/x', crumbs: [], entries: [] } diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index 19e16bf628..3d0713e523 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -15,19 +15,11 @@ export interface DirectoryEntry { hidden: boolean } -/** - * host.listDirectory response value: one directory level plus its ancestry. - * Every path in one listing — `path`, `crumbs[].path`, `entries[].path`, - * and `home` — is host-resolved canonical form: no `.`/`..` segments, no - * repeated or trailing separators (bare roots `/`, `C:\`, `\\server\share\` - * excepted), one platform separator. Resolution is lexical (`resolve()`), - * never realpath: a symlinked ancestry keeps the logical path the operator - * navigated. Clients compare paths on this promise without re-normalizing. - */ +/** host.listDirectory response value: one directory level plus its ancestry. */ export interface DirectoryListing { /** Absolute path of the listed directory. */ path: string - /** The host account's home directory (breadcrumb "Home" rooting), in the interface's canonical shape like every other path here. */ + /** The host account's home directory (breadcrumb "Home" rooting). */ home: string /** * Ancestor chain from the filesystem root to the listed directory @@ -83,9 +75,7 @@ export interface HostApi { * Create one child directory under an existing parent (the browser's * "New folder"). Only served under the `browse` capability; an existing * child fails with `directory-exists`, every other filesystem failure with - * `directory-create-failed`. The returned path is in the listing - * contract's canonical shape — verbatim equal to the child's - * `entries[].path` in the parent's next listing. + * `directory-create-failed`. */ createDirectory( request: RpcRequest<{ path: string; name: string }>, diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 2ab8b5a667..d807bd737a 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/README.i18n.yaml @@ -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/directory-picker-browse/README.md -README.md: d6ed7181ffbec85d11e0abf2aa8d0053173ba4e9 -README.zh.md: 67f5f2bc40297d96bc3fcd3cfba0a3fd26855adc +README.md: 23153881b84dcb71dfb05d4f297a5818c410ca77 +README.zh.md: d7010e2941a801ba6358082824330eaae46e42b7 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index d6ed7181ff..23153881b8 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -19,6 +19,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Windows hidden attribute is not read** — Node dirents do not expose `FILE_ATTRIBUTE_HIDDEN`, so `hidden` means dot-prefixed on every platform until a native probe is worth its cost. -- **Name-normalizing volumes void the create-path equality** — `createDirectory` promises its return verbatim-equal to the child's next `entries[].path`; Node's namespaced Win32 paths store even trailing-dot/space segments literally, but a volume that rewrites names on storage (NFD normalization on HFS+-style volumes) breaks the match, and the create landing degrades to a two-pane view whose left pane lacks the aria-current row while focus falls back to the crumb edit zone. - **No drive-root enumeration** — on Windows the ancestry stops at the drive root; crossing drives waits for the browser UI's path-entry affordance rather than an enumeration primitive here. - **Whole-filesystem scope** — no per-deployment browse-root restriction; `workspace.create` accepts arbitrary paths today, so a root here would be UX scoping, not a boundary — deferred until a deployment needs it. diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 67f5f2bc40..d7010e2941 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -19,6 +19,5 @@ ## 已知限制与延期工作 - **不读取 Windows 隐藏属性**——Node 的 dirent 不暴露 `FILE_ATTRIBUTE_HIDDEN`,因此在所有平台上 `hidden` 都意味着点前缀,直到原生探测值回其成本为止。 -- **名称规范化的卷会使创建路径等式失效**——`createDirectory` 承诺其返回值与该子项下一次的 `entries[].path` 逐字相等;Node 带命名空间的 Win32 路径连末尾点/空格段都按字面存储,但在存储时改写名称的卷(HFS+ 风格卷上的 NFD 规范化)会破坏这一匹配,创建落地随之退化为左栏缺少 aria-current 行的双栏视图,同时焦点回落至 crumb 编辑区。 - **不枚举盘符根**——Windows 上祖先链止于盘符根;跨盘依赖浏览器 UI 的路径输入入口,而不是这里的枚举原语。 - **全盘可浏览**——没有按部署限定的浏览根;`workspace.create` 今天就接受任意路径,这里的根只会是 UX 范围而非边界——等到有部署需要时再做。 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index 8bd044f610..85349962e5 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -52,6 +52,24 @@ /* Deep chains scroll inside the trail (the effect pins the tail into view) * so the edit zone to the right never leaves the bar. */ +/* The Miller columns keep their own row so a status/error line below never + * competes with the fixed column widths for horizontal space. */ +/* A narrow viewport shrinks the dialog below two fixed panes; the row + * scrolls horizontally (the effect pins the child pane into view) so + * descent never hides behind the Modal's clipping. */ +.millerRow { + display: flex; + align-items: stretch; + flex: 1 1 0; + min-height: 0; + /* 12px of row gap on each side of the divider; the left side reads wider + * by the column's trailing 8px scrollbar clearance, which is deliberate — + * the thumb needs that room, the right pane's rows do not. */ + gap: 12px; + overflow-x: auto; + scrollbar-width: none; +} + .crumbTrail { display: flex; align-items: center; @@ -62,12 +80,6 @@ scrollbar-width: none; } -/* Pseudo-element-path engines (see .millerRow's twin rule): the 20px crumb - * bar has no room for a bar at all. */ -.crumbTrail::-webkit-scrollbar { - display: none; -} - .crumbSeat { display: inline-flex; align-items: center; @@ -134,36 +146,11 @@ flex-direction: column; flex: 1 1 0; min-height: 0; - /* Right inset is slimmer than the left: the trailing column's own - * scrollbar clearance (see .column) makes up the optical difference. */ + /* Right inset is slimmer than the left: the trailing column's own 8px + * scrollbar clearance makes up the optical difference. */ padding: 16px 16px 16px 24px; } -/* The Miller columns keep their own row so a status/error line below never - * competes with the fixed column widths for horizontal space. */ -/* A narrow viewport shrinks the dialog below two fixed panes; the row - * scrolls horizontally (the effect pins the child pane into view) so - * descent never hides behind the Modal's clipping. */ -.millerRow { - display: flex; - align-items: stretch; - flex: 1 1 0; - min-height: 0; - /* 12px of row gap on each side of the divider; the left side reads wider - * by the column's trailing scrollbar clearance (see .column) — the thumb - * needs that room, the right pane's rows do not. */ - gap: 12px; - overflow-x: auto; - scrollbar-width: none; -} - -/* Engines that predate scrollbar-width take the pseudo-element path (the - * two are mutually exclusive by construction — see ui-theme's scrollbar - * contract); hide the row's horizontal bar there too. */ -.millerRow::-webkit-scrollbar { - display: none; -} - /* Columns split the row evenly around the divider (a solo column takes the * whole row); 256px is the floor below which the row scrolls (scrollbar * hidden, the effect pins the child pane into view) instead of squeezing @@ -305,12 +292,6 @@ color: var(--dsw-alias-label-primary); } -/* Flex-none: the nowrap label refuses to shrink, which would leave the - * glyph as the only compressible item under wrap or narrow-viewport clamp. */ -.toggleCheck { - flex: none; -} - .footerGap { flex: 1 1 0; } diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index f0f153ecc6..859667d115 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -2,20 +2,16 @@ * The in-app workspace-directory browser (figma Harness 813-23126 family): a * 680×500 dialog (clamped to short/narrow viewports — the Miller row scrolls * sideways, the columns scroll down) whose header carries the title, the selection-path - * breadcrumb, and a click-to-edit path zone; below it a Miller view of one - * or two columns splitting the row evenly (256px floor; level | selected - * folder's children) around a hairline divider — the display root and - * degraded landings keep the single wide level, while any selection opens - * the second pane, including the one a navigation lands with: a crumb jump - * or a submitted path commits the target immediately, then re-selects it - * in its parent level once that level arrives, so stepping back keeps two - * panes away from the display root. Selecting in the + * breadcrumb, and a click-to-edit path zone; below it a Miller view — one + * full-width level until a row is selected, then two columns splitting the + * row evenly (256px floor; level | selected folder's children) around a + * hairline divider. Navigations land selection-anchored: a crumb jump or a + * submitted path commits the target immediately, then re-selects it in its + * parent level once that level arrives, so stepping back keeps two panes + * away from the display root. Selecting in the * right column shifts the view one level deeper. "New folder" opens a nested * create dialog targeting the selected folder (or the level itself) and - * selects the created folder — unless a newer pick or crumb jump supersedes - * the post-create relist, in which case neither the level nor the selection - * refreshes (see closeCreateDialog's two-stage parking for the matching - * focus story). Open adopts the selected folder, falling back + * selects the created folder. Open adopts the selected folder, falling back * to the listed level. Pure consumer of the injected browse calls — the * owning flow decides what "Open" means and owns the workspace-creation * error surface. Hidden entries are host-flagged and hidden by default; the @@ -37,16 +33,11 @@ import css from './DirectoryBrowser.module.css' /** Owner-supplied browser props: browse calls, pick semantics, and copy. */ export interface DirectoryBrowserProps { - /** Dialog visibility (owner-local; closing resets the per-open state, so a reopen starts clean on its first frame). */ + /** Dialog visibility (owner-local; closed unmounts nothing but resets on reopen). */ open: boolean /** List one directory level (absent path = the Host home directory); the signal aborts a superseded scan on the wire. */ listDirectory: (path?: string, signal?: AbortSignal) => Promise - /** - * Create one child directory under an existing parent; the returned path - * is verbatim the child's `entries[].path` in the parent's next listing - * (`IWorkspaces.createDirectory`'s contract) — the create landing anchors - * its selection and focus on that equality. - */ + /** Create one child directory under an existing parent. */ createDirectory: (path: string, name: string) => Promise /** The operator confirmed a directory (the selection, else the listed level). */ onOpen: (path: string) => void @@ -64,126 +55,46 @@ function failureText(error: unknown): string { return error instanceof Error ? error.message : String(error) } -/** - * Case-folds a path for comparisons under the given separator's platform: - * backslash (Windows) paths compare case-insensitively — a typed path - * legally differs in case from the host's stamped one — while slash - * platforms compare exactly (the filesystem may be case-sensitive; only a - * FINAL-segment macOS case drift misses parent-entry matching and keeps - * the single-pane landing, since parent entry paths inherit the typed - * prefix). - */ -function foldPathFor(sep: '\\' | '/'): (value: string) => string { - return value => (sep === '\\' ? value.toLowerCase() : value) -} - -/** - * Folds separators to the platform's canonical one: win32 treats a forward - * slash as a separator too (resolve() folds them the same way), while - * POSIX must not — a backslash there is a name character. - */ -function foldSeparatorsFor(sep: '\\' | '/'): (value: string) => string { - return value => (sep === '\\' ? value.replaceAll('/', sep) : value) -} - -/** - * Lexically normalizes a typed absolute path for comparisons against the - * backend's resolved ones (every listing path arrives in the - * DirectoryListing contract's canonical shape; only the DRAFT side, the - * one path a user types, needs this): collapses repeated and trailing - * separators, drops `.` segments, and applies `..` without ever crossing - * the root — POSIX's `/`, a drive's `C:`, or UNC's `\\server\share` pair — - * mirroring resolve()'s lexical behavior. Expects separators already - * folded to `sep` (foldSeparatorsFor); a lexical mirror only, symlinks are - * the backend's business. - */ -function normalizePathFor(sep: '\\' | '/'): (value: string) => string { - return (value) => { - const unc = sep === '\\' && value.startsWith(`${sep}${sep}`) - const rawSegments = (unc ? value.slice(2) : value).split(sep) - // Empty segments are separator noise everywhere except POSIX's leading - // root marker, which must survive as the first segment; scrubbing them - // up front keeps a doubled separator from being locked into the UNC - // server + share root below. - const segments = unc ? rawSegments.filter(segment => segment !== '') : rawSegments - // The unpoppable root: POSIX's leading empty segment / the drive - // segment, or UNC's server + share pair. - const rootLength = unc ? 2 : 1 - const out = segments.slice(0, rootLength) - for (const segment of segments.slice(rootLength)) { - if (segment === '' || segment === '.') continue - if (segment === '..') { - if (out.length > rootLength) out.pop() - continue - } - out.push(segment) - } - // A bare root keeps (or regains) the trailing separator resolve() - // emits for `/`, `C:\`, and `\\server\share\`. - return `${unc ? sep + sep : ''}${out.join(sep)}${out.length === rootLength ? sep : ''}` - } -} - /** * Breadcrumb rows for display: inside the home subtree the chain starts at a * localized Home crumb; outside it the full ancestry shows, the root labeled - * by its own path. `home` and every crumb path arrive in the same resolved - * shape (the wire contract), so only the platform case fold remains — a - * typed-case Windows chain still collapses to the Home crumb. + * by its own path. */ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryEntry[] { - const fold = foldPathFor(separatorOf(listing)) - const home = fold(listing.home) - const homeIndex = listing.crumbs.findIndex(crumb => fold(crumb.path) === home) + const homeIndex = listing.crumbs.findIndex(crumb => crumb.path === listing.home) if (homeIndex === -1) return listing.crumbs const tail = listing.crumbs.slice(homeIndex + 1) return [{ name: homeLabel, path: listing.home, hidden: false }, ...tail] } /** - * The listing's platform separator, read from the host-resolved root crumb - * (`/`, `C:\`, `\\server\share\`) — exact for every root form the backend - * emits, and immune to backslashes inside POSIX names (which the home text - * may legally carry; the wire contract already excludes non-canonical - * shapes elsewhere). + * The listing's platform separator, inferred from the home path the host + * stamped — never from typed text or entry paths, where a backslash is a + * legal POSIX name character. Still a heuristic at the last step: a POSIX + * home directory whose own name contains a backslash would misread. * TODO: replace with a host-stamped `separator` field on the wire * DirectoryListing so the platform fact travels verbatim (the trade-off is * recorded in the directory-picker capability seam Agent Note). */ function separatorOf(listing: DirectoryListing): '\\' | '/' { - const rootCrumb = listing.crumbs.at(0) - // The seam type allows an empty chain (this backend never emits one, but - // create-target naming supports it, see targetName): degrade to a - // best-effort read of the home text — the pre-root-crumb heuristic, with - // its backslash-in-a-POSIX-name blind spot. - if (rootCrumb === undefined) return listing.home.includes('\\') ? '\\' : '/' - return rootCrumb.path.includes('\\') ? '\\' : '/' + return listing.home.includes('\\') ? '\\' : '/' } /** - * The path draft's final segment, when its directory part names the level - * `listing` lists — the segment the level prefix-filters on while the user - * types. Any other draft (no separator yet, or naming some other directory) - * leaves the level unfiltered. Only the directory part is lexically - * normalized (dot segments, repeated separators, and win32 forward slashes - * all match what Enter would navigate to) and platform-case-folded (exact - * on slash platforms; Windows folds, since an upgraded selection may carry - * the actual entry's case while the level below still carries the typed - * one); the FINAL segment stays a literal name prefix — a lone `.` reads - * as the dot-reveal, `..` matches no entry (Enter still navigates it) — - * and the name filter downstream is case-insensitive everywhere. + * The path draft's final segment, when its directory part is exactly the + * level `listing` lists — the segment the level prefix-filters on while the + * user types. Any other draft (no separator yet, or naming some other + * directory) leaves the level unfiltered. The directory part compares + * exactly (it is the host's own path text, reached by seeding or erasing); + * only the name filter downstream is case-insensitive. */ function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null { if (draft === null) return null const sep = separatorOf(listing) - const folded = foldSeparatorsFor(sep)(draft) - const cut = folded.lastIndexOf(sep) + const cut = draft.lastIndexOf(sep) if (cut === -1) return null - const fold = foldPathFor(sep) - const normalize = normalizePathFor(sep) - return fold(normalize(folded.slice(0, cut + 1))) === fold(listing.path) - ? folded.slice(cut + 1) - : null + const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}` + return draft.slice(0, cut + 1) === level ? draft.slice(cut + 1) : null } /** One column of folder rows (the Miller view renders one or two of these). */ @@ -224,10 +135,10 @@ function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPr // where the blur lands before our guards) drop this click. // Outside editing, rows keep native focus behavior. onMouseDown={pathEditing ? (event) => { event.preventDefault() } : undefined} - // Focus parking happens after commit (the DirectoryBrowser - // refocus effect): a right-pane pick replaces this very - // column, so focusing the clicked node here would still fall - // to body. + // Editing-time focus parking happens after commit (the + // DirectoryBrowser refocus effect): a right-pane pick replaces + // this very column, so focusing the clicked node here would + // still fall to body. onClick={() => { onPick(entry) }} > {selected @@ -258,7 +169,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const [error, setError] = useState(null) // Path-edit state: null = breadcrumb mode; a string = the draft being typed. const [pathDraft, setPathDraft] = useState(null) - // Show-hidden toggle state (pure client-side filter, reset on close). + // Show-hidden toggle state (pure client-side filter, reset on each open). const [showHidden, setShowHidden] = useState(false) // Create-folder state: null = closed; a string = the nested dialog's draft. const [folderDraft, setFolderDraft] = useState(null) @@ -267,13 +178,8 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const requestSeq = useRef(0) // The in-flight listing's controller: superseding intent aborts the wire // request too — the Host stops scanning — instead of only discarding the - // eventual result while the scan keeps consuming host resources. Always - // holds a controller so no consumer needs a null guard: initially a - // placeholder that the first supersede aborts unused (minted lazily — - // useRef evaluates its argument every render), afterwards the latest - // scan's, settled or aborted between scans. - const [initialScanController] = useState(() => new AbortController()) - const scanController = useRef(initialScanController) + // eventual result while the scan keeps consuming host resources. + const scanController = useRef(null) // Bumped on every open/close edge: settlements from a previous open (a // pending creation included) must never mutate a reopened dialog. const openGeneration = useRef(0) @@ -289,7 +195,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, useEffect(() => () => { requestSeq.current += 1 openGeneration.current += 1 - scanController.current.abort() + scanController.current?.abort() }, []) const compositionGuard = { onCompositionStart: () => { composingRef.current = true }, @@ -298,7 +204,8 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, /** Newer intent wins: invalidate the pending listing's settlement AND abort its wire request. */ const supersede = useCallback((): number => { - scanController.current.abort() + scanController.current?.abort() + scanController.current = null return ++requestSeq.current }, []) @@ -310,54 +217,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, return { seq, scan: listDirectory(path, controller.signal) } }, [supersede, listDirectory]) - // The miller row's scroll host, shared by the pin and refocus effects - // below and read by navigate's upgrade leg (declared ahead of both). - const millerRowRef = useRef(null) - // Focus parking (consumed by the refocus effect below): a pick — and a - // parent-leg upgrade that displaces focused rows — parks on the - // selection's row; every other displacing exit (Enter, Escape, a landing - // whose new level dropped the focused row, a failed pick, and every - // create-dialog exit, whose close-time parking is also what a failed - // relist inherits) parks on the crumb edit zone, each only when focus - // actually fell to body. One parking bypasses both flags: the - // show-hidden toggle's click reclaims focus onto itself, synchronously, - // when the click finds focus among the rows. Pointer-out cancels never - // set (or clear) these — yanking focus back from wherever the user - // clicked would be worse than the fall. - const refocusPick = useRef(false) - const refocusEditZone = useRef(false) - const editZoneRef = useRef(null) - - /** - * Whether the focused element sits among the miller rows — probed before - * a landing replaces the row nodes to decide focus parking, and by the - * show-hidden toggle's click to decide whether to reclaim the native - * focus outcome. A probe only: it never gates its caller — a torn-down - * ref in a landing's close race merely skips the parking, and - * committing the landing into a closing dialog is safe: the close edge's - * supersede() fences every later settlement, and the same close effect - * zeroes parent/selected/child for the one frame that can slip between - * the close render and its effect. - * @returns true when `document.activeElement` is inside the miller row. - */ - const focusInMillerRows = useCallback((): boolean => { - const rowHost = millerRowRef.current - // Only the landing callers can race a close (commit precedes the reset - // effect); the toggle's click caller always finds the host mounted. - /* v8 ignore next -- close-race guard: not deterministically reproducible. */ - if (rowHost === null) return false - return rowHost.contains(document.activeElement) - }, []) - /** * Launch a follow-up listing under the CURRENT supersession seq: a newer * intent aborts it like the leg it continues, and it supersedes nothing. */ const continueScan = useCallback((path: string): Promise => { - // Abort whatever the slot last tracked before overwriting it (the - // caller's settled leg: a no-op) — the slot must never silently strand - // a live scan, the exact waste supersede() exists to prevent. - scanController.current.abort() const controller = new AbortController() scanController.current = controller return listDirectory(path, controller.signal) @@ -372,13 +236,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, * shape never disagree — a parent leg then upgrades the landing in place: * the target's ACTUAL parent-level entry re-selected (left pane = parent, * right pane = the target), so a crumb jump reads as stepping back one - * pane (Windows folds case; on slash platforms only a FINAL-segment case - * drift misses the match and keeps the single-pane landing — parent - * entries inherit the typed prefix, so ancestor-segment drift still - * matches, at the cost of the Home collapse). A failed parent leg, or a - * truncated parent window that lacks the target, leaves the committed - * single-pane landing — the upgrade must never orphan the selection it - * exists to anchor. + * pane. A failed parent leg, or a truncated parent window that lacks the + * target, leaves the committed single-pane landing — the upgrade must + * never orphan the selection it exists to anchor. */ const navigate = useCallback((path?: string) => { const { seq, scan } = launchListing(path) @@ -386,13 +246,6 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setError(null) scan.then((target) => { if (seq !== requestSeq.current) return - // The landing replaces every row key; a slow jump leaves the OLD - // rows tabbable meanwhile (parentInert excludes loading), so focus - // may live among them. With no selection yet the edit zone is the - // park target (body-guarded, like every other exit). The probe never - // gates the commit below — stranding the dialog in loading over a - // focus check would be far worse than a skipped parking. - if (focusInMillerRows()) refocusEditZone.current = true setParent(target) setSelected(null) setChild(null) @@ -406,15 +259,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, continueScan(parentCrumb.path).then((parentLevel) => { if (seq !== requestSeq.current) return // Windows resolves a typed path preserving its case; anchor on the - // parent level's actual entry so selection comparisons hold (slash - // platforms compare exactly — see foldPathFor). - const fold = foldPathFor(separatorOf(parentLevel)) + // parent level's actual entry so selection comparisons hold. + const sep = separatorOf(parentLevel) + const fold = (value: string): string => (sep === '\\' ? value.toLowerCase() : value) const match = parentLevel.entries.find(entry => fold(entry.path) === fold(target.path)) if (match === undefined) return - // The upgrade replaces every committed row node; if focus lives - // among them (Tab reached the rows during the parent leg), arm the - // refocus effect so it re-parks on the re-selected row. - if (focusInMillerRows()) refocusPick.current = true setParent(parentLevel) setSelected(match) setChild(target) @@ -428,33 +277,25 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setLoading(false) setError(failureText(reason)) }) - }, [launchListing, continueScan, focusInMillerRows]) + }, [launchListing, continueScan]) - /** - * Close the nested create dialog. Its unmount drops focus to body (the - * Modal has no focus trap), so every exit — Escape, mask, Cancel, and a - * successful create — arms the body-guarded edit-zone parking. A - * successful create therefore parks in TWO stages: the edit zone on this - * close, then the relist's select() re-parks on the created row one RTT - * later — deliberately re-parking even focus the user moved during the - * relist window, and doubling as the parking a failed relist inherits. - */ - const closeCreateDialog = useCallback(() => { - setFolderDraft(null) - refocusEditZone.current = true - }, []) + // Editor-close focus parking (consumed by the refocus effect below the + // miller-row ref): a pick parks on the selection's row, Enter and an + // input-focused Escape park on the crumb edit zone that replaces the + // input. Pointer-out cancels never set (or clear) these — yanking focus + // back from wherever the user clicked would be worse than the fall. + const refocusPick = useRef(false) + const refocusEditZone = useRef(false) + const pathInputRef = useRef(null) + const editZoneRef = useRef(null) /** Select a row of the listed level and preview its children on the right. */ const select = useCallback((entry: DirectoryEntry) => { const { seq, scan } = launchListing(entry.path) // A pick while the path editor is open adopts the (filtered) row and - // closes the editor — the draft served its purpose. EVERY pick re-parks - // focus on the selection after commit (see the refocus effect below): - // a left-pane pick lands on the very row that was clicked (a near - // no-op), while a right-pane advance and a create landing replace the - // picked button's column entirely and would otherwise drop focus to - // body. - refocusPick.current = true + // closes the editor — the draft served its purpose. Focus re-parks on + // the selection after commit (see the refocus effect below). + if (pathDraft !== null) refocusPick.current = true setPathDraft(null) setSelected(entry) setChild(null) @@ -476,7 +317,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // re-parks on the edit zone only if focus actually fell to body. refocusEditZone.current = true }) - }, [launchListing]) + }, [launchListing, pathDraft]) /** Abandon path editing (Escape or clicking away) and restore the crumb view. */ const cancelPathEdit = useCallback(() => { @@ -506,26 +347,19 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }, [child, select]) // Every open starts fresh at the Host home directory; closing invalidates - // any in-flight response so a late arrival cannot repopulate a closed - // dialog. The per-open state resets live on the CLOSE edge: resetting on - // open would let the reopen's first commit paint one frame of the stale - // view (revealed hidden rows, a pressed toggle) before this passive - // effect runs. No automated gate observes that ordering (act() hides the - // frame in tests) — this comment is the guard; read it before moving - // these back. + // any in-flight response so a late arrival cannot repopulate a closed dialog. useEffect(() => { openGeneration.current += 1 if (open) { + setParent(null) + setSelected(null) + setChild(null) + setCreatingFolder(false) + setShowHidden(false) navigate() return } supersede() - setParent(null) - setSelected(null) - setChild(null) - setCreatingFolder(false) - setShowHidden(false) - setLoading(false) setError(null) setPathDraft(null) setFolderDraft(null) @@ -557,20 +391,19 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // the fresh dialog or issue a relist against the stale target. if (generation !== openGeneration.current) return setCreatingFolder(false) - closeCreateDialog() + setFolderDraft(null) // Land like a right-column pick (figma 802:57446 → 813:23278 flow): the // create target becomes the listed level and the new folder its selection. const { seq, scan } = launchListing(targetPath) setLoading(true) scan.then((level) => { - // The nested dialog closed before this relist launched, so the card - // is interactive meanwhile: a pick or crumb jump supersedes it. + /* v8 ignore next -- same fence as navigate/select; the modal blocks superseding input */ if (seq !== requestSeq.current) return setParent(level) setLoading(false) select({ name, path: createdPath, hidden: false }) }, (reason: unknown) => { - // Same interactive-window fence as the success branch above. + /* v8 ignore next -- same fence as navigate/select; the modal blocks superseding input */ if (seq !== requestSeq.current) return setLoading(false) setError(failureText(reason)) @@ -592,29 +425,19 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }, [crumbTail]) // On viewports too narrow for both fixed panes the Miller row scrolls; // whenever a child preview lands, pin it into view the way the crumb tail - // pins — otherwise descent is unreachable on a phone-width window. The - // refocus effect's row.focus() and this pin can fight on such viewports, - // and whichever commit runs later wins by design: on a parent-leg - // upgrade (one commit) focus placement runs after the pin and keeps the - // selected LEFT row in view; on a plain advance or create landing the - // child arrives in a later commit, so the pin runs after the focus and - // descent reachability wins. + // pins — otherwise descent is unreachable on a phone-width window. + const millerRowRef = useRef(null) const childPath = child?.path useEffect(() => { const row = millerRowRef.current if (row !== null && childPath !== undefined) row.scrollLeft = row.scrollWidth }, [childPath]) - // Every pick and editor exit that would drop focus to body re-parks it - // after commit, so THIS DIALOG'S OWN node replacements never leak focus - // out of the card: a pick lands on the selection's row — aria-current in - // the freshly rendered left pane, which survives even a right-pane - // advance or a create landing replacing the picked button's column — - // while the edit-zone exits enumerated at the flag declarations fall - // back to the crumb edit zone. Outside the guarantee: the Modal has no - // focus trap, so tabbing past the card's edge legitimately leaves, and - // the owner's adopt window (busy inerts every control; browsers blur - // disabled elements to body) gets no parking — the owner closes the - // dialog either way. + // Every editor exit that would drop focus to body re-parks it after + // commit, so keyboard traversal stays inside the dialog (the Modal has no + // focus trap): a pick lands on the selection's row — aria-current in the + // freshly rendered left pane, which survives even a right-pane advance + // replacing the picked button's column — while Enter and an input-focused + // Escape land on the crumb edit zone that replaces the input. useEffect(() => { if (pathDraft !== null) return if (refocusPick.current) { @@ -624,14 +447,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, /* v8 ignore next -- narrowing guard: the miller row is mounted whenever a pick just committed. */ if (rowHost === null) return const row = rowHost.querySelector('button[aria-current="true"]') - if (row !== null) { - row.focus() - return - } - // The pick lost its row (a truncated relist after Create can drop - // the created directory outside the window): fall through to the - // edit-zone parking below instead of leaving focus where it fell. - refocusEditZone.current = true + /* v8 ignore next -- narrowing guard: the pick that set the flag just rendered its aria-current row. */ + if (row === null) return + row.focus() + return } if (refocusEditZone.current) { refocusEditZone.current = false @@ -639,9 +458,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // the user parked elsewhere (a surviving row) stays theirs. if (document.activeElement !== document.body) return const zone = editZoneRef.current - // The effect already returned while a draft is open, and the close - // reset cleared both flags — so crumb mode's zone is always mounted. - /* v8 ignore next -- narrowing guard: crumb mode always renders the edit zone. */ + /* v8 ignore next -- narrowing guard: crumb mode renders the edit zone whenever the editor just closed. */ if (zone === null) return zone.focus() } @@ -685,13 +502,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // document listener — the same containment the input previously // provided for itself. event.stopPropagation() - // The cancel may unmount whatever holds focus — the input, or a - // dot-revealed row the cleared draft re-hides. Arm the parking - // unconditionally: the refocus effect's body guard already - // distinguishes a surviving focused row (left alone) from focus - // that actually fell. Assignment (not a conditional set) also + // Escape while the input holds focus is about to unmount it; with + // focus already parked on a row, that row survives the cancel and + // keeps focus naturally. Assignment (not a conditional set) also // retires a stale flag a failed or still-upgrading Enter left. - refocusEditZone.current = true + refocusEditZone.current = document.activeElement === pathInputRef.current cancelPathEdit() }} // Focus leaving THIS dialog card while editing cancels like Escape. @@ -775,6 +590,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, value={pathDraft} aria-label={t('browser.editPath')} autoFocus + ref={pathInputRef} disabled={parentInert} onChange={(event) => { // Editing the draft supersedes any in-flight navigation: @@ -863,19 +679,12 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // toggling never blur-cancels a draft mid-thought. Outside editing // it keeps native focus behavior. onMouseDown={draftPending ? (event) => { event.preventDefault() } : undefined} - onClick={(event) => { - // The suppression above exists to protect the INPUT's focus; - // with focus among the rows instead, hand back the native - // click outcome wholesale — the clicked toggle takes focus - // and stays in the card. Accepted cost: this also moves - // focus off a row the toggle would NOT have hidden; tracking - // which rows a direction change unmounts is not worth it. - if (focusInMillerRows()) event.currentTarget.focus() - setShowHidden(prev => !prev) - }} + onClick={() => { setShowHidden(prev => !prev) }} > {t('browser.showHidden')} - {showHidden && } + {/* Trailing check (Menu's selected vocabulary): the label never + * shifts when the pressed state toggles. */} + {showHidden && } @@ -893,7 +702,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, {/* Nested create dialog (figma 813:23278): names one folder inside the target. */} { if (!creatingFolder) closeCreateDialog() }} + onClose={() => { if (!creatingFolder) setFolderDraft(null) }} title={t('browser.newFolder')} className={clsx(css.createDialog)} headless @@ -917,13 +726,13 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, } if (event.key === 'Escape') { event.stopPropagation() - if (!creatingFolder) closeCreateDialog() + if (!creatingFolder) setFolderDraft(null) } }} /> {createError !== null &&
{createError}
}
- +
- {loading &&
{t('browser.loading')}
} + {loading && slowScan + &&
{t('browser.loading')}
} {/* The backend bounds a level at its complete-result limit; say so * whenever a visible pane was cut instead of letting the tail of a - * huge directory go silently missing. */} - {(parent?.truncated === true || child?.truncated === true) && !loading + * huge directory go silently missing. The note describes the panes + * on screen, so an in-flight scan leaves it alone — hiding it while + * the stale view still shows the cut level would shift the columns + * on every navigation away from it. */} + {(parent?.truncated === true || child?.truncated === true) &&
{t('browser.truncated')}
} {error !== null &&
{error}
}
diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index 6fa9bb999f..2528af4b46 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -235,7 +235,7 @@ describe('DirectoryBrowser', () => { expect(columns()).toHaveLength(1) }) - it('commits the target immediately, aborts a superseded parent leg on the wire, and drops its late resolution', async () => { + it('lands the target single-pane at the wait bound, aborts a superseded parent leg on the wire, and drops its late resolution', async () => { const signals: (AbortSignal | undefined)[] = [] const settlers: ((value: DirectoryListing) => void)[] = [] // Only the FIRST explicit HOME request (the parent leg) hangs; the later @@ -253,8 +253,8 @@ describe('DirectoryBrowser', () => { fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: DOCS } }) fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) - // The target leg commits at once: editor closed, single-pane DOCS level, - // while the parent leg (upgrade) is still in flight. + // The parent leg (upgrade) hangs past the landing wait bound: the target + // commits alone — editor closed, single-pane DOCS level. await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() expect(columns()).toHaveLength(1) @@ -270,6 +270,128 @@ describe('DirectoryBrowser', () => { expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull() }) + /** + * Listing fake whose explicit-path scans stay pending until the test + * settles them by path; the absent-path form (the initial home listing) + * resolves normally so mounting is a one-flush setup. + */ + function manualLister() { + const settlers = new Map void>() + const listDirectory = vi.fn((path?: string, _signal?: AbortSignal) => { + if (path === undefined) return Promise.resolve(listingFor(path)) + return new Promise((resolve) => { settlers.set(path, resolve) }) + }) + return { settlers, listDirectory } + } + + it('lands a navigation as ONE two-pane frame: the stale view holds until both legs arrive', async () => { + vi.useFakeTimers() + try { + const { settlers, listDirectory } = manualLister() + mount({ listDirectory }) + await act(async () => {}) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: DOCS } }) + fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) + // The target settles while the parent leg is still in flight: nothing + // commits yet — the editor stays open over the stale home level, and no + // single-pane DOCS frame ever renders. + await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) }) + expect(screen.getByLabelText('browser.editPath', { selector: 'input' })).toBeTruthy() + expect(screen.queryByText('harness')).toBeNull() + // The parent leg settles inside the wait bound: one commit straight to + // the two-pane landing, editor closed. + await act(async () => { settlers.get(HOME)!(listingFor(HOME)) }) + expect(columns()).toHaveLength(2) + expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true') + expect(within(columns()[0]!).getByText('Documents')).toBeTruthy() + expect(within(columns()[1]!).getByText('harness')).toBeTruthy() + expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + // The wait-bound timer firing after the landing is a no-op. + await act(async () => { vi.advanceTimersByTime(200) }) + expect(columns()).toHaveLength(2) + } finally { + vi.useRealTimers() + } + }) + + it('a stalled parent leg lands the target alone at the wait bound, then upgrades in place', async () => { + vi.useFakeTimers() + try { + const { settlers, listDirectory } = manualLister() + mount({ listDirectory }) + await act(async () => {}) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: DOCS } }) + fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) + await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) }) + // The parent leg outlives PARENT_LEG_WAIT_MS: the target lands alone. + await act(async () => { vi.advanceTimersByTime(200) }) + expect(columns()).toHaveLength(1) + expect(screen.getByRole('listitem').textContent).toBe('harness') + expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + // The late parent leg still upgrades the landing in place, exactly as + // if it had made the bound. (Reopening the editor meanwhile would + // supersede the upgrade — the editor-open handler withdraws pending + // listings — so a late upgrade can never close a resumed draft.) + await act(async () => { settlers.get(HOME)!(listingFor(HOME)) }) + expect(columns()).toHaveLength(2) + expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true') + expect(within(columns()[1]!).getByText('harness')).toBeTruthy() + } finally { + vi.useRealTimers() + } + }) + + it('Escape inside the landing window withdraws the submitted navigation', async () => { + vi.useFakeTimers() + try { + const { settlers, listDirectory } = manualLister() + mount({ listDirectory }) + await act(async () => {}) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: DOCS } }) + fireEvent.keyDown(input, { key: 'Enter' }) + await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) }) + // Nothing has committed yet; Escape supersedes the landing entirely. + fireEvent.keyDown(input, { key: 'Escape' }) + await act(async () => { vi.advanceTimersByTime(200) }) + expect(columns()).toHaveLength(1) + expect(screen.queryByText('harness')).toBeNull() + expect(within(columns()[0]!).getByText('Documents')).toBeTruthy() + expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('shows the loading indicator only once a scan outlives its silence window, floating over the stale view', async () => { + vi.useFakeTimers() + try { + const { settlers, listDirectory } = manualLister() + mount({ listDirectory }) + await act(async () => {}) + expect(screen.queryByText('browser.loading')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: DOCS } }) + fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) + // In flight but still inside the silence window: nothing shows. + expect(screen.queryByText('browser.loading')).toBeNull() + await act(async () => { vi.advanceTimersByTime(300) }) + // Past it: the indicator floats while the stale level keeps rendering. + expect(screen.getByRole('status').textContent).toBe('browser.loading') + expect(screen.getByText('Documents')).toBeTruthy() + // Landing (both legs) retires the indicator with the scan. + await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) }) + await act(async () => { settlers.get(HOME)!(listingFor(HOME)) }) + expect(screen.queryByText('browser.loading')).toBeNull() + expect(columns()).toHaveLength(2) + } finally { + vi.useRealTimers() + } + }) + it('keeps the single-pane landing when the truncated parent level lacks the target', async () => { const listDirectory = vi.fn(async (path?: string) => { // The parent leg names HOME explicitly; serve it a truncated window From 6ee2752ccaf7a3b9d933154ed45cdf251feff9eb Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 12:09:45 +0800 Subject: [PATCH 68/72] fix(directory-picker-browse): rebind the scrollbar elevation pair on the browser card The loading pill's layer-2 background made the sheet an elevated-surface painter, and the ui-theme scrollbar invariant rightly flagged what was already latent: the dialog's columns scroll on an l2 card while the thumbs rendered in the base-surface pair. Rebind the indirection on the card rule so it inherits to the scrolling columns. --- .../src/client/DirectoryBrowser.module.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index bfa50aa987..d440b94862 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -13,6 +13,12 @@ height: min(500px, calc(100dvh - 32px)); padding: 0; gap: 0; + /* The Modal card is an l2 surface and the columns below scroll on it: + * rebind the scrollbar indirection to the elevation pair here, on the + * surface, so it inherits down to whichever descendant scrolls (the + * rebinding contract in ui-theme styles/scrollbar.css). */ + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); } /* Card-scope wrapper hosting the path editor's Escape and focus-leave From 3ba25e25c0650dc443ba9eb7f7b10a82246ad143 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 12:53:07 +0800 Subject: [PATCH 69/72] =?UTF-8?q?fix(directory-picker-browse):=20bot=20rou?= =?UTF-8?q?nd=201=20=E2=80=94=20pill=20cascade+corner,=20slow-scan=20close?= =?UTF-8?q?=20reset,=20asymmetry+calibration=20recorded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .loadingFloat moved after the .status/.error block (its padding was losing the same-specificity race) and re-anchored bottom-right: the truncated/error rows own the bottom left and keep rendering through a scan, so the pill can never cover them; confirmCreate's relist now clears the stale failure text like every other scan launch. - The close edge resets loading, so the slow-scan effect disarms while hidden and a reopened dialog waits out a fresh silence window (regression test added). - The truncated note's survival through a scan is now asserted in the slow-scan test; the wait-bound test moved to fake timers with the 200ms bound explicit. - select()'s exemption from the one-frame rule and the constants' local calibration premise are recorded in JSDoc and the capability-seam Agent Note; the themed-scrollbars note's rebinding enumeration is replaced by a pointer to the mechanical gate (it had drifted twice). Both pairs re-recorded. --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- ...d-scrollbars-and-reserved-gutter.i18n.yaml | 4 +- ...8-themed-scrollbars-and-reserved-gutter.md | 2 +- ...hemed-scrollbars-and-reserved-gutter.zh.md | 2 +- .../src/client/DirectoryBrowser.module.css | 30 +++-- .../src/client/DirectoryBrowser.tsx | 19 ++- .../tests/directory-browser.spec.tsx | 127 +++++++++++++----- 9 files changed, 133 insertions(+), 59 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index edc0afb4c5..5b33f27a5c 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: cfe0de43294439fadca2d7bc40a8c175d2cda372 -2026-07-28-directory-picker-capability-seam.zh.md: 7e2e16aa24bb4430667bdc1a5c12dfd1bd3a2e4f +2026-07-28-directory-picker-capability-seam.md: 892bb4b2c4fe200df91866c4ec4cf8bb7c58e940 +2026-07-28-directory-picker-capability-seam.zh.md: d738773adc853dae7b3496f0dc2901eebd0f0a08 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index cfe0de4329..892bb4b2c4 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -20,7 +20,7 @@ Placement and policy rulings folded into this decision: - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. - **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. -- **Navigation lands selection-anchored, quiet, and bounded.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the landing is two-pane: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. Target and parent legs land as **one frame** when the parent leg settles within the 200ms wait bound — the stale view keeps rendering until then, so navigation swaps the panes without an intermediate single-pane flash — and past the bound the target commits alone at once (an Enter-submitted navigation is never held hostage by a stalled parent) with the late parent leg upgrading the landing in place. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent (Escape inside the landing window therefore withdraws the whole navigation); a failed parent leg, or a truncated parent window lacking the target, leaves the single-pane landing — the upgrade must never orphan the selection it exists to anchor. The loading indicator follows the same quiet rule: it floats over the content (never a layout-shifting row) and only once a scan outlives a 300ms silence window, so a local listing swaps with nothing shown at all. +- **Navigation lands selection-anchored, quiet, and bounded.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the landing is two-pane: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. Target and parent legs land as **one frame** when the parent leg settles within the 200ms wait bound — the stale view keeps rendering until then, so navigation swaps the panes without an intermediate single-pane flash — and past the bound the target commits alone at once (an Enter-submitted navigation is never held hostage by a stalled parent) with the late parent leg upgrading the landing in place. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent (Escape inside the landing window therefore withdraws the whole navigation); a failed parent leg, or a truncated parent window lacking the target, leaves the single-pane landing — the upgrade must never orphan the selection it exists to anchor. The loading indicator follows the same quiet rule: it floats over the content's bottom-right corner (never a layout-shifting row; the truncated/error rows own the bottom left and keep rendering through a scan) and only once a scan outlives a 300ms silence window, so a local listing swaps with nothing shown at all. Row picks are deliberately exempt from the one-frame rule: a pick's immediate pane split is its selected-state feedback (aria-current, crumbs following), while a navigation has nothing to acknowledge the click but the swap itself. Both timing constants are calibrated for local enumeration; a remote deployment (one RPC per level, commonly 100–400ms) would sit inside the silence window with no pressed state on the crumbs — revisit the window or add pressed feedback when a remote consumer lands. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. - **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 7e2e16aa24..d738773adc 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -20,7 +20,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 - **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。 -- **导航以选中项为锚、安静且有界地落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),落地即双栏:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在 200ms 等待上限内落定时,目标与父层级两程以**同一帧**落地——在此之前陈旧视图持续渲染,导航换栏时因此没有中间的单栏闪现——超出该上限则目标即刻单独提交(Enter 提交的导航绝不会被滞塞的父层级扣作人质),迟到的父层级这一程再就地升级这次落地。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止(因此在落地窗口内按 Escape 即撤回整次导航);父层级这一程失败,或被截断的父窗口缺少目标时,都保留单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。加载指示器遵循同一安静规则:它浮于内容之上(绝不是会挪动布局的一行),且仅在扫描超出 300ms 静默窗口后才出现,因此本地列举切换时什么也不显示。 +- **导航以选中项为锚、安静且有界地落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),落地即双栏:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在 200ms 等待上限内落定时,目标与父层级两程以**同一帧**落地——在此之前陈旧视图持续渲染,导航换栏时因此没有中间的单栏闪现——超出该上限则目标即刻单独提交(Enter 提交的导航绝不会被滞塞的父层级扣作人质),迟到的父层级这一程再就地升级这次落地。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止(因此在落地窗口内按 Escape 即撤回整次导航);父层级这一程失败,或被截断的父窗口缺少目标时,都保留单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。加载指示器遵循同一安静规则:它浮于内容右下角(绝不是会挪动布局的一行;截断/错误行占据左下角,并在扫描期间持续渲染),且仅在扫描超出 300ms 静默窗口后才出现,因此本地列举切换时什么也不显示。行选取被刻意豁免于同一帧规则:选取后立即分栏本身就是其选中态反馈(aria-current、crumb 跟随),而导航除了换栏本身没有任何东西可确认这次点击。两个时序常量都按本地列举校准;远程部署(每层级一次 RPC,通常 100–400ms)会落在静默窗口之内、crumb 上却没有按下态——待远程消费方落地时,重新审视该窗口或补上按下反馈。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.i18n.yaml index a205f48f54..8099344ace 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.i18n.yaml @@ -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 .agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md -2026-07-28-themed-scrollbars-and-reserved-gutter.md: 38228c868bb00210118e8110feb722fb81d0d56c -2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md: 9fafe1faa9303b5e2e23a1b3064904f71494026d +2026-07-28-themed-scrollbars-and-reserved-gutter.md: 76dcb6d9f3976faf3338a89f3ccab7182fe6d5ab +2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md: ff12c6884bec706dbbd9974684010cbcd9fa03bf diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md index 38228c868b..76dcb6d9f3 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md +++ b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md @@ -20,7 +20,7 @@ The rules sit on `body`, not `html`. `design-platform.css` declares the `--dsw-a The two renderings are mutually exclusive, and the exclusion is enforced rather than assumed. A non-`auto` `scrollbar-width` or `scrollbar-color` makes Chromium and Safari discard every `::-webkit-scrollbar*` rule for that element, `::-webkit-scrollbar-thumb:hover` included. Declaring both unconditionally therefore leaves the hover token rendering nowhere at all: the engines that implement the hover pseudo-element are exactly the ones the standard properties silence, and Firefox has no hover pseudo-element to fall back on. The standard properties consequently sit inside `@supports not selector(::-webkit-scrollbar)`, which is true only where the pseudo-element is unimplemented, so Firefox takes the standard path and WebKit-based engines take the pseudo-element path. The WebKit rules are not gated in turn: an engine without those pseudo-elements drops them as unknown selectors, so a gate would only restate what selector matching already does. An engine too old for the `selector()` function makes the condition invalid, which evaluates false and selects the pseudo-element path — the correct side for the pre-16.4 Safari that is the realistic case for that reading. -Both paths read one indirection pair, `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover`, bound on `body` to the l1 (base-surface) tokens. **This is the rebinding contract, and it is the part the CSS alone does not state**: an elevated surface 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, and that one rebind reaches the standard properties and the WebKit pseudo-elements together. The pair is rebound as a pair; rebinding the resting thumb alone leaves the hover state on the base-surface token. Eight surfaces rebind today: the command popup, the slash menu, the model-select panel, the settings panel, the shared `ui-primitives` menu card, the composer input card, the question composer card, and the todo panel. Most declare it on the elevated card rather than on the scrolling descendant, because the elevation is a property of the surface and custom properties inherit down to whichever child actually scrolls. +Both paths read one indirection pair, `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover`, bound on `body` to the l1 (base-surface) tokens. **This is the rebinding contract, and it is the part the CSS alone does not state**: an elevated surface 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, and that one rebind reaches the standard properties and the WebKit pseudo-elements together. The pair is rebound as a pair; rebinding the resting thumb alone leaves the hover state on the base-surface token. The set of rebinding surfaces is owned by the mechanical gate (`packages/client/ui-theme/tests/scrollbar-styles.spec.ts`): any sheet that scrolls and paints an elevated surface must rebind, so this note no longer enumerates them (an enumeration here drifted twice). Most declare it on the elevated card rather than on the scrolling descendant, because the elevation is a property of the surface and custom properties inherit down to whichever child actually scrolls. The last four were missed in the first implementation and found in review, which is why the rebinding contract is now checked mechanically rather than by inspection: a sheet that scrolls somewhere and paints an elevated surface somewhere must rebind. diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md index 9fafe1faa9..ff12c6884b 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md @@ -20,7 +20,7 @@ Status: implemented 两种渲染互斥,而这种互斥是被强制的,不是假定的。`scrollbar-width` 或 `scrollbar-color` 只要取非 `auto` 值,Chromium 与 Safari 就会丢弃该元素上的全部 `::-webkit-scrollbar*` 规则,`::-webkit-scrollbar-thumb:hover` 也在其中。因此无条件地同时声明会让 hover token 在任何地方都得不到渲染:实现了 hover 伪元素的引擎,恰恰就是被标准属性静音的那些,而 Firefox 没有 hover 伪元素可作退路。于是标准属性写在 `@supports not selector(::-webkit-scrollbar)` 之内,该条件只在伪元素未被实现处为真,因此 Firefox 走标准属性路径,WebKit 系引擎走伪元素路径。WebKit 规则不再反向加门禁:不实现这些伪元素的引擎会把它们当作未知选择器丢弃,因此加门禁只是重述选择器匹配本身已经做的事。对于旧到不支持 `selector()` 函数的引擎,该条件无效,从而求值为假并选中伪元素路径——对于这条判断下现实存在的 16.4 之前的 Safari,这正是正确的一侧。 -两条路径都读取同一组间接变量 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover`,它们在 `body` 上绑定到 l1(基础表面)token。**这就是重新绑定契约,也是单看 CSS 无法得知的部分**:抬升表面在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` 与 `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`,这一次重新绑定同时作用于标准属性和 WebKit 伪元素。这组变量必须成对重新绑定;只改静止态滑块会让 hover 状态仍留在基础表面的 token 上。目前有八处抬升表面做了重新绑定:命令浮层、斜杠菜单、模型选择面板、设置面板、`ui-primitives` 共用菜单卡片、输入条卡片、提问组件卡片与待办面板。多数把声明写在抬升卡片上而非滚动的后代元素上,因为抬升层级是这个表面的属性,而自定义属性会继承到真正滚动的那个子元素。 +两条路径都读取同一组间接变量 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover`,它们在 `body` 上绑定到 l1(基础表面)token。**这就是重新绑定契约,也是单看 CSS 无法得知的部分**:抬升表面在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` 与 `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`,这一次重新绑定同时作用于标准属性和 WebKit 伪元素。这组变量必须成对重新绑定;只改静止态滑块会让 hover 状态仍留在基础表面的 token 上。重新绑定表面的集合归机械门禁(`packages/client/ui-theme/tests/scrollbar-styles.spec.ts`)所有:任何既滚动又绘制抬升表面的样式表都必须重新绑定,因此本 note 不再枚举它们(这里的枚举已经漂移过两次)。多数把声明写在抬升卡片上而非滚动的后代元素上,因为抬升层级是这个表面的属性,而自定义属性会继承到真正滚动的那个子元素。 后四处在最初的实现里被漏掉、由评审发现,因此重新绑定契约现在由机械检查把关,而不再依赖人工审阅:一张样式表只要在某处滚动、又在某处绘制抬升表面,就必须重新绑定。 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index d440b94862..594f450756 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -159,19 +159,6 @@ padding: 16px 16px 16px 24px; } -/* The slow-scan indicator floats over the content's bottom-left on the card - * background instead of occupying a row: a scan must never shift the - * columns' height, and the stale view keeps rendering beneath it (it only - * appears at all once a scan outlives SLOW_SCAN_DELAY_MS). */ -.loadingFloat { - position: absolute; - left: 24px; - bottom: 8px; - padding: 2px 8px; - border-radius: 6px; - background: var(--dsw-alias-bg-layer-2); -} - /* Columns split the row evenly around the divider (a solo column takes the * whole row); 256px is the floor below which the row scrolls (scrollbar * hidden, the effect pins the child pane into view) instead of squeezing @@ -267,6 +254,23 @@ color: var(--dsw-alias-state-error-primary); } +/* The slow-scan indicator floats over the content's bottom-RIGHT corner on + * the card background instead of occupying a row: a scan must never shift + * the columns' height, and the stale view keeps rendering beneath it (it + * only appears at all once a scan outlives SLOW_SCAN_DELAY_MS). Right, + * not left: the truncated/error status rows flow at the bottom LEFT and + * stay on screen through a scan, so the opposite corner keeps both + * legible. After .status in the cascade — the element carries both + * classes and this padding must win the same-specificity race. */ +.loadingFloat { + position: absolute; + right: 16px; + bottom: 8px; + padding: 2px 8px; + border-radius: 6px; + background: var(--dsw-alias-bg-layer-2); +} + /* Footer: l3 separator on top, symmetric padding so the row sits vertically * centered in the bar; New-folder and the show-hidden toggle pin left. */ .footerBar { diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 0610dbb8a6..023404f71d 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -331,7 +331,15 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const pathInputRef = useRef(null) const editZoneRef = useRef(null) - /** Select a row of the listed level and preview its children on the right. */ + /** + * Select a row of the listed level and preview its children on the right. + * Deliberately NOT one-frame like navigate(): a pick's first duty is the + * immediate selected state on the clicked row, and the pane split IS that + * feedback (aria-current pill, crumbs following the selection) — holding + * it back for the child listing would make clicks feel dropped. The quiet + * rule governs whole-view replacement, where nothing acknowledges the + * click but the swap itself. + */ const select = useCallback((entry: DirectoryEntry) => { const { seq, scan } = launchListing(entry.path) // A pick while the path editor is open adopts the (filtered) row and @@ -402,6 +410,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, return } supersede() + // Closing mid-scan leaves nothing to load: without this edge the + // slow-scan effect keeps arming while hidden and the reopened dialog + // would show the indicator on its first frame instead of waiting out a + // fresh silence window (reopen's navigate() produces no loading edge). + setLoading(false) setError(null) setPathDraft(null) setFolderDraft(null) @@ -438,6 +451,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // create target becomes the listed level and the new folder its selection. const { seq, scan } = launchListing(targetPath) setLoading(true) + // Symmetric with navigate/select: a launched scan clears the stale + // failure text (and keeps the floating indicator's corner the only + // occupant of the content's right edge while it shows). + setError(null) scan.then((level) => { /* v8 ignore next -- same fence as navigate/select; the modal blocks superseding input */ if (seq !== requestSeq.current) return diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index 2528af4b46..0fe1f91e00 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -236,38 +236,49 @@ describe('DirectoryBrowser', () => { }) it('lands the target single-pane at the wait bound, aborts a superseded parent leg on the wire, and drops its late resolution', async () => { - const signals: (AbortSignal | undefined)[] = [] - const settlers: ((value: DirectoryListing) => void)[] = [] - // Only the FIRST explicit HOME request (the parent leg) hangs; the later - // home crumb jump lists normally. - let homeCalls = 0 - const listDirectory = vi.fn(async (path?: string, signal?: AbortSignal) => { - signals.push(signal) - if (path === HOME && ++homeCalls === 1) { - return new Promise((resolve) => { settlers.push(resolve) }) - } - return listingFor(path) - }) - mount({ listDirectory }) - await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) - fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) - fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: DOCS } }) - fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) - // The parent leg (upgrade) hangs past the landing wait bound: the target - // commits alone — editor closed, single-pane DOCS level. - await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) - expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() - expect(columns()).toHaveLength(1) - await waitFor(() => { expect(settlers).toHaveLength(1) }) - // A newer jump aborts the pending parent leg ON THE WIRE, not merely - // dropping its settlement. - fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) - expect(signals[2]?.aborted).toBe(true) - await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') }) - // Its late resolution changes nothing either. - await act(async () => { settlers[0]!(listingFor(HOME)) }) - expect(columns()).toHaveLength(1) - expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull() + vi.useFakeTimers() + try { + const signals: (AbortSignal | undefined)[] = [] + const settlers: ((value: DirectoryListing) => void)[] = [] + // Only the FIRST explicit HOME request (the parent leg) hangs; the + // later home crumb jump lists normally. + let homeCalls = 0 + const listDirectory = vi.fn((path?: string, signal?: AbortSignal) => { + signals.push(signal) + if (path === HOME && ++homeCalls === 1) { + return new Promise((resolve) => { settlers.push(resolve) }) + } + return Promise.resolve(listingFor(path)) + }) + mount({ listDirectory }) + await act(async () => {}) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: DOCS } }) + fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) + // The target settled but the parent leg hangs: inside the wait bound + // nothing commits yet. + await act(async () => {}) + expect(settlers).toHaveLength(1) + expect(screen.getByLabelText('browser.editPath', { selector: 'input' })).toBeTruthy() + // The wait bound expires: the target commits alone — editor closed, + // single-pane DOCS level. + await act(async () => { vi.advanceTimersByTime(200) }) + expect(screen.getByRole('listitem').textContent).toBe('harness') + expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + expect(columns()).toHaveLength(1) + // A newer jump aborts the pending parent leg ON THE WIRE, not merely + // dropping its settlement. + fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) + expect(signals[2]?.aborted).toBe(true) + await act(async () => {}) + expect(screen.getByRole('listitem').textContent).toBe('Documents') + // Its late resolution changes nothing either. + await act(async () => { settlers[0]!(listingFor(HOME)) }) + expect(columns()).toHaveLength(1) + expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull() + } finally { + vi.useRealTimers() + } }) /** @@ -369,29 +380,71 @@ describe('DirectoryBrowser', () => { it('shows the loading indicator only once a scan outlives its silence window, floating over the stale view', async () => { vi.useFakeTimers() try { - const { settlers, listDirectory } = manualLister() + // The home level is truncated so its note is on screen when the slow + // scan starts: dropping the note's old !loading guard means it must + // keep rendering through the scan, coexisting with the indicator. + const settlers = new Map void>() + const listDirectory = vi.fn((path?: string, _signal?: AbortSignal) => { + if (path === undefined) return Promise.resolve({ ...listingFor(path), truncated: true }) + return new Promise((resolve) => { settlers.set(path, resolve) }) + }) mount({ listDirectory }) await act(async () => {}) expect(screen.queryByText('browser.loading')).toBeNull() + expect(screen.getByText('browser.truncated')).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: DOCS } }) fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) - // In flight but still inside the silence window: nothing shows. + // In flight but still inside the silence window: no indicator, and the + // stale level's truncated note stays put (no layout churn on launch). expect(screen.queryByText('browser.loading')).toBeNull() + expect(screen.getByText('browser.truncated')).toBeTruthy() await act(async () => { vi.advanceTimersByTime(300) }) - // Past it: the indicator floats while the stale level keeps rendering. - expect(screen.getByRole('status').textContent).toBe('browser.loading') + // Past it: the indicator floats while the stale level — truncated note + // included — keeps rendering beneath it. + expect(screen.getByText('browser.loading')).toBeTruthy() + expect(screen.getByText('browser.truncated')).toBeTruthy() expect(screen.getByText('Documents')).toBeTruthy() - // Landing (both legs) retires the indicator with the scan. + // Landing (both legs) retires the indicator with the scan, and the + // fresh listings' own truncated state replaces the stale note. await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) }) await act(async () => { settlers.get(HOME)!(listingFor(HOME)) }) expect(screen.queryByText('browser.loading')).toBeNull() + expect(screen.queryByText('browser.truncated')).toBeNull() expect(columns()).toHaveLength(2) } finally { vi.useRealTimers() } }) + it('a close mid-scan resets the slow-scan gate: reopening waits a fresh silence window', async () => { + vi.useFakeTimers() + try { + // Every home listing hangs: the initial open's scan is the one the + // close interrupts, and the reopen's scan proves the fresh window. + const settlers: ((value: DirectoryListing) => void)[] = [] + const listDirectory = vi.fn((_path?: string, _signal?: AbortSignal) => + new Promise((resolve) => { settlers.push(resolve) })) + const { view, props } = mount({ listDirectory }) + await act(async () => { vi.advanceTimersByTime(300) }) + expect(screen.getByText('browser.loading')).toBeTruthy() + // Close while the scan is in flight, then reopen: the first frame must + // wait out a fresh silence window, not inherit the armed indicator. + view.rerender() + view.rerender() + await act(async () => {}) + expect(screen.queryByText('browser.loading')).toBeNull() + await act(async () => { vi.advanceTimersByTime(300) }) + expect(screen.getByText('browser.loading')).toBeTruthy() + // The reopened scan settles normally. + await act(async () => { settlers.at(-1)!(listingFor(undefined)) }) + expect(screen.queryByText('browser.loading')).toBeNull() + expect(screen.getByText('Documents')).toBeTruthy() + } finally { + vi.useRealTimers() + } + }) + it('keeps the single-pane landing when the truncated parent level lacks the target', async () => { const listDirectory = vi.fn(async (path?: string) => { // The parent leg names HOME explicitly; serve it a truncated window From 0ad6bf4b06d880a429243309670f751962570476 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 15:00:43 +0800 Subject: [PATCH 70/72] test(web): re-record directory-browser golden after master merge The English-locale golden refreshed on master (8c753fd8f1) was recorded against master's pre-miller dialog; this branch's own golden predated the miller two-pane landing and was never enforced before the web browser snapshot CI gate landed. Re-recorded against the merged tree: two-pane landing plus the show-hidden footer toggle. --- .../directory-browser.expected.md | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md b/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md index 4bbd790afa..baaaa6f3dc 100644 --- a/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md +++ b/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md @@ -5,6 +5,37 @@ - img - button "browse-golden" - button "Edit path" + - list: + - listitem: + - button "adopted": + - img + - text: adopted + - img + - listitem: + - button "alpha-ws": + - img + - text: alpha-ws + - img + - listitem: + - button "beta-ws": + - img + - text: beta-ws + - img + - listitem: + - button "browse-golden": + - img + - text: browse-golden + - img + - listitem: + - button "same-name": + - img + - text: same-name + - img + - listitem: + - button "workspace": + - img + - text: workspace + - img - list: - listitem: - button "alpha": @@ -19,5 +50,6 @@ - button "New folder": - img - text: New folder + - button "Show hidden files" - button "Cancel" - button "Open" From b21f13baa14673e7587dfe237e2f2a854168ff56 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:21:42 +0800 Subject: [PATCH 71/72] test: update tool agent fixtures for inbox API --- packages/fs/tool-str-replace-editor/tests/tools.spec.ts | 1 + .../pty/tool-bash-persistent/tests/loader-composition.spec.ts | 1 + packages/pty/tool-bash-persistent/tests/tools.spec.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 9a948ed164..0a3b65ba5e 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -39,6 +39,7 @@ function agent(ctx: Context, cwd: string): Agent { steer: () => {}, inject: () => {}, send: () => {}, + updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts index b8586161f2..9e18402477 100644 --- a/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts @@ -49,6 +49,7 @@ function agent(ctx: Context, cwd: string): Agent { steer: () => {}, inject: () => {}, send: () => {}, + updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index 4b5401a256..9949879292 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -45,6 +45,7 @@ function agent(ctx: Context, cwd: string | undefined): Agent { steer: () => {}, inject: () => {}, send: () => {}, + updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), } From e596d300d745d25c4cb0fb0dde0430e3cc2ea00c Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 15:22:28 +0800 Subject: [PATCH 72/72] fix(directory-picker-browse): resolve quiet-navigation review --- ...d-scrollbars-and-reserved-gutter.i18n.yaml | 4 +- ...8-themed-scrollbars-and-reserved-gutter.md | 6 +-- ...hemed-scrollbars-and-reserved-gutter.zh.md | 6 +-- .../src/client/DirectoryBrowser.module.css | 12 ++++-- .../src/client/DirectoryBrowser.tsx | 31 +++++++++---- .../tests/directory-browser.spec.tsx | 43 ++++++++++++++++++- 6 files changed, 80 insertions(+), 22 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.i18n.yaml index 8099344ace..45e957b824 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.i18n.yaml @@ -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 .agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md -2026-07-28-themed-scrollbars-and-reserved-gutter.md: 76dcb6d9f3976faf3338a89f3ccab7182fe6d5ab -2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md: ff12c6884bec706dbbd9974684010cbcd9fa03bf +2026-07-28-themed-scrollbars-and-reserved-gutter.md: b45f70b126d083916c756afb88a8b646a4e9bb85 +2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md: 8afa36429ce7e6e061b014d63dcb20e5a642a84c diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md index 76dcb6d9f3..b45f70b126 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md +++ b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md @@ -20,13 +20,13 @@ The rules sit on `body`, not `html`. `design-platform.css` declares the `--dsw-a The two renderings are mutually exclusive, and the exclusion is enforced rather than assumed. A non-`auto` `scrollbar-width` or `scrollbar-color` makes Chromium and Safari discard every `::-webkit-scrollbar*` rule for that element, `::-webkit-scrollbar-thumb:hover` included. Declaring both unconditionally therefore leaves the hover token rendering nowhere at all: the engines that implement the hover pseudo-element are exactly the ones the standard properties silence, and Firefox has no hover pseudo-element to fall back on. The standard properties consequently sit inside `@supports not selector(::-webkit-scrollbar)`, which is true only where the pseudo-element is unimplemented, so Firefox takes the standard path and WebKit-based engines take the pseudo-element path. The WebKit rules are not gated in turn: an engine without those pseudo-elements drops them as unknown selectors, so a gate would only restate what selector matching already does. An engine too old for the `selector()` function makes the condition invalid, which evaluates false and selects the pseudo-element path — the correct side for the pre-16.4 Safari that is the realistic case for that reading. -Both paths read one indirection pair, `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover`, bound on `body` to the l1 (base-surface) tokens. **This is the rebinding contract, and it is the part the CSS alone does not state**: an elevated surface 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, and that one rebind reaches the standard properties and the WebKit pseudo-elements together. The pair is rebound as a pair; rebinding the resting thumb alone leaves the hover state on the base-surface token. The set of rebinding surfaces is owned by the mechanical gate (`packages/client/ui-theme/tests/scrollbar-styles.spec.ts`): any sheet that scrolls and paints an elevated surface must rebind, so this note no longer enumerates them (an enumeration here drifted twice). Most declare it on the elevated card rather than on the scrolling descendant, because the elevation is a property of the surface and custom properties inherit down to whichever child actually scrolls. +Both paths read one indirection pair, `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover`, bound on `body` to the l1 (base-surface) tokens. **This is the rebinding contract, and it is the part the CSS alone does not state**: an elevated surface 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, and that one rebind reaches the standard properties and the WebKit pseudo-elements together. The pair is rebound as a pair; rebinding the resting thumb alone leaves the hover state on the base-surface token. The mechanically discoverable subset is owned by `packages/client/ui-theme/tests/scrollbar-styles.spec.ts`: any sheet that both scrolls and paints an elevated surface must rebind, so this note no longer maintains a complete surface inventory. Most declare the pair on the elevated card rather than on the scrolling descendant, because elevation belongs to the surface and custom properties inherit to whichever child actually scrolls. -The last four were missed in the first implementation and found in review, which is why the rebinding contract is now checked mechanically rather than by inspection: a sheet that scrolls somewhere and paints an elevated surface somewhere must rebind. +Four surfaces — `Menu`, `InputBar`, `QuestionComposer`, and `TodoPanel` — were missed in the first implementation and found in review, which is why the per-sheet rebinding contract is checked mechanically rather than by inspection. The elevated set is resolved from the palette's own dark elevation ladder — the surface tokens whose dark value lands on `bg-layer-2` or `bg-layer-3`, which is the step the l1/l2 split encodes. Deriving it instead from the sheets that already rebind was the first attempt and is unsound: such a set can only confirm what someone already remembered, and a surface nobody has rebound yet — exactly the case the check exists for — defines itself as unelevated. `--dsw-specific-tip` proved it, resolving to the menu surface's rung while the todo panel scrolled on it unrebound and the derived check stayed green. -Scope is by token family, not by geometry: only `--dsw-alias-bg-*` and `--dsw-specific-*` name a surface. `--dsw-alias-button-*`, `--dsw-alias-interactive-*`, and `--dsw-alias-markdown-*` reach the same rungs while naming a control or an inline span that no scroll container renders its bar against. Shape cannot make that call, since a floating button legitimately carries a radius, a shadow, and a fixed size. The check is per sheet rather than per rule because the card and the descendant that scrolls are separate rules, and CSS text does not express which contains which. +Scope is by token family, not by geometry: only `--dsw-alias-bg-*` and `--dsw-specific-*` name a surface. `--dsw-alias-button-*`, `--dsw-alias-interactive-*`, and `--dsw-alias-markdown-*` reach the same rungs while naming a control or an inline span that no scroll container renders its bar against. Shape cannot make that call, since a floating button legitimately carries a radius, a shadow, and a fixed size. The check is per sheet rather than per rule because the card and the descendant that scrolls are separate rules. That approximation cannot detect a scrolling component embedded in an elevated card painted by another package's stylesheet, as `DirectoryBrowser` inside `Modal` demonstrated; cross-sheet composition remains a review and assembled-UI responsibility. The track and the corner stay transparent, so the thumb reads against whatever surface scrolls under it; only the thumb and its hover state carry a token color. diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md index ff12c6884b..8afa36429c 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md @@ -20,13 +20,13 @@ Status: implemented 两种渲染互斥,而这种互斥是被强制的,不是假定的。`scrollbar-width` 或 `scrollbar-color` 只要取非 `auto` 值,Chromium 与 Safari 就会丢弃该元素上的全部 `::-webkit-scrollbar*` 规则,`::-webkit-scrollbar-thumb:hover` 也在其中。因此无条件地同时声明会让 hover token 在任何地方都得不到渲染:实现了 hover 伪元素的引擎,恰恰就是被标准属性静音的那些,而 Firefox 没有 hover 伪元素可作退路。于是标准属性写在 `@supports not selector(::-webkit-scrollbar)` 之内,该条件只在伪元素未被实现处为真,因此 Firefox 走标准属性路径,WebKit 系引擎走伪元素路径。WebKit 规则不再反向加门禁:不实现这些伪元素的引擎会把它们当作未知选择器丢弃,因此加门禁只是重述选择器匹配本身已经做的事。对于旧到不支持 `selector()` 函数的引擎,该条件无效,从而求值为假并选中伪元素路径——对于这条判断下现实存在的 16.4 之前的 Safari,这正是正确的一侧。 -两条路径都读取同一组间接变量 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover`,它们在 `body` 上绑定到 l1(基础表面)token。**这就是重新绑定契约,也是单看 CSS 无法得知的部分**:抬升表面在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` 与 `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`,这一次重新绑定同时作用于标准属性和 WebKit 伪元素。这组变量必须成对重新绑定;只改静止态滑块会让 hover 状态仍留在基础表面的 token 上。重新绑定表面的集合归机械门禁(`packages/client/ui-theme/tests/scrollbar-styles.spec.ts`)所有:任何既滚动又绘制抬升表面的样式表都必须重新绑定,因此本 note 不再枚举它们(这里的枚举已经漂移过两次)。多数把声明写在抬升卡片上而非滚动的后代元素上,因为抬升层级是这个表面的属性,而自定义属性会继承到真正滚动的那个子元素。 +两条路径都读取同一组间接变量 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover`,它们在 `body` 上绑定到 l1(基础表面)token。**这就是重新绑定契约,也是单看 CSS 无法得知的部分**:抬升表面在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` 与 `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`,这一次重新绑定同时作用于标准属性和 WebKit 伪元素。这组变量必须成对重新绑定;只改静止态滑块会让 hover 状态仍留在基础表面的 token 上。可由机械检查发现的子集归 `packages/client/ui-theme/tests/scrollbar-styles.spec.ts` 所有:任何既滚动又绘制抬升表面的样式表都必须重新绑定,因此本 note 不再维护完整的表面清单。多数把这组变量声明在抬升卡片上而非滚动的后代元素上,因为抬升层级属于这个表面,而自定义属性会继承到真正滚动的那个子元素。 -后四处在最初的实现里被漏掉、由评审发现,因此重新绑定契约现在由机械检查把关,而不再依赖人工审阅:一张样式表只要在某处滚动、又在某处绘制抬升表面,就必须重新绑定。 +`Menu`、`InputBar`、`QuestionComposer` 与 `TodoPanel` 这四个表面在最初的实现里被漏掉、由评审发现,因此逐样式表的重新绑定契约由机械检查而非人工审阅把关。 抬升表面集合是从调色板自身的暗色抬升阶梯解析出来的——暗色取值落在 `bg-layer-2` 或 `bg-layer-3` 上的那些表面 token,而这一档正是 l1/l2 之分所编码的层级差。最初的做法是从已经做了重新绑定的样式表反向推导,那是不成立的:这样得到的集合只能确认别人已经记得的部分,而尚无人重新绑定的表面——恰恰就是这项检查存在的理由——会把自己定义成「非抬升」。`--dsw-specific-tip` 证明了这一点:它解析到与菜单表面相同的那一档,待办面板在它上面滚动却没有重新绑定,而推导式的检查依然是绿的。 -判定范围依据 token 家族而非几何形状:只有 `--dsw-alias-bg-*` 与 `--dsw-specific-*` 表述的是表面。`--dsw-alias-button-*`、`--dsw-alias-interactive-*` 与 `--dsw-alias-markdown-*` 会落到相同档位,但它们表述的是控件或行内片段,没有任何滚动容器会把滚动条画在它们之上。形状无法做这个判断,因为悬浮按钮本来就会带圆角、阴影和固定尺寸。这项检查以样式表为粒度而非以规则为粒度,因为卡片与真正滚动的后代元素是两条不同的规则,而 CSS 文本无法表达谁包含谁。 +判定范围依据 token 家族而非几何形状:只有 `--dsw-alias-bg-*` 与 `--dsw-specific-*` 表述的是表面。`--dsw-alias-button-*`、`--dsw-alias-interactive-*` 与 `--dsw-alias-markdown-*` 会落到相同档位,但它们表述的是控件或行内片段,没有任何滚动容器会把滚动条画在它们之上。形状无法做这个判断,因为悬浮按钮本来就会带圆角、阴影和固定尺寸。这项检查以样式表为粒度而非以规则为粒度,因为卡片与真正滚动的后代元素是两条不同的规则。这种近似检查无法检测嵌在由另一个包的样式表绘制的抬升卡片中的滚动组件,`Modal` 内的 `DirectoryBrowser` 就证明了这一点;跨样式表的组合仍需在评审和组装后 UI 层面把关。 轨道与两条滚动条相交的角落保持透明,因此滑块是以其下滚动的任何表面为背景被看到;只有滑块及其 hover 状态带 token 颜色。 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index 594f450756..2f207e4195 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -242,6 +242,10 @@ .status, .error { padding: 4px; + /* The loading pill occupies the opposite corner while a stale status stays + * visible. Reserve its widest localized footprint so wrapped text cannot + * run underneath it on a narrow card. */ + padding-right: 120px; font-size: 12px; line-height: 18px; } @@ -259,15 +263,15 @@ * the columns' height, and the stale view keeps rendering beneath it (it * only appears at all once a scan outlives SLOW_SCAN_DELAY_MS). Right, * not left: the truncated/error status rows flow at the bottom LEFT and - * stay on screen through a scan, so the opposite corner keeps both - * legible. After .status in the cascade — the element carries both - * classes and this padding must win the same-specificity race. */ + * stay on screen through a scan, with their reserved right padding keeping + * both legible even on a narrow card. After .status in the cascade — the + * element carries both classes and this padding must win the + * same-specificity race. */ .loadingFloat { position: absolute; right: 16px; bottom: 8px; padding: 2px 8px; - border-radius: 6px; background: var(--dsw-alias-bg-layer-2); } diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 023404f71d..f5510fda7c 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -186,10 +186,14 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const [selected, setSelected] = useState(null) const [child, setChild] = useState(null) const [loading, setLoading] = useState(false) - // Derived from `loading` by the slow-scan effect below: true only once a - // scan has been in flight for SLOW_SCAN_DELAY_MS, so fast listings never - // render the indicator at all. + // Derived from `loading` and `scanWindow` by the slow-scan effect below: + // true only once the current listing call has been in flight for + // SLOW_SCAN_DELAY_MS, so fast listings never render the indicator at all. const [slowScan, setSlowScan] = useState(false) + // Every listing call owns a fresh silence window. `loading` may stay true + // across a superseding row pick or across a navigation's target and parent + // legs, so its boolean edge cannot identify the start of each scan. + const [scanWindow, setScanWindow] = useState(0) const [error, setError] = useState(null) // Path-edit state: null = breadcrumb mode; a string = the draft being typed. const [pathDraft, setPathDraft] = useState(null) @@ -233,13 +237,20 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, return ++requestSeq.current }, []) + /** Hide any prior indicator and start a fresh silence window for one listing call. */ + const restartSlowScanWindow = useCallback((): void => { + setSlowScan(false) + setScanWindow(value => value + 1) + }, []) + /** Launch one listing under a fresh controller so a later supersession can abort it. */ const launchListing = useCallback((path: string | undefined): { seq: number; scan: Promise } => { const seq = supersede() const controller = new AbortController() scanController.current = controller + restartSlowScanWindow() return { seq, scan: listDirectory(path, controller.signal) } - }, [supersede, listDirectory]) + }, [supersede, restartSlowScanWindow, listDirectory]) /** * Launch a follow-up listing under the CURRENT supersession seq: a newer @@ -248,8 +259,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const continueScan = useCallback((path: string): Promise => { const controller = new AbortController() scanController.current = controller + restartSlowScanWindow() return listDirectory(path, controller.signal) - }, [listDirectory]) + }, [restartSlowScanWindow, listDirectory]) /** * Replace the whole view with a freshly navigated level. Away from the @@ -474,9 +486,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }) } - // The slow-scan gate for the loading indicator: arm a timer when a scan - // starts, retire it (and the indicator) the moment loading ends. A settle - // inside the window means the swap happened with nothing shown. + // The slow-scan gate for the loading indicator: each listing call restarts + // the timer even when a superseding scan or a navigation's parent leg keeps + // `loading` continuously true. A settle inside its own window means the swap + // happened with nothing shown. useEffect(() => { if (!loading) { setSlowScan(false) @@ -484,7 +497,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, } const timer = window.setTimeout(() => { setSlowScan(true) }, SLOW_SCAN_DELAY_MS) return () => { window.clearTimeout(timer) } - }, [loading]) + }, [loading, scanWindow]) // After the hooks: a closed dialog renders nothing and evaluates no copy. const crumbSource = child ?? parent diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index 0fe1f91e00..ce9f03fb0b 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -111,6 +111,12 @@ function rowButton(item: HTMLElement): HTMLButtonElement { } describe('DirectoryBrowser', () => { + it('renders nothing and launches no listing while initially closed', () => { + const b = mount({ open: false }) + expect(screen.queryByRole('dialog')).toBeNull() + expect(b.listDirectory).not.toHaveBeenCalled() + }) + it('opens at the Host home as one wide column, hides hidden entries, and roots the crumbs at Home', async () => { const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) @@ -335,9 +341,16 @@ describe('DirectoryBrowser', () => { fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: DOCS } }) fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) + // The target can consume most of the outer scan's silence window. + await act(async () => { vi.advanceTimersByTime(250) }) await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) }) + // Its parent leg gets a fresh silence window. Crossing the original + // scan's 300ms deadline therefore cannot flash the indicator during the + // bounded landing wait. + await act(async () => { vi.advanceTimersByTime(199) }) + expect(screen.queryByText('browser.loading')).toBeNull() // The parent leg outlives PARENT_LEG_WAIT_MS: the target lands alone. - await act(async () => { vi.advanceTimersByTime(200) }) + await act(async () => { vi.advanceTimersByTime(1) }) expect(columns()).toHaveLength(1) expect(screen.getByRole('listitem').textContent).toBe('harness') expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() @@ -417,6 +430,34 @@ describe('DirectoryBrowser', () => { } }) + it('restarts the silence window when a row pick supersedes a pending scan', async () => { + vi.useFakeTimers() + try { + const pending: ((value: DirectoryListing) => void)[] = [] + const listDirectory = vi.fn((path?: string, _signal?: AbortSignal) => { + if (path === undefined) return Promise.resolve(listingFor(path)) + return new Promise((resolve) => { pending.push(resolve) }) + }) + mount({ listDirectory }) + await act(async () => {}) + const documents = rowButton(screen.getByRole('listitem')) + fireEvent.click(documents) + await act(async () => { vi.advanceTimersByTime(300) }) + expect(screen.getByText('browser.loading')).toBeTruthy() + // The same row remains actionable while its preview is pending. A second + // pick starts a new listing without a false `loading` edge. + fireEvent.click(documents) + expect(screen.queryByText('browser.loading')).toBeNull() + await act(async () => { vi.advanceTimersByTime(299) }) + expect(screen.queryByText('browser.loading')).toBeNull() + await act(async () => { vi.advanceTimersByTime(1) }) + expect(screen.getByText('browser.loading')).toBeTruthy() + await act(async () => { pending.at(-1)!(listingFor(DOCS)) }) + } finally { + vi.useRealTimers() + } + }) + it('a close mid-scan resets the slow-scan gate: reopening waits a fresh silence window', async () => { vi.useFakeTimers() try {