Files
deepseek-harness/packages/context/file-reference-local/tests/service.spec.ts
T
Yichen Jiang 9b0f6f9017 Merge remote-tracking branch 'origin/master' into worktree/web-file-session-references
# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md
#	.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md
#	.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-21-cross-session-references.md
#	.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md
#	.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml
#	apps/cli/config/web.cordis.yml
#	apps/cli/package.json
#	apps/web/tests/scaffold.ts
#	docs/capability-seams.md
#	docs/config-catalog.md
#	docs/cordis-catalog/services.md
#	docs/event-producer-consumer.md
#	docs/module-graph.md
#	packages/client/connection/src/client/api.ts
#	packages/client/connection/src/client/fixture.ts
#	packages/client/connection/src/client/index.ts
#	packages/client/runtime/src/client/contract/session.ts
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/runtime/tests/session.client.spec.ts
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/README.md
#	packages/client/ui-conversation/README.zh.md
#	packages/client/ui-conversation/src/client/chat/ChatView.tsx
#	packages/client/ui-conversation/src/client/chat/MessageItem.tsx
#	packages/client/ui-conversation/src/client/chat/chat-flow.ts
#	packages/client/ui-conversation/src/client/input/facade.ts
#	packages/client/ui-conversation/src/client/input/hub.ts
#	packages/client/ui-conversation/tests/apply-inject.client.spec.tsx
#	packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx
#	packages/client/ui-conversation/tests/chat-view.client.spec.tsx
#	packages/client/ui-conversation/tests/input-bar.client.spec.tsx
#	packages/client/ui-conversation/tests/input-matrix.client.spec.tsx
#	packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx
#	packages/client/ui-conversation/tests/skeleton.client.spec.tsx
#	packages/client/ui-input-trigger/package.json
#	packages/client/ui-input-trigger/src/types.ts
#	packages/client/ui-jobs/README.i18n.yaml
#	packages/client/ui-slash/README.md
#	packages/client/ui-slash/README.zh.md
#	packages/client/ui-subagent/README.i18n.yaml
#	packages/client/ui-subagent/README.md
#	packages/client/ui-subagent/README.zh.md
#	packages/client/ui-subagent/package.json
#	packages/client/ui-subagent/src/client/index.ts
#	packages/client/ui-subagent/tests/browser-plugin.client.spec.ts
#	packages/client/ui-subagent/tsconfig.json
#	packages/context/session-reference/README.i18n.yaml
#	packages/context/session-reference/README.md
#	packages/context/session-reference/README.zh.md
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/core/session/README.i18n.yaml
#	packages/core/session/README.zh.md
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/README.md
#	packages/host/apiproxy/README.zh.md
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/index.ts
#	packages/host/apiproxy/src/api/rpc-map.ts
#	packages/host/apiproxy/src/api/rpc.schema.ts
#	packages/host/apiproxy/src/api/rpc.ts
#	packages/host/apiproxy/src/api/sessions.ts
#	packages/host/apiproxy/src/fetch/client.ts
#	packages/host/apiproxy/src/fetch/handler.ts
#	packages/host/apiproxy/src/index.ts
#	packages/host/apiproxy/tests/client-handler.spec.ts
#	packages/host/apiproxy/tsconfig.json
#	pnpm-lock.yaml
#	scripts/gen-cordis-catalog.ts
#	scripts/gen-doc-graphs.ts
#	scripts/verify-package-readme-model-experience.ts
#	tsconfig.base.json
#	tsconfig.client.json
#	vitest.config.ts
2026-08-14 16:18:40 +08:00

163 lines
7.1 KiB
TypeScript

