|
|
@@ -12,7 +12,7 @@ import { describe, expect, it, vi } from 'vitest'
|
|
|
import type { CommandResult } from '@deepseek-ai/dsh-commands/types'
|
|
|
import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
|
|
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
|
|
-import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
|
|
+import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource, SubmitImageAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
|
|
import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts'
|
|
|
import type { CommandDescriptor } from '../src/client/directory.ts'
|
|
|
import { CommandUiRuntime } from '../src/client/service.ts'
|
|
|
@@ -32,7 +32,7 @@ const S2_CMDS: CommandDescriptor[] = [
|
|
|
{ name: 'attach', description: 'scoped shadow', input: { hint: 'path' } },
|
|
|
]
|
|
|
|
|
|
-type ExecuteValue = { matched: boolean; commandId?: string }
|
|
|
+type ExecuteValue = { matched: boolean; commandId?: string; result?: CommandResult }
|
|
|
|
|
|
interface BenchOptions {
|
|
|
/** Scripted catalog per list payload; default serves the fixed catalogs by session. */
|
|
|
@@ -67,7 +67,7 @@ async function bench(opts: BenchOptions = {}) {
|
|
|
const ctx = new Context()
|
|
|
const registered = new Map<string, InputTriggerSource>()
|
|
|
const listCalls: Array<{ sessionId: SessionId }> = []
|
|
|
- const executeCalls: Array<{ sessionId: SessionId; line: string }> = []
|
|
|
+ const executeCalls: Array<{ sessionId: SessionId; line: string; images: readonly SubmitImageAttachment[] }> = []
|
|
|
// The service reads the generated commands Remote, which delivers the
|
|
|
// carrier's outcome, so a programmed failure answers the error branch.
|
|
|
const commandsRemote = {
|
|
|
@@ -80,13 +80,13 @@ async function bench(opts: BenchOptions = {}) {
|
|
|
return value.commands
|
|
|
})
|
|
|
},
|
|
|
- execute: async (sessionId: SessionId, line: string) => {
|
|
|
- executeCalls.push({ sessionId, line })
|
|
|
+ execute: async (sessionId: SessionId, line: string, images: readonly SubmitImageAttachment[] = []) => {
|
|
|
+ executeCalls.push({ sessionId, line, images })
|
|
|
return await carried(async () => {
|
|
|
const fallback = (): Promise<ExecuteValue> => Promise.resolve({ matched: true })
|
|
|
const value = await (opts.execute ?? fallback)({ sessionId, line })
|
|
|
return value.matched
|
|
|
- ? { commandId: value.commandId ?? 'fake-command', result: { kind: 'success' as const } }
|
|
|
+ ? { commandId: value.commandId ?? 'fake-command', result: value.result ?? { kind: 'success' as const } }
|
|
|
: undefined
|
|
|
})
|
|
|
},
|
|
|
@@ -98,6 +98,11 @@ async function bench(opts: BenchOptions = {}) {
|
|
|
return () => { registered.delete(key) }
|
|
|
},
|
|
|
})
|
|
|
+ // Deterministic key-echo translator: notice assertions read `key{json}`.
|
|
|
+ ctx.provide('locale', {
|
|
|
+ bind: (ns: string) => (key: string, params?: Record<string, unknown>) =>
|
|
|
+ `${ns}:${key}${params === undefined ? '' : JSON.stringify(params)}`,
|
|
|
+ })
|
|
|
// Real scope tags behind a fake sessions face.
|
|
|
const scopes = new Map<SessionId, { ctx: Context; fiber: { dispose(): Promise<void> } }>()
|
|
|
ctx.provide('sessions', {
|
|
|
@@ -297,9 +302,9 @@ describe('decorations (bare-invocation UI on host commands)', () => {
|
|
|
command.decorate(goalDecoration())
|
|
|
const scope = mint('s1')
|
|
|
await warm(proj('s1'))
|
|
|
- expect(await source.matchEnter!(proj('s1'), '/goal', new AbortController().signal)).toBe('handled')
|
|
|
+ expect(await source.matchEnter!(proj('s1'), '/goal', new AbortController().signal, { images: 0 })).toBe('handled')
|
|
|
expect(command.popupFor(scope.ctx).state.getSnapshot()).toMatchObject({ open: true, command: 'goal' })
|
|
|
- const argued = await source.matchEnter!(proj('s1'), '/goal ship it', new AbortController().signal)
|
|
|
+ const argued = await source.matchEnter!(proj('s1'), '/goal ship it', new AbortController().signal, { images: 0 })
|
|
|
if (argued === undefined || argued === 'handled' || !('claim' in argued)) throw new Error('expected the host claim')
|
|
|
expect(argued.claim.token).toBe('/goal ')
|
|
|
})
|
|
|
@@ -318,7 +323,7 @@ describe('decorations (bare-invocation UI on host commands)', () => {
|
|
|
command.decorate(goalDecoration({ name: 'phantom' }))
|
|
|
const scope = mint('s1')
|
|
|
await warm(proj('s1'))
|
|
|
- expect(await source.matchEnter!(proj('s1'), '/phantom', new AbortController().signal)).toBeUndefined()
|
|
|
+ expect(await source.matchEnter!(proj('s1'), '/phantom', new AbortController().signal, { images: 0 })).toBeUndefined()
|
|
|
expect(menuPick(source, 'phantom', proj('s1'))).toBeUndefined()
|
|
|
expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false)
|
|
|
})
|
|
|
@@ -327,8 +332,8 @@ describe('decorations (bare-invocation UI on host commands)', () => {
|
|
|
const { command, source, warm, executeCalls } = await bench()
|
|
|
command.decorate(goalDecoration({ name: 'plan', available: () => false }))
|
|
|
await warm(proj('s1'))
|
|
|
- expect(await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal)).toBe('handled')
|
|
|
- expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }])
|
|
|
+ expect(await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal, { images: 0 })).toBe('handled')
|
|
|
+ expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan', images: [] }])
|
|
|
})
|
|
|
|
|
|
it('duplicate decoration names fail loud', async () => {
|
|
|
@@ -383,7 +388,7 @@ describe('dispatch (menu column)', () => {
|
|
|
expect(menuPick(source, 'plan', proj('s1'), 5)).toBe('handled')
|
|
|
expect(consumes).toEqual([{ guard: { kind: 'span', span: { start: 0, end: 5, draftRev: 3 } } }])
|
|
|
await vi.waitFor(() => {
|
|
|
- expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }])
|
|
|
+ expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan', images: [] }])
|
|
|
expect(executions).toEqual([{
|
|
|
sessionId: sid('s1'),
|
|
|
name: 'plan',
|
|
|
@@ -440,7 +445,7 @@ describe('matchEnter (enter column)', () => {
|
|
|
const { source } = await bench({
|
|
|
commands: () => new Promise((resolve) => { release = resolve }),
|
|
|
})
|
|
|
- const wait = source.matchEnter!(proj('s1'), '/goal args', signal())
|
|
|
+ const wait = source.matchEnter!(proj('s1'), '/goal args', signal(), { images: 0 })
|
|
|
release({ commands: S1_CMDS })
|
|
|
const outcome = await wait
|
|
|
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
|
|
|
@@ -451,14 +456,14 @@ describe('matchEnter (enter column)', () => {
|
|
|
const { source } = await bench({
|
|
|
commands: () => Promise.reject(new Error('warmup boom')),
|
|
|
})
|
|
|
- await expect(source.matchEnter!(proj('s1'), '/goal', signal())).rejects.toThrow('warmup boom')
|
|
|
+ await expect(source.matchEnter!(proj('s1'), '/goal', signal(), { images: 0 })).rejects.toThrow('warmup boom')
|
|
|
})
|
|
|
|
|
|
it('leadingInput claims args-tolerant (bare and with trailing text)', async () => {
|
|
|
const { source, warm } = await bench()
|
|
|
await warm(proj('s1'))
|
|
|
for (const line of ['/goal', '/goal refactor the loop']) {
|
|
|
- const outcome = await source.matchEnter!(proj('s1'), line, signal())
|
|
|
+ const outcome = await source.matchEnter!(proj('s1'), line, signal(), { images: 0 })
|
|
|
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
|
|
|
expect(outcome.claim.token).toBe('/goal ')
|
|
|
}
|
|
|
@@ -473,16 +478,16 @@ describe('matchEnter (enter column)', () => {
|
|
|
return true
|
|
|
})
|
|
|
await warm(proj('s1'))
|
|
|
- await expect(source.matchEnter!(proj('s1'), '/plan', signal())).resolves.toBe('handled')
|
|
|
+ await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { images: 0 })).resolves.toBe('handled')
|
|
|
expect(consumes).toEqual([{ guard: { kind: 'bare-token', token: '/plan' } }])
|
|
|
await Promise.resolve()
|
|
|
- expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }])
|
|
|
+ expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan', images: [] }])
|
|
|
})
|
|
|
|
|
|
it('bare kind with trailing text → undefined and no RPC (default sink owns the line)', async () => {
|
|
|
const { source, warm, executeCalls } = await bench()
|
|
|
await warm(proj('s1'))
|
|
|
- await expect(source.matchEnter!(proj('s1'), '/plan now', signal())).resolves.toBeUndefined()
|
|
|
+ await expect(source.matchEnter!(proj('s1'), '/plan now', signal(), { images: 0 })).resolves.toBeUndefined()
|
|
|
expect(executeCalls).toEqual([])
|
|
|
})
|
|
|
|
|
|
@@ -490,18 +495,86 @@ describe('matchEnter (enter column)', () => {
|
|
|
const { command, source, mint, listCalls } = await bench()
|
|
|
command.register(themeContribution())
|
|
|
const scope = mint('s1')
|
|
|
- await expect(source.matchEnter!(proj('s1'), '/theme', signal())).resolves.toBe('handled')
|
|
|
+ await expect(source.matchEnter!(proj('s1'), '/theme', signal(), { images: 0 })).resolves.toBe('handled')
|
|
|
expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(true)
|
|
|
expect(listCalls).toEqual([]) // contribution short-circuits ahead of ensureReady
|
|
|
- await expect(source.matchEnter!(proj('s1'), '/theme dark', signal())).resolves.toBeUndefined()
|
|
|
+ await expect(source.matchEnter!(proj('s1'), '/theme dark', signal(), { images: 0 })).resolves.toBeUndefined()
|
|
|
})
|
|
|
|
|
|
it('unknown name, bare "/", and non-slash lines → undefined', async () => {
|
|
|
const { source, warm } = await bench()
|
|
|
await warm(proj('s1'))
|
|
|
- await expect(source.matchEnter!(proj('s1'), '/nope', signal())).resolves.toBeUndefined()
|
|
|
- await expect(source.matchEnter!(proj('s1'), '/', signal())).resolves.toBeUndefined()
|
|
|
- await expect(source.matchEnter!(proj('s1'), 'plain text', signal())).resolves.toBeUndefined()
|
|
|
+ await expect(source.matchEnter!(proj('s1'), '/nope', signal(), { images: 0 })).resolves.toBeUndefined()
|
|
|
+ await expect(source.matchEnter!(proj('s1'), '/', signal(), { images: 0 })).resolves.toBeUndefined()
|
|
|
+ await expect(source.matchEnter!(proj('s1'), 'plain text', signal(), { images: 0 })).resolves.toBeUndefined()
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+describe('matchEnter envelope policy (images)', () => {
|
|
|
+ const signal = () => new AbortController().signal
|
|
|
+ const IMG_CMDS: CommandDescriptor[] = [
|
|
|
+ ...S1_CMDS,
|
|
|
+ { name: 'vision', description: 'image-accepting leadingInput', input: { hint: 'describe', images: true } },
|
|
|
+ ]
|
|
|
+ const png: SubmitImageAttachment = { mediaType: 'image/png', data: 'AA==' }
|
|
|
+
|
|
|
+ it('a leadingInput command not declaring acceptance refuses; a declaring one claims with images minted', async () => {
|
|
|
+ const { source, warm } = await bench({ commands: () => Promise.resolve({ commands: IMG_CMDS }) })
|
|
|
+ await warm(proj('s1'))
|
|
|
+ await expect(source.matchEnter!(proj('s1'), '/goal ship', signal(), { images: 1 }))
|
|
|
+ .rejects.toThrow('command:notice.imagesUnsupported{"command":"goal"}')
|
|
|
+ const outcome = await source.matchEnter!(proj('s1'), '/vision what is this', signal(), { images: 1 })
|
|
|
+ if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
|
|
|
+ expect(outcome.claim.token).toBe('/vision ')
|
|
|
+ expect(outcome.claim.images).toBe(true)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('bare popup routes refuse images: contribution and decorated host both stay closed', async () => {
|
|
|
+ const { command, source, mint, warm } = await bench()
|
|
|
+ command.register(themeContribution())
|
|
|
+ command.decorate({ name: 'plan', available: () => true, ui: themeUi() })
|
|
|
+ const scope = mint('s1')
|
|
|
+ await warm(proj('s1'))
|
|
|
+ await expect(source.matchEnter!(proj('s1'), '/theme', signal(), { images: 1 }))
|
|
|
+ .rejects.toThrow('command:notice.imagesUnsupported{"command":"theme"}')
|
|
|
+ await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { images: 2 }))
|
|
|
+ .rejects.toThrow('command:notice.imagesUnsupported{"command":"plan"}')
|
|
|
+ expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('bare host detached execute refuses images before any RPC', async () => {
|
|
|
+ const { source, warm, executeCalls } = await bench()
|
|
|
+ await warm(proj('s1'))
|
|
|
+ await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { images: 1 }))
|
|
|
+ .rejects.toThrow('command:notice.imagesUnsupported{"command":"plan"}')
|
|
|
+ expect(executeCalls).toEqual([])
|
|
|
+ })
|
|
|
+
|
|
|
+ it('claim.submit forwards the images to execute; consumption follows the handler outcome', async () => {
|
|
|
+ let result: CommandResult = { kind: 'error', text: 'handler refused' }
|
|
|
+ const { source, warm, executeCalls } = await bench({
|
|
|
+ commands: () => Promise.resolve({ commands: IMG_CMDS }),
|
|
|
+ execute: () => Promise.resolve({ matched: true, result }),
|
|
|
+ })
|
|
|
+ await warm(proj('s1'))
|
|
|
+ const outcome = await source.matchEnter!(proj('s1'), '/vision x', signal(), { images: 1 })
|
|
|
+ if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
|
|
|
+ // Handler error: the error outcome keeps draft and images in the composer.
|
|
|
+ await expect(outcome.claim.submit('x', new Context(), [png]))
|
|
|
+ .resolves.toEqual({ kind: 'error', text: 'handler refused' })
|
|
|
+ expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/vision x', images: [png] }])
|
|
|
+ result = { kind: 'success', text: 'described' }
|
|
|
+ await expect(outcome.claim.submit('x', new Context(), [png])).resolves.toEqual({ kind: 'success' })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('an imageless submission keeps the always-success admission mapping over a handler error', async () => {
|
|
|
+ const { source, warm } = await bench({
|
|
|
+ execute: () => Promise.resolve({ matched: true, result: { kind: 'error', text: 'late failure' } }),
|
|
|
+ })
|
|
|
+ await warm(proj('s1'))
|
|
|
+ const outcome = source.matchSpace!(proj('s1'), '/goal')
|
|
|
+ if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
|
|
|
+ await expect(outcome.claim.submit('x', new Context(), [])).resolves.toEqual({ kind: 'success' })
|
|
|
})
|
|
|
})
|
|
|
|
|
|
@@ -513,8 +586,8 @@ describe('execute payload', () => {
|
|
|
await warm(proj('s1'))
|
|
|
const outcome = source.matchSpace!(proj('s1'), '/goal')
|
|
|
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
|
|
|
- const settled = await outcome.claim.submit('ship it', new Context())
|
|
|
- expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/goal ship it' }])
|
|
|
+ const settled = await outcome.claim.submit('ship it', new Context(), [])
|
|
|
+ expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/goal ship it', images: [] }])
|
|
|
// Pure admission: no outcome text ever rides the submit result — the
|
|
|
// durable command lifecycle events render the outcome in the flow.
|
|
|
expect(settled).toEqual({ kind: 'success' })
|
|
|
@@ -539,7 +612,7 @@ describe('execute payload', () => {
|
|
|
b.ctx.on('command/executed', rejectingListener)
|
|
|
b.ctx.on('command/executed', after)
|
|
|
|
|
|
- await expect(outcome.claim.submit('ship it', new Context())).resolves.toEqual({ kind: 'success' })
|
|
|
+ await expect(outcome.claim.submit('ship it', new Context(), [])).resolves.toEqual({ kind: 'success' })
|
|
|
expect(after).toHaveBeenCalledOnce()
|
|
|
await Promise.resolve()
|
|
|
await Promise.resolve()
|
|
|
@@ -557,10 +630,10 @@ describe('execute payload', () => {
|
|
|
return outcome.claim
|
|
|
}
|
|
|
const first = await claimOf({ execute: () => Promise.resolve({ matched: false }) })
|
|
|
- const bad = await first.submit('x', new Context())
|
|
|
+ const bad = await first.submit('x', new Context(), [])
|
|
|
expect(bad.kind).toBe('error')
|
|
|
const second = await claimOf({ execute: () => Promise.resolve({ matched: true }) })
|
|
|
- await expect(second.submit('', new Context())).resolves.toEqual({ kind: 'success' })
|
|
|
+ await expect(second.submit('', new Context(), [])).resolves.toEqual({ kind: 'success' })
|
|
|
})
|
|
|
})
|
|
|
|
|
|
@@ -584,7 +657,7 @@ describe('detached admission notices', () => {
|
|
|
|
|
|
// Admission miss (matched:false): immediate composer feedback stays.
|
|
|
mode = 'miss'
|
|
|
- await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal)
|
|
|
+ await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal, { images: 0 })
|
|
|
await flush()
|
|
|
expect(notices).toEqual([{ scope: sid('s1'), level: 'error', text: 'unknown or malformed command: /plan' }])
|
|
|
|
|
|
@@ -663,7 +736,7 @@ describe('popupFor', () => {
|
|
|
consumes.push(r)
|
|
|
return true
|
|
|
})
|
|
|
- await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal)
|
|
|
+ await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal, { images: 0 })
|
|
|
const popup = command.popupFor(scope.ctx)
|
|
|
await Promise.resolve()
|
|
|
await popup.select(0)
|
|
|
@@ -674,7 +747,7 @@ describe('popupFor', () => {
|
|
|
const { command, source, mint } = await bench()
|
|
|
command.register(themeContribution())
|
|
|
const scope = mint('s1')
|
|
|
- await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal)
|
|
|
+ await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal, { images: 0 })
|
|
|
const popup = command.popupFor(scope.ctx)
|
|
|
expect(popup.state.getSnapshot().open).toBe(true)
|
|
|
|