# Conflicts: # .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml # .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml # .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md # .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md # .agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml # .agents/notes/implemented/feature/2026-07-06-sandbox.md # .agents/notes/implemented/feature/2026-07-06-sandbox.zh.md # .agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml # .agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml # .agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml # .agents/notes/implemented/simplification/2026-07-30-private-agent-send.i18n.yaml # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/compaction.i18n.yaml # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/core.md # docs/core-data-structures/core.zh.md # docs/core-data-structures/llm-streaming.i18n.yaml # docs/core-data-structures/llm-streaming.md # docs/core-data-structures/llm-streaming.zh.md # docs/core-data-structures/session.i18n.yaml # docs/event-producer-consumer.md # docs/module-graph.md # docs/persistence-catalog.md # examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/acp-agent/tests/snapshots/bash-spill/session.jsonl # examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl # examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl # examples/acp-agent/tests/snapshots/cancel/session.jsonl # examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl # examples/acp-agent/tests/snapshots/error-finish/session.jsonl # examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl # examples/acp-agent/tests/snapshots/fs-edit/session.jsonl # examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl # examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl # examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl # examples/acp-agent/tests/snapshots/fs-read/session.jsonl # examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl # examples/acp-agent/tests/snapshots/fs-write/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl # examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl # examples/acp-agent/tests/snapshots/multi-turn/session.jsonl # examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl # examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl # examples/acp-agent/tests/snapshots/pty-tools/session.jsonl # examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl # examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl # examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl # examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl # examples/acp-agent/tests/snapshots/skill-load/session.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl # examples/acp-agent/tests/snapshots/text-turn/session.jsonl # examples/acp-agent/tests/snapshots/todo-write/session.jsonl # examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl # examples/acp-agent/tests/snapshots/web-fetch/session.jsonl # examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl # examples/acp-agent/tests/snapshots/workflow-run/session.jsonl # examples/acp-agent/tests/snapshots/workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl # examples/headless-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/pty-tools/session.jsonl # examples/headless-agent/tests/snapshots/pty-tools/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/ralph-loop/stream-json.expected.jsonl # examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl # examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/bash-tool/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl # examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl # examples/jsonrpc-agent/tests/snapshots/text-turn/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl # packages/client/runtime/README.i18n.yaml # packages/client/runtime/src/client/sessions/request-inspection.ts # packages/compact/compact-basic/README.i18n.yaml # packages/compact/compact-basic/README.md # packages/compact/compact-basic/README.zh.md # packages/compact/compact-basic/src/index.ts # packages/context/time-context/tests/time-context.spec.ts # packages/context/tmux-context/README.i18n.yaml # packages/context/tmux-context/tests/tmux-context.spec.ts # packages/context/workspace-context/tests/workspace-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-loop/README.i18n.yaml # packages/core/agent-loop/README.md # packages/core/agent-loop/README.zh.md # packages/core/agent-loop/src/agent.ts # packages/core/agent/README.i18n.yaml # packages/core/agent/README.md # packages/core/agent/README.zh.md # packages/core/agent/src/types.ts # packages/core/session/README.i18n.yaml # packages/core/session/README.md # packages/core/session/README.zh.md # packages/fs/tool-str-replace-editor/tests/tools.spec.ts # packages/goal/command-goal/tests/command-goal.spec.ts # packages/goal/goal/tests/goal.spec.ts # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api/index.ts # packages/host/apiproxy/tests/api-proxy-workspace.spec.ts # packages/llm/llm/README.i18n.yaml # packages/llm/llm/README.md # packages/llm/llm/README.zh.md # packages/llm/llm/src/index.ts # packages/pty/pty-local/tests/index.spec.ts # packages/pty/pty-local/tests/local.spec.ts # packages/pty/pty/tests/service.spec.ts # packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts # packages/pty/tool-bash-persistent/tests/tools.spec.ts # packages/pty/tool-pty/tests/loader-composition.spec.ts # packages/pty/tool-pty/tests/tools.spec.ts # packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts # packages/skill/tool-skill/tests/tool-skill.spec.ts # packages/tasks/tasks-local/tests/tasks.spec.ts # packages/ui/tui/README.i18n.yaml # packages/ui/tui/tests/tui.spec.ts # packages/ui/user-approval/src/index.ts # packages/ui/user-approval/tests/approval.spec.ts
222 lines
9.9 KiB
TypeScript
222 lines
9.9 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { Context } from 'cordis'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
|
import type { DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client'
|
|
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
|
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
|
import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
|
import { apply, inject } from '../src/client/index.ts'
|
|
import { BrowseDirectoryFlow } from '../src/client/flow.ts'
|
|
|
|
// The service reads its initial locale from the browser; these specs assert
|
|
// the shipped Chinese copy, so they state the browser they assume.
|
|
usePinnedBrowserLanguages('zh-CN')
|
|
|
|
afterEach(cleanup)
|
|
|
|
const HOLES = ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const
|
|
|
|
const HOME = '/home/u'
|
|
const homeListing: DirectoryListing = {
|
|
path: HOME,
|
|
home: HOME,
|
|
crumbs: [{ name: '/', path: '/', hidden: false }, { name: 'u', path: HOME, hidden: false }],
|
|
entries: [{ name: 'Documents', path: `${HOME}/Documents`, hidden: false }],
|
|
truncated: false,
|
|
}
|
|
|
|
async function bench() {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SlotsService).await()
|
|
ctx.provide('locale', new LocaleService(ctx))
|
|
const listDirectory = vi.fn(async (): Promise<DirectoryListing> => homeListing)
|
|
const createDirectory = vi.fn(async (path: string, name: string) => `${path}/${name}`)
|
|
ctx.provide('workspaces', { listDirectory, createDirectory } as never)
|
|
const slots = ctx.get('slots') as SlotsService
|
|
const declare = () => slots.register({
|
|
name: 'root',
|
|
children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])),
|
|
} as never, () => null)
|
|
return { ctx, slots, listDirectory, createDirectory, declare }
|
|
}
|
|
|
|
function owner(overrides: Partial<DirectoryFlowOwnerProps> = {}): DirectoryFlowOwnerProps {
|
|
return {
|
|
open: true, busy: false,
|
|
onPicked: vi.fn(), onCancel: vi.fn(), onError: vi.fn(),
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
describe('directory-picker-browse client half', () => {
|
|
it('declares the services it drives', () => {
|
|
expect(inject).toEqual(['slots', 'workspaces', 'locale'])
|
|
})
|
|
|
|
it('fills both directory-flow holes for declarations before or after apply, and leaves with its fiber', async () => {
|
|
const before = await bench()
|
|
before.declare()
|
|
const fiber = before.ctx.plugin({ inject: [...inject], apply })
|
|
await fiber.await()
|
|
for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(1)
|
|
// Registry-contribution disposal proof: the fiber going down empties the holes.
|
|
await fiber.dispose()
|
|
for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(0)
|
|
|
|
const after = await bench()
|
|
await after.ctx.plugin({ inject: [...inject], apply }).await()
|
|
for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(0)
|
|
after.declare()
|
|
await Promise.resolve()
|
|
for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(1)
|
|
})
|
|
|
|
it('rolls back the first deferral when the second hole is already occupied', async () => {
|
|
const b = await bench()
|
|
b.declare()
|
|
// Foreign occupant in the SECOND registered hole: the pair construction
|
|
// throws after the first deferral installed its subscription.
|
|
b.slots.register({ name: HOLES[1] } as never, () => null)
|
|
const rejections: unknown[] = []
|
|
const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
|
|
process.on('unhandledRejection', onUnhandled)
|
|
try {
|
|
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
|
await expect(fiber.await()).rejects.toThrow(/already has a registration/)
|
|
// A leaked first deferral would now race this probe registration and
|
|
// throw from its orphaned subscription against the HERO hole; the
|
|
// rollback leaves only the activation failure itself (cordis re-raises
|
|
// the apply throw as a late rejection — installFailLoud's contract).
|
|
const disposeProbe = b.slots.register({ name: HOLES[0] } as never, () => null)
|
|
await new Promise(resolve => setTimeout(resolve, 20))
|
|
expect(rejections.map(String).filter(text => text.includes(HOLES[0]))).toEqual([])
|
|
disposeProbe()
|
|
} finally {
|
|
process.off('unhandledRejection', onUnhandled)
|
|
}
|
|
})
|
|
|
|
it('rolls back wholesale and reports loudly when a rival provider wins after deferred activation', async () => {
|
|
const b = await bench()
|
|
const rejections: unknown[] = []
|
|
const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
|
|
process.on('unhandledRejection', onUnhandled)
|
|
process.on('uncaughtException', onUnhandled)
|
|
try {
|
|
// This provider activates BEFORE any hole exists: both deferrals wait.
|
|
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
|
b.declare()
|
|
// A rival occupies both holes ahead of the pending microtask flush.
|
|
b.slots.register({ name: HOLES[0] } as never, () => null)
|
|
b.slots.register({ name: HOLES[1] } as never, () => null)
|
|
await new Promise(resolve => setTimeout(resolve, 20))
|
|
// The rival keeps both holes; this provider rolled back wholesale and
|
|
// surfaced the conflict on the fail-loud channel — no partial mix.
|
|
for (const hole of HOLES) expect(b.slots.entries(hole)).toHaveLength(1)
|
|
expect(rejections.map(String).join('\n')).toContain('already has a registration')
|
|
|
|
// Non-Error conflicts wrap before the loud rethrow (same channel).
|
|
const c = await bench()
|
|
await c.ctx.plugin({ inject: [...inject], apply }).await()
|
|
const original = c.slots.register.bind(c.slots)
|
|
const slotsAny = c.slots as { register: typeof original }
|
|
slotsAny.register = ((options: never, component: never) => {
|
|
if ((options as { name?: string }).name === HOLES[0]) throw 'string conflict'
|
|
return original(options, component)
|
|
}) as typeof original
|
|
c.declare()
|
|
await new Promise(resolve => setTimeout(resolve, 20))
|
|
expect(rejections.map(String).join('\n')).toContain('string conflict')
|
|
} finally {
|
|
process.off('unhandledRejection', onUnhandled)
|
|
process.off('uncaughtException', onUnhandled)
|
|
}
|
|
})
|
|
|
|
it('rolls back the zh dictionary when a rival already owns the namespace en slot', async () => {
|
|
const b = await bench()
|
|
b.declare()
|
|
const locale = b.ctx.get('locale') as LocaleService
|
|
const disposeRival = locale.register('directory-browser', 'en', { 'browser.title': 'rival' })
|
|
const rejections: unknown[] = []
|
|
const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
|
|
// cordis re-raises the apply throw as a late rejection (installFailLoud's contract).
|
|
process.on('unhandledRejection', onUnhandled)
|
|
try {
|
|
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
|
await expect(fiber.await()).rejects.toThrow(/already has locale/)
|
|
// The zh registration rolled back with the failure: once the rival
|
|
// leaves, a fresh registrant owns the whole namespace again.
|
|
disposeRival()
|
|
const disposeZh = locale.register('directory-browser', 'zh', { 'browser.title': '空闲' })
|
|
disposeZh()
|
|
} finally {
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
process.off('unhandledRejection', onUnhandled)
|
|
}
|
|
})
|
|
|
|
it('registers the dialog dictionaries and binds this package namespace', async () => {
|
|
const b = await bench()
|
|
b.declare()
|
|
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
|
const entry = b.slots.entries(HOLES[0])[0]!
|
|
const injected = (entry.inject as () => { t: (key: string) => string })()
|
|
// zh is the shipped default locale.
|
|
expect(injected.t('browser.title')).toBe('选择工作区目录')
|
|
expect(injected.t('browser.newFolder')).toBe('新建文件夹')
|
|
expect(injected.t('browser.showHidden')).toBe('显示隐藏文件')
|
|
})
|
|
|
|
it('drives the injected browse calls through the hole entry', async () => {
|
|
const b = await bench()
|
|
b.declare()
|
|
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
|
const entry = b.slots.entries(HOLES[1])[0]!
|
|
const injected = (entry.inject as () => {
|
|
listDirectory: (path?: string) => Promise<DirectoryListing>
|
|
createDirectory: (path: string, name: string) => Promise<string>
|
|
})()
|
|
await expect(injected.listDirectory()).resolves.toBe(homeListing)
|
|
await expect(injected.createDirectory(HOME, 'fresh')).resolves.toBe(`${HOME}/fresh`)
|
|
expect(b.listDirectory).toHaveBeenCalledOnce()
|
|
expect(b.createDirectory).toHaveBeenCalledWith(HOME, 'fresh')
|
|
})
|
|
|
|
it('adapts the owner conversation onto the dialog: confirm picks, dismissal cancels', async () => {
|
|
const props = owner()
|
|
const listDirectory = vi.fn(async (): Promise<DirectoryListing> => homeListing)
|
|
const t = (key: string): string => key
|
|
render(
|
|
<BrowseDirectoryFlow
|
|
{...props}
|
|
listDirectory={listDirectory}
|
|
createDirectory={vi.fn(async () => '')}
|
|
t={t}
|
|
/>,
|
|
)
|
|
// The dialog opened at home; its confirm (browser.open) adopts the listed level.
|
|
const openButton = screen.getByRole<HTMLButtonElement>('button', { name: 'browser.open' })
|
|
await waitFor(() => { expect(openButton.disabled).toBe(false) })
|
|
fireEvent.click(openButton)
|
|
expect(props.onPicked).toHaveBeenCalledWith(HOME)
|
|
fireEvent.click(screen.getByRole('button', { name: 'browser.cancel' }))
|
|
expect(props.onCancel).toHaveBeenCalled()
|
|
expect(props.onError).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('renders nothing while the flow is closed', () => {
|
|
const view = render(
|
|
<BrowseDirectoryFlow
|
|
{...owner({ open: false })}
|
|
listDirectory={vi.fn(async () => homeListing)}
|
|
createDirectory={vi.fn(async () => '')}
|
|
t={key => key}
|
|
/>,
|
|
)
|
|
expect(view.container.innerHTML).toBe('')
|
|
})
|
|
})
|