Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts: # apps/cli/src/web.ts # apps/web/tests/smoke-fixture.e2e.ts # docs/architecture.i18n.yaml # packages/client/connection/src/client/fixture.ts # packages/client/runtime/src/client/sessions/conversation.ts # packages/client/runtime/src/client/sessions/session.ts # packages/client/ui-conversation/package.json # packages/client/ui-conversation/src/client/contract/slots.ts # packages/client/ui-conversation/src/client/index.ts # packages/client/ui-conversation/src/client/service.ts # packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx # packages/client/ui-conversation/tests/apply-inject.spec.tsx # packages/client/ui-conversation/tests/service-orchestration.spec.ts # packages/host/runtime/src/api-proxy.ts # packages/host/runtime/src/boot.ts # packages/host/webserver/tests/webserver.spec.ts # pnpm-lock.yaml
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { homedir } from 'node:os'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
@@ -11,11 +12,11 @@ import SkillService, { type SkillDefinition, type SkillSummary } from '@deepseek
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import SessionQueryService from '@deepseek-ai/dsh-session-query'
|
||||
import SessionReferenceService, { formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference'
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry'
|
||||
import {
|
||||
createTuiChat,
|
||||
FILE_REFERENCE_PROMPT,
|
||||
mountTui,
|
||||
renderSkillInvocation,
|
||||
resolveTuiConfig,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
type TuiOverlaySession,
|
||||
type TuiRuntime,
|
||||
} from '../src/index.ts'
|
||||
import { WorkspaceFileSearch } from '../src/file-autocomplete.ts'
|
||||
import {
|
||||
appendAssistant,
|
||||
appendUser,
|
||||
@@ -30,6 +32,7 @@ import {
|
||||
disposeTuiTestHarness,
|
||||
type TuiHarnessOptions,
|
||||
} from './harness.ts'
|
||||
import { TestSessionQueryService } from './session-query.ts'
|
||||
|
||||
const UNUSED_TOOL_OUTPUT: ToolDefinition['output'] = {
|
||||
schema: { type: 'null' },
|
||||
@@ -153,6 +156,9 @@ describe('TUI config', () => {
|
||||
questionDialogMaxHeight: 20,
|
||||
modelDialogWidth: 72,
|
||||
modelDialogMaxHeight: 20,
|
||||
fileSearchMaxResults: 20,
|
||||
fileSearchMaxEntries: 10_000,
|
||||
fileSearchExcludedDirectories: ['.git', 'node_modules'],
|
||||
showHardwareCursor: false,
|
||||
color: true,
|
||||
truecolor: false,
|
||||
@@ -167,6 +173,9 @@ describe('TUI config', () => {
|
||||
questionDialogMaxHeight: 14,
|
||||
modelDialogWidth: 64,
|
||||
modelDialogMaxHeight: 16,
|
||||
fileSearchMaxResults: 7,
|
||||
fileSearchMaxEntries: 123,
|
||||
fileSearchExcludedDirectories: ['.git', 'generated'],
|
||||
showHardwareCursor: true,
|
||||
color: false,
|
||||
truecolor: true,
|
||||
@@ -180,6 +189,9 @@ describe('TUI config', () => {
|
||||
questionDialogMaxHeight: 14,
|
||||
modelDialogWidth: 64,
|
||||
modelDialogMaxHeight: 16,
|
||||
fileSearchMaxResults: 7,
|
||||
fileSearchMaxEntries: 123,
|
||||
fileSearchExcludedDirectories: ['.git', 'generated'],
|
||||
showHardwareCursor: true,
|
||||
color: false,
|
||||
truecolor: true,
|
||||
@@ -847,31 +859,43 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
appendAssistant(session, [{ type: 'text', text: 'home' }], { inputTokens: 25_000, outputTokens: 10_000 })
|
||||
},
|
||||
})
|
||||
expect(homeResult.terminal.output).toContain('~ ↑25k ↓10k')
|
||||
await vi.waitFor(() => {
|
||||
expect(homeResult.terminal.output).toContain('~ ↑25k ↓10k')
|
||||
})
|
||||
await dispose(homeResult)
|
||||
|
||||
const childResult = await setup({ cwd: join(home, 'projects', 'dsh-tui') })
|
||||
expect(childResult.terminal.output).toContain(join('~', 'projects', 'dsh-tui'))
|
||||
await vi.waitFor(() => {
|
||||
expect(childResult.terminal.output).toContain(join('~', 'projects', 'dsh-tui'))
|
||||
})
|
||||
await dispose(childResult)
|
||||
|
||||
const unsetResult = await setup({ cwd: null })
|
||||
expect(unsetResult.terminal.output).toContain('cwd unset')
|
||||
await vi.waitFor(() => {
|
||||
expect(unsetResult.terminal.output).toContain('cwd unset')
|
||||
})
|
||||
await dispose(unsetResult)
|
||||
|
||||
const homeParent = resolve(home, '..')
|
||||
const parentResult = await setup({ cwd: homeParent })
|
||||
expect(parentResult.terminal.output).toContain(homeParent)
|
||||
await vi.waitFor(() => {
|
||||
expect(parentResult.terminal.output).toContain(homeParent)
|
||||
})
|
||||
await dispose(parentResult)
|
||||
|
||||
const outsideResult = await setup({ cwd: '/opt' })
|
||||
expect(outsideResult.terminal.output).toContain('/opt')
|
||||
await vi.waitFor(() => {
|
||||
expect(outsideResult.terminal.output).toContain('/opt')
|
||||
})
|
||||
await dispose(outsideResult)
|
||||
|
||||
const logicalResult = await setup({
|
||||
cwd: '/w',
|
||||
formatCwd: cwd => `logical:${cwd}\x1b`,
|
||||
})
|
||||
expect(logicalResult.terminal.output).toContain('logical:/w\\x1b')
|
||||
await vi.waitFor(() => {
|
||||
expect(logicalResult.terminal.output).toContain('logical:/w\\x1b')
|
||||
})
|
||||
await dispose(logicalResult)
|
||||
})
|
||||
|
||||
@@ -1065,7 +1089,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
const result = await setup({
|
||||
async configureContext(ctx) {
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
await ctx.plugin(SessionQueryService)
|
||||
await ctx.plugin(TestSessionQueryService)
|
||||
await ctx.plugin(SessionReferenceService)
|
||||
const source = ctx.sessions.create(SessionId('source-session'), { meta: { cwd: process.cwd(), createdAt: 1 } })
|
||||
sourceId = source.id
|
||||
@@ -1109,13 +1133,132 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('fuzzy-completes files and directories while sending only the selected path text', async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'dsh-tui-file-completion-'))
|
||||
await mkdir(join(cwd, 'src'), { recursive: true })
|
||||
await mkdir(join(cwd, 'docs'), { recursive: true })
|
||||
await writeFile(join(cwd, 'src', 'source-file.ts'), 'export const source = true\n')
|
||||
await writeFile(join(cwd, 'docs', 'design notes.md'), '# Design\n')
|
||||
await writeFile(join(cwd, 'unsafe\nfile.ts'), 'unsafe name\n')
|
||||
const result = await setup({
|
||||
cwd,
|
||||
tools: {
|
||||
read: {
|
||||
name: 'read',
|
||||
description: 'Read a file.',
|
||||
parameters: {},
|
||||
output: UNUSED_TOOL_OUTPUT,
|
||||
execute: () => Promise.resolve([]),
|
||||
},
|
||||
},
|
||||
})
|
||||
try {
|
||||
const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent))
|
||||
expect(assembly.sections).toContainEqual({
|
||||
name: 'ui:tui-file-reference',
|
||||
text: FILE_REFERENCE_PROMPT,
|
||||
})
|
||||
|
||||
result.terminal.send('@sfts')
|
||||
await vi.waitFor(() => {
|
||||
expect(result.terminal.output).toContain('File · source-file.ts')
|
||||
})
|
||||
expect(result.terminal.output).toContain('src/source-file.ts')
|
||||
result.terminal.send('\t')
|
||||
await tick()
|
||||
result.terminal.send('\r')
|
||||
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })
|
||||
expect(result.agent.sent[0]).toEqual([{ type: 'text', text: '@src/source-file.ts' }])
|
||||
expect(result.agent.sentOptions[0]?.contexts).toEqual([])
|
||||
|
||||
result.terminal.send('@do')
|
||||
await vi.waitFor(() => {
|
||||
expect(result.terminal.output).toContain('Folder · docs/')
|
||||
})
|
||||
result.terminal.send('\t')
|
||||
await vi.waitFor(() => {
|
||||
expect(result.terminal.output).toContain('File · design notes.md')
|
||||
})
|
||||
result.terminal.send('\t')
|
||||
await tick()
|
||||
result.terminal.send('\r')
|
||||
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(2) })
|
||||
expect(result.agent.sent[1]).toEqual([{ type: 'text', text: '@"docs/design notes.md"' }])
|
||||
expect(result.agent.sentOptions[1]?.contexts).toEqual([])
|
||||
|
||||
result.terminal.send('@unsafe')
|
||||
await tick()
|
||||
expect(result.terminal.output).not.toContain('File · unsafe')
|
||||
result.terminal.send('\x03')
|
||||
} finally {
|
||||
await result.controller.dispose()
|
||||
const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent))
|
||||
expect(assembly.sections).not.toContainEqual({
|
||||
name: 'ui:tui-file-reference',
|
||||
text: FILE_REFERENCE_PROMPT,
|
||||
})
|
||||
await result.ctx.fiber.dispose()
|
||||
await rm(cwd, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('isolates failed file discovery from editor autocomplete', async () => {
|
||||
const list = vi.spyOn(WorkspaceFileSearch.prototype, 'list').mockRejectedValue(new Error('search failed'))
|
||||
const result = await setup()
|
||||
try {
|
||||
result.terminal.send('@failed')
|
||||
await vi.waitFor(() => { expect(list).toHaveBeenCalled() })
|
||||
await tick()
|
||||
expect(result.agent.sent).toEqual([])
|
||||
} finally {
|
||||
list.mockRestore()
|
||||
await dispose(result)
|
||||
}
|
||||
})
|
||||
|
||||
it('shows file-reference guidance only while read is visible to the agent', async () => {
|
||||
const read: ToolDefinition = {
|
||||
name: 'read',
|
||||
description: 'Read a file.',
|
||||
parameters: {},
|
||||
output: UNUSED_TOOL_OUTPUT,
|
||||
execute: () => Promise.resolve([]),
|
||||
}
|
||||
let visibility: 'none' | 'global' | 'agent' = 'none'
|
||||
const result = await setup({
|
||||
async configureContext(ctx) {
|
||||
ctx.provide('tools', {
|
||||
get(name: string, scope?: Agent) {
|
||||
if (name !== 'read' || visibility === 'none') return undefined
|
||||
return (scope === undefined) === (visibility === 'global') ? read : undefined
|
||||
},
|
||||
} as never)
|
||||
},
|
||||
})
|
||||
const fileReferenceText = async (): Promise<string | undefined> => {
|
||||
const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent))
|
||||
return assembly.sections.find(section => section.name === 'ui:tui-file-reference')?.text
|
||||
}
|
||||
try {
|
||||
expect(await fileReferenceText()).toBe('')
|
||||
visibility = 'global'
|
||||
expect(await fileReferenceText()).toBe('')
|
||||
visibility = 'agent'
|
||||
expect(await fileReferenceText()).toBe(FILE_REFERENCE_PROMPT)
|
||||
visibility = 'none'
|
||||
expect(await fileReferenceText()).toBe('')
|
||||
} finally {
|
||||
await dispose(result)
|
||||
}
|
||||
})
|
||||
|
||||
it('escapes session autocomplete metadata while preserving the referenced session id', async () => {
|
||||
const unsafeId = SessionId('evil\x1b\x07\u009b\ns')
|
||||
const unsafeCwd = '/x/\x1b\x07\u009b\nf'
|
||||
const result = await setup({
|
||||
async configureContext(ctx) {
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
await ctx.plugin(SessionQueryService)
|
||||
await ctx.plugin(TestSessionQueryService)
|
||||
await ctx.plugin(SessionReferenceService)
|
||||
const source = ctx.sessions.create(unsafeId, { meta: { cwd: unsafeCwd, createdAt: 1 } })
|
||||
appendUser(source, 'safe background')
|
||||
@@ -1147,7 +1290,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
const result = await setup({
|
||||
async configureContext(ctx) {
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
await ctx.plugin(SessionQueryService)
|
||||
await ctx.plugin(TestSessionQueryService)
|
||||
await ctx.plugin(SessionReferenceService)
|
||||
},
|
||||
})
|
||||
@@ -1212,7 +1355,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
const result = await setup({
|
||||
async configureContext(ctx) {
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
await ctx.plugin(SessionQueryService)
|
||||
await ctx.plugin(TestSessionQueryService)
|
||||
await ctx.plugin(SessionReferenceService)
|
||||
},
|
||||
})
|
||||
@@ -1332,7 +1475,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
const result = await setup({
|
||||
async configureContext(ctx) {
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
await ctx.plugin(SessionQueryService)
|
||||
await ctx.plugin(TestSessionQueryService)
|
||||
await ctx.plugin(SessionReferenceService)
|
||||
ctx.sessions.create(SessionId('source'))
|
||||
},
|
||||
@@ -1382,7 +1525,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
const lateSuccess = await setup({
|
||||
async configureContext(ctx) {
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
await ctx.plugin(SessionQueryService)
|
||||
await ctx.plugin(TestSessionQueryService)
|
||||
await ctx.plugin(SessionReferenceService)
|
||||
ctx.sessions.create(SessionId('source'))
|
||||
},
|
||||
@@ -1621,6 +1764,11 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
handler: () => ({ kind: 'error' as const, text: 'plugin error result' }),
|
||||
})
|
||||
|
||||
result.terminal.send('/plugin-ch')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('<value> — Run a plugin command')
|
||||
result.terminal.send('\x03')
|
||||
|
||||
result.terminal.send('/plugin-check value ')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
|
||||
Reference in New Issue
Block a user