Files
deepseek-harness/packages/feedback/command-feedback/tests/loader-composition.spec.ts
T
creatixchu 8d9fee19f9 feat(commands): route composer image attachments through slash commands
A claimed slash command consumed only the text half of the composer
submission: /goal with reference images executed, cleared the draft, and
silently stranded the images in the rail. Model-visible attachment intent
had no route through the command plane.

The submission envelope is now modeled end to end. CommandDefinition
input.images declares acceptance; the declaration rides the descriptor to
every client, onto the minted CommandClaim, and into the input machine's
claim snapshot. commands.execute carries the submission's base64 images
and enforces the declaration in the executor: non-declaring commands, a
missing attachment store, and exceeded batch limits settle as logged
error results before the handler runs. Admission reuses the attachment
package's new admitEncodedImages, extracted from api-proxy's prompt path
so both wire endpoints share one limits/validation/commit sequence.

Producers own model visibility: /goal submits one user followup (image
blocks + a fixed reference line) after a successful create/edit so goal
rounds read the images from session history; /plan folds them into its
steered message. Grammar misfits (/goal pause, bare /plan, /plan off)
return direct errors and the composer keeps the images.

On the client, enter adjudication carries a SubmitEnvelope and every
command route that cannot consume images throws a localized refusal that
renders as one composer notice with draft and images retained; the
claimed pre-gate applies the same copy. An accepting claim serializes the
draft images, forwards them to commands.execute, and clears plus releases
them only on a success outcome.

The assembled web test roster gains the ui-input-trigger and ui-commands
plugins, mirroring the shipped composition, so slash submissions exercise
the command plane; a new keyless snapshot pins the refusal banner and the
accepting /goal flow over the built client graph.
2026-08-17 18:57:55 +08:00

118 lines
4.9 KiB
TypeScript

import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import CommandRuntime from '@deepseek-ai/dsh-commands'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import * as CommandFeedback from '@deepseek-ai/dsh-command-feedback'
import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id'
let root: string | undefined
let context: Context | undefined
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
vi.unstubAllEnvs()
})
/** Register one idle agent over a store-owned session, as an app's spine does. */
function agent(ctx: Context): Agent {
const scope = ctx.plugin(() => {})
const id = SessionId('feedback-loader-agent')
const session = ctx.sessions.create(id)
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
let status: AgentStatus = 'idle'
const value: Agent = {
id,
options: {},
session,
inbox,
ctx: scope.ctx,
get status() { return status },
send: () => {},
followup: () => {},
steer: () => {},
inject: () => {},
cancel() { status = 'idle' },
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
ctx.agents.register(value)
return value
}
describe('/feedback real Loader composition through cordis.yml', () => {
it('boots cordis.yml and records feedback without model-visible output', async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-command-feedback-loader-'))
vi.stubEnv('DSH_HOME', root)
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
"- name: '@deepseek-ai/dsh-agent'",
"- name: '@deepseek-ai/dsh-session'",
"- name: '@deepseek-ai/dsh-commands'",
"- name: '@deepseek-ai/dsh-command-feedback'",
'',
].join('\n'))
context = new Context()
context.baseUrl = pathToFileURL(root).href + '/'
await context.plugin(Loader)
context.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['@deepseek-ai/dsh-agent', AgentRegistry],
['@deepseek-ai/dsh-session', SessionStore],
['@deepseek-ai/dsh-commands', CommandRuntime],
['@deepseek-ai/dsh-command-feedback', CommandFeedback],
])
context.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
return modules.get(specifier)
},
} as unknown as NonNullable<typeof context.loader.internal>
await context.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
await context.loader.await()
const owner = agent(context)
const signal = new AbortController().signal
// Discoverable through the composed registry, as a UI adapter finds it.
expect(context.commands.list(owner).map(command => command.name)).toContain('feedback')
const accepted = await context.commands.execute(owner, '/feedback the diff view is unreadable', [], signal)
const userId = getOrCreateAnonymousUserId({ env: { DSH_HOME: root } })
expect(accepted?.result).toEqual({
kind: 'success',
text: `Feedback recorded for session feedback-loader-agent\nAnonymous user: ${userId}. Session sharing is not configured.`,
})
const rejected = await context.commands.execute(owner, '/feedback', [], signal)
expect(rejected?.result).toEqual({
kind: 'error',
text: 'Feedback text is required. Usage: /feedback <text>',
})
// The domain event owns the payload; generic command bookkeeping omits it.
expect(owner.session.events.map(event => event.type))
.toEqual(['command/run', 'feedback/record', 'command/done', 'command/run', 'command/done'])
const run = owner.session.events.find(event => event.type === 'command/run')
expect(run?.type === 'command/run' && Object.hasOwn(run.data, 'args')).toBe(false)
const feedback = owner.session.events.find(event => event.type === 'feedback/record')
expect(feedback?.type === 'feedback/record' && feedback.data.text).toBe('the diff view is unreadable')
expect(JSON.stringify(owner.session.events).match(/the diff view is unreadable/gu)).toHaveLength(1)
// Nothing reached the model.
expect(owner.session.deriveMessages()).toEqual([])
expect(owner.session.surface.nodes).toEqual([])
})
})