Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts: # .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml # apps/web/tests/code-mode-fixture.snapshot.ts # docs/architecture.i18n.yaml # docs/core-data-structures/core.i18n.yaml # docs/module-graph.md # packages/README.i18n.yaml # packages/client/runtime/README.i18n.yaml # packages/client/runtime/README.md # packages/client/runtime/README.zh.md # packages/client/test-runtime/tests/runtime.spec.tsx # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/package.json # packages/client/ui-conversation/src/client/chat/ChatView.tsx # packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx # packages/client/ui-conversation/src/client/skeleton/InputBar.tsx # packages/client/ui-conversation/tests/skeleton.spec.tsx # packages/compact/compact-basic/README.i18n.yaml # packages/compact/compact-basic/README.zh.md # packages/compact/compact-basic/src/summarizer.ts # packages/host/apiproxy/src/api/sessions.ts # packages/llm/llm-pi-ai/README.i18n.yaml # packages/llm/llm/README.i18n.yaml # packages/ui/tui/README.i18n.yaml # pnpm-lock.yaml
This commit is contained in:
29
packages/ui/tui/tests/chat-helpers.spec.ts
Normal file
29
packages/ui/tui/tests/chat-helpers.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { gitBranch } from '../src/chat/helpers.ts'
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFileSync: vi.fn(() => 'main\n'),
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('chat helpers', () => {
|
||||
it('scrubs ambient credentials and DSH names from the Git child', () => {
|
||||
vi.stubEnv('TUI_TEST_PASSWORD', 'ambient-password')
|
||||
vi.stubEnv('DSH_TUI_TEST_FLAG', 'ambient-harness-state')
|
||||
expect(gitBranch('/workspace')).toBe('main')
|
||||
const call = vi.mocked(execFileSync).mock.calls[0] as unknown as [
|
||||
string,
|
||||
string[],
|
||||
{ env: NodeJS.ProcessEnv },
|
||||
]
|
||||
expect(call[0]).toBe('git')
|
||||
expect(call[1]).toEqual(['branch', '--show-current'])
|
||||
expect(call[2].env).not.toHaveProperty('TUI_TEST_PASSWORD')
|
||||
expect(call[2].env).not.toHaveProperty('DSH_TUI_TEST_FLAG')
|
||||
})
|
||||
})
|
||||
@@ -18,7 +18,7 @@ import { GOAL_CHANGE_VERSION, GoalId, renderGoalChange, type GoalSnapshotChangeM
|
||||
import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands'
|
||||
import SessionStore, { SessionId, type JsonValue, type SessionEvent, type SessionHeader, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionRecord } from '@deepseek-ai/dsh-session-query'
|
||||
import SkillService, { type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill'
|
||||
import SkillService, { type SkillCatalogSnapshot, type SkillDefinition, type SkillProvider, type SkillSummary } from '@deepseek-ai/dsh-skill'
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
@@ -3874,10 +3874,30 @@ describe('skill slash command', () => {
|
||||
if (skills === undefined) throw new Error('skills service not mounted')
|
||||
skills.register({ name: 'demo-skill', description: 'Demo skill for tests', source: 'runtime', provider: 'runtime', content: 'Demo instructions body.' })
|
||||
skills.register({ name: 'project-skill', description: 'Project skill for tests', source: 'project-dsh', provider: 'runtime', content: 'Project instructions body.' })
|
||||
skills.register({ name: 'hidden-skill', description: 'Model-hidden skill', source: 'runtime', provider: 'runtime', content: 'Hidden instructions body.', disableModelInvocation: true })
|
||||
skills.register({
|
||||
name: 'user-only-skill',
|
||||
description: 'User-only skill',
|
||||
invocation: { modelInvocable: false, userInvocable: true },
|
||||
source: 'runtime',
|
||||
content: 'User-only instructions body.',
|
||||
})
|
||||
skills.register({
|
||||
name: 'model-only-skill',
|
||||
description: 'Model-only skill',
|
||||
invocation: { modelInvocable: true, userInvocable: false },
|
||||
source: 'runtime',
|
||||
content: 'Model-only instructions body.',
|
||||
})
|
||||
skills.register({
|
||||
name: 'trusted-only-skill',
|
||||
description: 'Trusted-only skill',
|
||||
invocation: { modelInvocable: false, userInvocable: false },
|
||||
source: 'runtime',
|
||||
content: 'Trusted-only instructions body.',
|
||||
})
|
||||
}
|
||||
|
||||
it('labels slash completions by scope and hides model-disabled skills', async () => {
|
||||
it('labels slash completions by scope and applies user invocation policy', async () => {
|
||||
const result = await setup({ configureContext: withSkills })
|
||||
result.terminal.send('/skill')
|
||||
await tick()
|
||||
@@ -3885,8 +3905,139 @@ describe('skill slash command', () => {
|
||||
expect(result.terminal.output).toContain('(user)')
|
||||
expect(result.terminal.output).toContain('project-skill')
|
||||
expect(result.terminal.output).toContain('(project)')
|
||||
expect(result.terminal.output).toContain('user-only-skill')
|
||||
expect(result.terminal.output).not.toContain('[instructions]')
|
||||
expect(result.terminal.output).not.toContain('hidden-skill')
|
||||
expect(result.terminal.output).not.toContain('model-only-skill')
|
||||
expect(result.terminal.output).not.toContain('trusted-only-skill')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('refreshes slash completions after runtime skill additions and complete removals', async () => {
|
||||
let skills: SkillService | undefined
|
||||
const result = await setup({
|
||||
configureContext: async (ctx) => {
|
||||
ctx.provide('tools', { get() { return undefined } } as never)
|
||||
await ctx.plugin(SkillService)
|
||||
skills = ctx.get('skills')
|
||||
},
|
||||
})
|
||||
if (skills === undefined) throw new Error('skills service not mounted')
|
||||
|
||||
result.terminal.send('/skill:dynamic')
|
||||
await tick()
|
||||
result.terminal.output = ''
|
||||
const disposeSkill = skills.register({
|
||||
name: 'dynamic-skill',
|
||||
description: 'DYNAMIC_COMPLETION_MARKER',
|
||||
source: 'runtime',
|
||||
content: 'Dynamic body.',
|
||||
})
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('DYNAMIC_COMPLETION_MARKER')
|
||||
|
||||
result.terminal.send('\x03')
|
||||
disposeSkill()
|
||||
await tick()
|
||||
result.terminal.output = ''
|
||||
result.terminal.send('/skill:dynamic')
|
||||
await tick()
|
||||
expect(result.terminal.output).not.toContain('DYNAMIC_COMPLETION_MARKER')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('retains last-good slash completions across incomplete snapshots', async () => {
|
||||
let skills: SkillService | undefined
|
||||
let provider: SkillProvider | undefined
|
||||
let invalidate = (): void => {}
|
||||
let fail = false
|
||||
const result = await setup({
|
||||
configureContext: async (ctx) => {
|
||||
ctx.provide('tools', { get() { return undefined } } as never)
|
||||
await ctx.plugin(SkillService)
|
||||
skills = ctx.get('skills')
|
||||
provider = {
|
||||
name: 'flaky-completion',
|
||||
async list() {
|
||||
if (fail) throw new Error('transient completion failure')
|
||||
return [{
|
||||
name: 'stable-skill',
|
||||
description: 'STABLE_COMPLETION_MARKER',
|
||||
invocation: { modelInvocable: true, userInvocable: true },
|
||||
source: 'test',
|
||||
provider: 'flaky-completion',
|
||||
rank: 1,
|
||||
locator: 'stable',
|
||||
}]
|
||||
},
|
||||
async get() {
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
skills?.registerProvider((control) => {
|
||||
invalidate = control.invalidate
|
||||
return provider as SkillProvider
|
||||
})
|
||||
},
|
||||
})
|
||||
if (skills === undefined || provider === undefined) throw new Error('skills provider not mounted')
|
||||
|
||||
fail = true
|
||||
invalidate()
|
||||
await tick()
|
||||
result.terminal.output = ''
|
||||
result.terminal.send('/skill:stable')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('STABLE_COMPLETION_MARKER')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('keeps the latest slash catalog when asynchronous refreshes settle out of order', async () => {
|
||||
const pendingSnapshots: Array<PromiseWithResolvers<SkillCatalogSnapshot>> = []
|
||||
const result = await setup({
|
||||
configureContext: async (ctx) => {
|
||||
ctx.provide('tools', { get() { return undefined } } as never)
|
||||
ctx.provide('skills', {
|
||||
snapshot: () => {
|
||||
const pending = Promise.withResolvers<SkillCatalogSnapshot>()
|
||||
pendingSnapshots.push(pending)
|
||||
return pending.promise
|
||||
},
|
||||
get: () => Promise.resolve(undefined),
|
||||
} as never)
|
||||
},
|
||||
})
|
||||
expect(pendingSnapshots).toHaveLength(1)
|
||||
|
||||
result.ctx.emit('skills/change')
|
||||
result.ctx.emit('skills/change')
|
||||
expect(pendingSnapshots).toHaveLength(3)
|
||||
pendingSnapshots[2]?.resolve({
|
||||
skills: [{
|
||||
name: 'latest-skill',
|
||||
description: 'LATEST_COMPLETION_MARKER',
|
||||
invocation: { modelInvocable: true, userInvocable: true },
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
}],
|
||||
complete: true,
|
||||
})
|
||||
await tick()
|
||||
pendingSnapshots[0]?.resolve({
|
||||
skills: [{ name: 'stale-first', description: 'STALE_FIRST', invocation: { modelInvocable: true, userInvocable: true }, source: 'runtime', provider: 'runtime' }],
|
||||
complete: true,
|
||||
})
|
||||
pendingSnapshots[1]?.resolve({
|
||||
skills: [{ name: 'stale-second', description: 'STALE_SECOND', invocation: { modelInvocable: true, userInvocable: true }, source: 'runtime', provider: 'runtime' }],
|
||||
complete: true,
|
||||
})
|
||||
await tick()
|
||||
|
||||
result.terminal.output = ''
|
||||
result.terminal.send('/skill:latest')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('LATEST_COMPLETION_MARKER')
|
||||
expect(result.terminal.output).not.toContain('STALE_FIRST')
|
||||
expect(result.terminal.output).not.toContain('STALE_SECOND')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
@@ -3905,12 +4056,62 @@ describe('skill slash command', () => {
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('invokes a model-disabled skill by its exact name', async () => {
|
||||
it('invokes a user-only skill by its exact name', async () => {
|
||||
const result = await setup({ configureContext: withSkills })
|
||||
result.terminal.send('/skill:hidden-skill')
|
||||
result.terminal.send('/skill:user-only-skill')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.agent.sent).toEqual([[{ type: 'text', text: '<skill name="hidden-skill">\nHidden instructions body.\n</skill>' }]])
|
||||
expect(result.agent.sent).toEqual([[{ type: 'text', text: '<skill name="user-only-skill">\nUser-only instructions body.\n</skill>' }]])
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('checks user policy before loading and rechecks the loaded definition', async () => {
|
||||
const summaries: SkillSummary[] = [
|
||||
{
|
||||
name: 'model-only-skill',
|
||||
description: 'Model-only skill',
|
||||
invocation: { modelInvocable: true, userInvocable: false },
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
},
|
||||
{
|
||||
name: 'policy-race-skill',
|
||||
description: 'Policy race skill',
|
||||
invocation: { modelInvocable: true, userInvocable: true },
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
},
|
||||
]
|
||||
const get = vi.fn((name: string) => Promise.resolve<SkillDefinition | undefined>({
|
||||
name,
|
||||
description: 'Policy race skill',
|
||||
invocation: { modelInvocable: true, userInvocable: false },
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
content: 'Instructions must not be delivered.',
|
||||
}))
|
||||
const result = await setup({
|
||||
configureContext: async (ctx) => {
|
||||
ctx.provide('tools', { get() { return undefined } } as never)
|
||||
ctx.provide('skills', {
|
||||
snapshot: () => Promise.resolve({ skills: summaries, complete: true }),
|
||||
list: () => Promise.resolve(summaries),
|
||||
get,
|
||||
} as never)
|
||||
},
|
||||
})
|
||||
result.terminal.send('/skill:model-only-skill')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
result.terminal.send('/skill:policy-race-skill')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.agent.sent).toEqual([])
|
||||
expect(get).toHaveBeenCalledTimes(1)
|
||||
expect(get).toHaveBeenCalledWith('policy-race-skill', expect.objectContaining({ cwd: '/workspace' }))
|
||||
expect(result.terminal.output).toContain('Skill "model-only-skill" is not available for user invocation.')
|
||||
expect(result.terminal.output).toContain('Skill "policy-race-skill" is not available for user invocation.')
|
||||
expect(result.terminal.output).not.toContain('Instructions must not be delivered.')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
@@ -3965,6 +4166,7 @@ describe('skill slash command', () => {
|
||||
configureContext: async (ctx) => {
|
||||
ctx.provide('tools', { get() { return undefined } } as never)
|
||||
ctx.provide('skills', {
|
||||
snapshot: () => Promise.reject(new Error('list boom')),
|
||||
list: () => Promise.reject(new Error('list boom')),
|
||||
get: () => Promise.reject(new Error('get boom')),
|
||||
} as never)
|
||||
@@ -3974,37 +4176,84 @@ describe('skill slash command', () => {
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('failed to load')
|
||||
expect(result.terminal.output).toContain('get boom')
|
||||
expect(result.terminal.output).toContain('list boom')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('drops skill list and lookup results that settle after disposal', async () => {
|
||||
const pendingList: Array<(value: SkillSummary[]) => void> = []
|
||||
let listCalls = 0
|
||||
let resolvePendingList: ((value: SkillSummary[]) => void) | undefined
|
||||
const pendingSnapshots: Array<(value: SkillCatalogSnapshot) => void> = []
|
||||
const pendingGet: Array<{ resolve: (value: SkillDefinition | undefined) => void; reject: (error: unknown) => void }> = []
|
||||
const result = await setup({
|
||||
configureContext: async (ctx) => {
|
||||
ctx.provide('tools', { get() { return undefined } } as never)
|
||||
ctx.provide('skills', {
|
||||
list: () => new Promise<SkillSummary[]>((resolve) => { pendingList.push(resolve) }),
|
||||
snapshot: () => new Promise<SkillCatalogSnapshot>((resolve) => { pendingSnapshots.push(resolve) }),
|
||||
list: () => {
|
||||
listCalls += 1
|
||||
if (listCalls === 1 || listCalls === 2) {
|
||||
const name = listCalls === 1 ? 'demo-skill' : 'error-skill'
|
||||
return Promise.resolve<SkillSummary[]>([{
|
||||
name,
|
||||
description: 'demo',
|
||||
invocation: { modelInvocable: true, userInvocable: true },
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
}])
|
||||
}
|
||||
return new Promise<SkillSummary[]>((resolve) => { resolvePendingList = resolve })
|
||||
},
|
||||
get: () => new Promise<SkillDefinition | undefined>((resolve, reject) => { pendingGet.push({ resolve, reject }) }),
|
||||
} as never)
|
||||
},
|
||||
})
|
||||
await tick()
|
||||
result.terminal.send('/skill:demo-skill')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
result.terminal.send('/skill:error-skill')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
result.terminal.send('/skill:other-skill')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
await dispose(result)
|
||||
|
||||
for (const resolve of pendingList) resolve([{ name: 'late', description: 'late', source: 'runtime', provider: 'runtime' }])
|
||||
pendingGet[0]?.resolve({ name: 'demo-skill', description: 'late', source: 'runtime', provider: 'runtime', content: 'late body' })
|
||||
result.ctx.emit('skills/change')
|
||||
expect(pendingSnapshots).toHaveLength(1)
|
||||
for (const resolve of pendingSnapshots) {
|
||||
resolve({
|
||||
skills: [{
|
||||
name: 'late',
|
||||
description: 'late',
|
||||
invocation: { modelInvocable: true, userInvocable: true },
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
}],
|
||||
complete: true,
|
||||
})
|
||||
}
|
||||
resolvePendingList?.([{
|
||||
name: 'other-skill',
|
||||
description: 'late',
|
||||
invocation: { modelInvocable: true, userInvocable: true },
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
}])
|
||||
pendingGet[0]?.resolve({
|
||||
name: 'demo-skill',
|
||||
description: 'late',
|
||||
invocation: { modelInvocable: true, userInvocable: true },
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
content: 'late body',
|
||||
})
|
||||
pendingGet[1]?.reject(new Error('late failure'))
|
||||
await tick()
|
||||
expect(result.agent.sent).toEqual([])
|
||||
expect(result.terminal.output).not.toContain('late failure')
|
||||
expect(result.terminal.output).not.toContain('late body')
|
||||
expect(result.terminal.output).not.toContain('late failure')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4012,6 +4261,7 @@ describe('renderSkillInvocation', () => {
|
||||
const skill: SkillDefinition = {
|
||||
name: 'demo-skill',
|
||||
description: 'Demo skill',
|
||||
invocation: { modelInvocable: true, userInvocable: true },
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
content: 'Body text.',
|
||||
|
||||
Reference in New Issue
Block a user