import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { FILE_REFERENCE_PROMPT } from '@deepseek-ai/dsh-file-reference'
import LocalFileReferenceService, { WorkspaceFileSearch } from '../src/index.ts'
const roots: string[] = []
afterEach(async () => {
vi.restoreAllMocks()
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
})
async function harness(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
return ctx
}
async function stubAgent(
ctx: Context,
id = 'file-reference-agent',
includeCwd = true,
): Promise<{ agent: Agent; dispose: () => void }> {
const root = await mkdtemp(join(tmpdir(), 'dsh-file-reference-service-'))
roots.push(root)
await writeFile(join(root, 'README.md'), 'readme')
const session = ctx.sessions.create(SessionId(id), { meta: includeCwd ? { cwd: root } : {} })
const agent = {
id: session.id,
options: {},
session,
status: 'idle',
acceptsNextStep: false,
ctx,
followup() {},
steer() {},
inject() {},
send() {},
updateInbox() { return 'not-found' as const },
cancel() {},
whenIdle: () => Promise.resolve(),
} as unknown as Agent
return { agent, dispose: ctx.agents.register(agent) }
}
describe('LocalFileReferenceService', () => {
it('serves the addressed workspace and installs read-tool guidance for existing agents', async () => {
const ctx = await harness()
const { agent } = await stubAgent(ctx)
const fiber = ctx.plugin(LocalFileReferenceService, {
maxResults: 5,
maxEntries: 100,
excludedDirectories: ['.git'],
})
await fiber
await expect(ctx.fileReferences.list(agent, 'README', new AbortController().signal))
.resolves.toEqual([{ path: 'README.md', kind: 'file' }])
expect(renderPrompt(await ctx.systemPrompt.assemble())).not.toContain(FILE_REFERENCE_PROMPT)
ctx.tools.register(defineContentToolFixture({
name: 'read',
description: 'read a file',
parameters: {},
execute: () => Promise.resolve([]),
}))
expect(renderPrompt(await ctx.systemPrompt.assemble())).toContain(FILE_REFERENCE_PROMPT)
await fiber.dispose()
expect(renderPrompt(await ctx.systemPrompt.assemble())).not.toContain(FILE_REFERENCE_PROMPT)
})
it('invalidates cached searches after tool results and disposes them with the agent', async () => {
const ctx = await harness()
const { agent, dispose } = await stubAgent(ctx)
const invalidate = vi.spyOn(WorkspaceFileSearch.prototype, 'invalidate')
const close = vi.spyOn(WorkspaceFileSearch.prototype, 'dispose')
await ctx.plugin(LocalFileReferenceService)
await ctx.fileReferences.list(agent, 'README', new AbortController().signal)
ctx.emit('session/event', agent.session, { type: 'tool/result' } as never)
expect(invalidate).toHaveBeenCalledOnce()
ctx.emit('session/event', agent.session, { type: 'assistant/message' } as never)
expect(invalidate).toHaveBeenCalledOnce()
const orphan = ctx.sessions.create(SessionId('file-reference-orphan'))
ctx.emit('session/event', orphan, { type: 'tool/result' } as never)
expect(invalidate).toHaveBeenCalledOnce()
dispose()
expect(close).toHaveBeenCalledOnce()
ctx.emit('agent/disposed', { agent })
})
it('installs guidance for agents announced after the service and validates deployment tunables', async () => {
const ctx = await harness()
await ctx.plugin(LocalFileReferenceService)
const { agent } = await stubAgent(ctx)
await expect(ctx.fileReferences.list(agent, '', new AbortController().signal))
.resolves.toEqual([{ path: 'README.md', kind: 'file' }])
const badResults = await harness()
expect(() => new LocalFileReferenceService(badResults, { maxResults: 0 })).toThrow('maxResults')
const badEntries = await harness()
expect(() => new LocalFileReferenceService(badEntries, { maxEntries: 1.5 })).toThrow('maxEntries')
const badExclusion = await harness()
expect(() => new LocalFileReferenceService(badExclusion, { excludedDirectories: ['nested/name'] }))
.toThrow('excludedDirectories')
const fractionalResults = await harness()
expect(() => new LocalFileReferenceService(fractionalResults, { maxResults: 1.5 })).toThrow('maxResults')
const zeroEntries = await harness()
expect(() => new LocalFileReferenceService(zeroEntries, { maxEntries: 0 })).toThrow('maxEntries')
const emptyExclusion = await harness()
expect(() => new LocalFileReferenceService(emptyExclusion, { excludedDirectories: [''] }))
.toThrow('excludedDirectories')
const backslashExclusion = await harness()
expect(() => new LocalFileReferenceService(backslashExclusion, { excludedDirectories: ['nested\\name'] }))
.toThrow('excludedDirectories')
})
it('deduplicates lifecycle announcements and falls back to the process cwd', async () => {
const ctx = await harness()
const fiber = ctx.plugin(LocalFileReferenceService)
await fiber
const { agent } = await stubAgent(ctx, 'cwd-fallback', false)
ctx.emit('agent/created', { agent })
const list = vi.spyOn(WorkspaceFileSearch.prototype, 'list').mockResolvedValue([])
await expect(ctx.fileReferences.list(agent, '', new AbortController().signal)).resolves.toEqual([])
await expect(ctx.fileReferences.list(agent, 'src', new AbortController().signal)).resolves.toEqual([])
expect(list).toHaveBeenCalledTimes(2)
})
it('logs rejected prompt cleanup without failing service teardown', async () => {
const ctx = await harness()
const fiber = ctx.plugin(LocalFileReferenceService)
await fiber
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const inject = vi.spyOn(ctx, 'inject')
.mockReturnValueOnce({ dispose: () => Promise.reject(new Error('error cleanup')) } as never)
// Deliberately proves cleanup tolerates JavaScript callers rejecting non-Error values.
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
.mockReturnValueOnce({ dispose: () => Promise.reject('string cleanup') } as never)
const first = await stubAgent(ctx, 'cleanup-one')
const second = await stubAgent(ctx, 'cleanup-two')
expect(inject).toHaveBeenCalledTimes(2)
first.dispose()
second.dispose()
await vi.waitFor(() => {
expect(warn).toHaveBeenCalledWith('file-reference-local: prompt cleanup failed: error cleanup')
expect(warn).toHaveBeenCalledWith('file-reference-local: prompt cleanup failed: string cleanup')
})
await expect(fiber.dispose()).resolves.toBeUndefined()
})
})