mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-30 04:40:37 +00:00
# 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
163 lines
7.1 KiB
TypeScript
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()
|
|
})
|
|
})
|