mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge master into docs/post-v3-release-proofreading
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-command/README.md
|
||||
README.md: 60b70cfbc3784dd5b138f8857270c4bbdc0fd634
|
||||
README.zh.md: 0429b980092acf9cbf808b1571c92ddea937e780
|
||||
README.md: 0281df76fe601eaad86cefc0dcaecc6d8999df60
|
||||
README.zh.md: 87c98cc92b80fe62a7522bf95a5612c388e07a1a
|
||||
|
||||
@@ -8,6 +8,8 @@ Client command API (`ctx.command`): the session-keyed command-directory cache, t
|
||||
|
||||
`CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the forwarded `commands/change` owner event (old snapshots serve while the repull flies) and by forwarded `agent-preset/selected` for that one session (recomposing an agent registers nothing, so the registry-wide signal never fires for it), hard-invalidated by `connection/reset`, and epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt.
|
||||
|
||||
After `command.execute` returns a matched command result, this browser emits local `command/executed(sessionId, name, result)`. Other clients receive the durable command nodes through the Host event stream but never this acknowledgment, so a browser-only side effect can select successful results from the client that submitted the command without treating Session replay as an action request. Listener failures are logged and contained one by one; they cannot change the already-admitted command result or prevent later listeners from running.
|
||||
|
||||
Menu queries fuzzy-match ordered, case-insensitive subsequences of command names. Prefixes rank first; separator boundaries, adjacent characters, and shorter gaps rank the remaining matches, with directory and contribution order breaking ties. This affects discovery only: space and Enter still require an exact command name. Rationale: [Web slash-command fuzzy discovery](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md).
|
||||
|
||||
`PopupSelectController` (`src/client/popup.ts`) is the headless shell state: `PopupSelectView` self-registers into `conversation.input.overlay` (the SlotMap key is ui-conversation's; this package pulls the declaration in with a type-only import — no runtime edge). The shell is a transient layer holding focus while open; token-segment consumption after onSelect runs both branches through `consumeTokenSegment` (menu-path span CAS, enter-path bare-token equality) against the draft face the wiring layer binds via `bindDraft`.
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
`CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent,若预热它,就会仅因查看持久化历史而激活子代理。缓存项由转发的 owner 事件 `commands/change` 软失效(重拉在途期间旧快照继续服务),也由转发的 `agent-preset/selected` 对该会话单独软失效(重组 agent 不产生任何注册,注册表级信号不会为它触发),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。
|
||||
|
||||
`command.execute` 返回已匹配的命令结果后,当前浏览器会发布本地 `command/executed(sessionId, name, result)`。其他客户端只会通过 Host 事件流收到持久命令节点,不会收到这条确认,因此浏览器专属副作用可以筛选由实际提交命令的客户端收到的成功结果,而不会把 Session 回放当成操作请求。监听器失败会逐项记录并隔离,不会改变已经准入的命令结果,也不会阻止后续监听器运行。
|
||||
|
||||
菜单查询会按顺序且不区分大小写地模糊匹配命令名的子序列。前缀排名最高;其余匹配项按分隔符边界优先、相邻字符优先、间隔越短越优先的规则排序,若仍同分,则以目录顺序和贡献项顺序打破平局。此行为只影响命令发现:space 和 Enter 仍要求命令名精确匹配。原理:[Web 斜杠命令模糊发现](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)。
|
||||
|
||||
`PopupSelectController`(`src/client/popup.ts`)是不含界面的外壳状态:`PopupSelectView` 自行注册进 `conversation.input.overlay`(SlotMap key 归 ui-conversation 所有;本包只以 type-only 导入引入该声明——没有运行时依赖边)。壳是打开期间持有焦点的瞬态层;onSelect 之后的 token 片段消费在两条分支上都经 `consumeTokenSegment` 执行(菜单路径做 span CAS,回车路径做裸 token 相等比较),作用于接线层经 `bindDraft` 绑定的草稿表层。
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { Context } from '@deepseek-ai/cordis'
|
||||
// Type-only: pulls the ctx.remote merge and the forwarded-event key face
|
||||
// (`commands/change` rides the allowlist) into this program.
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { CommandResult } from '@deepseek-ai/dsh-commands/types'
|
||||
import type { ClientContext, ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick,
|
||||
@@ -23,6 +24,28 @@ import { CommandDirectory } from './directory.ts'
|
||||
import { PopupSelectController } from './popup.ts'
|
||||
import type { TokenSegment } from './popup.ts'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
* This browser client completed one admitted Host command execution.
|
||||
* Other clients receive the durable command nodes but never this local
|
||||
* submission acknowledgment.
|
||||
* @param sessionId - Session addressed by the local submission.
|
||||
* @param name - Executed command name without the leading slash.
|
||||
* @param result - Host command result returned to this browser.
|
||||
* @mode emit
|
||||
*/
|
||||
'command/executed'(sessionId: SessionId, name: string, result: CommandResult): void
|
||||
}
|
||||
}
|
||||
|
||||
/** Recover the command name from a line the Host confirmed as executed. */
|
||||
function submittedCommandName(line: string): string {
|
||||
const trimmed = line.trim()
|
||||
const separator = trimmed.search(/\s/u)
|
||||
return (separator === -1 ? trimmed : trimmed.slice(0, separator)).slice(1)
|
||||
}
|
||||
|
||||
/** Live mutable state in one holder (service methods run behind the caller-ctx tracker). */
|
||||
interface LiveState {
|
||||
readonly contributions: Map<string, CommandContribution>
|
||||
@@ -351,9 +374,33 @@ export class CommandService extends Service implements CommandServiceContract {
|
||||
const result = await this.ctx.remote.commands.execute(session.sessionId, line)
|
||||
if (!result.ok) throw new Error(`command.execute failed: ${result.error.code}: ${result.error.message}`)
|
||||
if (result.value === undefined) return { kind: 'error', text: `unknown or malformed command: ${line}` }
|
||||
this.notifyExecuted(session.sessionId, submittedCommandName(line), result.value.result)
|
||||
return { kind: 'success' }
|
||||
}
|
||||
|
||||
/** Publish the local acknowledgment without letting an observer change command admission. */
|
||||
private notifyExecuted(sessionId: SessionId, name: string, result: CommandResult): void {
|
||||
const args = ['command/executed', sessionId, name, result]
|
||||
for (const listener of this.ctx.events.dispatch('emit', args) as Array<(...listenerArgs: unknown[]) => unknown>) {
|
||||
try {
|
||||
const returned = listener(sessionId, name, result)
|
||||
if (returned != null && typeof (returned as PromiseLike<unknown>).then === 'function') {
|
||||
void Promise.resolve(returned as PromiseLike<unknown>).then(undefined, (error: unknown) => {
|
||||
this.warnExecutedListenerFailure(name, error)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
this.warnExecutedListenerFailure(name, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Log one contained `command/executed` observer failure. */
|
||||
private warnExecutedListenerFailure(name: string, error: unknown): void {
|
||||
this.ctx.logger.warn('client command: a command/executed listener for "%s" failed', name)
|
||||
this.ctx.logger.warn(error)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget execute for the internal ('handled') paths. Outcomes are
|
||||
* NOT surfaced here: the host executor durably logs the command lifecycle
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
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, SlashPick, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
@@ -120,6 +121,10 @@ async function bench(opts: BenchOptions = {}) {
|
||||
},
|
||||
})
|
||||
ctx.provide('remote.commands', commandsRemote)
|
||||
const executions: Array<{ sessionId: SessionId; name: string; result: CommandResult }> = []
|
||||
ctx.on('command/executed', (sessionId, name, result) => {
|
||||
executions.push({ sessionId, name, result })
|
||||
})
|
||||
/** Notices the fake conversation face collected (runDetached routing). */
|
||||
const notices: Array<{ scope: SessionId | undefined; level: 'info' | 'error'; text: string }> = []
|
||||
ctx.provide('conversation', {
|
||||
@@ -145,7 +150,7 @@ async function bench(opts: BenchOptions = {}) {
|
||||
const warm = async (session: ClientSessionContext) => {
|
||||
await source.candidates(session, { query: '', position: 'leading', signal: new AbortController().signal })
|
||||
}
|
||||
return { ctx, fiber, command, source, mint, warm, listCalls, executeCalls, registered, notices }
|
||||
return { ctx, fiber, command, source, mint, warm, listCalls, executeCalls, executions, registered, notices }
|
||||
}
|
||||
|
||||
function menuPick(source: SlashSource, name: string, session: ClientSessionContext, end?: number) {
|
||||
@@ -367,7 +372,7 @@ describe('dispatch (menu column)', () => {
|
||||
})
|
||||
|
||||
it('host bare → consume-token span guard on the session scope + detached execute', async () => {
|
||||
const { source, mint, warm, executeCalls } = await bench()
|
||||
const { source, mint, warm, executeCalls, executions } = await bench()
|
||||
const scope = mint('s1')
|
||||
const consumes: ConsumeTokenRequest[] = []
|
||||
scope.ctx.on('slash/input-consume-token', (r) => {
|
||||
@@ -377,8 +382,14 @@ describe('dispatch (menu column)', () => {
|
||||
await warm(proj('s1'))
|
||||
expect(menuPick(source, 'plan', proj('s1'), 5)).toBe('handled')
|
||||
expect(consumes).toEqual([{ guard: { kind: 'span', span: { start: 0, end: 5, draftRev: 3 } } }])
|
||||
await Promise.resolve()
|
||||
expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }])
|
||||
await vi.waitFor(() => {
|
||||
expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }])
|
||||
expect(executions).toEqual([{
|
||||
sessionId: sid('s1'),
|
||||
name: 'plan',
|
||||
result: { kind: 'success' },
|
||||
}])
|
||||
})
|
||||
})
|
||||
|
||||
it('a name the directory no longer serves → undefined (snapshot swapped between menu and pick)', async () => {
|
||||
@@ -496,7 +507,7 @@ describe('matchEnter (enter column)', () => {
|
||||
|
||||
describe('execute payload', () => {
|
||||
it('claim.submit addresses the session; admitted outcomes stay off the composer (flow card owns them)', async () => {
|
||||
const { source, warm, executeCalls } = await bench({
|
||||
const { source, warm, executeCalls, executions } = await bench({
|
||||
execute: () => Promise.resolve({ matched: true }),
|
||||
})
|
||||
await warm(proj('s1'))
|
||||
@@ -507,6 +518,34 @@ describe('execute payload', () => {
|
||||
// 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' })
|
||||
expect(executions).toEqual([{
|
||||
sessionId: sid('s1'),
|
||||
name: 'goal',
|
||||
result: { kind: 'success' },
|
||||
}])
|
||||
})
|
||||
|
||||
it('contains local acknowledgment listeners without changing an admitted result', async () => {
|
||||
const b = await bench({ execute: () => Promise.resolve({ matched: true }) })
|
||||
await b.warm(proj('s1'))
|
||||
const outcome = b.source.matchSpace!(proj('s1'), '/goal')
|
||||
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
|
||||
const syncFailure = new Error('sync observer failed')
|
||||
const asyncFailure = new Error('async observer failed')
|
||||
const after = vi.fn()
|
||||
const warn = vi.spyOn(b.ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
b.ctx.on('command/executed', () => { throw syncFailure })
|
||||
const rejectingListener = (() => Promise.reject(asyncFailure)) as unknown as () => void
|
||||
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' })
|
||||
expect(after).toHaveBeenCalledOnce()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(warn).toHaveBeenCalledWith('client command: a command/executed listener for "%s" failed', 'goal')
|
||||
expect(warn).toHaveBeenCalledWith(syncFailure)
|
||||
expect(warn).toHaveBeenCalledWith(asyncFailure)
|
||||
})
|
||||
|
||||
it('maps matched:false to an error outcome and a matched bare result to success', async () => {
|
||||
|
||||
Reference in New Issue
Block a user