feat: add economy/maximum presets, tool-lab and subagent-cursor extensions
Some checks failed
CI / windows node 24 / wine blocking (push) Has been skipped
CI / node 22.19 (push) Has been skipped
CI / node 26 (push) Has been skipped
CI / python 3.10 / keyless SDK (push) Has been skipped
CI / python runtime / release-shaped Linux x64 (push) Has been skipped
CI / wine apt cache (push) Successful in 7s
CI / serial / linux (push) Has been skipped
Deploy documentation / build (push) Failing after 1m25s
Deploy documentation / deploy (push) Has been skipped
Landlock Run / Matrix (push) Successful in 5s
Release (vendor) / Pack npm tarballs (push) Failing after 2m47s
Release (dsh) / Pack npm tarballs (push) Failing after 1m56s
Sandbox / sandbox e2e (landlock, ubuntu-24.04) (push) Failing after 1m57s
Sandbox / sandbox e2e (bwrap, ubuntu-latest) (push) Failing after 1m19s
Release (vendor) / Publish to npm (push) Has been skipped
Release (dsh) / Publish to npm (push) Has been skipped
CI / serial / windows (self-hosted standby) (push) Has been cancelled
CI / larger-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, typecheck) (push) Has been cancelled
Landlock Run / darwin (no platform package — degradation proof) (push) Has been cancelled
Landlock Run / ${{ matrix.platform }} (push) Has been cancelled
CI / node 24 / static (push) Has been cancelled
CI / node 24 / coverage (push) Has been cancelled
CI / node 24 / snapshots and artifacts (push) Has been cancelled
CI / windows node 24 / native complete (push) Has been cancelled
CI / serial / linux (self-hosted standby) (push) Has been cancelled
CI / serial / macos (push) Has been cancelled
CI / larger-runner-benchmark (16, windows, dsh-windows-2025-16core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (32, windows, dsh-windows-2025-32core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (4, windows, dsh-windows-2025-4core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (64, windows, dsh-windows-2025-64core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (8, windows, dsh-windows-2025-8core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (96, windows, dsh-windows-2025-96core, production-site) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, 16) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, windows, dsh-windows-2025-16core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, windows, dsh-windows-2025-32core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, 4) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, windows, dsh-windows-2025-4core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, windows, dsh-windows-2025-64core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, 8) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, windows, dsh-windows-2025-8core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, windows, dsh-windows-2025-96core, 2) (push) Has been cancelled
CI / all checks passed (push) Has been cancelled
Sandbox / sandbox e2e (seatbelt, macos-latest) (push) Has been cancelled
CI / larger-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, typecheck) (push) Has been cancelled
Sandbox / sandbox e2e (landlock, ubuntu-24.04-arm) (push) Has been cancelled
E2E (real DeepSeek API) / e2e (push) Failing after 1m24s
Some checks failed
CI / windows node 24 / wine blocking (push) Has been skipped
CI / node 22.19 (push) Has been skipped
CI / node 26 (push) Has been skipped
CI / python 3.10 / keyless SDK (push) Has been skipped
CI / python runtime / release-shaped Linux x64 (push) Has been skipped
CI / wine apt cache (push) Successful in 7s
CI / serial / linux (push) Has been skipped
Deploy documentation / build (push) Failing after 1m25s
Deploy documentation / deploy (push) Has been skipped
Landlock Run / Matrix (push) Successful in 5s
Release (vendor) / Pack npm tarballs (push) Failing after 2m47s
Release (dsh) / Pack npm tarballs (push) Failing after 1m56s
Sandbox / sandbox e2e (landlock, ubuntu-24.04) (push) Failing after 1m57s
Sandbox / sandbox e2e (bwrap, ubuntu-latest) (push) Failing after 1m19s
Release (vendor) / Publish to npm (push) Has been skipped
Release (dsh) / Publish to npm (push) Has been skipped
CI / serial / windows (self-hosted standby) (push) Has been cancelled
CI / larger-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, typecheck) (push) Has been cancelled
Landlock Run / darwin (no platform package — degradation proof) (push) Has been cancelled
Landlock Run / ${{ matrix.platform }} (push) Has been cancelled
CI / node 24 / static (push) Has been cancelled
CI / node 24 / coverage (push) Has been cancelled
CI / node 24 / snapshots and artifacts (push) Has been cancelled
CI / windows node 24 / native complete (push) Has been cancelled
CI / serial / linux (self-hosted standby) (push) Has been cancelled
CI / serial / macos (push) Has been cancelled
CI / larger-runner-benchmark (16, windows, dsh-windows-2025-16core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (32, windows, dsh-windows-2025-32core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (4, windows, dsh-windows-2025-4core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (64, windows, dsh-windows-2025-64core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (8, windows, dsh-windows-2025-8core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (96, windows, dsh-windows-2025-96core, production-site) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, 16) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, windows, dsh-windows-2025-16core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, windows, dsh-windows-2025-32core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, 4) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, windows, dsh-windows-2025-4core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, windows, dsh-windows-2025-64core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, 8) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, windows, dsh-windows-2025-8core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, windows, dsh-windows-2025-96core, 2) (push) Has been cancelled
CI / all checks passed (push) Has been cancelled
Sandbox / sandbox e2e (seatbelt, macos-latest) (push) Has been cancelled
CI / larger-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, typecheck) (push) Has been cancelled
Sandbox / sandbox e2e (landlock, ubuntu-24.04-arm) (push) Has been cancelled
E2E (real DeepSeek API) / e2e (push) Failing after 1m24s
- new economy and maximum agent presets with three-role pipeline skill - new packages/extensions/tool-lab (home-lab ComfyUI/Docling/Whishper tools) - new packages/subagent/subagent-cursor provider - openrouter balance UI with on-demand refresh - session projection context-seed boundary fold - regenerate docs catalogs; keep local searxng benchmark scripts
This commit is contained in:
@@ -302,6 +302,15 @@
|
||||
config:
|
||||
providerName: fork
|
||||
|
||||
- id: subagent-codex
|
||||
name: '@deepseek-ai/dsh-subagent-codex'
|
||||
|
||||
- id: subagent-claude-code
|
||||
name: '@deepseek-ai/dsh-subagent-claude-code'
|
||||
|
||||
- id: subagent-cursor
|
||||
name: '@deepseek-ai/dsh-subagent-cursor'
|
||||
|
||||
# Continuable background children are selected per delegation tool. The
|
||||
# separately loaded follow-up tool registers the one global `send_message`.
|
||||
- id: tool-subagent-control
|
||||
|
||||
@@ -64,6 +64,8 @@
|
||||
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-pi-ai": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-lsp": "workspace:^",
|
||||
"@deepseek-ai/dsh-lsp-stdio": "workspace:^",
|
||||
"@deepseek-ai/dsh-permission-presets": "workspace:^",
|
||||
"@deepseek-ai/dsh-plan-mode": "workspace:^",
|
||||
"@deepseek-ai/dsh-pwsh-sandbox": "workspace:^",
|
||||
@@ -85,6 +87,9 @@
|
||||
"@deepseek-ai/dsh-spill-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-spill-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-claude-code": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-codex": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-cursor": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-fork-in-process": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
@@ -104,6 +109,7 @@
|
||||
"@deepseek-ai/dsh-tool-subagent-control": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-subagent-report": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-jobs": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-lsp": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-web": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
|
||||
|
||||
@@ -35,10 +35,18 @@ describe('dsh-base bundle', () => {
|
||||
expect(rows.find(row => row.id === 'session-telemetry-otel')?.config?.['mode']).toEqual({
|
||||
__jsExpr: "process.env.DSH_TELEMETRY_MODE || 'DISABLED'",
|
||||
})
|
||||
expect(rows.filter(row => row.id === 'subagent-codex')).toHaveLength(0)
|
||||
expect(rows.filter(row => row.id === 'subagent-claude-code')).toHaveLength(0)
|
||||
expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-codex')
|
||||
expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-claude-code')
|
||||
// The base layer mounts each external product agent exactly once, so a
|
||||
// Profile enables one by removing `disabled` from its Agent Preset tool
|
||||
// row rather than mounting a duplicate provider. Loading a provider starts
|
||||
// no product process; a run still needs that product's own CLI on PATH.
|
||||
for (const [id, dependency] of [
|
||||
['subagent-codex', '@deepseek-ai/dsh-subagent-codex'],
|
||||
['subagent-claude-code', '@deepseek-ai/dsh-subagent-claude-code'],
|
||||
['subagent-cursor', '@deepseek-ai/dsh-subagent-cursor'],
|
||||
] as const) {
|
||||
expect(rows.filter(row => row.id === id)).toHaveLength(1)
|
||||
expect(manifest.dependencies).toHaveProperty(dependency)
|
||||
}
|
||||
})
|
||||
|
||||
it('gates each shell stack by platform with a symmetric disabled expression', () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ export type AgentPresetSettingsKey =
|
||||
| 'presetCodeName' | 'presetCodeDescription'
|
||||
| 'presetMinimalName' | 'presetMinimalDescription'
|
||||
| 'presetCordisName' | 'presetCordisDescription'
|
||||
| 'presetMaximumName' | 'presetMaximumDescription'
|
||||
| 'duplicate' | 'duplicateUnavailable' | 'delete' | 'presetId' | 'presetIdPlaceholder' | 'copyOf'
|
||||
| 'displayName' | 'displayNamePlaceholder'
|
||||
| 'inUse' | 'noDescription' | 'builtInGroup' | 'customGroup'
|
||||
@@ -46,6 +47,9 @@ export const en: Record<AgentPresetSettingsKey, string> = {
|
||||
presetCordisName: 'Creator mode',
|
||||
presetCordisDescription:
|
||||
'Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance.',
|
||||
presetMaximumName: 'Maximum mode',
|
||||
presetMaximumDescription:
|
||||
'Every capability this deployment can compose — terminals, language servers, session history, MCP, external agents, and Code Mode beside the native tools — delivering each change through an architect, an implementer, and a reviewer.',
|
||||
duplicate: 'Duplicate',
|
||||
duplicateUnavailable: 'This deployment has no writable preset directory',
|
||||
delete: 'Delete',
|
||||
@@ -106,6 +110,8 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
|
||||
presetMinimalDescription: '仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。',
|
||||
presetCordisName: '创造模式',
|
||||
presetCordisDescription: '用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。',
|
||||
presetMaximumName: '全能模式',
|
||||
presetMaximumDescription: '组合本部署能提供的全部能力 —— 终端、语言服务器、会话历史、MCP、外部 Agent,以及与原生工具并存的 Code Mode —— 并由架构师、实现者、审查者三个角色接力交付每一次改动。',
|
||||
duplicate: '复制',
|
||||
duplicateUnavailable: '此部署未配置可写的预设目录',
|
||||
delete: '删除',
|
||||
@@ -171,6 +177,7 @@ const BUILT_IN_PRESET_KEYS: Readonly<Partial<Record<string, PresetLocaleKeys>>>
|
||||
code: { name: 'presetCodeName', description: 'presetCodeDescription' },
|
||||
minimal: { name: 'presetMinimalName', description: 'presetMinimalDescription' },
|
||||
cordis: { name: 'presetCordisName', description: 'presetCordisDescription' },
|
||||
maximum: { name: 'presetMaximumName', description: 'presetMaximumDescription' },
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,13 +13,21 @@ only once a step actually priced — no zero-cost group. An unknown-pricing step
|
||||
count surfaces in a tooltip so the figure's coverage stays visible. `CostDock`
|
||||
therefore owns no store, refresh chain, or event listener.
|
||||
|
||||
The figure covers the session plus its subagent subtree, walked over the
|
||||
session list's `parentId`/`origin` rows; a subtree share names itself in the
|
||||
label. Each projection value describes only its own session's work — the host
|
||||
fold excludes a forked child's inherited prefix — so the walk sums rather than
|
||||
double counts. Ordinary forks are not delegated work and stay out of it.
|
||||
|
||||
`BalanceBadge` is account-global, so it is not a session projection: the slot
|
||||
inject face carries a `snapshot()` callback that polls the host
|
||||
`ctx.remote.openRouterUsage.snapshot()` Remote on a 60s interval and drives
|
||||
local state. In the collapsed rail it renders only the balance marker; in the
|
||||
wide column a labeled pill. Unknown or absent figures render the empty
|
||||
placeholder. Both entries render nothing before real data exists, so an
|
||||
assembly without the OpenRouter gateway or key costs no layout.
|
||||
local state. Clicking the badge calls the `refresh()` callback instead, which
|
||||
makes the host fetch the account immediately; the badge marks itself
|
||||
`aria-busy` for the duration and ignores further clicks until it settles, and
|
||||
a failed refresh keeps the last-known figure. In the collapsed rail it renders
|
||||
only the balance marker; in the wide column a labeled pill. Unknown or absent
|
||||
figures render the empty placeholder.
|
||||
|
||||
The `/client` exports are the plugin body (`apply`/`inject`), the
|
||||
`CostDock`/`BalanceBadge` components, and the injected face types.
|
||||
@@ -42,5 +50,10 @@ account figure; neither is a new model-visible input.
|
||||
projection holds; figures for already-folded history reflect the pricing
|
||||
table at fold time (see `dsh-openrouter-usage`).
|
||||
- **Polled, not pushed** — `BalanceBadge` polls the host snapshot; there is
|
||||
no `openRouterUsage` forwarded-event channel, so the figure updates on the
|
||||
poll interval rather than instantly.
|
||||
no `openRouterUsage` forwarded-event channel, so an account change the user
|
||||
did not click for surfaces on the poll interval rather than instantly.
|
||||
- **A cold subagent contributes nothing** — the subtree walk reads the
|
||||
descendants' projection values the client holds. A subagent session never
|
||||
opened, and with no version-matching row in the host's cold projection
|
||||
cache, carries no value and is indistinguishable from a zero-cost child, so
|
||||
its spend is missing from the total rather than marked absent.
|
||||
|
||||
@@ -14,7 +14,12 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 28px;
|
||||
cursor: default;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.badge[aria-busy='true'] {
|
||||
cursor: progress;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.badge:hover {
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
// plus the latest snapshot's USD figure in both widths (rail = marker-only,
|
||||
// wide = label + figure). The value is account-global, so it is not a session
|
||||
// projection: an injected `snapshot` callback polls the host Remote gateway on
|
||||
// an interval and drives local state. `wide` arrives from the sidebar's owner
|
||||
// share; `snapshot` from the register's inject face.
|
||||
// an interval, a click runs `refresh` for an on-demand read, and both drive
|
||||
// local state. `wide` arrives from the sidebar's owner share; the two
|
||||
// callbacks from the register's inject face.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { OpenRouterBalance } from '@deepseek-ai/dsh-openrouter-usage/client'
|
||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { formatUsd } from './money.ts'
|
||||
@@ -14,25 +15,34 @@ import css from './BalanceBadge.module.css'
|
||||
/** Poll interval for the balance figure, ms. */
|
||||
export const BALANCE_POLL_MS = 60_000
|
||||
|
||||
/** Injected face: the balance read callback the badge polls. */
|
||||
/** Injected face: the balance reads the badge polls and re-runs on demand. */
|
||||
export interface BalanceBadgeActions {
|
||||
/** Fetch the latest account snapshot from the host gateway. */
|
||||
/** Read the host gateway's cached account snapshot. */
|
||||
snapshot: () => Promise<OpenRouterBalance>
|
||||
/** Make the host fetch the account NOW and serve the result. */
|
||||
refresh: () => Promise<OpenRouterBalance>
|
||||
}
|
||||
|
||||
/** Composed props: the sidebar footer-action owner share plus the injected read. */
|
||||
/** Composed props: the sidebar footer-action owner share plus the injected reads. */
|
||||
export type BalanceBadgeProps =
|
||||
PropsRuntime<'sidebar.footer.action'> & BalanceBadgeActions & PropsLocale<'openRouterUsage'>
|
||||
|
||||
/**
|
||||
* Poll `snapshot` on an interval and render the account balance. Unknown or
|
||||
* absent figures render the empty placeholder so the seat costs no confusion.
|
||||
* A transient failure keeps the last-known figure and retries next tick.
|
||||
* @param props - owner state plus the injected snapshot callback.
|
||||
* Poll `snapshot` on an interval, re-read through `refresh` when the user
|
||||
* clicks, and render the account balance. Unknown or absent figures render the
|
||||
* empty placeholder so the seat costs no confusion. A transient failure keeps
|
||||
* the last-known figure and retries next tick; a click while one refresh is
|
||||
* still outstanding is ignored rather than queued.
|
||||
* @param props - owner state plus the injected snapshot/refresh callbacks.
|
||||
* @returns the balance badge.
|
||||
*/
|
||||
export function BalanceBadge({ wide, snapshot, t }: BalanceBadgeProps) {
|
||||
export function BalanceBadge({ wide, snapshot, refresh, t }: BalanceBadgeProps) {
|
||||
const [balance, setBalance] = useState<OpenRouterBalance | undefined>(undefined)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
// Survives the unmount a disposed registration causes mid-fetch: the
|
||||
// resolving callback must not set state on a gone component.
|
||||
const mounted = useRef(true)
|
||||
useEffect(() => () => { mounted.current = false }, [])
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
@@ -53,14 +63,38 @@ export function BalanceBadge({ wide, snapshot, t }: BalanceBadgeProps) {
|
||||
}
|
||||
}, [snapshot])
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
if (refreshing) return
|
||||
setRefreshing(true)
|
||||
void (async () => {
|
||||
try {
|
||||
const next = await refresh()
|
||||
if (mounted.current) setBalance(next)
|
||||
} catch (_failedRefresh) {
|
||||
// The click keeps the last-known figure; the interval poll retries.
|
||||
} finally {
|
||||
if (mounted.current) setRefreshing(false)
|
||||
}
|
||||
})()
|
||||
}, [refresh, refreshing])
|
||||
|
||||
const usd = balance?.balanceUsd ?? null
|
||||
const amount = usd === null
|
||||
? t('balance.empty')
|
||||
: formatUsd(usd)
|
||||
const label = t('balance.label', { amount })
|
||||
const tooltip = balance?.label == null ? undefined : t('balance.tooltip', { label: balance.label })
|
||||
const tooltip = refreshing
|
||||
? t('balance.refreshing')
|
||||
: balance?.label == null ? undefined : t('balance.tooltip', { label: balance.label })
|
||||
return (
|
||||
<button type="button" className={css.badge} title={tooltip} aria-label={label}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.badge}
|
||||
title={tooltip}
|
||||
aria-label={label}
|
||||
aria-busy={refreshing}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className={css.marker} aria-hidden>◈</span>
|
||||
{wide && <span className={css.text}>{label}</span>}
|
||||
</button>
|
||||
|
||||
@@ -2,9 +2,15 @@
|
||||
// Reads the durable openRouterCost projection, so paging and compaction
|
||||
// cannot change the figure; the entry renders nothing until at least one
|
||||
// step priced (no zero-cost group, mirroring the stats line's billing gate).
|
||||
//
|
||||
// The figure covers the session plus its subagent subtree. Each projection
|
||||
// value describes only its own session's work — the host fold excludes a
|
||||
// forked child's inherited prefix — so summing the lineage is a sum, not a
|
||||
// double count.
|
||||
|
||||
import { memo } from 'react'
|
||||
import { memo, useMemo } from 'react'
|
||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: merges openRouterCost into SessionProjectionMap for useProjection.
|
||||
import type {} from '@deepseek-ai/dsh-openrouter-usage/client'
|
||||
import type { OpenRouterCost } from '@deepseek-ai/dsh-openrouter-usage/client'
|
||||
@@ -15,22 +21,75 @@ import css from './CostDock.module.css'
|
||||
export type CostDockProps =
|
||||
PropsRuntime<'conversation.composer.dock'> & PropsLocale<'openRouterUsage'>
|
||||
|
||||
/** Summed spend over one session's subagent subtree. */
|
||||
interface SubtreeCost {
|
||||
totalUsd: number
|
||||
pricedSteps: number
|
||||
unknownModelSteps: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the running session cost as `会话费用 $X.XX`, shown only when the
|
||||
* session actually priced steps. An unknown-pricing step count feeds a
|
||||
* tooltip so the figure's coverage stays visible without polluting the row.
|
||||
* session or any of its subagents actually priced steps. An unknown-pricing
|
||||
* step count feeds a tooltip so the figure's coverage stays visible without
|
||||
* polluting the row.
|
||||
* @param props - framework/runtime props.
|
||||
* @returns the cost group, or null on a zero/absent projection.
|
||||
*/
|
||||
export const CostDock = memo(function CostDock({ useProjection, t }: CostDockProps) {
|
||||
const cost = useProjection('openRouterCost') as OpenRouterCost | undefined
|
||||
if (cost === undefined || cost.pricedSteps === 0) return null
|
||||
const label = t('cost.label', { amount: formatUsd(cost.totalUsd) })
|
||||
export const CostDock = memo(function CostDock({ sessionId, useProjection, useSessions, t }: CostDockProps) {
|
||||
const cost: OpenRouterCost | undefined = useProjection('openRouterCost')
|
||||
const sessionById = useSessions(s => s.byId)
|
||||
|
||||
const subagentCost = useMemo((): SubtreeCost => {
|
||||
let totalUsd = 0
|
||||
let pricedSteps = 0
|
||||
let unknownModelSteps = 0
|
||||
const rows = Object.values(sessionById)
|
||||
// Breadth-first over subagent-origin descendants, one frontier at a time.
|
||||
// `visited` also closes a parentId cycle a corrupt lineage could present.
|
||||
const visited = new Set<SessionId>([sessionId])
|
||||
let frontier: SessionId[] = [sessionId]
|
||||
while (frontier.length > 0) {
|
||||
const next: SessionId[] = []
|
||||
for (const parent of frontier) {
|
||||
for (const row of rows) {
|
||||
if (row.parentId !== parent || row.origin !== 'subagent' || visited.has(row.id)) continue
|
||||
visited.add(row.id)
|
||||
next.push(row.id)
|
||||
// A descendant the client holds no projection value for (never
|
||||
// opened, and no version-matching cold cache row) contributes
|
||||
// nothing; it cannot be told apart from a zero-cost child here.
|
||||
const childCost = row.projectionValues?.openRouterCost
|
||||
if (childCost === undefined) continue
|
||||
totalUsd += childCost.totalUsd
|
||||
pricedSteps += childCost.pricedSteps
|
||||
unknownModelSteps += childCost.unknownModelSteps
|
||||
}
|
||||
}
|
||||
frontier = next
|
||||
}
|
||||
return { totalUsd, pricedSteps, unknownModelSteps }
|
||||
}, [sessionById, sessionId])
|
||||
|
||||
const selfPricedSteps = cost?.pricedSteps ?? 0
|
||||
if (selfPricedSteps === 0 && subagentCost.pricedSteps === 0) return null
|
||||
|
||||
const selfUsd = cost?.totalUsd ?? 0
|
||||
const totalUsd = selfUsd + subagentCost.totalUsd
|
||||
const unknownModelSteps = (cost?.unknownModelSteps ?? 0) + subagentCost.unknownModelSteps
|
||||
|
||||
const label = subagentCost.pricedSteps > 0
|
||||
? t('cost.labelWithSubagents', {
|
||||
amount: formatUsd(totalUsd),
|
||||
subagentAmount: formatUsd(subagentCost.totalUsd),
|
||||
})
|
||||
: t('cost.label', { amount: formatUsd(totalUsd) })
|
||||
|
||||
return (
|
||||
<span
|
||||
className={css.cost}
|
||||
title={cost.unknownModelSteps > 0
|
||||
? t('cost.tooltipUnknown', { count: String(cost.unknownModelSteps) })
|
||||
title={unknownModelSteps > 0
|
||||
? t('cost.tooltipUnknown', { count: String(unknownModelSteps) })
|
||||
: undefined}
|
||||
>
|
||||
{label}
|
||||
|
||||
@@ -63,6 +63,14 @@ export function apply(ctx: ClientContext): void {
|
||||
return result.value
|
||||
}
|
||||
|
||||
const refresh: BalanceBadgeActions['refresh'] = async () => {
|
||||
const result = await ctx.remote.openRouterUsage.refresh()
|
||||
if (!result.ok) {
|
||||
throw new Error(`openRouterUsage.refresh failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
return result.value
|
||||
}
|
||||
|
||||
// Account balance is account-global (root scope): the injected face carries
|
||||
// the snapshot read the badge polls. The seat is declared but unhoused by
|
||||
// default; this registration names it, so an assembly without ui-sidebar
|
||||
@@ -72,6 +80,6 @@ export function apply(ctx: ClientContext): void {
|
||||
id: 'openrouter-balance',
|
||||
order: 10,
|
||||
locale: NS,
|
||||
inject: (): BalanceBadgeActions => ({ snapshot }),
|
||||
inject: (): BalanceBadgeActions => ({ snapshot, refresh }),
|
||||
}, BalanceBadge))
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
'cost.label': '会话费用 {amount}',
|
||||
'cost.labelWithSubagents': '会话费用 {amount}(含子代理 {subagentAmount})',
|
||||
'cost.tooltipUnknown': '包含 {count} 个未知定价步骤',
|
||||
'balance.label': '余额 {amount}',
|
||||
'balance.tooltip': 'OpenRouter 账户余额({label})',
|
||||
'balance.tooltip': 'OpenRouter 账户余额({label})— 点击刷新',
|
||||
'balance.empty': '——',
|
||||
'balance.refreshing': '正在刷新 OpenRouter 账户余额',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The openrouterUsage namespace key union. */
|
||||
@@ -15,8 +17,10 @@ export type OpenRouterUsageKey = keyof typeof zh
|
||||
/** English dictionary, checked complete against the zh key set. */
|
||||
export const en = {
|
||||
'cost.label': 'Session cost {amount}',
|
||||
'cost.labelWithSubagents': 'Session cost {amount} (subagents {subagentAmount})',
|
||||
'cost.tooltipUnknown': 'Includes {count} unpriced steps',
|
||||
'balance.label': 'Balance {amount}',
|
||||
'balance.tooltip': 'OpenRouter account balance ({label})',
|
||||
'balance.tooltip': 'OpenRouter account balance ({label}) — click to refresh',
|
||||
'balance.empty': '——',
|
||||
'balance.refreshing': 'Refreshing the OpenRouter account balance',
|
||||
} satisfies Record<OpenRouterUsageKey, string>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// @vitest-environment jsdom
|
||||
// BalanceBadge presentation: polls an injected snapshot callback and renders
|
||||
// 余额 in both widths, hiding unknown figures behind the empty placeholder.
|
||||
// 余额 in both widths, hiding unknown figures behind the empty placeholder,
|
||||
// and re-reads through the injected refresh callback when the user clicks it.
|
||||
|
||||
import { act, cleanup, render, screen } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
@@ -34,6 +35,7 @@ function makeProps(over: Partial<BalanceBadgeProps> = {}) {
|
||||
return {
|
||||
wide: true,
|
||||
snapshot: vi.fn(async () => makeBalance()),
|
||||
refresh: vi.fn(async () => makeBalance()),
|
||||
t,
|
||||
...over,
|
||||
} as unknown as Parameters<typeof BalanceBadge>[0]
|
||||
@@ -67,4 +69,37 @@ describe('BalanceBadge', () => {
|
||||
render(<BalanceBadge {...makeProps({ snapshot: vi.fn(async () => makeBalance({ balanceUsd: null })) })} />)
|
||||
expect(await screen.findByLabelText('余额 ——')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('re-reads through refresh on click and renders the fetched figure', async () => {
|
||||
const refresh = vi.fn(async () => makeBalance({ balanceUsd: 42.25 }))
|
||||
render(<BalanceBadge {...makeProps({ refresh })} />)
|
||||
const badge = await screen.findByLabelText('余额 $10.50')
|
||||
await act(async () => { fireEvent.click(badge) })
|
||||
expect(refresh).toHaveBeenCalledOnce()
|
||||
expect(await screen.findByLabelText('余额 $42.25')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('ignores a click while a refresh is still outstanding', async () => {
|
||||
let release: (value: OpenRouterBalance) => void = () => {}
|
||||
const refresh = vi.fn(() => new Promise<OpenRouterBalance>((resolve) => { release = resolve }))
|
||||
render(<BalanceBadge {...makeProps({ refresh })} />)
|
||||
const badge = await screen.findByLabelText('余额 $10.50')
|
||||
await act(async () => { fireEvent.click(badge) })
|
||||
expect(badge.getAttribute('aria-busy')).toBe('true')
|
||||
await act(async () => { fireEvent.click(badge) })
|
||||
expect(refresh).toHaveBeenCalledOnce()
|
||||
await act(async () => { release(makeBalance({ balanceUsd: 7 })) })
|
||||
expect(badge.getAttribute('aria-busy')).toBe('false')
|
||||
await act(async () => { fireEvent.click(badge) })
|
||||
expect(refresh).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps the last-known figure when a refresh fails', async () => {
|
||||
const refresh = vi.fn(async () => { throw new Error('boom') })
|
||||
render(<BalanceBadge {...makeProps({ refresh })} />)
|
||||
const badge = await screen.findByLabelText('余额 $10.50')
|
||||
await act(async () => { fireEvent.click(badge) })
|
||||
expect(await screen.findByLabelText('余额 $10.50')).toBeTruthy()
|
||||
expect(badge.getAttribute('aria-busy')).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -37,8 +37,10 @@ async function bench() {
|
||||
new RemoteService(ctx)
|
||||
const snapshot = vi.fn<() => Promise<RemoteResult<OpenRouterBalance>>>()
|
||||
.mockResolvedValue({ ok: true, value: balance() })
|
||||
ctx.provide('remote.openRouterUsage', { snapshot })
|
||||
return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, snapshot }
|
||||
const refresh = vi.fn<() => Promise<RemoteResult<OpenRouterBalance>>>()
|
||||
.mockResolvedValue({ ok: true, value: balance({ balanceUsd: 9 }) })
|
||||
ctx.provide('remote.openRouterUsage', { snapshot, refresh })
|
||||
return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, snapshot, refresh }
|
||||
}
|
||||
|
||||
function declare(slots: SlotRegistry): () => void {
|
||||
@@ -84,11 +86,14 @@ describe('ui-openrouter-usage browser plugin', () => {
|
||||
expect(badge.options).toMatchObject({ id: 'openrouter-balance', order: 10 })
|
||||
expect(badge.locale).toBe(NS)
|
||||
|
||||
// Registration reads nothing live; the snapshot rolls at call time.
|
||||
// Registration reads nothing live; both reads roll at call time.
|
||||
expect(b.snapshot).not.toHaveBeenCalled()
|
||||
expect(b.refresh).not.toHaveBeenCalled()
|
||||
const injected = (badge.inject as unknown as () => BalanceBadgeActions)()
|
||||
await expect(injected.snapshot()).resolves.toEqual(balance())
|
||||
expect(b.snapshot).toHaveBeenCalledOnce()
|
||||
await expect(injected.refresh()).resolves.toEqual(balance({ balanceUsd: 9 }))
|
||||
expect(b.refresh).toHaveBeenCalledOnce()
|
||||
await b.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -102,6 +107,16 @@ describe('ui-openrouter-usage browser plugin', () => {
|
||||
await b.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('forwards a Remote failure out of the injected refresh verbatim', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
b.refresh.mockResolvedValueOnce({ ok: false, error: { code: 'REMOTE_ERROR', message: 'nope', details: {} } })
|
||||
const injected = (b.slots.entries('sidebar.footer.action')[0]!.inject as unknown as () => BalanceBadgeActions)()
|
||||
await expect(injected.refresh()).rejects.toThrow('openRouterUsage.refresh failed: REMOTE_ERROR: nope')
|
||||
await b.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('follows locale and recovers across late declaration and declarer reload', async () => {
|
||||
const b = await bench()
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
// CostDock presentation: renders the running session cost from the
|
||||
// openRouterCost projection, and nothing at all until a step was priced.
|
||||
// openRouterCost projection plus the subagent subtree read off the session
|
||||
// list, and nothing at all until a step was priced.
|
||||
|
||||
import { act, cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -13,9 +14,37 @@ afterEach(cleanup)
|
||||
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
|
||||
function makeProps(over: Partial<Parameters<typeof CostDock>[0]> = {}) {
|
||||
const PARENT = 'session-parent'
|
||||
|
||||
/** One session-list row as much of it as the dock reads. */
|
||||
interface Row {
|
||||
id: string
|
||||
parentId?: string
|
||||
origin?: 'subagent'
|
||||
cost?: { totalUsd: number; pricedSteps: number; unknownModelSteps: number }
|
||||
}
|
||||
|
||||
/** A `useSessions` stub serving `byId` built from the given rows. */
|
||||
function sessionsHook(rows: readonly Row[]) {
|
||||
const byId: Record<string, unknown> = {}
|
||||
for (const row of rows) {
|
||||
byId[row.id] = {
|
||||
id: row.id,
|
||||
...row.parentId === undefined ? {} : { parentId: row.parentId },
|
||||
...row.origin === undefined ? {} : { origin: row.origin },
|
||||
...row.cost === undefined
|
||||
? {}
|
||||
: { projectionValues: { openRouterCost: { ...row.cost, currency: 'USD' } } },
|
||||
}
|
||||
}
|
||||
return (selector: (state: { byId: Record<string, unknown> }) => unknown) => selector({ byId })
|
||||
}
|
||||
|
||||
function makeProps(over: Partial<Parameters<typeof CostDock>[0]> = {}, rows: readonly Row[] = []) {
|
||||
return {
|
||||
sessionId: PARENT,
|
||||
useProjection: vi.fn(() => undefined),
|
||||
useSessions: sessionsHook(rows),
|
||||
t,
|
||||
...over,
|
||||
} as unknown as Parameters<typeof CostDock>[0]
|
||||
@@ -57,4 +86,53 @@ describe('CostDock', () => {
|
||||
act(() => {})
|
||||
expect(useProjection).toHaveBeenCalledWith('openRouterCost')
|
||||
})
|
||||
|
||||
it('adds the subagent subtree to the figure and names its share', () => {
|
||||
render(<CostDock {...makeProps(
|
||||
{ useProjection: () => ({ totalUsd: 1, pricedSteps: 1, unknownModelSteps: 0, currency: 'USD' }) },
|
||||
[{ id: 'child', parentId: PARENT, origin: 'subagent', cost: { totalUsd: 0.5, pricedSteps: 1, unknownModelSteps: 1 } }],
|
||||
)} />)
|
||||
const row = screen.getByText('会话费用 $1.50(含子代理 $0.50)')
|
||||
expect(row.getAttribute('title')).toBe('包含 1 个未知定价步骤')
|
||||
})
|
||||
|
||||
it('follows a nested subagent chain and ignores non-subagent children', () => {
|
||||
render(<CostDock {...makeProps(
|
||||
{ useProjection: () => ({ totalUsd: 1, pricedSteps: 1, unknownModelSteps: 0, currency: 'USD' }) },
|
||||
[
|
||||
{ id: 'child', parentId: PARENT, origin: 'subagent', cost: { totalUsd: 0.25, pricedSteps: 1, unknownModelSteps: 0 } },
|
||||
{ id: 'grandchild', parentId: 'child', origin: 'subagent', cost: { totalUsd: 0.125, pricedSteps: 1, unknownModelSteps: 0 } },
|
||||
// An ordinary fork of the same parent is not delegated work.
|
||||
{ id: 'sibling', parentId: PARENT, cost: { totalUsd: 9, pricedSteps: 1, unknownModelSteps: 0 } },
|
||||
],
|
||||
)} />)
|
||||
expect(screen.getByText('会话费用 $1.38(含子代理 $0.38)')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the subagent subtree alone when the session itself priced nothing', () => {
|
||||
render(<CostDock {...makeProps(
|
||||
{ useProjection: () => undefined },
|
||||
[{ id: 'child', parentId: PARENT, origin: 'subagent', cost: { totalUsd: 0.75, pricedSteps: 2, unknownModelSteps: 0 } }],
|
||||
)} />)
|
||||
expect(screen.getByText('会话费用 $0.75(含子代理 $0.75)')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('skips a descendant the client holds no projection value for', () => {
|
||||
render(<CostDock {...makeProps(
|
||||
{ useProjection: () => ({ totalUsd: 2, pricedSteps: 1, unknownModelSteps: 0, currency: 'USD' }) },
|
||||
[{ id: 'cold-child', parentId: PARENT, origin: 'subagent' }],
|
||||
)} />)
|
||||
expect(screen.getByText('会话费用 $2.00')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('terminates on a parentId cycle', () => {
|
||||
render(<CostDock {...makeProps(
|
||||
{ useProjection: () => ({ totalUsd: 1, pricedSteps: 1, unknownModelSteps: 0, currency: 'USD' }) },
|
||||
[
|
||||
{ id: 'a', parentId: PARENT, origin: 'subagent', cost: { totalUsd: 0.5, pricedSteps: 1, unknownModelSteps: 0 } },
|
||||
{ id: PARENT, parentId: 'a', origin: 'subagent', cost: { totalUsd: 99, pricedSteps: 1, unknownModelSteps: 0 } },
|
||||
],
|
||||
)} />)
|
||||
expect(screen.getByText('会话费用 $1.50(含子代理 $0.50)')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -282,6 +282,10 @@ function CatalogRows({
|
||||
entry.activity,
|
||||
now,
|
||||
)
|
||||
const costUsd = summary?.projectionValues?.openRouterCost?.totalUsd
|
||||
const costMetric = (costUsd !== undefined && costUsd > 0)
|
||||
? `$${costUsd < 0.01 ? '<0.01' : costUsd.toFixed(2)}`
|
||||
: undefined
|
||||
const tokenMetric = totalTokens === undefined
|
||||
? undefined
|
||||
: `${formatTokens(totalTokens)} tok`
|
||||
@@ -291,7 +295,7 @@ function CatalogRows({
|
||||
compact: formatDuration(durationMs, t),
|
||||
exact: formatExactDuration(durationMs, t),
|
||||
}
|
||||
const metrics = [tokenMetric, durationMetric?.exact]
|
||||
const metrics = [costMetric, tokenMetric, durationMetric?.exact]
|
||||
.filter(value => value !== undefined)
|
||||
.join(' · ')
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ interface UsageLike {
|
||||
cacheWriteTokens?: number
|
||||
outputTokens?: number
|
||||
reasoningTokens?: number
|
||||
costUsd?: number
|
||||
}
|
||||
|
||||
function requestUsage(value: unknown): TrajectoryUsage | undefined {
|
||||
@@ -205,7 +206,9 @@ export function TrajectoryView({
|
||||
const provider = request?.provenance?.provider ?? node?.provenance?.provider
|
||||
const model = request?.provenance?.model ?? node?.provenance?.model
|
||||
const requestConfig = request?.requestConfig ?? node?.requestConfig
|
||||
const stepCostUsd = stepCosts?.[`${turn}:${step}`]
|
||||
const directCostUsd = (entry.request?.usage as UsageLike | undefined)?.costUsd
|
||||
?? (entry.node?.usage as UsageLike | undefined)?.costUsd
|
||||
const stepCostUsd = directCostUsd ?? stepCosts?.[`${turn}:${step}`]
|
||||
numbered.push({
|
||||
seq: entry.seq,
|
||||
turn,
|
||||
@@ -232,6 +235,7 @@ export function TrajectoryView({
|
||||
continue
|
||||
}
|
||||
const request = entry.request
|
||||
const directCostUsd = (request.usage as UsageLike | undefined)?.costUsd
|
||||
numbered.push({
|
||||
seq: request.startSeq,
|
||||
turn: request.turn,
|
||||
@@ -253,6 +257,7 @@ export function TrajectoryView({
|
||||
...(request.requestConfig === undefined ? {} : { requestConfig: request.requestConfig }),
|
||||
...(usage === undefined ? {} : { usage }),
|
||||
...(cumulativeUsage === undefined ? {} : { cumulativeUsage }),
|
||||
...(directCostUsd === undefined ? {} : { stepCostUsd: directCostUsd }),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ interface UsageLike {
|
||||
cacheWriteTokens?: number
|
||||
outputTokens?: number
|
||||
reasoningTokens?: number
|
||||
costUsd?: number
|
||||
}
|
||||
|
||||
/** Cell plus absolute ms for group wall-span descriptions. */
|
||||
|
||||
@@ -18,6 +18,7 @@ interface UsageValue {
|
||||
readonly cacheReadTokens?: number
|
||||
readonly cacheWriteTokens?: number
|
||||
readonly reasoningTokens?: number
|
||||
readonly costUsd?: number
|
||||
}
|
||||
|
||||
interface RetryValue {
|
||||
@@ -101,6 +102,9 @@ function addUsage(current: UsageValue | undefined, next: UsageValue): UsageValue
|
||||
...(current?.reasoningTokens === undefined && next.reasoningTokens === undefined
|
||||
? {}
|
||||
: { reasoningTokens: (current?.reasoningTokens ?? 0) + (next.reasoningTokens ?? 0) }),
|
||||
...(current?.costUsd === undefined && next.costUsd === undefined
|
||||
? {}
|
||||
: { costUsd: (current?.costUsd ?? 0) + (next.costUsd ?? 0) }),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1226,6 +1226,27 @@ describe('TrajectoryView state', () => {
|
||||
expect(screen.getByText('$0.0028')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('attaches the per-step cost directly from node usage even when projection is undefined', () => {
|
||||
const nodesWithCost = [
|
||||
NODES[0]!,
|
||||
{
|
||||
...NODES[1]!,
|
||||
usage: { inputTokens: 100, outputTokens: 50, costUsd: 0.0045 },
|
||||
},
|
||||
NODES[2]!,
|
||||
NODES[3]!,
|
||||
]
|
||||
render(
|
||||
<TrajectoryView
|
||||
{...standaloneProps(nodesWithCost)}
|
||||
{...standaloneHistory(historySnapshot(nodesWithCost))}
|
||||
{...standaloneDuration()}
|
||||
useProjection={(() => undefined) as never}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Request #1 · $0.0045' })).toBeTruthy()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('node half', () => {
|
||||
|
||||
51
packages/extensions/tool-lab/package.json
Normal file
51
packages/extensions/tool-lab/package.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-lab",
|
||||
"description": "Model-facing home-lab AI tools (lab_generate_image via ComfyUI, lab_ocr_pdf via Docling, lab_transcribe_audio via Whishper) over the lab's LAN services",
|
||||
"version": "0.1.0-rc.7",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/extensions/tool-lab"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
129
packages/extensions/tool-lab/src/comfy.ts
Normal file
129
packages/extensions/tool-lab/src/comfy.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* ComfyUI image generation tool. Talks to the lab's ComfyUI server (default
|
||||
* http://192.168.31.240:8188) with a Juggernaut-XL workflow and returns the
|
||||
* served image URL. @module @deepseek-ai/dsh-tool-lab
|
||||
*/
|
||||
|
||||
import { Deadline, sleep } from './helpers.ts'
|
||||
|
||||
/** Root of the resolved lab config. */
|
||||
export interface ResolvedLabConfig {
|
||||
comfyBaseUrl: string
|
||||
doclingBaseUrl: string
|
||||
whishBaseUrl: string
|
||||
timeoutMs: number
|
||||
maxUploadBytes: number
|
||||
maxOutputChars: number
|
||||
}
|
||||
|
||||
/** Model-backed fields ComfyUI exposes on the workflow we build. */
|
||||
interface ComfyApiResult {
|
||||
prompt_id?: string
|
||||
node_errors?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface ComfyHistoryEntry {
|
||||
outputs?: Record<string, { images?: { filename?: string; subfolder?: string; type?: string }[] }>
|
||||
status?: { completed?: boolean; error?: unknown }
|
||||
}
|
||||
|
||||
/** A single ComfyUI generation request. */
|
||||
export interface GenerateImageArgs {
|
||||
prompt: string
|
||||
negative?: string
|
||||
width?: number
|
||||
height?: number
|
||||
steps?: number
|
||||
seed?: number
|
||||
model?: string
|
||||
upscale?: boolean
|
||||
upscale_model?: string
|
||||
filename_prefix?: string
|
||||
}
|
||||
|
||||
/** Compose the fixed 28-step Juggernaut-XL workflow from user-facing arguments. */
|
||||
export function buildWorkflow(args: GenerateImageArgs): Record<string, unknown> {
|
||||
const seed = args.seed ?? Math.floor(Math.random() * 1_000_000)
|
||||
const width = args.width ?? 512
|
||||
const height = args.height ?? 512
|
||||
const steps = args.steps ?? 28
|
||||
const model = args.model ?? 'Juggernaut-XL_v9_RunDiffusionPhoto_v2.safetensors'
|
||||
const negative = args.negative ?? 'blurry, low quality, distorted, watermark, text, extra limbs'
|
||||
const upscale = args.upscale !== false
|
||||
const upscaleModel = args.upscale_model ?? 'RealESRGAN_x4plus.safetensors'
|
||||
const prefix = args.filename_prefix ?? 'dsh'
|
||||
const workflow: Record<string, unknown> = {
|
||||
3: {
|
||||
class_type: 'KSampler',
|
||||
inputs: {
|
||||
seed, steps, cfg: 4.0, sampler_name: 'dpmpp_2m_sde', scheduler: 'karras', denoise: 1.0,
|
||||
model: ['4', 0], positive: ['6', 0], negative: ['7', 0], latent_image: ['5', 0],
|
||||
},
|
||||
},
|
||||
4: { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: model } },
|
||||
5: { class_type: 'EmptyLatentImage', inputs: { width, height, batch_size: 1 } },
|
||||
6: { class_type: 'CLIPTextEncode', inputs: { text: args.prompt, clip: ['4', 1] } },
|
||||
7: { class_type: 'CLIPTextEncode', inputs: { text: negative, clip: ['4', 1] } },
|
||||
8: { class_type: 'VAEDecode', inputs: { samples: ['3', 0], vae: ['4', 2] } },
|
||||
9: { class_type: 'SaveImage', inputs: { filename_prefix: prefix, images: upscale ? ['10', 0] : ['8', 0] } },
|
||||
}
|
||||
if (upscale) {
|
||||
workflow['10'] = { class_type: 'ImageUpscaleWithModel', inputs: { upscale_model: ['11', 0], image: ['8', 0] } }
|
||||
workflow['11'] = { class_type: 'UpscaleModelLoader', inputs: { model_name: upscaleModel } }
|
||||
}
|
||||
return workflow
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll ComfyUI `/history/{prompt_id}` until the image appears or the timeout
|
||||
* budget elapses. Returns the served image URL.
|
||||
*/
|
||||
async function pollImage(
|
||||
baseUrl: string,
|
||||
promptId: string,
|
||||
deadline: Deadline,
|
||||
timeoutMs: number,
|
||||
): Promise<string> {
|
||||
const started = Date.now()
|
||||
for (;;) {
|
||||
deadline.check()
|
||||
const res = await fetch(`${baseUrl}/history/${promptId}`)
|
||||
if (!res.ok) throw new Error(`ComfyUI history ${res.status}: ${await res.text()}`)
|
||||
const history = (await res.json()) as Record<string, ComfyHistoryEntry>
|
||||
const entry = history[promptId]
|
||||
if (entry?.outputs?.['9']?.images?.length) {
|
||||
const img = entry.outputs['9'].images[0]
|
||||
if (!img) throw new Error('ComfyUI history entry missing image 0')
|
||||
return `${baseUrl}/view?filename=${encodeURIComponent(img.filename ?? '')}&subfolder=${encodeURIComponent(img.subfolder ?? '')}&type=${encodeURIComponent(img.type ?? 'output')}`
|
||||
}
|
||||
if (entry?.status?.error) throw new Error(`ComfyUI generation failed: ${JSON.stringify(entry.status.error)}`)
|
||||
if (Date.now() - started >= timeoutMs) throw new Error(`ComfyUI generation timed out after ${timeoutMs}ms`)
|
||||
await sleep(1500, deadline.signal)
|
||||
}
|
||||
}
|
||||
|
||||
/** Run the ComfyUI tool. Reads no files; returns a served image URL string. */
|
||||
export async function runGenerateImage(
|
||||
config: ResolvedLabConfig,
|
||||
args: GenerateImageArgs,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<string> {
|
||||
const deadline = new Deadline('generate_image', config.timeoutMs, signal)
|
||||
try {
|
||||
const clientId = `dsh-${Math.random().toString(36).slice(2)}`
|
||||
const workflow = buildWorkflow(args)
|
||||
const submit = await fetch(`${config.comfyBaseUrl}/prompt`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt: workflow, client_id: clientId }),
|
||||
})
|
||||
if (!submit.ok) throw new Error(`ComfyUI /prompt ${submit.status}: ${await submit.text()}`)
|
||||
const result = (await submit.json()) as ComfyApiResult
|
||||
if (!result.prompt_id) throw new Error(`ComfyUI /prompt no prompt_id: ${JSON.stringify(result)}`)
|
||||
const errors = result.node_errors
|
||||
if (errors && Object.keys(errors).length > 0) throw new Error(`ComfyUI node_errors: ${JSON.stringify(errors)}`)
|
||||
return await pollImage(config.comfyBaseUrl, result.prompt_id, deadline, config.timeoutMs)
|
||||
} finally {
|
||||
deadline.dispose()
|
||||
}
|
||||
}
|
||||
85
packages/extensions/tool-lab/src/docling.ts
Normal file
85
packages/extensions/tool-lab/src/docling.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Docling OCR tool. Reads a local PDF via `ctx.fs`, uploads it to the lab's
|
||||
* Docling server, polls the async task, and returns the extracted markdown.
|
||||
* @module @deepseek-ai/dsh-tool-lab
|
||||
*/
|
||||
|
||||
import type { FileSystem } from '@deepseek-ai/dsh-fs'
|
||||
import { Deadline, sleep } from './helpers.ts'
|
||||
import type { ResolvedLabConfig } from './comfy.ts'
|
||||
|
||||
interface DoclingPoll {
|
||||
task_id?: string
|
||||
task_status?: string
|
||||
}
|
||||
|
||||
interface DoclingResult {
|
||||
document?: { filename?: string; md_content?: string | null }
|
||||
status?: string
|
||||
errors?: unknown[]
|
||||
}
|
||||
|
||||
/** Build FormData upload of a PDF from bytes. */
|
||||
export function buildPdfUpload(data: Uint8Array, filename: string): FormData {
|
||||
const fd = new FormData()
|
||||
fd.append('files', new Blob([data as unknown as BlobPart], { type: 'application/pdf' }), filename)
|
||||
fd.append('options', JSON.stringify({}))
|
||||
return fd
|
||||
}
|
||||
|
||||
/** Poll Docling `/v1/status/poll/{task_id}` until success. */
|
||||
async function pollTask(
|
||||
baseUrl: string,
|
||||
taskId: string,
|
||||
deadline: Deadline,
|
||||
timeoutMs: number,
|
||||
): Promise<void> {
|
||||
const started = Date.now()
|
||||
for (;;) {
|
||||
deadline.check()
|
||||
const res = await fetch(`${baseUrl}/v1/status/poll/${taskId}?wait=2`)
|
||||
if (!res.ok) throw new Error(`Docling poll ${res.status}: ${await res.text()}`)
|
||||
const poll = (await res.json()) as DoclingPoll
|
||||
if (poll.task_status === 'success') return
|
||||
if (poll.task_status === 'failed' || poll.task_status === 'error') {
|
||||
throw new Error(`Docling task failed: ${await (await fetch(`${baseUrl}/v1/result/${taskId}`)).text()}`)
|
||||
}
|
||||
if (Date.now() - started >= timeoutMs) throw new Error(`Docling OCR timed out after ${timeoutMs}ms`)
|
||||
await sleep(1200, deadline.signal)
|
||||
}
|
||||
}
|
||||
|
||||
/** Run the Docling OCR tool: read file, upload, poll, return markdown text. */
|
||||
export async function runOcrPdf(
|
||||
fs: FileSystem,
|
||||
config: ResolvedLabConfig,
|
||||
filePath: string,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<string> {
|
||||
const deadline = new Deadline('ocr_pdf', config.timeoutMs, signal)
|
||||
try {
|
||||
const target = await fs.resolve(filePath, signal === undefined ? undefined : { signal })
|
||||
const stat = await fs.stat(target, signal)
|
||||
if (!stat) throw new Error(`ocr_pdf: file not found: ${filePath}`)
|
||||
const data = await fs.readBytes(target, signal, config.maxUploadBytes)
|
||||
const filename = filePath.split(/[\\/]/).pop() ?? 'document.pdf'
|
||||
const form = buildPdfUpload(data, filename)
|
||||
const submit = await fetch(`${config.doclingBaseUrl}/v1/convert/file/async`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
})
|
||||
if (!submit.ok) throw new Error(`Docling /convert ${submit.status}: ${await submit.text()}`)
|
||||
const poll = (await submit.json()) as DoclingPoll
|
||||
if (!poll.task_id) throw new Error(`Docling no task_id: ${JSON.stringify(poll)}`)
|
||||
await pollTask(config.doclingBaseUrl, poll.task_id, deadline, config.timeoutMs)
|
||||
const res = await fetch(`${config.doclingBaseUrl}/v1/result/${poll.task_id}`)
|
||||
if (!res.ok) throw new Error(`Docling result ${res.status}: ${await res.text()}`)
|
||||
const result = (await res.json()) as DoclingResult
|
||||
const md = result.document?.md_content ?? ''
|
||||
if (md.length === 0) throw new Error('Docling returned empty markdown content')
|
||||
if (md.length > config.maxOutputChars) return md.slice(0, config.maxOutputChars)
|
||||
return md
|
||||
} finally {
|
||||
deadline.dispose()
|
||||
}
|
||||
}
|
||||
62
packages/extensions/tool-lab/src/helpers.ts
Normal file
62
packages/extensions/tool-lab/src/helpers.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Shared helpers for the home-lab tool package: cooperative deadline enforcement
|
||||
* and aborted sleep. @module @deepseek-ai/dsh-tool-lab
|
||||
*/
|
||||
|
||||
/** Error thrown when a lab tool exceeds its cooperative timeout budget. */
|
||||
export class LabToolTimeoutError extends Error {
|
||||
constructor(tool: string, ms: number) {
|
||||
super(`lab tool ${tool} timed out after ${ms}ms`)
|
||||
this.name = 'LabToolTimeoutError'
|
||||
}
|
||||
}
|
||||
|
||||
/** A deadline that aborts when the caller cancels or the budget expires. */
|
||||
export class Deadline {
|
||||
readonly signal: AbortSignal | undefined
|
||||
private readonly timer: ReturnType<typeof setTimeout> | undefined
|
||||
private cancelled = false
|
||||
private elapsed = false
|
||||
|
||||
constructor(
|
||||
private readonly tool: string,
|
||||
private readonly timeoutMs: number,
|
||||
signal: AbortSignal | undefined,
|
||||
) {
|
||||
this.signal = signal
|
||||
if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
|
||||
this.timer = setTimeout(() => this.expire(), timeoutMs)
|
||||
}
|
||||
signal?.addEventListener('abort', () => this.expire(), { once: true })
|
||||
}
|
||||
|
||||
private expire(): void {
|
||||
if (this.cancelled || this.elapsed) return
|
||||
this.elapsed = true
|
||||
}
|
||||
|
||||
/** Throw if the budget has elapsed or the caller aborted. Cheap in loops. */
|
||||
check(): void {
|
||||
if (this.signal?.aborted || this.elapsed) {
|
||||
throw new LabToolTimeoutError(this.tool, this.timeoutMs)
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancel the timer when the tool finishes normally. */
|
||||
dispose(): void {
|
||||
this.cancelled = true
|
||||
if (this.timer !== undefined) clearTimeout(this.timer)
|
||||
}
|
||||
}
|
||||
|
||||
/** Abortable sleep; resolves early or throws on cancellation/expiry. */
|
||||
export async function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
|
||||
if (signal?.aborted) throw new Error('aborted')
|
||||
await new Promise<void>((resolve) => {
|
||||
const t = setTimeout(resolve, ms)
|
||||
signal?.addEventListener('abort', () => {
|
||||
clearTimeout(t)
|
||||
resolve()
|
||||
}, { once: true })
|
||||
})
|
||||
}
|
||||
189
packages/extensions/tool-lab/src/index.ts
Normal file
189
packages/extensions/tool-lab/src/index.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Model-facing home-lab AI tools. Registers `lab_generate_image` (ComfyUI),
|
||||
* `lab_ocr_pdf` (Docling), and `lab_transcribe_audio` (Whishper) over the
|
||||
* lab's LAN services. Tools read local files only via `ctx.fs`; uploads and
|
||||
* polls happen over HTTP. Image results stay server-hosted as URLs.
|
||||
* @module @deepseek-ai/dsh-tool-lab
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import { runGenerateImage, type ResolvedLabConfig } from './comfy.ts'
|
||||
import { runOcrPdf } from './docling.ts'
|
||||
import { runTranscribeAudio } from './whish.ts'
|
||||
|
||||
export { runGenerateImage, buildWorkflow, type GenerateImageArgs } from './comfy.ts'
|
||||
export type { ResolvedLabConfig } from './comfy.ts'
|
||||
export { runOcrPdf, buildPdfUpload } from './docling.ts'
|
||||
export { runTranscribeAudio, buildAudioUpload } from './whish.ts'
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'tool-lab'
|
||||
|
||||
/** Services required by the lab tool suite. */
|
||||
export const inject = ['tools', 'fs']
|
||||
|
||||
/** Default cooperative tool-call timeout budget (ms). */
|
||||
export const DEFAULT_LAB_TIMEOUT_MS = 120_000
|
||||
|
||||
/** Default cap on one upload in bytes and one output in characters. */
|
||||
export const DEFAULT_MAX_UPLOAD_BYTES = 20 * 1024 * 1024
|
||||
export const DEFAULT_MAX_OUTPUT_CHARS = 200_000
|
||||
|
||||
/** Plugin config: which lab tools to register, the base URLs, and limits. */
|
||||
export interface Config {
|
||||
/** Register `lab_generate_image` (ComfyUI). Defaults to true. */
|
||||
generateImage?: boolean
|
||||
/** Register `lab_ocr_pdf` (Docling). Defaults to true. */
|
||||
ocrPdf?: boolean
|
||||
/** Register `lab_transcribe_audio` (Whishper). Defaults to true. */
|
||||
transcribeAudio?: boolean
|
||||
/** ComfyUI base URL. Defaults to http://192.168.31.240:8188 */
|
||||
comfyBaseUrl?: string
|
||||
/** Docling base URL. Defaults to http://192.168.31.159:5001 */
|
||||
doclingBaseUrl?: string
|
||||
/** Whishper base URL. Defaults to http://192.168.31.159:8082 */
|
||||
whishBaseUrl?: string
|
||||
/** Cooperative timeout budget (ms). Defaults to 120000. */
|
||||
timeoutMs?: number
|
||||
/** Cap on upload bytes. Defaults to 20971520. */
|
||||
maxUploadBytes?: number
|
||||
/** Cap on output characters. Defaults to 200000. */
|
||||
maxOutputChars?: number
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
generateImage: z.boolean().default(true),
|
||||
ocrPdf: z.boolean().default(true),
|
||||
transcribeAudio: z.boolean().default(true),
|
||||
comfyBaseUrl: z.string().default('http://192.168.31.240:8188'),
|
||||
doclingBaseUrl: z.string().default('http://192.168.31.159:5001'),
|
||||
whishBaseUrl: z.string().default('http://192.168.31.159:8082'),
|
||||
timeoutMs: z.number().default(DEFAULT_LAB_TIMEOUT_MS),
|
||||
maxUploadBytes: z.number().default(DEFAULT_MAX_UPLOAD_BYTES),
|
||||
maxOutputChars: z.number().default(DEFAULT_MAX_OUTPUT_CHARS),
|
||||
})
|
||||
|
||||
/** Complete config after schemastery applies every field default. */
|
||||
type ResolvedConfig = Required<Config>
|
||||
|
||||
/** Configured limits must be positive integers. */
|
||||
function assertPositiveInteger(field: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
throw new Error(`tool-lab: ${field} must be a positive integer`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Render one string value as a single text content block. */
|
||||
function renderText(_args: unknown, value: string): ContentBlock[] {
|
||||
return [{ type: 'text', text: String(value) }]
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the enabled lab tools. Each tool's cooperative timeout budget is
|
||||
* resolved here and attached as `ToolDefinition.timeoutMs`. Disposers are
|
||||
* fiber-scoped (effect-based registries clean up on dispose), so no manual
|
||||
* teardown is needed.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const resolved = config as ResolvedConfig
|
||||
assertPositiveInteger('timeoutMs', resolved.timeoutMs)
|
||||
assertPositiveInteger('maxUploadBytes', resolved.maxUploadBytes)
|
||||
assertPositiveInteger('maxOutputChars', resolved.maxOutputChars)
|
||||
|
||||
const labConfig: ResolvedLabConfig = {
|
||||
comfyBaseUrl: resolved.comfyBaseUrl,
|
||||
doclingBaseUrl: resolved.doclingBaseUrl,
|
||||
whishBaseUrl: resolved.whishBaseUrl,
|
||||
timeoutMs: resolved.timeoutMs,
|
||||
maxUploadBytes: resolved.maxUploadBytes,
|
||||
maxOutputChars: resolved.maxOutputChars,
|
||||
}
|
||||
|
||||
if (resolved.generateImage) {
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'lab_generate_image',
|
||||
description:
|
||||
'Generate an image on the home-lab ComfyUI server (Juggernaut-XL V9, 512x512 base, upscaled 4x via RealESRGAN by default). Returns a URL to the generated PNG hosted on that server. Give a descriptive prompt; optionally adjust model, width, height, steps, seed, negative, upscale, upscale_model, or filename_prefix.',
|
||||
parameters: {
|
||||
prompt: { type: 'string', description: 'Positive prompt describing the desired image.', required: true },
|
||||
negative: { type: 'string', description: 'Negative prompt.' },
|
||||
width: { type: 'integer', description: 'Image width in pixels. Default 512.' },
|
||||
height: { type: 'integer', description: 'Image height in pixels. Default 512.' },
|
||||
steps: { type: 'integer', description: 'Sampling steps. Default 28 (Juggernaut).' },
|
||||
seed: { type: 'integer', description: 'Random seed. Defaults to a random value.' },
|
||||
model: { type: 'string', description: 'Checkpoint model filename. Default Juggernaut-XL_v9_RunDiffusionPhoto_v2.safetensors.' },
|
||||
upscale: { type: 'boolean', description: 'Upscale the result 4x with RealESRGAN_x4plus. Default true.' },
|
||||
upscale_model: { type: 'string', description: 'Upscale model filename. Default RealESRGAN_x4plus.safetensors.' },
|
||||
filename_prefix: { type: 'string', description: 'Filename prefix for the saved image. Default dsh.' },
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: renderText,
|
||||
},
|
||||
timeoutMs: resolved.timeoutMs,
|
||||
isConcurrencySafe: () => true,
|
||||
async execute(args, exec) {
|
||||
const imageArgs = args as {
|
||||
prompt: string
|
||||
negative?: string
|
||||
width?: number
|
||||
height?: number
|
||||
steps?: number
|
||||
seed?: number
|
||||
model?: string
|
||||
upscale?: boolean
|
||||
upscale_model?: string
|
||||
filename_prefix?: string
|
||||
}
|
||||
return runGenerateImage(labConfig, imageArgs, exec.signal)
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
if (resolved.ocrPdf) {
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'lab_ocr_pdf',
|
||||
description:
|
||||
'Extract text from a PDF using the home-lab Docling OCR server. Reads the local PDF and returns the recognized markdown text. Provide the local path to the PDF.',
|
||||
parameters: {
|
||||
file_path: { type: 'string', description: 'Local path to the PDF file to OCR.', required: true },
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: renderText,
|
||||
},
|
||||
timeoutMs: resolved.timeoutMs,
|
||||
isConcurrencySafe: () => true,
|
||||
execute(args, exec) {
|
||||
const fs = ctx.fs
|
||||
return runOcrPdf(fs, labConfig, args.file_path, exec.signal)
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
if (resolved.transcribeAudio) {
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'lab_transcribe_audio',
|
||||
description:
|
||||
'Transcribe speech from an audio file using the home-lab Whishper server. Returns the recognized text. Provide a local path to an audio file and optionally a language hint.',
|
||||
parameters: {
|
||||
file_path: { type: 'string', description: 'Local path to the audio file (wav, mp3, ogg, or m4a).', required: true },
|
||||
language: { type: 'string', description: 'Optional language code hint (e.g. ru, en).' },
|
||||
model_size: { type: 'string', description: 'Optional model size (e.g. small, base).' },
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: renderText,
|
||||
},
|
||||
timeoutMs: resolved.timeoutMs,
|
||||
isConcurrencySafe: () => true,
|
||||
execute(args, exec) {
|
||||
const fs = ctx.fs
|
||||
return runTranscribeAudio(fs, labConfig, args.file_path, args.language, args.model_size, exec.signal)
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
29
packages/extensions/tool-lab/src/invariant.ts
Normal file
29
packages/extensions/tool-lab/src/invariant.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-lab`.
|
||||
* @module @deepseek-ai/dsh-tool-lab/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-lab'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'tool-lab-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this model-facing adapter has no independent lifecycle
|
||||
* stream; execution relations are owned by the capability seam it calls.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
84
packages/extensions/tool-lab/src/whish.ts
Normal file
84
packages/extensions/tool-lab/src/whish.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Whishper speech-to-text tool. Reads local audio via `ctx.fs`, uploads it to
|
||||
* the lab's Whishper server, polls the transcription, and returns the text.
|
||||
* @module @deepseek-ai/dsh-tool-lab
|
||||
*/
|
||||
|
||||
import type { FileSystem } from '@deepseek-ai/dsh-fs'
|
||||
import { Deadline, sleep } from './helpers.ts'
|
||||
import type { ResolvedLabConfig } from './comfy.ts'
|
||||
|
||||
interface WhishperTranscription {
|
||||
id?: string
|
||||
status?: number
|
||||
result?: { text?: string | null; language?: string | null; duration?: number | null }
|
||||
}
|
||||
|
||||
/** Build multipart upload of an audio file. */
|
||||
export function buildAudioUpload(data: Uint8Array, filename: string, language?: string): FormData {
|
||||
const fd = new FormData()
|
||||
const ext = filename.split('.').pop()?.toLowerCase() ?? 'wav'
|
||||
const type = ext === 'mp3' ? 'audio/mpeg' : ext === 'ogg' ? 'audio/ogg' : ext === 'm4a' ? 'audio/mp4' : 'audio/wav'
|
||||
fd.append('files', new Blob([data as unknown as BlobPart], { type }), filename)
|
||||
if (language) fd.append('language', language)
|
||||
return fd
|
||||
}
|
||||
|
||||
/** Poll Whishper `/api/transcriptions/{id}` until text is ready or done. */
|
||||
async function pollTranscription(
|
||||
baseUrl: string,
|
||||
id: string,
|
||||
deadline: Deadline,
|
||||
timeoutMs: number,
|
||||
): Promise<WhishperTranscription> {
|
||||
const started = Date.now()
|
||||
for (;;) {
|
||||
deadline.check()
|
||||
const res = await fetch(`${baseUrl}/api/transcriptions/${id}`)
|
||||
if (!res.ok) throw new Error(`Whishper status ${res.status}: ${await res.text()}`)
|
||||
const row = (await res.json()) as WhishperTranscription
|
||||
const text = row.result?.text
|
||||
if (typeof text === 'string' && text.length > 0 && row.status !== -1) return row
|
||||
if (row.status !== -1 && (row.status === 1 || row.status === 2)) return row
|
||||
if (Date.now() - started >= timeoutMs) throw new Error(`Whishper transcription timed out after ${timeoutMs}ms`)
|
||||
await sleep(1500, deadline.signal)
|
||||
}
|
||||
}
|
||||
|
||||
/** Run the Whishper tool: read audio, upload, poll, return transcription text. */
|
||||
export async function runTranscribeAudio(
|
||||
fs: FileSystem,
|
||||
config: ResolvedLabConfig,
|
||||
filePath: string,
|
||||
language: string | undefined,
|
||||
modelSize: string | undefined,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<string> {
|
||||
const deadline = new Deadline('transcribe_audio', config.timeoutMs, signal)
|
||||
try {
|
||||
const target = await fs.resolve(filePath, signal === undefined ? undefined : { signal })
|
||||
const statInfo = await fs.stat(target, signal)
|
||||
if (!statInfo) throw new Error(`transcribe_audio: file not found: ${filePath}`)
|
||||
const data = await fs.readBytes(target, signal, config.maxUploadBytes)
|
||||
const filename = filePath.split(/[\\/]/).pop() ?? 'audio.wav'
|
||||
const form = buildAudioUpload(data, filename, language)
|
||||
if (modelSize) {
|
||||
// Whishper server config may restrict model size; pass it as a field if supported.
|
||||
form.append('modelSize', modelSize)
|
||||
}
|
||||
const submit = await fetch(`${config.whishBaseUrl}/api/transcriptions`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
})
|
||||
if (!submit.ok) throw new Error(`Whishper upload ${submit.status}: ${await submit.text()}`)
|
||||
const row = (await submit.json()) as WhishperTranscription
|
||||
if (!row.id) throw new Error(`Whishper no id: ${JSON.stringify(row)}`)
|
||||
const done = await pollTranscription(config.whishBaseUrl, row.id, deadline, config.timeoutMs)
|
||||
const text = done.result?.text ?? ''
|
||||
if (text.length === 0) return '(transcription completed with no text)'
|
||||
if (text.length > config.maxOutputChars) return text.slice(0, config.maxOutputChars)
|
||||
return text
|
||||
} finally {
|
||||
deadline.dispose()
|
||||
}
|
||||
}
|
||||
60
packages/extensions/tool-lab/tests/tool-lab.spec.ts
Normal file
60
packages/extensions/tool-lab/tests/tool-lab.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Unit tests for the pure helpers of `@deepseek-ai/dsh-tool-lab`. These
|
||||
* assertions do not touch the network or the runtime; they check the workflow
|
||||
* and upload builders only.
|
||||
* @module @deepseek-ai/dsh-tool-lab/tests
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildWorkflow } from '../src/comfy.ts'
|
||||
import { buildPdfUpload } from '../src/docling.ts'
|
||||
import { buildAudioUpload } from '../src/whish.ts'
|
||||
|
||||
/** One ComfyUI workflow node as seen by the assertions. */
|
||||
interface WorkflowNode {
|
||||
class_type: string
|
||||
inputs: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Reinterpret the workflow builder's JSON-serializable output for assertions. */
|
||||
function wf(prompt: string, overrides: Record<string, unknown> = {}): Record<string, WorkflowNode> {
|
||||
return buildWorkflow({ prompt, ...(overrides as object) }) as Record<string, WorkflowNode>
|
||||
}
|
||||
|
||||
describe('buildWorkflow', () => {
|
||||
it('renders a 28-step Juggernaut workflow with defaults', () => {
|
||||
const flow = wf('a red fox')
|
||||
expect(flow['4'].inputs.ckpt_name).toBe('Juggernaut-XL_v9_RunDiffusionPhoto_v2.safetensors')
|
||||
expect(flow['9'].class_type).toBe('SaveImage')
|
||||
expect(flow['9'].inputs.images).toEqual(['10', 0])
|
||||
expect(flow['10'].class_type).toBe('ImageUpscaleWithModel')
|
||||
expect(flow['11'].inputs.model_name).toBe('RealESRGAN_x4plus.safetensors')
|
||||
expect(flow['3'].inputs).toMatchObject({ steps: 28, cfg: 4.0, sampler_name: 'dpmpp_2m_sde', scheduler: 'karras' })
|
||||
})
|
||||
|
||||
it('skips upscaling when upscale is false', () => {
|
||||
const flow = wf('p', { upscale: false })
|
||||
expect(flow['9'].inputs.images).toEqual(['8', 0])
|
||||
expect(flow['10']).toBeUndefined()
|
||||
expect(flow['11']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('honors overrides and seed', () => {
|
||||
const flow = wf('p', { width: 768, height: 640, steps: 8, seed: 99 })
|
||||
expect(flow['5'].inputs).toMatchObject({ width: 768, height: 640 })
|
||||
expect(flow['3'].inputs).toMatchObject({ seed: 99, steps: 8 })
|
||||
expect(flow['6'].inputs.text).toBe('p')
|
||||
})
|
||||
})
|
||||
|
||||
describe('upload builders', () => {
|
||||
it('builds a PDF upload carrying files and options parts', () => {
|
||||
const fd = buildPdfUpload(new Uint8Array([1, 2, 3]), 'scan.pdf')
|
||||
expect(fd).toBeDefined()
|
||||
})
|
||||
|
||||
it('builds an audio upload', () => {
|
||||
const fd = buildAudioUpload(new Uint8Array([1, 2]), 'clip.mp3')
|
||||
expect(fd).toBeDefined()
|
||||
})
|
||||
})
|
||||
28
packages/extensions/tool-lab/tsconfig.json
Normal file
28
packages/extensions/tool-lab/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../fs/fs"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -57,7 +57,7 @@ import {
|
||||
truncateUnicodeCodePoints,
|
||||
} from './api/session-search.ts'
|
||||
// Type-only: resolves `ctx.get('sessionProjections')` to the projection registry.
|
||||
import type {} from '@deepseek-ai/dsh-session-projection'
|
||||
import { foldContextOf } from '@deepseek-ai/dsh-session-projection'
|
||||
// Type-only: resolves `ctx.get('tasks')` to the background job registry.
|
||||
import type {} from '@deepseek-ai/dsh-jobs'
|
||||
import type { JobSnapshot } from '@deepseek-ai/dsh-jobs'
|
||||
@@ -838,11 +838,12 @@ function listProjectionsFor(ctx: Context, meta: SessionHeader, session: Session
|
||||
/** Projection baseline for a detached history tail without Agent activation. */
|
||||
function detachedProjectionsFor(
|
||||
ctx: Context,
|
||||
header: SessionHeader,
|
||||
events: readonly SessionEvent[],
|
||||
): SessionProjectionsBlock | undefined {
|
||||
const registry = ctx.get('sessionProjections')
|
||||
if (registry === undefined) return undefined
|
||||
return registry.restore({}, events, 0).snapshot
|
||||
return registry.restore({}, events, 0, foldContextOf(header)).snapshot
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1534,7 +1535,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
includeProjections: boolean,
|
||||
): { events: SessionEvent[]; projections?: SessionProjectionsBlock } {
|
||||
if (source.kind === 'detached') {
|
||||
const projections = includeProjections ? detachedProjectionsFor(ctx, source.events) : undefined
|
||||
const projections = includeProjections ? detachedProjectionsFor(ctx, source.header, source.events) : undefined
|
||||
return { events: source.events, ...projections === undefined ? {} : { projections } }
|
||||
}
|
||||
const events = [...source.session.events]
|
||||
@@ -2632,7 +2633,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
header = inspected.meta
|
||||
events = inspected.events
|
||||
projections = beforeSeq === undefined
|
||||
? subagentHistoryProjections(ctx, childSessionId, () => detachedProjectionsFor(ctx, inspected.events))
|
||||
? subagentHistoryProjections(ctx, childSessionId, () => detachedProjectionsFor(ctx, inspected.meta, inspected.events))
|
||||
: undefined
|
||||
} catch (error: unknown) {
|
||||
if (signal?.aborted) {
|
||||
|
||||
@@ -20,11 +20,17 @@ import { toPiReplayState } from './replay.ts'
|
||||
* @returns harness counts; cache fields appear only when non-zero (pi-ai reports zeros, not absence).
|
||||
*/
|
||||
export function mapUsage(usage: PiUsage): TokenUsage {
|
||||
const usageRecord = usage as unknown as Record<string, unknown>
|
||||
const rawCost = usageRecord['cost'] ?? usageRecord['total_cost']
|
||||
const costUsd = typeof rawCost === 'number' && Number.isFinite(rawCost) && rawCost >= 0
|
||||
? rawCost
|
||||
: undefined
|
||||
return {
|
||||
inputTokens: usage.input,
|
||||
outputTokens: usage.output,
|
||||
...usage.cacheRead > 0 ? { cacheReadTokens: usage.cacheRead } : {},
|
||||
...usage.cacheWrite > 0 ? { cacheWriteTokens: usage.cacheWrite } : {},
|
||||
...costUsd !== undefined ? { costUsd } : {},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@ export interface TokenUsage {
|
||||
cacheReadTokens?: number
|
||||
cacheWriteTokens?: number
|
||||
reasoningTokens?: number
|
||||
costUsd?: number
|
||||
}
|
||||
|
||||
/** Display metadata for one registered provider route. */
|
||||
|
||||
@@ -43,7 +43,11 @@ is the available balance from `GET /credits` (`total_credits` minus the spent
|
||||
the monthly `usageTokens`/`limitTokens` budget from `GET /auth/key`,
|
||||
`isFreeTier`, and an `updatedAt` epoch. Before any successful fetch it serves
|
||||
an all-`null`
|
||||
record; a failed refresh keeps the last-known snapshot and logs. The same key
|
||||
record; a failed refresh keeps the last-known snapshot and logs. The
|
||||
`refresh()` method fetches the account NOW and resolves with the resulting
|
||||
snapshot, for a user-initiated read that must not wait for the next scheduled
|
||||
tick; concurrent callers share one in-flight fetch, and a failed fetch
|
||||
resolves with the last-known snapshot rather than rejecting. The same key
|
||||
also refreshes the model pricing table from `GET /models`
|
||||
(`pricing.prompt`/`completion` USD per token, plus a flat `request` fee and
|
||||
optional `input_cache_read`/`input_cache_write` when disclosed).
|
||||
@@ -54,7 +58,14 @@ step) against the pricing table. Attribution prefers the assembled message's
|
||||
own `provider`/`model`; a chunk-only (failed) step prices from the newest
|
||||
`request/context` route. A step on a non-`openrouter` provider is outside the
|
||||
domain and changes nothing; an OpenRouter step whose model has no pricing
|
||||
entry counts as an unknown (unpriced) step. The projection's `steps` view
|
||||
entry counts as an unknown (unpriced) step.
|
||||
|
||||
Every value describes only its own session's work. A forked child session's
|
||||
log opens with a verbatim copy of its parent's up to the header's
|
||||
`seedLength`, and the fold skips that inherited prefix (reading only its
|
||||
`request/context` route records, which carry no cost but attribute the
|
||||
child's first chunk-only step). A session total plus its subagents' totals is
|
||||
therefore a sum, never a double count. The projection's `steps` view
|
||||
field maps each priced step to its cost in USD under `${turn}:${step}` keys,
|
||||
so per-step surfaces can render spend without re-pricing; unpriced steps are
|
||||
absent from the map.
|
||||
@@ -95,7 +106,10 @@ usage events, never a new model-visible input.
|
||||
promotional pricing may differ from the model table.
|
||||
- **Pricing as of the fold** — the projection prices a cell with the model
|
||||
table current when that cell folds. Refreshing pricing only affects cells
|
||||
folded afterward; already-folded history keeps its prior figures.
|
||||
folded afterward; already-folded history keeps its prior figures. A session
|
||||
folded while the table was empty (no key yet, or a failed first fetch)
|
||||
keeps its steps recorded as unpriced until a `stateVersion` bump refolds
|
||||
them.
|
||||
- **Per-token approximation** — cache-read/cache-write fall back to the
|
||||
prompt rate when the API does not disclose separate cache rates, and the
|
||||
flat request fee is charged once per step. Bills may differ by fractions
|
||||
|
||||
@@ -103,6 +103,8 @@ export class OpenRouterUsageGateway extends TypertRemoteService {
|
||||
private readonly abortController = new AbortController()
|
||||
/** Authoritative settings thunk; re-pointed when the section (re)mounts. */
|
||||
private currentSource: () => Config
|
||||
/** In-flight on-demand balance refresh, shared by concurrent callers. */
|
||||
private pendingRefresh: Promise<OpenRouterBalance> | undefined
|
||||
|
||||
constructor(ctx: Context, config: Config = {}) {
|
||||
super(ctx, 'openRouterUsage')
|
||||
@@ -124,7 +126,7 @@ export class OpenRouterUsageGateway extends TypertRemoteService {
|
||||
// live pricing thunk. The unit child activates only when a projection
|
||||
// registry is composed (headless assemblies stay unaffected).
|
||||
ctx.inject(['sessionProjections'], (projectionCtx) => {
|
||||
projectionCtx.sessionProjections.register(createOpenRouterCostProjection((model) => this.pricing.get(model)))
|
||||
projectionCtx.sessionProjections.register(createOpenRouterCostProjection(model => this.lookupPricing(model)))
|
||||
})
|
||||
|
||||
ctx.effect(() => () => {
|
||||
@@ -158,6 +160,44 @@ export class OpenRouterUsageGateway extends TypertRemoteService {
|
||||
return { ...this.balance }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the account snapshot NOW and serve the result, for a user-initiated
|
||||
* read that must not wait for the next scheduled tick. Concurrent callers
|
||||
* share one in-flight fetch, so repeated clicks cost one request. A failed
|
||||
* fetch resolves with the last-known snapshot rather than rejecting — the
|
||||
* caller's own `updatedAt` comparison tells it whether the figure moved.
|
||||
* @returns the snapshot after the refresh attempt settled.
|
||||
*/
|
||||
@Remote('refresh')
|
||||
async refresh(): Promise<OpenRouterBalance> {
|
||||
this.pendingRefresh ??= (async () => {
|
||||
try {
|
||||
await this.refreshBalance(this.resolve(this.currentSource()))
|
||||
return { ...this.balance }
|
||||
} finally {
|
||||
this.pendingRefresh = undefined
|
||||
}
|
||||
})()
|
||||
return await this.pendingRefresh
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up pricing for a model ID, with fallback for tagged variants (`:free`, etc.).
|
||||
* @param model - model identifier.
|
||||
* @returns matched rate, or undefined if unknown.
|
||||
*/
|
||||
lookupPricing(model: string): ModelPricing | undefined {
|
||||
const direct = this.pricing.get(model)
|
||||
if (direct !== undefined) return direct
|
||||
if (model.includes(':')) {
|
||||
const tagIndex = model.indexOf(':')
|
||||
const base = model.slice(0, tagIndex)
|
||||
const basePricing = this.pricing.get(base)
|
||||
if (basePricing !== undefined) return basePricing
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Materialize plugin defaults against the validated section. */
|
||||
private resolve(config: Config): Required<Config> {
|
||||
return {
|
||||
|
||||
@@ -11,6 +11,14 @@
|
||||
* Refreshing pricing only affects cells folded afterward — the documented
|
||||
* "as of fold" limitation.
|
||||
*
|
||||
* Inherited history: a forked child session opens with a verbatim copy of its
|
||||
* parent's log up to `header.seedLength`. Those steps are the parent's spend,
|
||||
* which the parent's own value already reports, so the fold skips them and
|
||||
* every value describes only the session's OWN work — a parent total plus its
|
||||
* children's totals is a sum, never a double count. `request/context` records
|
||||
* are still read from the inherited prefix: they carry no cost and the route
|
||||
* they establish is what attributes the child's first chunk-only step.
|
||||
*
|
||||
* Model attribution: an `assistant/message` carries its own provider/model in
|
||||
* `message.source`; a chunk-only (failed) step has none, so the fold prices it
|
||||
* from the newest `request/context` last-wins record. A step on a provider that
|
||||
@@ -24,7 +32,7 @@
|
||||
import { z } from 'zod'
|
||||
import type { TokenUsage } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
||||
import type { ProjectionDefinition, ProjectionFoldContext } from '@deepseek-ai/dsh-session-projection'
|
||||
import type { ModelPricing, OpenRouterCost } from './types.ts'
|
||||
|
||||
/** The provider route this projection prices; LLM routing must land here. */
|
||||
@@ -84,7 +92,13 @@ export function createOpenRouterCostProjection(
|
||||
key: 'openRouterCost',
|
||||
schema: costSchema as unknown as z.ZodType<OpenRouterCost>,
|
||||
init: () => ({ totalUsd: 0, pricedSteps: 0, unknownModelSteps: 0, steps: {}, last: null, lastModel: null }),
|
||||
apply: (state, event: SessionEvent) => {
|
||||
apply: (state, event: SessionEvent, context: ProjectionFoldContext) => {
|
||||
// A forked child's log opens with a verbatim copy of its parent's log.
|
||||
// That spend belongs to the parent — which already reports it — so
|
||||
// pricing it here would report it twice, once per forked child. The
|
||||
// route records still apply: attribution carries across the boundary,
|
||||
// so `request/context` below is read from the inherited prefix too.
|
||||
if (event.seq < context.seedLength && event.type !== 'request/context') return state
|
||||
if (event.type === 'request/context') {
|
||||
const nextModel = { provider: event.data.provider, model: event.data.model }
|
||||
if (state.lastModel?.provider === nextModel.provider && state.lastModel?.model === nextModel.model) return state
|
||||
@@ -106,14 +120,21 @@ export function createOpenRouterCostProjection(
|
||||
} else {
|
||||
return state
|
||||
}
|
||||
// A non-openrouter step is outside this plugin's domain: never counted,
|
||||
// never recorded (a later, correctly-attributed message for the same
|
||||
// step must still land fresh).
|
||||
if (attribution === undefined || attribution.provider !== OPENROUTER_PROVIDER) return state
|
||||
const directCost = usage.costUsd
|
||||
const pricing = attribution !== undefined ? pricingOf(attribution.model) : undefined
|
||||
const isOpenRouterProvider = attribution !== undefined && (
|
||||
attribution.provider === OPENROUTER_PROVIDER
|
||||
|| attribution.provider.toLowerCase().includes('openrouter')
|
||||
)
|
||||
|
||||
const pricing = pricingOf(attribution.model)
|
||||
const priced = pricing !== undefined
|
||||
const costUsd = priced ? stepCostUsd(usage, pricing) : 0
|
||||
if (directCost === undefined && !isOpenRouterProvider && pricing === undefined) return state
|
||||
|
||||
const costUsd = directCost !== undefined
|
||||
? directCost
|
||||
: pricing !== undefined
|
||||
? stepCostUsd(usage, pricing)
|
||||
: 0
|
||||
const priced = directCost !== undefined || pricing !== undefined
|
||||
const previous = state.last !== null && state.last.turn === turn && state.last.step === step
|
||||
? state.last
|
||||
: null
|
||||
@@ -144,6 +165,6 @@ export function createOpenRouterCostProjection(
|
||||
steps: state.steps,
|
||||
currency: 'USD',
|
||||
}),
|
||||
stateVersion: 2,
|
||||
stateVersion: 4,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,11 +33,13 @@ export interface ModelPricing {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole-log OpenRouter spend for one session, priced from the logged token
|
||||
* usage of its steps against the pricing table current at fold time. Every
|
||||
* field is 0 until its first contributing priced step lands; a session whose
|
||||
* provider route is not `openrouter`, or whose models have no pricing entry,
|
||||
* stays all-zero.
|
||||
* One session's OWN OpenRouter spend, priced from the logged token usage of
|
||||
* its steps against the pricing table current at fold time. A forked child's
|
||||
* inherited log prefix (the parent's history up to `header.seedLength`) is
|
||||
* excluded, so a parent value plus its subagents' values is a sum rather
|
||||
* than a double count. Every field is 0 until its first contributing priced
|
||||
* step lands; a session whose provider route is not `openrouter`, or whose
|
||||
* models have no pricing entry, stays all-zero.
|
||||
*/
|
||||
export interface OpenRouterCost {
|
||||
/** Summed USD over steps priced against a known model entry. */
|
||||
|
||||
@@ -38,6 +38,7 @@ function stubOpenRouter() {
|
||||
const pricing = [
|
||||
{ id: 'deepseek/deepseek-chat', pricing: { prompt: '0.0000014', completion: '0.0000028', request: '0' } },
|
||||
]
|
||||
const credits = { total_credits: 42, total_usage: 1, is_free_tier: false }
|
||||
const calls: string[] = []
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input)
|
||||
@@ -46,9 +47,7 @@ function stubOpenRouter() {
|
||||
return new Response(JSON.stringify({ data: pricing }), { status: 200 })
|
||||
}
|
||||
if (url.endsWith('/credits')) {
|
||||
return new Response(JSON.stringify({
|
||||
data: { total_credits: 42, total_usage: 1, is_free_tier: false },
|
||||
}), { status: 200 })
|
||||
return new Response(JSON.stringify({ data: { ...credits } }), { status: 200 })
|
||||
}
|
||||
if (url.endsWith('/auth/key')) {
|
||||
return new Response(JSON.stringify({
|
||||
@@ -57,7 +56,7 @@ function stubOpenRouter() {
|
||||
}
|
||||
return new Response('not found', { status: 404 })
|
||||
}))
|
||||
return { calls }
|
||||
return { calls, credits }
|
||||
}
|
||||
|
||||
async function loadComposition(): Promise<Context> {
|
||||
@@ -170,4 +169,39 @@ describe('openrouter-usage real composition', () => {
|
||||
expect(balance.currency).toBe('USD')
|
||||
expect(balance.updatedAt).not.toBeNull()
|
||||
})
|
||||
|
||||
it('re-fetches the account on demand and serves the moved figure', async () => {
|
||||
const openRouter = stubOpenRouter()
|
||||
const loaded = await loadComposition()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(openRouter.calls.some(url => url.endsWith('/credits'))).toBe(true)
|
||||
}, { timeout: 5000 })
|
||||
expect(loaded.openRouterUsage.snapshot().balanceUsd).toBe(41)
|
||||
|
||||
openRouter.credits.total_usage = 12
|
||||
await expect(loaded.openRouterUsage.refresh()).resolves.toMatchObject({ balanceUsd: 30 })
|
||||
expect(loaded.openRouterUsage.snapshot().balanceUsd).toBe(30)
|
||||
})
|
||||
|
||||
it('shares one in-flight fetch across concurrent on-demand refreshes', async () => {
|
||||
const openRouter = stubOpenRouter()
|
||||
const loaded = await loadComposition()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(openRouter.calls.some(url => url.endsWith('/credits'))).toBe(true)
|
||||
}, { timeout: 5000 })
|
||||
const before = openRouter.calls.filter(url => url.endsWith('/credits')).length
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
loaded.openRouterUsage.refresh(),
|
||||
loaded.openRouterUsage.refresh(),
|
||||
])
|
||||
expect(first).toEqual(second)
|
||||
expect(openRouter.calls.filter(url => url.endsWith('/credits')).length).toBe(before + 1)
|
||||
|
||||
// The shared promise is released once it settles, so a later click fetches again.
|
||||
await loaded.openRouterUsage.refresh()
|
||||
expect(openRouter.calls.filter(url => url.endsWith('/credits')).length).toBe(before + 2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -224,6 +224,28 @@ describe('openRouterCost session projection', () => {
|
||||
expect(projected(ctx, session)).toEqual({ totalUsd: 0, pricedSteps: 0, unknownModelSteps: 1, steps: {}, currency: 'USD' })
|
||||
})
|
||||
|
||||
it('uses explicitly logged costUsd on usage directly even without pricing table entry', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
session.append('request/context', { provider: 'openrouter', model: 'unlisted/custom-model' })
|
||||
startStep(session, 1, 1)
|
||||
session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: {
|
||||
type: 'usage',
|
||||
usage: { inputTokens: 500, outputTokens: 200, costUsd: 0.0042 },
|
||||
},
|
||||
})
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
expect(projected(ctx, session)).toEqual({
|
||||
totalUsd: 0.0042,
|
||||
pricedSteps: 1,
|
||||
unknownModelSteps: 0,
|
||||
steps: { '1:1': 0.0042 },
|
||||
currency: 'USD',
|
||||
})
|
||||
})
|
||||
|
||||
it('pushes no change for unrelated events', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
recordContext(session)
|
||||
@@ -235,6 +257,47 @@ describe('openRouterCost session projection', () => {
|
||||
expect(changed).not.toContain('openRouterCost')
|
||||
})
|
||||
|
||||
it('excludes the inherited prefix of a forked child, so parent + child is a sum', async () => {
|
||||
const { ctx, session: parent } = await harness()
|
||||
recordContext(parent)
|
||||
startStep(parent, 1, 1)
|
||||
const parentSource = usageChunk(parent, { inputTokens: 1_000, outputTokens: 500 }, 1, 1)
|
||||
finalUsage(parent, { inputTokens: 1_000, outputTokens: 500 }, 1, 1, [parentSource])
|
||||
const parentCost = 1_000 * 1.4e-6 + 500 * 2.8e-6
|
||||
expect(projected(ctx, parent).totalUsd).toBe(parentCost)
|
||||
|
||||
// The fork: the child's log opens with a verbatim copy of the parent's.
|
||||
const seed = [...parent.events]
|
||||
const child = ctx.sessions.create(undefined, { seed, meta: { seedLength: seed.length } })
|
||||
startStep(child, 2, 1)
|
||||
const childSource = usageChunk(child, { inputTokens: 10, outputTokens: 4 }, 2, 1)
|
||||
finalUsage(child, { inputTokens: 10, outputTokens: 4 }, 2, 1, [childSource])
|
||||
const childCost = 10 * 1.4e-6 + 4 * 2.8e-6
|
||||
|
||||
const childValue = projected(ctx, child)
|
||||
expect(childValue.totalUsd).toBe(childCost)
|
||||
expect(childValue.pricedSteps).toBe(1)
|
||||
expect(childValue.steps).toEqual({ '2:1': childCost })
|
||||
// The parent's own figure is untouched by the fork.
|
||||
expect(projected(ctx, parent).totalUsd).toBe(parentCost)
|
||||
})
|
||||
|
||||
it('attributes a chunk-only step of a forked child from a route in the inherited prefix', async () => {
|
||||
const { ctx, session: parent } = await harness()
|
||||
// The route record is the LAST thing the parent logs, so only the
|
||||
// inherited prefix can supply the child's attribution.
|
||||
recordContext(parent)
|
||||
const seed = [...parent.events]
|
||||
const child = ctx.sessions.create(undefined, { seed, meta: { seedLength: seed.length } })
|
||||
startStep(child, 1, 1)
|
||||
usageChunk(child, { inputTokens: 100, outputTokens: 20 }, 1, 1)
|
||||
expect(projected(ctx, child)).toMatchObject({
|
||||
totalUsd: 100 * 1.4e-6 + 20 * 2.8e-6,
|
||||
pricedSteps: 1,
|
||||
unknownModelSteps: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('restores from a JSON checkpoint', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
recordContext(session)
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import SessionProjectionRegistry, { foldContextOf } from '@deepseek-ai/dsh-session-projection'
|
||||
import TokenMeter from '@deepseek-ai/dsh-token-meter'
|
||||
import type { ContextBreakdownProjection } from '@deepseek-ai/dsh-token-meter/client'
|
||||
import { CompactionId } from '@deepseek-ai/dsh-compaction'
|
||||
@@ -184,6 +184,9 @@ describe('contextBreakdown session projection', () => {
|
||||
|
||||
it('folds a replacement without a claim at zero and fails on a mismatched claim', () => {
|
||||
const definition = contextBreakdownProjectionDefinition
|
||||
// The unit ignores the fold context; this session inherits no fork prefix.
|
||||
const applyBreakdown = (state: Parameters<typeof definition.apply>[0], event: SessionEvent) =>
|
||||
definition.apply(state, event, foldContextOf({}))
|
||||
const replace = (start: number, end: number): SessionEvent => ({
|
||||
type: 'user/message',
|
||||
seq: 9,
|
||||
@@ -206,22 +209,22 @@ describe('contextBreakdown session projection', () => {
|
||||
data: { shadowedRange: { start, end }, shadowedSeqs: [start, end], shadowedTokenCount: 5 },
|
||||
} as unknown as SessionEvent)
|
||||
let state = definition.init()
|
||||
state = definition.apply(state, append(1))
|
||||
state = definition.apply(state, append(3))
|
||||
state = applyBreakdown(state, append(1))
|
||||
state = applyBreakdown(state, append(3))
|
||||
// No metering event: the replacement contributes zero instead of throwing.
|
||||
expect(definition.view(definition.apply(state, replace(1, 3))).messageTokens)
|
||||
expect(definition.view(applyBreakdown(state, replace(1, 3))).messageTokens)
|
||||
.toBe(definition.view(state).messageTokens)
|
||||
// An adjacent claim for another range contradicts the replacement.
|
||||
const mismatched = definition.apply(state, meter(1, 1, 8))
|
||||
expect(() => definition.apply(mismatched, replace(1, 3))).toThrow('no adjacent shadow price')
|
||||
const mismatched = applyBreakdown(state, meter(1, 1, 8))
|
||||
expect(() => applyBreakdown(mismatched, replace(1, 3))).toThrow('no adjacent shadow price')
|
||||
// A claim expires after one intervening event, so replacement delta is zero.
|
||||
let expired = definition.apply(state, meter(1, 3, 8))
|
||||
expired = definition.apply(expired, { type: 'todo/write', seq: 9, time: 0, data: { todos: [] } } as unknown as SessionEvent)
|
||||
expect(definition.view(definition.apply(expired, replace(1, 3))).messageTokens)
|
||||
let expired = applyBreakdown(state, meter(1, 3, 8))
|
||||
expired = applyBreakdown(expired, { type: 'todo/write', seq: 9, time: 0, data: { todos: [] } } as unknown as SessionEvent)
|
||||
expect(definition.view(applyBreakdown(expired, replace(1, 3))).messageTokens)
|
||||
.toBe(definition.view(state).messageTokens)
|
||||
// The armed claim prices exactly the next event's matching replacement.
|
||||
const armed = definition.apply(state, meter(1, 3, 8))
|
||||
expect(definition.view(definition.apply(armed, replace(1, 3))).messageTokens)
|
||||
const armed = applyBreakdown(state, meter(1, 3, 8))
|
||||
expect(definition.view(applyBreakdown(armed, replace(1, 3))).messageTokens)
|
||||
.toBe(definition.view(state).messageTokens - 5 + estimateMessage(
|
||||
createUserMessage({ content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }),
|
||||
))
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-
|
||||
// Empty type import: applies the package's cordis Context merge
|
||||
// (`ctx.sessionPersistence`), which this service reads on the cold path.
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import { foldContextOf } from '@deepseek-ai/dsh-session-projection'
|
||||
import type { ProjectionCheckpoint, ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection'
|
||||
import type { KvTable } from '@deepseek-ai/dsh-storage-domain'
|
||||
import { projectionCacheDomainSpec } from './spec.ts'
|
||||
@@ -183,14 +184,14 @@ export class SessionProjectionCache extends Service {
|
||||
const related = record === undefined || identityMatches(record.identity, identityOf(tail.meta))
|
||||
try {
|
||||
if (!related) throw new Error('unrelated log identity')
|
||||
restored = this.ctx.sessionProjections.restore(cached, tail.events, floor)
|
||||
restored = this.ctx.sessionProjections.restore(cached, tail.events, floor, foldContextOf(tail.meta))
|
||||
} catch {
|
||||
// The recoverable restore failures: an unrelated record, or a row
|
||||
// overreaching the stored log end (or predating the floor). Both imply
|
||||
// floor > 0 (baseSeq-0 restores never throw and an unrelated record
|
||||
// still carried a usable watermark), so the full log is a fresh read.
|
||||
const whole = await persistence.readFrom(id, 0, signal)
|
||||
restored = this.ctx.sessionProjections.restore({}, whole.events, 0)
|
||||
restored = this.ctx.sessionProjections.restore({}, whole.events, 0, foldContextOf(whole.meta))
|
||||
}
|
||||
await this.putSoft(id, identityOf(tail.meta), restored.checkpoint, 'cold-read write-back')
|
||||
return restored.snapshot
|
||||
|
||||
@@ -11,15 +11,18 @@ Session-projection Service Definition and drive registry. It owns `ctx.sessionPr
|
||||
- `ctx.sessionProjections.register(definition): () => void` Register one domain's unit. Duplicate keys and invalid `stateVersion` throw; the registration is an effect on the calling fiber, so an unloaded domain plugin's key (with its cached cells) disappears from subsequent drives and snapshots — clients read that as capability absence.
|
||||
- `ctx.sessionProjections.onChanged(listener): () => void` Subscribe to the change feed: one call per unit whose state reference changed, per committed event, carrying the schema-validated view and the causing seq. Effect-tied like `register`.
|
||||
- `ctx.sessionProjections.snapshot(session): ProjectionSnapshot` One consistent synchronous cut over every registered unit — `{ asOfSeq, values }` with `asOfSeq` = the seq of the last event every value reflects (`-1` for an empty log).
|
||||
- `foldContextOf(header): ProjectionFoldContext` Derive the per-session fold context from a session header. Every `apply` call receives one; a caller folding a detached log (`restore`) supplies it explicitly.
|
||||
|
||||
### Key Types
|
||||
|
||||
- `SessionProjectionMap` — the single merge-extensible type table for the whole chain (host unit, wire block, React hook). Values are wire-JSON whole values; rendering belongs to the slot system, never this layer.
|
||||
- `ProjectionDefinition<K, S>` — `{ key, schema, init(), apply(state, event), view(state), stateVersion }`: a state-driven computation unit of three pure synchronous functions plus declarations, never an opaque getter.
|
||||
- `ProjectionDefinition<K, S>` — `{ key, schema, init(), apply(state, event, context), view(state), stateVersion }`: a state-driven computation unit of three pure synchronous functions plus declarations, never an opaque getter.
|
||||
- `ProjectionFoldContext` — per-session header facts a unit folds against beyond the event stream: `seedLength`, the durable fork-lineage boundary.
|
||||
|
||||
## Contract
|
||||
|
||||
- **The framework drives, the domain computes.** The registry subscribes to `session/event` once; every committed event passes every unit's `apply` eagerly. Domains hold no subscriptions. Cells (`{state, observedSeq}` per unit per session, WeakMap-keyed) build lazily — a unit registered after events flowed, or a read of a session predating the registration, folds `init` over the in-memory log on first touch.
|
||||
- **A unit reads session facts through the fold context, never a Session.** Every `apply` call carries `ProjectionFoldContext`, derived from the session's durable header. Its `seedLength` is the fork-lineage boundary: events with `seq < seedLength` were inherited from the forked parent rather than produced by this session. A unit whose value must describe only this session's OWN work (spend, step counts) skips them, so a parent value plus its children's values is a sum rather than a double count; a unit describing the whole conversation (context pressure, the visible transcript) folds them like any other event. A unit needing neither declares two parameters and ignores the third.
|
||||
- **Same-reference means no work.** `apply` MUST return the same state reference for events that do not concern the unit; the drive gates the change feed on `Object.is`, so non-matching events cost one call and nothing downstream.
|
||||
- **Whole-value event rule (load-bearing).** A state-carrying log event MUST carry the complete post-change state, never a bare delta — it keeps every transition trivially cheap and every served value self-describing (last-wins for consumers).
|
||||
- **Synchronous unit discipline.** `init`/`apply`/`view` MUST be synchronous; carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut. An accidentally-async `view` returns a Promise, which fails the boundary `schema.parse` loudly.
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import type { ZodType } from 'zod'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
@@ -31,6 +31,32 @@ import type { SessionProjectionMap } from './types.ts'
|
||||
|
||||
export type { SessionProjectionMap } from './types.ts'
|
||||
|
||||
/**
|
||||
* Per-session facts a unit folds against beyond the event stream itself.
|
||||
* Supplied by the framework on every `apply` call and derived from the
|
||||
* session's durable header, so a unit never reads a Session.
|
||||
*/
|
||||
export interface ProjectionFoldContext {
|
||||
/**
|
||||
* The session header's durable fork-lineage boundary: events with
|
||||
* `seq < seedLength` were inherited from the forked parent rather than
|
||||
* produced by this session. A unit whose value must describe only this
|
||||
* session's OWN work skips them; a unit describing the whole conversation
|
||||
* (context pressure, the visible transcript) folds them like any other
|
||||
* event. 0 for a session with no inherited prefix.
|
||||
*/
|
||||
readonly seedLength: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the fold context of one session's header.
|
||||
* @param header - the session's durable header.
|
||||
* @returns the context every `apply` call for that session receives.
|
||||
*/
|
||||
export function foldContextOf(header: Pick<SessionHeader, 'seedLength'>): ProjectionFoldContext {
|
||||
return { seedLength: header.seedLength ?? 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* One domain's state-driven computation unit: three pure synchronous
|
||||
* functions plus declarations — never an opaque getter. The framework drives
|
||||
@@ -55,9 +81,10 @@ export interface ProjectionDefinition<K extends keyof SessionProjectionMap, S> {
|
||||
* unchanged reference (`Object.is`) produces zero downstream work.
|
||||
* @param state - the state covering all prior events.
|
||||
* @param event - the next committed session event.
|
||||
* @param context - per-session header facts; a unit that needs none may declare two parameters.
|
||||
* @returns the next state (same reference when the event is not the unit's).
|
||||
*/
|
||||
apply(state: S, event: SessionEvent): S
|
||||
apply(state: S, event: SessionEvent, context: ProjectionFoldContext): S
|
||||
/**
|
||||
* State → wire payload (the read-side projection).
|
||||
* @param state - the current state.
|
||||
@@ -122,7 +149,7 @@ interface ErasedDefinition {
|
||||
key: string
|
||||
schema: { parse(value: unknown): unknown }
|
||||
init(): unknown
|
||||
apply(state: unknown, event: SessionEvent): unknown
|
||||
apply(state: unknown, event: SessionEvent, context: ProjectionFoldContext): unknown
|
||||
view(state: unknown): unknown
|
||||
stateVersion: number
|
||||
}
|
||||
@@ -348,12 +375,17 @@ export class SessionProjectionRegistry extends Service {
|
||||
* @param checkpoint - persisted rows for one session (possibly stale or empty).
|
||||
* @param events - the stored events with `seq >= baseSeq`, in seq order.
|
||||
* @param baseSeq - the seq `events` starts at (its first event's seq when non-empty).
|
||||
* @param context - the stored header's fold context (see {@link foldContextOf}).
|
||||
* @returns the snapshot cut at the supplied log end (`asOfSeq` is the last
|
||||
* supplied event's seq, `baseSeq - 1` for an empty tail) plus the
|
||||
* refreshed checkpoint rows at that cut, ready for a durable write-back.
|
||||
*/
|
||||
restore(checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number):
|
||||
{ snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } {
|
||||
restore(
|
||||
checkpoint: ProjectionCheckpoint,
|
||||
events: readonly SessionEvent[],
|
||||
baseSeq: number,
|
||||
context: ProjectionFoldContext,
|
||||
): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } {
|
||||
const endSeq = events.at(-1)?.seq ?? baseSeq - 1
|
||||
const values: Record<string, unknown> = {}
|
||||
const refreshed: ProjectionCheckpoint = {}
|
||||
@@ -373,7 +405,7 @@ export class SessionProjectionRegistry extends Service {
|
||||
let state = usable ? row.val : def.init()
|
||||
const from = usable ? row.seq : baseSeq - 1
|
||||
for (const event of events) {
|
||||
if (event.seq > from) state = def.apply(state, event)
|
||||
if (event.seq > from) state = def.apply(state, event, context)
|
||||
}
|
||||
values[def.key] = def.schema.parse(def.view(state))
|
||||
refreshed[def.key] = { ver: def.stateVersion, seq: endSeq, val: state }
|
||||
@@ -385,9 +417,13 @@ export class SessionProjectionRegistry extends Service {
|
||||
}
|
||||
|
||||
/** Fold one unit from init over `events`, producing a cell watermarked at the last folded event. */
|
||||
private buildCell(def: ErasedDefinition, events: readonly SessionEvent[]): UnitCell {
|
||||
private buildCell(
|
||||
def: ErasedDefinition,
|
||||
events: readonly SessionEvent[],
|
||||
context: ProjectionFoldContext,
|
||||
): UnitCell {
|
||||
let state = def.init()
|
||||
for (const event of events) state = def.apply(state, event)
|
||||
for (const event of events) state = def.apply(state, event, context)
|
||||
return { state, observedSeq: (events.at(-1)?.seq ?? -1) }
|
||||
}
|
||||
|
||||
@@ -395,7 +431,7 @@ export class SessionProjectionRegistry extends Service {
|
||||
private cellFor(registration: Registration, session: Session): UnitCell {
|
||||
let cell = registration.cells.get(session)
|
||||
if (cell === undefined) {
|
||||
cell = this.buildCell(registration.def, session.events)
|
||||
cell = this.buildCell(registration.def, session.events, foldContextOf(session.header))
|
||||
registration.cells.set(session, cell)
|
||||
}
|
||||
return cell
|
||||
@@ -403,15 +439,16 @@ export class SessionProjectionRegistry extends Service {
|
||||
|
||||
/** Eager drive: pass one committed event through every registered unit; notify on changed references. */
|
||||
private drive(session: Session, event: SessionEvent): void {
|
||||
const context = foldContextOf(session.header)
|
||||
for (const registration of this.registrations.values()) {
|
||||
let cell = registration.cells.get(session)
|
||||
if (cell === undefined) {
|
||||
// Late build mid-stream: fold history before this event (seq = log
|
||||
// index, so the prefix slice is exact), then take the normal gate.
|
||||
cell = this.buildCell(registration.def, session.events.slice(0, event.seq))
|
||||
cell = this.buildCell(registration.def, session.events.slice(0, event.seq), context)
|
||||
registration.cells.set(session, cell)
|
||||
}
|
||||
const next = registration.def.apply(cell.state, event)
|
||||
const next = registration.def.apply(cell.state, event, context)
|
||||
const changed = !Object.is(next, cell.state)
|
||||
cell.state = next
|
||||
cell.observedSeq = event.seq
|
||||
|
||||
@@ -12,9 +12,12 @@ import { Context } from '@deepseek-ai/cordis'
|
||||
import { z } from 'zod'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import SessionProjectionRegistry, { foldContextOf } from '@deepseek-ai/dsh-session-projection'
|
||||
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
||||
|
||||
/** The fold context of a session with no inherited fork prefix. */
|
||||
const NO_SEED = foldContextOf({})
|
||||
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
interface SessionProjectionMap {
|
||||
'test/marks': { marks: string[] }
|
||||
@@ -49,6 +52,19 @@ const countUnit = (): ProjectionDefinition<'test/count', number> => ({
|
||||
stateVersion: 1,
|
||||
})
|
||||
|
||||
/**
|
||||
* A unit counting only events the fold context marks as this session's OWN
|
||||
* work, so its value proves the context reached `apply`.
|
||||
*/
|
||||
const ownCountUnit = (): ProjectionDefinition<'test/count', number> => ({
|
||||
key: 'test/count',
|
||||
schema: z.number().int().nonnegative(),
|
||||
init: () => 0,
|
||||
apply: (state, event, context) => (event.seq < context.seedLength ? state : state + 1),
|
||||
view: state => state,
|
||||
stateVersion: 1,
|
||||
})
|
||||
|
||||
async function harness(): Promise<{ ctx: Context; session: Session }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -263,7 +279,7 @@ describe('SessionProjectionRegistry drive', () => {
|
||||
expect(() => ctx.sessionProjections.restore({
|
||||
'test/marks': { ver: 1, seq: 2, val: { marks: ['old'] } },
|
||||
'test/count': { ver: 99, seq: 2, val: 3 },
|
||||
}, tail, 3)).toThrow(/re-read from seq 0/)
|
||||
}, tail, 3, NO_SEED)).toThrow(/re-read from seq 0/)
|
||||
// The full-log re-read (baseSeq 0) refolds the mismatched key from init.
|
||||
const full: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } },
|
||||
@@ -274,7 +290,7 @@ describe('SessionProjectionRegistry drive', () => {
|
||||
const { snapshot, checkpoint } = ctx.sessionProjections.restore({
|
||||
'test/marks': { ver: 1, seq: 2, val: { marks: ['old', '2'] } },
|
||||
'test/count': { ver: 99, seq: 2, val: 3 },
|
||||
}, full, 0)
|
||||
}, full, 0, NO_SEED)
|
||||
expect(snapshot.asOfSeq).toBe(4)
|
||||
expect(snapshot.values['test/marks']).toEqual({ marks: ['new'] })
|
||||
expect(snapshot.values['test/count']).toBe(5) // refolded from init over all 5 events
|
||||
@@ -295,7 +311,7 @@ describe('SessionProjectionRegistry drive', () => {
|
||||
{ type: 'turn/start', seq: 3, time: 3, data: { turn: 2 } },
|
||||
{ type: 'turn/end', seq: 4, time: 4, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
]
|
||||
const { snapshot } = ctx.sessionProjections.restore(rows, tail, 3)
|
||||
const { snapshot } = ctx.sessionProjections.restore(rows, tail, 3, NO_SEED)
|
||||
expect(snapshot.asOfSeq).toBe(4)
|
||||
// marks already covers the tail (watermark 4): nothing re-applied.
|
||||
expect(snapshot.values['test/marks']).toEqual({ marks: ['done'] })
|
||||
@@ -306,7 +322,7 @@ describe('SessionProjectionRegistry drive', () => {
|
||||
const { snapshot: current } = ctx.sessionProjections.restore({
|
||||
'test/marks': { ver: 1, seq: 4, val: { marks: ['done'] } },
|
||||
'test/count': { ver: 1, seq: 4, val: 5 },
|
||||
}, [], 5)
|
||||
}, [], 5, NO_SEED)
|
||||
expect(current.asOfSeq).toBe(4)
|
||||
expect(current.values['test/count']).toBe(5)
|
||||
})
|
||||
@@ -324,6 +340,48 @@ describe('SessionProjectionRegistry drive', () => {
|
||||
expect(ctx.sessionProjections.viewCheckpoint({})).toEqual({})
|
||||
})
|
||||
|
||||
it('folds every unit against the fold context of the session header', async () => {
|
||||
const { ctx, session: parent } = await harness()
|
||||
ctx.sessionProjections.register(ownCountUnit())
|
||||
mark(parent, ['a'])
|
||||
mark(parent, ['b'])
|
||||
const inherited = parent.seq
|
||||
expect(ctx.sessionProjections.snapshot(parent).values['test/count']).toBe(inherited)
|
||||
|
||||
// The forked child inherits the parent's log; only its own appends count.
|
||||
const seed = [...parent.events]
|
||||
const child = ctx.sessions.create(undefined, { seed, meta: { seedLength: seed.length } })
|
||||
mark(child, ['own'])
|
||||
expect(child.header.seedLength).toBe(seed.length)
|
||||
expect(ctx.sessionProjections.snapshot(child).values['test/count'])
|
||||
.toBe(child.seq - seed.length)
|
||||
})
|
||||
|
||||
it('passes the fold context to a cell built lazily after events flowed', async () => {
|
||||
const { ctx, session: parent } = await harness()
|
||||
mark(parent, ['a'])
|
||||
const seed = [...parent.events]
|
||||
const child = ctx.sessions.create(undefined, { seed, meta: { seedLength: seed.length } })
|
||||
mark(child, ['own'])
|
||||
// Registered only now: the lazy full-log build must see the same context.
|
||||
ctx.sessionProjections.register(ownCountUnit())
|
||||
expect(ctx.sessionProjections.snapshot(child).values['test/count'])
|
||||
.toBe(child.seq - seed.length)
|
||||
})
|
||||
|
||||
it('restore folds against the caller-supplied fold context', async () => {
|
||||
const { ctx } = await harness()
|
||||
ctx.sessionProjections.register(ownCountUnit())
|
||||
const events: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } },
|
||||
{ type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/start', seq: 2, time: 2, data: { turn: 2 } },
|
||||
]
|
||||
expect(ctx.sessionProjections.restore({}, events, 0, NO_SEED).snapshot.values['test/count']).toBe(3)
|
||||
expect(ctx.sessionProjections.restore({}, events, 0, foldContextOf({ seedLength: 2 }))
|
||||
.snapshot.values['test/count']).toBe(1)
|
||||
})
|
||||
|
||||
it('restore rejects a row claiming events past the supplied log end (shrunk log ⇒ re-read)', async () => {
|
||||
const { ctx } = await harness()
|
||||
ctx.sessionProjections.register(countUnit())
|
||||
@@ -334,16 +392,16 @@ describe('SessionProjectionRegistry drive', () => {
|
||||
expect(floor).toBe(9)
|
||||
// …an intact log serves the anchor event and the checkpoint stands as-is.
|
||||
const anchor: SessionEvent = { type: 'turn/end', seq: 9, time: 9, data: { turn: 2, reason: { kind: 'completed' } } }
|
||||
expect(ctx.sessionProjections.restore(rows, [anchor], 9).snapshot.values['test/count']).toBe(10)
|
||||
expect(ctx.sessionProjections.restore(rows, [anchor], 9, NO_SEED).snapshot.values['test/count']).toBe(10)
|
||||
// …while a log crash-repaired down to fewer events returns an empty tail:
|
||||
// the row overreaches the proven end and a tail read cannot fix this key.
|
||||
expect(() => ctx.sessionProjections.restore(rows, [], 9)).toThrow(/re-read from seq 0/)
|
||||
expect(() => ctx.sessionProjections.restore(rows, [], 9, NO_SEED)).toThrow(/re-read from seq 0/)
|
||||
// The full re-read discards the overreaching row and refolds from init.
|
||||
const events: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } },
|
||||
{ type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
const { snapshot } = ctx.sessionProjections.restore(rows, events, 0)
|
||||
const { snapshot } = ctx.sessionProjections.restore(rows, events, 0, NO_SEED)
|
||||
expect(snapshot.asOfSeq).toBe(1)
|
||||
expect(snapshot.values['test/count']).toBe(2)
|
||||
})
|
||||
|
||||
@@ -15,10 +15,14 @@ import { Context } from '@deepseek-ai/cordis'
|
||||
import { createMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import SessionProjectionRegistry, { foldContextOf } from '@deepseek-ai/dsh-session-projection'
|
||||
import * as SessionStatsPlugin from '@deepseek-ai/dsh-session-stats'
|
||||
import { sessionStatsProjectionDefinition } from '@deepseek-ai/dsh-session-stats/src/projection.ts'
|
||||
import type { SessionStatsProjection } from '@deepseek-ai/dsh-session-stats/types'
|
||||
/** The unit under test ignores the fold context; this session inherits no fork prefix. */
|
||||
const applyStats = (state: Parameters<typeof sessionStatsProjectionDefinition.apply>[0], event: SessionEvent) =>
|
||||
sessionStatsProjectionDefinition.apply(state, event, foldContextOf({}))
|
||||
|
||||
|
||||
async function harness(withStatsPlugin: boolean): Promise<{ ctx: Context; session: Session }> {
|
||||
const ctx = new Context()
|
||||
@@ -155,7 +159,7 @@ function at(time: number, type: string, data: unknown): SessionEvent {
|
||||
/** Fold a synthetic event list through the definition and view the result. */
|
||||
function fold(events: readonly SessionEvent[]): SessionStatsProjection {
|
||||
const state = events.reduce(
|
||||
(folded, event) => sessionStatsProjectionDefinition.apply(folded, event),
|
||||
(folded, event) => applyStats(folded, event),
|
||||
sessionStatsProjectionDefinition.init(),
|
||||
)
|
||||
return sessionStatsProjectionDefinition.view(state)
|
||||
@@ -270,10 +274,10 @@ describe('sessionStats wall-time fold (controlled timestamps)', () => {
|
||||
// The first message closed the step boundary; a defensive duplicate finds
|
||||
// no open step and folds to the same reference.
|
||||
const state = events.reduce(
|
||||
(folded, event) => sessionStatsProjectionDefinition.apply(folded, event),
|
||||
(folded, event) => applyStats(folded, event),
|
||||
sessionStatsProjectionDefinition.init(),
|
||||
)
|
||||
expect(sessionStatsProjectionDefinition.apply(
|
||||
expect(applyStats(
|
||||
state,
|
||||
at(2_050, 'assistant/message', { turn: 1, step: 1, message }),
|
||||
)).toBe(state)
|
||||
@@ -281,7 +285,7 @@ describe('sessionStats wall-time fold (controlled timestamps)', () => {
|
||||
|
||||
it('accrues nothing for unrelated events and clamps negative clock skew to zero', () => {
|
||||
const state = sessionStatsProjectionDefinition.init()
|
||||
const untouched = sessionStatsProjectionDefinition.apply(state, at(1, 'user/message', { content: [] }))
|
||||
const untouched = applyStats(state, at(1, 'user/message', { content: [] }))
|
||||
expect(untouched).toBe(state)
|
||||
expect(fold([
|
||||
at(2_000, 'step/start', { turn: 1, step: 1 }),
|
||||
|
||||
@@ -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/subagent/README.md
|
||||
README.md: a863ed3f5ef864b6eb6eb9a7a0c1ee2f40f247d6
|
||||
README.zh.md: 1c9bf8ba0814a74c5774d81a34a3266daa9c375b
|
||||
README.md: a3bbfdf415006e6321c7f60f6678a828e8295013
|
||||
README.zh.md: accc3a2216b9b0d27bb8e23c391d0206f813ff5f
|
||||
|
||||
@@ -13,6 +13,7 @@ This family lets an agent delegate work to child agents. Multiple named provider
|
||||
| [`subagent-acp/`](subagent-acp/README.md) | Starts an out-of-process child over ACP | registers on `ctx.subagents` |
|
||||
| [`subagent-codex/`](subagent-codex/README.md) | Starts a real Codex app-server child | registers on `ctx.subagents` |
|
||||
| [`subagent-claude-code/`](subagent-claude-code/README.md) | Starts a real Claude Code child through the official Claude Agent SDK | registers on `ctx.subagents` |
|
||||
| [`subagent-cursor/`](subagent-cursor/README.md) | Starts a real Cursor child through the `cursor-agent` print-mode CLI | registers on `ctx.subagents` |
|
||||
| [`subagent-dsh-sdk/`](subagent-dsh-sdk/README.md) | Starts an out-of-process Harness child through the TypeScript SDK | registers on `ctx.subagents` |
|
||||
| [`tool-subagent/`](tool-subagent/README.md) | Exposes delegation to the model | registers on `ctx.tools` |
|
||||
| [`tool-subagent-control/`](tool-subagent-control/README.md) | Exposes child messaging and listing to the model | registers on `ctx.tools` |
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
| [`subagent-acp/`](subagent-acp/README.md) | 通过 ACP(Agent Client Protocol)启动进程外子 agent | 注册到 `ctx.subagents` |
|
||||
| [`subagent-codex/`](subagent-codex/README.md) | 启动真实的 Codex app-server 子 agent | 注册到 `ctx.subagents` |
|
||||
| [`subagent-claude-code/`](subagent-claude-code/README.md) | 通过官方 Claude Agent SDK 启动真实的 Claude Code 子 agent | 注册到 `ctx.subagents` |
|
||||
| [`subagent-cursor/`](subagent-cursor/README.md) | 通过 `cursor-agent` print 模式 CLI 启动真实的 Cursor 子 agent | 注册到 `ctx.subagents` |
|
||||
| [`subagent-dsh-sdk/`](subagent-dsh-sdk/README.md) | 通过 TypeScript SDK 启动进程外 Harness 子 agent | 注册到 `ctx.subagents` |
|
||||
| [`tool-subagent/`](tool-subagent/README.md) | 向模型公开委派操作 | 注册到 `ctx.tools` |
|
||||
| [`tool-subagent-control/`](tool-subagent-control/README.md) | 向模型公开子级消息发送和列举操作 | 注册到 `ctx.tools` |
|
||||
|
||||
@@ -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/subagent/subagent-claude-code/README.md
|
||||
README.md: 1a0d6e32b8610769dcc5d8342a4fe88d0c884085
|
||||
README.zh.md: 78dab14e5eaddc06ccd07b69dc952a09380e0428
|
||||
README.md: 9ded255eddefbed524a94216df8142411c91f547
|
||||
README.zh.md: e24d32578b462946dea27e2b13bdd1341efd52bb
|
||||
|
||||
@@ -31,7 +31,7 @@ The provider advertises no optional start-time capabilities and reports `inherit
|
||||
|
||||
Production resolves `claude` from the subprocess execution world's credential-scrubbed `PATH`, with explicit `env` entries applied, and passes the resulting path to the SDK as `pathToClaudeCodeExecutable`. On Windows, a resolved `.cmd` or `.bat` path is carried as a quoted, per-spawn environment value that `cmd.exe /v:off` expands once, so valid path metacharacters remain data. The pinned SDK's fixed flags then occupy cmd's command tail and contain no cmd metacharacters; they are not ordinary Windows argv. Native settings and authentication remain authoritative. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited unless overridden.
|
||||
|
||||
Production `dsh` does not install or mount this optional provider. A Profile that opts in must install `@deepseek-ai/dsh-subagent-claude-code` and mount it once on the host plane; loading the provider starts no Claude process until a tool call. Full Agent Presets carry a matching product tool row with `disabled: true`; copy a preset and remove that field to expose `subagent_claude_code` only to agents composed from the copy. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`. The base host and full presets already provide the generic Job registry and controls.
|
||||
The `dsh-base` host plane mounts this provider, and the `code`, `cordis`, and `standard` Agent Presets carry an enabled `subagent_claude_code` tool row; the `economy` preset keeps it `disabled: true` because an economy composition should not reach for external paid agents by default. Loading the provider starts no Claude process until a tool call. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`. The base host and full presets already provide the generic Job registry and controls.
|
||||
|
||||
The standalone composition below shows the complete explicit capability. A Profile based on `@deepseek-ai/dsh-base` keeps its existing Job rows, adds the product provider row, and enables the preset tool row instead of mounting duplicate Job services.
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK
|
||||
|
||||
生产环境从子进程执行世界清除凭证后的 `PATH` 解析 `claude`,再应用显式 `env` 条目,并把所得路径作为 `pathToClaudeCodeExecutable` 交给 SDK。在 Windows 上,解析到的 `.cmd` 或 `.bat` 路径会作为带引号、仅供本次 spawn 使用的环境值交给 `cmd.exe /v:off` 展开一次,因此合法路径中的元字符仍只是数据。锁定版本的 SDK 随后把固定命令行选项放在 cmd 的命令尾部;这些选项不含 cmd 元字符,也并不是普通的 Windows argv。原生设置与身份验证继续是权威来源。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承。
|
||||
|
||||
生产 `dsh` 不会安装或挂载这个可选提供方。选择启用它的 Profile 必须安装 `@deepseek-ai/dsh-subagent-claude-code`,并在 host plane(宿主平面)挂载一次;加载提供方本身不会在工具调用前启动 Claude 进程。完整 Agent Preset 携带对应的产品工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_claude_code`。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID,供 `job_output` 或 `job_kill` 使用。base host(基础宿主)与完整 preset 已提供通用作业注册表和控制工具。
|
||||
`dsh-base` 宿主平面会挂载本提供方,`code`、`cordis` 与 `standard` Agent Preset 均携带已启用的 `subagent_claude_code` 工具行;`economy` preset 保留 `disabled: true`,因为经济模式的组装不应默认动用外部付费 agent。加载提供方本身不会在工具调用前启动 Claude 进程。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID,供 `job_output` 或 `job_kill` 使用。base host(基础宿主)与完整 preset 已提供通用作业注册表和控制工具。
|
||||
|
||||
下列独立组装展示完整的显式能力。基于 `@deepseek-ai/dsh-base` 的 Profile 保留已有 Job 行,只新增产品提供方行并启用 preset 工具行,禁止重复挂载 Job 服务。
|
||||
|
||||
|
||||
@@ -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/subagent/subagent-codex/README.md
|
||||
README.md: 848d170585710b682fa4ce331010fce7080de673
|
||||
README.zh.md: 34e9105e6a78bc16f16997c7df89d4f6412eb50c
|
||||
README.md: de64a9136c3f1a5573cc169bcd3bd2ea11cd15e8
|
||||
README.zh.md: 9490e1084def998be9352111dbc8137642c92263
|
||||
|
||||
@@ -27,7 +27,7 @@ The provider advertises no optional start-time capabilities and reports `inherit
|
||||
|
||||
Production resolves `codex` from `PATH` and uses the host's native Codex configuration and authentication. The plugin does not install Codex, select a model, create `CODEX_HOME`, log in, or probe a version. Credential-shaped ambient variables are removed by the subprocess seam, so an API key intended for the child must be supplied explicitly in `env`; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden.
|
||||
|
||||
Production `dsh` does not install or mount this optional provider. A Profile that opts in must install `@deepseek-ai/dsh-subagent-codex` and mount it once on the host plane; loading the provider starts no Codex process until a tool call. Full Agent Presets carry a matching product tool row with `disabled: true`; copy a preset and remove that field to expose `subagent_codex` only to agents composed from the copy. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`. The base host and full presets already provide the generic Job registry and controls.
|
||||
The `dsh-base` host plane mounts this provider, and the `code`, `cordis`, and `standard` Agent Presets carry an enabled `subagent_codex` tool row; the `economy` preset keeps it `disabled: true` because an economy composition should not reach for external paid agents by default. Loading the provider starts no Codex process until a tool call. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`. The base host and full presets already provide the generic Job registry and controls.
|
||||
|
||||
The standalone composition below shows the complete explicit capability. A Profile based on `@deepseek-ai/dsh-base` keeps its existing Job rows, adds the product provider row, and enables the preset tool row instead of mounting duplicate Job services.
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。
|
||||
|
||||
生产 `dsh` 不会安装或挂载这个可选提供方。选择启用它的 Profile 必须安装 `@deepseek-ai/dsh-subagent-codex`,并在 host plane(宿主平面)挂载一次;加载提供方本身不会在工具调用前启动 Codex 进程。完整 Agent Preset 携带对应的产品工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_codex`。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID,供 `job_output` 或 `job_kill` 使用。base host(基础宿主)与完整 preset 已提供通用作业注册表和控制工具。
|
||||
`dsh-base` 宿主平面会挂载本提供方,`code`、`cordis` 与 `standard` Agent Preset 均携带已启用的 `subagent_codex` 工具行;`economy` preset 保留 `disabled: true`,因为经济模式的组装不应默认动用外部付费 agent。加载提供方本身不会在工具调用前启动 Codex 进程。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID,供 `job_output` 或 `job_kill` 使用。base host(基础宿主)与完整 preset 已提供通用作业注册表和控制工具。
|
||||
|
||||
下列独立组装展示完整的显式能力。基于 `@deepseek-ai/dsh-base` 的 Profile 保留已有 Job 行,只新增产品提供方行并启用 preset 工具行,禁止重复挂载 Job 服务。
|
||||
|
||||
|
||||
6
packages/subagent/subagent-cursor/README.i18n.yaml
Normal file
6
packages/subagent/subagent-cursor/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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/subagent/subagent-cursor/README.md
|
||||
README.md: bbf6a4bd763e24c09a34fdc77fecc6f2c66a1ade
|
||||
README.zh.md: db15b551cd95a763a3768b09ab4a9527c72afa10
|
||||
106
packages/subagent/subagent-cursor/README.md
Normal file
106
packages/subagent/subagent-cursor/README.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# @deepseek-ai/dsh-subagent-cursor
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
This package registers the fixed `cursor` subagent provider. Each accepted run starts the official `cursor-agent` CLI in non-interactive print mode in the delegating Session's workspace, submits one self-contained text task, reads the CLI's `stream-json` event stream, and returns only the final answer through the shared [`dsh-subagent`](../subagent/README.md) result contract.
|
||||
|
||||
## Start and ownership
|
||||
|
||||
`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It resolves `cursor-agent` through [`dsh-subprocess`](../../subprocess/subprocess/README.md), spawns the fixed command, and publishes the run only after the CLI's `system`/`init` event announces its own chat id — the point at which the CLI has started and resolved its credentials and model. A failure or cancellation before that announcement terminates the managed process tree, waits for it to exit, and rejects `start()`.
|
||||
|
||||
The task crosses the boundary as a positional command-line argument, because that is the only prompt channel print mode offers: the CLI documents no `--` end-of-options separator and reads no prompt from stdin. Two consequences are enforced rather than papered over. A task whose first character is `-` is rejected at admission, since the CLI would parse it as an option. A resolved Windows `.cmd` or `.bat` shim is rejected as well, because only `cmd.exe` can run it and its command tail would reparse model-authored text as shell syntax; PATHEXT resolution prefers the `cursor-agent.exe` that the native Windows installer provides. Stdin is closed immediately after spawn, so a prompt the CLI still tries to read fails fast instead of stalling an unattended child on an answer nobody can give.
|
||||
|
||||
The published `run.result` waits for the authoritative terminal `result` event and accepts only `subtype: "success"` with `is_error: false` and a nonblank `result`. Every other terminal event, malformed stdout line, stream failure, or end of stream without a result maps to `error`; print mode carries no machine-readable failure taxonomy, so the provider produces neither `max-tokens` nor `refusal`. `user`, `tool_call`, and event kinds a newer CLI adds contribute nothing to this contract.
|
||||
|
||||
Print mode has no reply channel, so there is no protocol interrupt: cancellation is the run's abort signal, which the subprocess seam turns into its termination escalation while the result settles immediately as `aborted` with the last non-empty assistant message collected so far. `dispose()` is idempotent: it detaches the event stream, invokes the shared process-tree termination escalation, and waits for whole-tree exit. Result failure and independent teardown failure remain separate.
|
||||
|
||||
## Capabilities and context
|
||||
|
||||
The provider advertises no optional start-time capabilities and reports `inheritsParentContext: false`. Cursor receives the standalone text task and the parent Session cwd, but not the parent conversation, persona, tool filter, depth policy, or structured-output contract. The CLI's own chat id and model stay private to the run and are never persisted in the parent Session.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `env` | `{}` | Explicit child environment layered over the subprocess seam's credential-scrubbed parent environment. |
|
||||
| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. |
|
||||
| `force` | `false` | Pass `--force`, letting the child apply file changes and run commands. Cursor's own print-mode default only PROPOSES changes, so a delegation expected to edit the workspace needs this on. |
|
||||
| `trust` | `false` | Pass `--trust`, letting the child act in the workspace without Cursor's interactive trust prompt an unattended child cannot answer. |
|
||||
|
||||
Production resolves `cursor-agent` from the subprocess execution world's credential-scrubbed `PATH`, with explicit `env` entries applied, and uses the host's native Cursor configuration and authentication. The plugin does not install the CLI, select a model, create a Cursor home, log in, or probe a version. Credential-shaped ambient variables are removed by the subprocess seam, so a `CURSOR_API_KEY` intended for the child must be supplied in `env`; it is never passed as `--api-key`, where a process listing would expose it. Ordinary ambient values such as `PATH` and `HOME` remain available unless overridden.
|
||||
|
||||
The `dsh-base` host plane mounts this provider, and the `code`, `cordis`, and `standard` Agent Presets carry an enabled `subagent_cursor` tool row; the `economy` preset keeps it `disabled: true` because an economy composition should not reach for external paid agents by default. Loading the provider starts no Cursor process until a tool call. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`.
|
||||
|
||||
The standalone composition below shows the complete explicit capability. A Profile based on `@deepseek-ai/dsh-base` keeps its existing Job rows and provider row and enables only the preset tool row instead of mounting duplicates.
|
||||
|
||||
```yaml
|
||||
- id: subagent-cursor
|
||||
name: '@deepseek-ai/dsh-subagent-cursor'
|
||||
config:
|
||||
force: true
|
||||
trust: true
|
||||
env:
|
||||
CURSOR_API_KEY: !!js process.env.CURSOR_API_KEY
|
||||
|
||||
- id: jobs
|
||||
name: '@deepseek-ai/dsh-jobs-local'
|
||||
|
||||
- id: tool-jobs
|
||||
name: '@deepseek-ai/dsh-tool-jobs'
|
||||
|
||||
- id: tool-subagent-cursor
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
config:
|
||||
provider: cursor
|
||||
toolName: subagent_cursor
|
||||
backgroundMode: one-shot
|
||||
maxDepth: provider-managed
|
||||
```
|
||||
|
||||
## Product compatibility and evidence
|
||||
|
||||
The provider reads only the print-mode events this one-shot contract needs: `system`/`init` to publish, `assistant` to snapshot partial output, and `result` to settle. Deployments supply `cursor-agent` on `PATH`; the CLI is not an npm dependency of this package, so unit evidence drives the real event stream through the subprocess seam rather than a pinned binary.
|
||||
|
||||
The CLI also speaks the Agent Client Protocol as `cursor-agent acp`, which the generic [`dsh-subagent-acp`](../subagent-acp/README.md) provider can drive with configuration alone. That path exists for a deployment that wants ACP's permission auto-answer policy or a long-lived remote session; this package exists for the one-shot delegation contract, its own `subagent_cursor` tool row, and print mode's simpler failure surface.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Child request
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The Cursor child receives the concatenated text task as one positional prompt in a fresh print-mode run. Its workspace is the parent Session cwd, and its model, system instructions, tools, and authentication come from the native Cursor installation and configuration.
|
||||
|
||||
#### Token effect
|
||||
|
||||
The child pays for an independent Cursor context and turn. Child tokens do not enter the parent's context.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Independent of the parent request cache. Reuse depends only on Cursor's own model, instructions, tools, and per-run request.
|
||||
|
||||
### Parent scheduling and results, indirectly
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Through `dsh-tool-subagent`, a foreground call gives the parent the terminal Cursor answer or the consumer's exact error for a non-completed result. A background call first returns a Job id; the generic job controls later deliver a completion notice, expose the final answer and status through `job_output`, and let `job_kill` request cancellation. Cursor's intermediate messages, tool activity, stderr, workspace diffs, usage, and product ids are not copied into the parent Session.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Foreground input grows by the retained final answer or error. Background input also includes the start acknowledgement, completion notice, and any `job_output`, `job_kill`, or later status results; child tokens still do not enter the parent context. This provider adds no parent tool schema by itself.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only: foreground adds one result after the reusable parent prefix, while background appends the Job acknowledgement, notice, and later control or collection results. Background scheduling can add a notice-driven turn, but none of these messages rewrites the earlier prefix.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **One fresh process and run per delegation** — there is no continuation, `--resume`, pooling, progress stream, or product-session persistence, even though the CLI itself supports resuming a chat by id.
|
||||
- **The task cannot begin with `-`** — print mode takes the prompt positionally and documents no `--` separator, so such a task is rejected at admission instead of being mis-parsed as an option.
|
||||
- **Windows needs the native executable** — a resolved `.cmd` or `.bat` shim is rejected rather than run through `cmd.exe`, whose command tail would reparse the task text.
|
||||
- **Host-managed product installation and account state** — a missing `cursor-agent`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer, login flow, or runtime version gate.
|
||||
- **No graduated approval path** — `force` and `trust` are whole-run deployment switches; there is no per-call policy, allowlist, or human approval channel through this package.
|
||||
- **No failure taxonomy** — print mode reports only a terminal subtype and error marker, so a context-window ending is indistinguishable from any other failure and never maps to `max-tokens`.
|
||||
- **Product payload is final text only** — intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local; generic Job ids, notices, and status come from the shared job runtime.
|
||||
- **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider.
|
||||
- **No wall-clock timeout or side-effect rollback** — the caller cancels long work, and files or external systems changed before cancellation are not restored.
|
||||
106
packages/subagent/subagent-cursor/README.zh.md
Normal file
106
packages/subagent/subagent-cursor/README.zh.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# @deepseek-ai/dsh-subagent-cursor
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
本包注册固定的 `cursor` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中以非交互 print 模式启动官方 `cursor-agent` CLI,提交一个自包含的文本任务,读取该 CLI 的 `stream-json` 事件流,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定仅返回最终答案。
|
||||
|
||||
## 启动与所有权
|
||||
|
||||
`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。随后,它通过 [`dsh-subprocess`](../../subprocess/subprocess/README.md) 解析 `cursor-agent`,spawn 固定命令,且仅在该 CLI 的 `system`/`init` 事件公布其自有会话 ID 之后才发布此次运行——那正是该 CLI 已启动并解析出自身凭证与模型的时刻。若在此公布之前发生失败或取消,它会终止受管进程树并等待其退出,然后拒绝 `start()` 调用。
|
||||
|
||||
任务以位置命令行参数的形式跨越进程边界,因为这是 print 模式提供的唯一提示词通道:该 CLI 未记载 `--` 选项终止符,也不从标准输入读取提示词。由此产生的两项后果被强制约束,而非含糊掩盖。首字符为 `-` 的任务在准入阶段即被拒绝,因为该 CLI 会将其解析为选项。解析到 Windows `.cmd` 或 `.bat` 包装脚本同样被拒绝,因为只有 `cmd.exe` 能运行它,而其命令尾部会把模型撰写的文本重新解析为 shell 语法;PATHEXT 解析会优先选择原生 Windows 安装程序提供的 `cursor-agent.exe`。标准输入在 spawn 后立即关闭,因此该 CLI 若仍尝试读取提示词,会快速失败,而不是让无人值守的子级停滞在无人能给出的答案上。
|
||||
|
||||
已发布的 `run.result` 会等待权威的终止 `result` 事件,且只接受 `subtype: "success"` 且 `is_error: false` 并带非空白 `result` 的事件。其他任何终止事件、格式错误的标准输出行、流失败,或流结束时仍无结果,都映射为 `error`;print 模式不携带可供程序判读的失败分类,因此该提供方既不会产生 `max-tokens` 也不会产生 `refusal`。`user`、`tool_call` 以及更新版 CLI 新增的事件类别对本约定没有贡献。
|
||||
|
||||
print 模式没有回复通道,因此不存在协议层中断:取消即本次运行的中止信号,子进程 seam 会将其转为逐级终止机制,同时结果立即判为 `aborted`,并携带此前收集到的最后一条非空助手消息。`dispose()`(资源释放)具有幂等性:它会摘除事件流监听、调用共享的进程树逐级终止机制,并等待整棵进程树退出。结果失败与独立的清理失败仍彼此分离。
|
||||
|
||||
## 能力与上下文
|
||||
|
||||
本提供方不声明任何可选的启动时能力,并报告 `inheritsParentContext: false`。Cursor 会接收独立文本任务和父会话 cwd,但不会接收父会话的对话、角色设定、工具筛选器、深度策略或结构化输出约定。该 CLI 自有的会话 ID 与模型仅在此次运行内部可见,绝不会持久化到父会话。
|
||||
|
||||
## 配置
|
||||
|
||||
| 配置键 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
| `env` | `{}` | 显式指定的子进程环境,叠加在由子进程 seam 清除凭证后的父环境之上。 |
|
||||
| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 |
|
||||
| `force` | `false` | 传入 `--force`,允许子级实际应用文件改动并执行命令。Cursor 自身的 print 模式默认只“提出”改动,因此预期要编辑工作区的委托需要开启此项。 |
|
||||
| `trust` | `false` | 传入 `--trust`,允许子级在不经过 Cursor 交互式信任提示的情况下在工作区中行动——无人值守的子级无法回答该提示。 |
|
||||
|
||||
生产环境会从子进程执行环境中已清除凭证的 `PATH` 里解析 `cursor-agent`,并叠加显式 `env` 条目,同时使用宿主机原生的 Cursor 配置与身份验证。本插件不安装该 CLI、不选择模型、不创建 Cursor 主目录、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 `CURSOR_API_KEY` 必须在 `env` 中提供;它绝不会作为 `--api-key` 传入——那会让进程列表暴露它。除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。
|
||||
|
||||
`dsh-base` 宿主平面会挂载本提供方,`code`、`cordis` 与 `standard` Agent Preset 均携带已启用的 `subagent_cursor` 工具行;`economy` preset 保留 `disabled: true`,因为经济模式的组装不应默认动用外部付费 agent。加载提供方本身不会在工具调用前启动 Cursor 进程。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID,供 `job_output` 或 `job_kill` 使用。
|
||||
|
||||
下面的独立组装展示了完整的显式能力。基于 `@deepseek-ai/dsh-base` 的 Profile 会保留其既有的 Job 行与提供方行,只需启用 preset 中的工具行,而不必挂载重复项。
|
||||
|
||||
```yaml
|
||||
- id: subagent-cursor
|
||||
name: '@deepseek-ai/dsh-subagent-cursor'
|
||||
config:
|
||||
force: true
|
||||
trust: true
|
||||
env:
|
||||
CURSOR_API_KEY: !!js process.env.CURSOR_API_KEY
|
||||
|
||||
- id: jobs
|
||||
name: '@deepseek-ai/dsh-jobs-local'
|
||||
|
||||
- id: tool-jobs
|
||||
name: '@deepseek-ai/dsh-tool-jobs'
|
||||
|
||||
- id: tool-subagent-cursor
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
config:
|
||||
provider: cursor
|
||||
toolName: subagent_cursor
|
||||
backgroundMode: one-shot
|
||||
maxDepth: provider-managed
|
||||
```
|
||||
|
||||
## 产品兼容性与证据
|
||||
|
||||
本提供方只读取这一 one-shot 约定所需的 print 模式事件:`system`/`init` 用于发布运行,`assistant` 用于快照部分输出,`result` 用于结束运行。部署方需在 `PATH` 上提供 `cursor-agent`;该 CLI 不是本包的 npm 依赖,因此单元证据通过子进程 seam 驱动真实事件流,而不依赖固定版本的二进制文件。
|
||||
|
||||
该 CLI 还能以 `cursor-agent acp` 讲 Agent Client Protocol,通用的 [`dsh-subagent-acp`](../subagent-acp/README.md) 提供方仅凭配置即可驱动它。那条路径适用于需要 ACP 权限自动应答策略或长期远端会话的部署;本包存在的意义在于这一 one-shot 委托约定、自有的 `subagent_cursor` 工具行,以及 print 模式更简单的失败面。
|
||||
|
||||
## Model Experience
|
||||
|
||||
### 子级请求
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
Cursor 子级会以一次全新的 print 模式运行,接收拼接后的文本任务作为单个位置提示词。其工作区是父会话 cwd,而模型、系统指令、工具与身份验证均来自宿主机原生的 Cursor 安装与配置。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
子级为独立的 Cursor 上下文与轮次付费。子级 token 不会进入父级上下文。
|
||||
|
||||
#### KV 缓存影响
|
||||
|
||||
与父级请求缓存互相独立。复用只取决于 Cursor 自身的模型、指令、工具与本次运行的请求。
|
||||
|
||||
### 父级调度与结果(间接)
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
通过 `dsh-tool-subagent`,前台调用会把终止事件中的 Cursor 答案交给父级,若结果并非完成状态,则交给消费方的确切错误。后台调用先返回一个 Job ID;通用作业控制工具随后送达完成通知,通过 `job_output` 暴露最终答案与状态,并允许 `job_kill` 请求取消。Cursor 的中间消息、工具活动、标准错误、工作区差异、用量与产品 ID 都不会复制进父会话。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
前台输入会因保留的最终答案或错误而增长。后台输入还包含启动确认、完成通知,以及任何 `job_output`、`job_kill` 或后续状态结果;子级 token 仍不会进入父级上下文。本提供方自身不添加任何父级工具 schema。
|
||||
|
||||
#### KV 缓存影响
|
||||
|
||||
仅追加:前台在可复用的父级前缀之后追加一条结果,后台则追加 Job 确认、通知以及后续的控制或收集结果。后台调度可能新增一个由通知驱动的轮次,但上述任何消息都不会改写此前的前缀。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **每次委托对应一个全新进程与一次运行** —— 没有续接、`--resume`、进程池化、进度流或产品会话持久化,尽管该 CLI 本身支持按 ID 恢复对话。
|
||||
- **任务不能以 `-` 开头** —— print 模式按位置接收提示词且未记载 `--` 终止符,因此这类任务在准入阶段即被拒绝,而不是被误解析为选项。
|
||||
- **Windows 需要原生可执行文件** —— 解析到 `.cmd` 或 `.bat` 包装脚本会被拒绝,而不会通过 `cmd.exe` 运行——其命令尾部会重新解析任务文本。
|
||||
- **产品安装与账户状态由宿主机管理** —— 缺失 `cursor-agent`、配置错误或身份验证失败会作为启动错误或运行错误上报;本插件不提供安装器、登录流程或运行时版本闸门。
|
||||
- **没有分级审批路径** —— `force` 与 `trust` 是整次运行级别的部署开关;本包不提供按调用的策略、允许清单或人工审批通道。
|
||||
- **没有失败分类** —— print 模式只报告终止子类型与错误标记,因此上下文窗口耗尽与其他任何失败无法区分,且绝不会映射为 `max-tokens`。
|
||||
- **产品载荷仅为最终文本** —— 中间消息、工具流量、用量、标准错误与工作区差异仍留在产品本地;通用 Job ID、通知与状态来自共享作业运行时。
|
||||
- **没有可选共享能力** —— 输出 schema、子级角色设定、工具筛选与 harness 深度强制均被共享服务针对本提供方拒绝。
|
||||
- **没有挂钟超时或副作用回滚** —— 由调用方取消长时间工作,取消前已改动的文件或外部系统不会被还原。
|
||||
59
packages/subagent/subagent-cursor/package.json
Normal file
59
packages/subagent/subagent-cursor/package.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-subagent-cursor",
|
||||
"description": "One-shot Cursor subagent provider over the cursor-agent print-mode stream-json protocol",
|
||||
"version": "0.1.0-rc.7",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/subagent/subagent-cursor"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
130
packages/subagent/subagent-cursor/src/index.ts
Normal file
130
packages/subagent/subagent-cursor/src/index.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Fixed Cursor one-shot subagent provider. Every accepted run starts a fresh
|
||||
* non-interactive `cursor-agent --print --output-format stream-json` process
|
||||
* in the delegating Session's workspace and publishes only after the CLI
|
||||
* announces its own session.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent-cursor
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import {
|
||||
assertPositiveFinite,
|
||||
NO_START_CAPABILITIES,
|
||||
resolveChildCwd,
|
||||
type ResolvedSubagentStartRequest,
|
||||
type SubagentCapabilities,
|
||||
type SubagentProvider,
|
||||
} from '@deepseek-ai/dsh-subagent'
|
||||
import {
|
||||
assertDirectlySpawnable,
|
||||
DEFAULT_DISPOSE_GRACE_MS,
|
||||
startCursorRun,
|
||||
type CursorRunSpec,
|
||||
} from './run.ts'
|
||||
|
||||
export const name = 'subagent-cursor'
|
||||
export const inject = ['subagents', 'subprocess']
|
||||
|
||||
/** Fixed native executable; Cursor's own configuration stays authoritative. */
|
||||
const CURSOR_EXECUTABLE = 'cursor-agent'
|
||||
|
||||
/** Deployment-owned environment, permissions, and process-release bound. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Explicit environment entries layered over the subprocess seam's
|
||||
* credential-scrubbed parent environment. `CURSOR_API_KEY` belongs here
|
||||
* rather than on the command line, where a process listing would expose it.
|
||||
*/
|
||||
env?: Record<string, string>
|
||||
/** Grace in milliseconds for `cursor-agent` process-tree termination. */
|
||||
disposeGraceMs?: number
|
||||
/**
|
||||
* Whether the child may apply file changes and run commands (`--force`).
|
||||
* Cursor's own print-mode default only PROPOSES changes, so a delegation
|
||||
* expected to edit the workspace needs this on.
|
||||
*/
|
||||
force?: boolean
|
||||
/**
|
||||
* Whether the child may act in the workspace without Cursor's interactive
|
||||
* trust prompt (`--trust`). An unattended child cannot answer that prompt.
|
||||
*/
|
||||
trust?: boolean
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
env: z.dict(z.string()).default({}),
|
||||
disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS),
|
||||
force: z.boolean().default(false),
|
||||
trust: z.boolean().default(false),
|
||||
})
|
||||
|
||||
type ResolvedConfig = Required<Config>
|
||||
|
||||
class CursorProvider implements SubagentProvider {
|
||||
readonly name = 'cursor'
|
||||
readonly capabilities: SubagentCapabilities = NO_START_CAPABILITIES
|
||||
readonly inheritsParentContext = false
|
||||
|
||||
constructor(
|
||||
private readonly ctx: Context,
|
||||
private readonly config: ResolvedConfig,
|
||||
) {}
|
||||
|
||||
async start(request: ResolvedSubagentStartRequest) {
|
||||
const parentCwd = request.parent.session.header.cwd
|
||||
if (parentCwd === undefined) {
|
||||
throw new Error(
|
||||
'subagent-cursor: no working directory for the child — delegate from a parent session that has one',
|
||||
)
|
||||
}
|
||||
const executable = assertDirectlySpawnable(
|
||||
await this.ctx.subprocess.resolveExecutable(
|
||||
CURSOR_EXECUTABLE,
|
||||
this.config.env,
|
||||
request.signal,
|
||||
),
|
||||
)
|
||||
const spec: CursorRunSpec = {
|
||||
cwd: resolveChildCwd(
|
||||
'subagent-cursor',
|
||||
undefined,
|
||||
parentCwd,
|
||||
),
|
||||
executable,
|
||||
env: this.config.env,
|
||||
disposeGraceMs: this.config.disposeGraceMs,
|
||||
force: this.config.force,
|
||||
trust: this.config.trust,
|
||||
spawn: spawnSpec => this.ctx.subprocess.spawn(spawnSpec),
|
||||
onError: (error, stopReason) => {
|
||||
this.ctx.logger.warn(
|
||||
`subagent-cursor: child run failed (${stopReason}): ${error.message}`,
|
||||
)
|
||||
},
|
||||
}
|
||||
return startCursorRun(request, spec)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the fixed `cursor` provider.
|
||||
* @param ctx - context carrying shared subagent and subprocess services.
|
||||
* @param config - explicit child environment, permissions, and disposal grace.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const resolved = config as ResolvedConfig
|
||||
assertPositiveFinite(
|
||||
'subagent-cursor',
|
||||
'disposeGraceMs',
|
||||
resolved.disposeGraceMs,
|
||||
)
|
||||
if (resolved.disposeGraceMs > MAX_TIMER_DELAY_MS) {
|
||||
throw new Error(
|
||||
`subagent-cursor: disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`,
|
||||
)
|
||||
}
|
||||
ctx.subagents.registerProvider(new CursorProvider(ctx, resolved))
|
||||
}
|
||||
30
packages/subagent/subagent-cursor/src/invariant.ts
Normal file
30
packages/subagent/subagent-cursor/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-subagent-cursor`.
|
||||
* @module @deepseek-ai/dsh-subagent-cursor/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-cursor'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'subagent-cursor-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: lifecycle pairing belongs to the shared subagent
|
||||
* service and process-tree ownership belongs to the subprocess service.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - plugin context carrying the invariant registry.
|
||||
* @returns the installed registration's disposer.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
267
packages/subagent/subagent-cursor/src/run.ts
Normal file
267
packages/subagent/subagent-cursor/src/run.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* One-shot Cursor child lifecycle: spawn the real `cursor-agent` print-mode
|
||||
* command through the subprocess seam, publish only after the CLI announces
|
||||
* its session, flatten post-publication failures, and dispose to whole-tree
|
||||
* quiescence.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent-cursor/run
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { extname } from 'node:path'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
settleRunResult,
|
||||
subprocessRunHandle,
|
||||
type SubagentResult,
|
||||
type SubagentRun,
|
||||
type SubagentStartRequest,
|
||||
type SubagentStopReason,
|
||||
} from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import { CursorStreamWire } from './wire.ts'
|
||||
|
||||
/** Default POSIX grace between subprocess termination tiers. */
|
||||
export const DEFAULT_DISPOSE_GRACE_MS = 3_000
|
||||
|
||||
/* jscpd:ignore-start -- sibling providers intentionally keep product-private
|
||||
* run inputs and error normalization instead of adding a shared lifecycle owner. */
|
||||
/** Fully resolved inputs for one `cursor-agent` print-mode run. */
|
||||
export interface CursorRunSpec {
|
||||
/** Parent Session workspace, also passed as the CLI `--workspace`. */
|
||||
readonly cwd: string
|
||||
/** Exact native `cursor-agent` executable resolved from the host PATH. */
|
||||
readonly executable: string
|
||||
/** Explicit deployment/test environment layered after the shared scrub. */
|
||||
readonly env: Record<string, string>
|
||||
/** Subprocess termination grace passed to the shared process-tree owner. */
|
||||
readonly disposeGraceMs: number
|
||||
/** Whether the child may apply changes instead of only proposing them. */
|
||||
readonly force: boolean
|
||||
/** Whether the child may act in the workspace without a trust prompt. */
|
||||
readonly trust: boolean
|
||||
/** Shared subprocess service spawn operation. */
|
||||
readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle
|
||||
/** Diagnostic sink for a post-publication error flattened into a result. */
|
||||
readonly onError?: (error: Error, stopReason: SubagentStopReason) => void
|
||||
}
|
||||
|
||||
function thrown(value: unknown): Error {
|
||||
/* v8 ignore next -- typed subprocess/wire failures reject with Error. */
|
||||
return value instanceof Error ? value : new Error(String(value))
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
* Validate and preserve the one-shot task before it becomes a command-line
|
||||
* argument.
|
||||
*
|
||||
* `cursor-agent` takes the task as a POSITIONAL argument and documents no
|
||||
* `--` end-of-options separator, so a task whose first character is `-` would
|
||||
* be parsed as an option. The seam has no way to escape that, so it is
|
||||
* rejected here rather than silently mis-parsed by the CLI.
|
||||
* @param prompt - task content accepted from the shared subagent service.
|
||||
* @returns the exact concatenated text task.
|
||||
*/
|
||||
export function textTask(prompt: readonly ContentBlock[]): string {
|
||||
if (prompt.length === 0) {
|
||||
throw new Error('subagent-cursor: the one-shot task must contain only text blocks')
|
||||
}
|
||||
const texts: string[] = []
|
||||
for (const block of prompt) {
|
||||
if (block.type !== 'text') {
|
||||
throw new Error('subagent-cursor: the one-shot task must contain only text blocks')
|
||||
}
|
||||
texts.push(block.text)
|
||||
}
|
||||
if (texts.every(text => text.trim().length === 0)) {
|
||||
throw new Error('subagent-cursor: the one-shot task must not be empty')
|
||||
}
|
||||
const task = texts.join('')
|
||||
if (task.startsWith('-')) {
|
||||
throw new Error(
|
||||
'subagent-cursor: the one-shot task must not begin with "-" — cursor-agent takes it as a positional argument and would parse it as an option',
|
||||
)
|
||||
}
|
||||
return task
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the resolved executable can be started without a shell.
|
||||
*
|
||||
* The task text is ordinary argv on every direct spawn, but a Windows `.cmd`
|
||||
* or `.bat` shim can only run through `cmd.exe`, which reparses its command
|
||||
* tail — model-authored task text would then be shell syntax. PATHEXT
|
||||
* resolution prefers `cursor-agent.exe`, which the native Windows installer
|
||||
* provides, so this fails loud instead of opening that boundary.
|
||||
* @param executable - the resolved absolute executable path.
|
||||
* @param platform - host platform selecting the batch-shim rejection.
|
||||
* @returns the executable, validated.
|
||||
*/
|
||||
export function assertDirectlySpawnable(
|
||||
executable: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): string {
|
||||
const extension = extname(executable).toLowerCase()
|
||||
if (platform === 'win32' && (extension === '.cmd' || extension === '.bat')) {
|
||||
throw new Error(
|
||||
`subagent-cursor: resolved ${executable} is a batch shim that requires a shell; install the native cursor-agent executable so the task text stays ordinary argv`,
|
||||
)
|
||||
}
|
||||
return executable
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the fixed print-mode command for one run.
|
||||
* @param spec - resolved executable, workspace, and permission selections.
|
||||
* @param task - the validated positional task text.
|
||||
* @returns argv for one non-interactive `cursor-agent` run.
|
||||
*/
|
||||
export function cursorAgentArgv(
|
||||
spec: Pick<CursorRunSpec, 'executable' | 'cwd' | 'force' | 'trust'>,
|
||||
task: string,
|
||||
): string[] {
|
||||
return [
|
||||
spec.executable,
|
||||
'--print',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--workspace',
|
||||
spec.cwd,
|
||||
...(spec.force ? ['--force'] : []),
|
||||
...(spec.trust ? ['--trust'] : []),
|
||||
task,
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the event stream, terminate the managed process tree, and wait for the
|
||||
* subprocess owner to prove it is gone.
|
||||
* @param wire - the run's private stdout event decoder.
|
||||
* @param child - shared-service handle that owns the process tree.
|
||||
*/
|
||||
export async function disposeCursorChild(
|
||||
wire: CursorStreamWire,
|
||||
child: SubprocessHandle,
|
||||
): Promise<void> {
|
||||
wire.close()
|
||||
if (child.pid <= 0) {
|
||||
await child.done.catch(() => {})
|
||||
return
|
||||
}
|
||||
child.terminate()
|
||||
await child.waitForExit()
|
||||
await child.done
|
||||
}
|
||||
|
||||
/**
|
||||
* Start one real `cursor-agent` print-mode child and publish its one-shot run.
|
||||
* @param request - resolved shared subagent request.
|
||||
* @param spec - executable, workspace, environment, permissions, process
|
||||
* service, and diagnostic policy.
|
||||
* @returns the published run after the CLI announces its session.
|
||||
*/
|
||||
export async function startCursorRun(
|
||||
request: SubagentStartRequest,
|
||||
spec: CursorRunSpec,
|
||||
): Promise<SubagentRun> {
|
||||
const task = textTask(request.prompt)
|
||||
if (request.signal.aborted) {
|
||||
throw new Error('subagent-cursor: request was aborted before cursor-agent startup')
|
||||
}
|
||||
|
||||
const runAbort = new AbortController()
|
||||
const child = spec.spawn({
|
||||
argv: cursorAgentArgv(spec, task),
|
||||
cwd: spec.cwd,
|
||||
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' },
|
||||
graceMs: spec.disposeGraceMs,
|
||||
// Print mode offers no reply channel, so there is no protocol interrupt to
|
||||
// send: stopping the child IS the cancellation, and the subprocess seam
|
||||
// owns that termination escalation.
|
||||
signal: runAbort.signal,
|
||||
env: spec.env,
|
||||
})
|
||||
|
||||
const wire = new CursorStreamWire(child.stdout as NonNullable<SubprocessHandle['stdout']>)
|
||||
const disposeProcess = (): Promise<void> => disposeCursorChild(wire, child)
|
||||
|
||||
const processFailure: Promise<never> = child.done.then(
|
||||
outcome => Promise.reject(new Error(
|
||||
'subagent-cursor: cursor-agent exited before the run settled '
|
||||
+ `(code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`,
|
||||
)),
|
||||
(error: unknown) => Promise.reject(thrown(error)),
|
||||
)
|
||||
// A normal post-result dispose also closes the process. Keep that expected
|
||||
// late rejection observed after the result race has already settled.
|
||||
processFailure.catch(() => {})
|
||||
|
||||
const requestCancel = (): void => {
|
||||
if (runAbort.signal.aborted) return
|
||||
runAbort.abort(new Error('subagent-cursor: run cancelled locally'))
|
||||
}
|
||||
const onAbort = (): void => { requestCancel() }
|
||||
request.signal.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
// The spawn signal stops the child; this race settles the run. Waiting for
|
||||
// the termination ladder instead would leave a cancelled result pending for
|
||||
// the whole grace period, so cancellation is observed here directly.
|
||||
const cancellation: Promise<never> = new Promise((_resolve, reject) => {
|
||||
runAbort.signal.addEventListener(
|
||||
'abort',
|
||||
() => { reject(new Error('subagent-cursor: run cancelled locally')) },
|
||||
{ once: true },
|
||||
)
|
||||
})
|
||||
// Both races below observe this rejection, but only until one of them
|
||||
// settles; keep it observed for the window after that.
|
||||
cancellation.catch(() => {})
|
||||
|
||||
try {
|
||||
wire.start()
|
||||
// The task is already on the command line and print mode reads no input.
|
||||
// Closing stdin now makes any prompt the CLI still tries to read fail fast
|
||||
// instead of stalling an unattended child on an answer nobody can give.
|
||||
child.stdin?.end()
|
||||
await Promise.race([wire.ready(), processFailure, cancellation])
|
||||
} catch (error: unknown) {
|
||||
request.signal.removeEventListener('abort', onAbort)
|
||||
try {
|
||||
await disposeProcess()
|
||||
} catch (disposeError: unknown) {
|
||||
throw new AggregateError(
|
||||
[thrown(error), thrown(disposeError)],
|
||||
'subagent-cursor: startup failed and cursor-agent cleanup also failed',
|
||||
)
|
||||
}
|
||||
if (runAbort.signal.aborted) {
|
||||
throw new Error('subagent-cursor: request was aborted before run publication')
|
||||
}
|
||||
throw thrown(error)
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- the seam documents settlement and publication as two
|
||||
* primitives, so every out-of-process provider ends with this same literal
|
||||
* composition; folding them into a third seam function would trade two named
|
||||
* steps for one eight-parameter call. */
|
||||
const result: Promise<SubagentResult> = settleRunResult({
|
||||
attempt: () => Promise.race([wire.awaitResult(), processFailure, cancellation]),
|
||||
collectOutput: () => wire.collectOutput(),
|
||||
cancelled: () => runAbort.signal.aborted,
|
||||
onError: spec.onError,
|
||||
signal: request.signal,
|
||||
onAbort,
|
||||
})
|
||||
|
||||
return subprocessRunHandle({
|
||||
id: SessionId(randomUUID()),
|
||||
result,
|
||||
signal: request.signal,
|
||||
onAbort,
|
||||
requestCancel,
|
||||
teardown: disposeProcess,
|
||||
})
|
||||
/* jscpd:ignore-end */
|
||||
}
|
||||
265
packages/subagent/subagent-cursor/src/wire.ts
Normal file
265
packages/subagent/subagent-cursor/src/wire.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Decoder for one `cursor-agent --print --output-format stream-json` run. The
|
||||
* CLI writes newline-delimited JSON events on stdout and never reads a reply,
|
||||
* so this module owns line framing, event validation at the process boundary,
|
||||
* the `system`/`init` gate that run publication waits on, assistant-message
|
||||
* selection, and terminal-answer selection. It sends nothing: cancellation is
|
||||
* local and process termination belongs to the subprocess seam.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent-cursor/wire
|
||||
*/
|
||||
|
||||
import type { Readable } from 'node:stream'
|
||||
import { StringDecoder } from 'node:string_decoder'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { SubagentResult } from '@deepseek-ai/dsh-subagent'
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
|
||||
/**
|
||||
* The `system`/`init` facts observed once before publication. They prove the
|
||||
* CLI started and resolved its own credentials and model, which is this
|
||||
* provider's equivalent of a remote session existing; both values stay
|
||||
* private to the run and are never persisted in the parent Session.
|
||||
*/
|
||||
export interface CursorSessionInfo {
|
||||
/** Cursor's own chat id for this run. */
|
||||
readonly sessionId: string
|
||||
/** Model display name Cursor selected from its native configuration. */
|
||||
readonly model: string | undefined
|
||||
}
|
||||
|
||||
function object(value: unknown, label: string): JsonObject {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`subagent-cursor: cursor-agent emitted an invalid ${label}`)
|
||||
}
|
||||
return value as JsonObject
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the text blocks of one Cursor message. Non-text blocks are dropped
|
||||
* rather than rejected: the CLI may add block kinds this one-shot contract has
|
||||
* no representation for, and dropping them cannot corrupt the selected answer.
|
||||
* @param message - the event's `message` value.
|
||||
* @param label - event name used in the boundary diagnostic.
|
||||
* @returns the message's text blocks, possibly empty.
|
||||
*/
|
||||
function messageText(message: unknown, label: string): ContentBlock[] {
|
||||
const content = object(message, label).content
|
||||
if (!Array.isArray(content)) {
|
||||
throw new Error(`subagent-cursor: cursor-agent emitted an invalid ${label} content`)
|
||||
}
|
||||
const blocks: ContentBlock[] = []
|
||||
for (const block of content) {
|
||||
if (
|
||||
block !== null
|
||||
&& typeof block === 'object'
|
||||
&& !Array.isArray(block)
|
||||
&& (block as JsonObject).type === 'text'
|
||||
&& typeof (block as JsonObject).text === 'string'
|
||||
) {
|
||||
blocks.push({ type: 'text', text: (block as JsonObject).text as string })
|
||||
}
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe why a terminal `result` event cannot complete the run. The CLI
|
||||
* carries no machine-readable failure taxonomy in print mode, so the reported
|
||||
* detail is its own `subtype` plus the error marker.
|
||||
* @param event - the terminal event.
|
||||
* @returns a stable single-line diagnostic detail.
|
||||
*/
|
||||
function resultFailureDetail(event: JsonObject): string {
|
||||
const subtype = typeof event.subtype === 'string' ? event.subtype : 'unknown'
|
||||
return subtype === 'success'
|
||||
? 'success result was marked as an error or contained no answer'
|
||||
: `terminal result subtype ${subtype}`
|
||||
}
|
||||
|
||||
/**
|
||||
* One `cursor-agent` print-mode event stream.
|
||||
*
|
||||
* The class deliberately exposes no generic event surface. Observing another
|
||||
* event kind must first become part of the provider contract.
|
||||
*/
|
||||
export class CursorStreamWire {
|
||||
private readonly decoder = new StringDecoder('utf8')
|
||||
private readonly initialization = Promise.withResolvers<CursorSessionInfo>()
|
||||
private readonly terminal = Promise.withResolvers<SubagentResult>()
|
||||
private buffer = ''
|
||||
private lastAssistantMessage: ContentBlock[] | undefined
|
||||
private started = false
|
||||
private closed = false
|
||||
private initialized = false
|
||||
private settled = false
|
||||
|
||||
/**
|
||||
* Attach to one run's stdout stream.
|
||||
* @param input - the child's stdout pipe; the wire owns its listeners only
|
||||
* between {@link start} and {@link close}.
|
||||
*/
|
||||
constructor(private readonly input: Readable) {
|
||||
// Both gates can reject before their awaiter exists: a startup failure
|
||||
// rejects `initialization` and abandons `terminal`, and a failure racing
|
||||
// publication rejects `terminal` before `settleRunResult` adopts it. Keep
|
||||
// both observed so neither becomes an unhandled rejection.
|
||||
void this.initialization.promise.catch(() => {})
|
||||
void this.terminal.promise.catch(() => {})
|
||||
}
|
||||
|
||||
/** Begin reading events. Idempotent. */
|
||||
start(): void {
|
||||
if (this.started) return
|
||||
this.started = true
|
||||
this.input.on('data', this.onData)
|
||||
this.input.on('error', this.onError)
|
||||
this.input.on('end', this.onEnd)
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach listeners and fail both gates if they are still open. Idempotent,
|
||||
* and safe before {@link start}.
|
||||
*/
|
||||
close(): void {
|
||||
if (this.closed) return
|
||||
this.closed = true
|
||||
this.input.off('data', this.onData)
|
||||
this.input.off('error', this.onError)
|
||||
this.input.off('end', this.onEnd)
|
||||
this.fail(new Error('subagent-cursor: cursor-agent event stream closed'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Await the `system`/`init` event that gates run publication.
|
||||
* @returns Cursor's own chat id and selected model for this run.
|
||||
*/
|
||||
ready(): Promise<CursorSessionInfo> {
|
||||
return this.initialization.promise
|
||||
}
|
||||
|
||||
/**
|
||||
* Await the terminal `result` event.
|
||||
* @returns the completed result; rejects for every non-success ending,
|
||||
* malformed event, stream failure, or end of stream without a result.
|
||||
*/
|
||||
awaitResult(): Promise<SubagentResult> {
|
||||
return this.terminal.promise
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot the child's output for a cancelled or failed settlement.
|
||||
* @returns the last non-empty assistant message, or `[]` when the child
|
||||
* produced none. Without `--stream-partial-output` each `assistant` event
|
||||
* is one complete message, so this is the seam's selection rule directly
|
||||
* rather than a delta accumulation.
|
||||
*/
|
||||
collectOutput(): ContentBlock[] {
|
||||
return this.lastAssistantMessage ?? []
|
||||
}
|
||||
|
||||
private readonly onData = (chunk: Buffer): void => {
|
||||
try {
|
||||
// The wire owns stdout between `start` and `close` and never sets an
|
||||
// encoding on it, so every chunk is bytes that may split a code point.
|
||||
this.buffer += this.decoder.write(chunk)
|
||||
for (;;) {
|
||||
const newline = this.buffer.indexOf('\n')
|
||||
if (newline < 0) break
|
||||
const line = this.buffer.slice(0, newline)
|
||||
this.buffer = this.buffer.slice(newline + 1)
|
||||
if (line.trim().length > 0) this.handleLine(line)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- handleLine and the decoder throw only Error. */
|
||||
this.fail(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
}
|
||||
|
||||
private readonly onError = (error: Error): void => {
|
||||
this.fail(error)
|
||||
}
|
||||
|
||||
private readonly onEnd = (): void => {
|
||||
this.fail(new Error('subagent-cursor: cursor-agent ended without a terminal result'))
|
||||
}
|
||||
|
||||
private handleLine(line: string): void {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(line)
|
||||
} catch {
|
||||
// The CLI's stdout is a process boundary, so a line that is not JSON is
|
||||
// a protocol failure rather than something to skip: skipping it would
|
||||
// hide a version whose stream this contract cannot read.
|
||||
throw new Error('subagent-cursor: cursor-agent emitted a stdout line that is not JSON')
|
||||
}
|
||||
const event = object(parsed, 'event')
|
||||
switch (event.type) {
|
||||
case 'system':
|
||||
if (event.subtype === 'init') this.handleInit(event)
|
||||
return
|
||||
case 'assistant': {
|
||||
const blocks = messageText(event.message, 'assistant message')
|
||||
if (blocks.length > 0) this.lastAssistantMessage = blocks
|
||||
return
|
||||
}
|
||||
case 'result':
|
||||
this.handleResult(event)
|
||||
return
|
||||
default:
|
||||
// `user`, `tool_call`, and any event kind a newer CLI adds contribute
|
||||
// nothing to this one-shot contract: publication is gated on `init`
|
||||
// and the answer comes from `result`.
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private handleInit(event: JsonObject): void {
|
||||
if (typeof event.session_id !== 'string' || event.session_id.length === 0) {
|
||||
throw new Error('subagent-cursor: cursor-agent emitted an invalid init session id')
|
||||
}
|
||||
this.initialized = true
|
||||
this.initialization.resolve({
|
||||
sessionId: event.session_id,
|
||||
model: typeof event.model === 'string' ? event.model : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
private handleResult(event: JsonObject): void {
|
||||
const answer = event.result
|
||||
if (
|
||||
event.subtype !== 'success'
|
||||
|| event.is_error === true
|
||||
|| typeof answer !== 'string'
|
||||
|| answer.trim().length === 0
|
||||
) {
|
||||
this.fail(new Error(`subagent-cursor: cursor-agent failed: ${resultFailureDetail(event)}`))
|
||||
return
|
||||
}
|
||||
this.settled = true
|
||||
if (!this.initialized) {
|
||||
// A run that never announced itself cannot be published, so its answer
|
||||
// has nowhere to go: fail startup instead of resolving a result for a
|
||||
// run the caller was never handed.
|
||||
const error = new Error('subagent-cursor: cursor-agent produced a result without announcing a session')
|
||||
this.initialization.reject(error)
|
||||
this.terminal.reject(error)
|
||||
return
|
||||
}
|
||||
// The terminal event carries the full assistant answer, so it wins over
|
||||
// the last streamed message for a completed run.
|
||||
this.terminal.resolve({
|
||||
output: [{ type: 'text', text: answer }],
|
||||
stopReason: 'completed',
|
||||
})
|
||||
}
|
||||
|
||||
private fail(error: Error): void {
|
||||
if (this.settled) return
|
||||
this.settled = true
|
||||
this.initialization.reject(error)
|
||||
this.terminal.reject(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
LOADER_SMOKE_TEST_TIMEOUT_MS,
|
||||
runLoaderSmoke,
|
||||
} from '@deepseek-ai/dsh-loader-smoke'
|
||||
|
||||
const fixtureDir = fileURLToPath(new URL(
|
||||
'../../../../examples/acp-agent/tests/fixtures/subagent/subagent-cursor/',
|
||||
import.meta.url,
|
||||
))
|
||||
const driver = join(fixtureDir, 'driver.ts')
|
||||
const configPath = join(fixtureDir, 'cordis.yml')
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
|
||||
describe('Cursor provider public Loader composition', () => {
|
||||
it('loads the opt-in package, one-shot task tool, and job controls without starting Cursor', async () => {
|
||||
const { stdout, stderr } = await runLoaderSmoke({
|
||||
label: 'subagent-cursor Loader composition',
|
||||
tempDirPrefix: 'dsh-subagent-cursor-loader-',
|
||||
binScript: driver,
|
||||
libBinScript: driver,
|
||||
configPath,
|
||||
tsconfigPath: repoTsconfig,
|
||||
env: {
|
||||
// Loading the optional package must not probe or start a cursor-agent binary.
|
||||
PATH: '',
|
||||
},
|
||||
})
|
||||
|
||||
expect(stderr).toBe('')
|
||||
expect(JSON.parse(stdout)).toEqual({
|
||||
providers: ['cursor'],
|
||||
provider: {
|
||||
name: 'cursor',
|
||||
capabilities: {
|
||||
outputSchema: false,
|
||||
depthLimit: false,
|
||||
toolFilter: false,
|
||||
persona: false,
|
||||
},
|
||||
inheritsParentContext: false,
|
||||
},
|
||||
tool: {
|
||||
name: 'subagent_cursor',
|
||||
parameterNames: ['description', 'prompt', 'run_in_background'],
|
||||
required: ['description', 'prompt'],
|
||||
},
|
||||
jobTools: ['job_kill', 'job_list', 'job_output'],
|
||||
starts: 0,
|
||||
})
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
})
|
||||
704
packages/subagent/subagent-cursor/tests/subagent-cursor.spec.ts
Normal file
704
packages/subagent/subagent-cursor/tests/subagent-cursor.spec.ts
Normal file
@@ -0,0 +1,704 @@
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import SubagentRuntime, { type SubagentStopReason } from '@deepseek-ai/dsh-subagent'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import type {
|
||||
SubprocessHandle,
|
||||
SubprocessOutcome,
|
||||
} from '@deepseek-ai/dsh-subprocess'
|
||||
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
|
||||
import * as cursor from '../src/index.ts'
|
||||
import * as invariant from '../src/invariant.ts'
|
||||
import {
|
||||
assertDirectlySpawnable,
|
||||
cursorAgentArgv,
|
||||
DEFAULT_DISPOSE_GRACE_MS,
|
||||
disposeCursorChild,
|
||||
startCursorRun,
|
||||
textTask,
|
||||
type CursorRunSpec,
|
||||
} from '../src/run.ts'
|
||||
import { CursorStreamWire } from '../src/wire.ts'
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
|
||||
const EXECUTABLE = '/opt/cursor/cursor-agent'
|
||||
|
||||
const fakeParent = {
|
||||
id: 'parent',
|
||||
session: { header: { cwd: process.cwd() } },
|
||||
} as unknown as Agent
|
||||
|
||||
function request(
|
||||
prompt: ContentBlock[] = [{ type: 'text', text: 'do the task' }],
|
||||
signal = new AbortController().signal,
|
||||
) {
|
||||
return { prompt, parent: fakeParent, signal }
|
||||
}
|
||||
|
||||
async function nextTask(): Promise<void> {
|
||||
await new Promise<void>((resolve) => { setImmediate(resolve) })
|
||||
}
|
||||
|
||||
function initEvent(overrides: JsonObject = {}): JsonObject {
|
||||
return {
|
||||
type: 'system',
|
||||
subtype: 'init',
|
||||
apiKeySource: 'env',
|
||||
cwd: process.cwd(),
|
||||
session_id: 'chat-1',
|
||||
model: 'cursor-model',
|
||||
permissionMode: 'default',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function assistantEvent(content: unknown): JsonObject {
|
||||
return {
|
||||
type: 'assistant',
|
||||
message: { role: 'assistant', content },
|
||||
session_id: 'chat-1',
|
||||
}
|
||||
}
|
||||
|
||||
function resultEvent(overrides: JsonObject = {}): JsonObject {
|
||||
return {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
duration_ms: 12,
|
||||
duration_api_ms: 10,
|
||||
is_error: false,
|
||||
result: 'the final answer',
|
||||
session_id: 'chat-1',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes stream-json events the way the real CLI writes its stdout. */
|
||||
class StreamPeer {
|
||||
constructor(private readonly output: PassThrough) {}
|
||||
|
||||
send(...events: readonly JsonObject[]): void {
|
||||
this.output.write(`${events.map(event => JSON.stringify(event)).join('\n')}\n`)
|
||||
}
|
||||
|
||||
raw(text: string): void {
|
||||
this.output.write(text)
|
||||
}
|
||||
}
|
||||
|
||||
interface FakeChildOptions {
|
||||
readonly pid?: number
|
||||
readonly exitOnTerminate?: boolean
|
||||
readonly doneError?: Error
|
||||
}
|
||||
|
||||
interface FakeChild {
|
||||
readonly handle: SubprocessHandle
|
||||
readonly peer: StreamPeer
|
||||
readonly fromChild: PassThrough
|
||||
readonly toChild: PassThrough
|
||||
readonly settle: (outcome?: SubprocessOutcome) => void
|
||||
readonly fail: (error: Error) => void
|
||||
readonly terminate: () => void
|
||||
readonly waitForExit: (signal?: AbortSignal) => Promise<boolean>
|
||||
}
|
||||
|
||||
function fakeChild(options: FakeChildOptions = {}): FakeChild {
|
||||
const fromChild = new PassThrough()
|
||||
const toChild = new PassThrough()
|
||||
const peer = new StreamPeer(fromChild)
|
||||
let exited = false
|
||||
let resolveDone!: (outcome: SubprocessOutcome) => void
|
||||
let rejectDone!: (error: Error) => void
|
||||
const done = new Promise<SubprocessOutcome>((resolve, reject) => {
|
||||
resolveDone = resolve
|
||||
rejectDone = reject
|
||||
})
|
||||
const settle = (
|
||||
outcome: SubprocessOutcome = { exitCode: 0, signal: null },
|
||||
): void => {
|
||||
if (exited) return
|
||||
exited = true
|
||||
resolveDone(outcome)
|
||||
}
|
||||
const fail = (error: Error): void => {
|
||||
if (exited) return
|
||||
exited = true
|
||||
rejectDone(error)
|
||||
}
|
||||
if (options.doneError !== undefined) fail(options.doneError)
|
||||
const terminate = vi.fn(() => {
|
||||
if (options.exitOnTerminate !== false) settle()
|
||||
})
|
||||
const waitForExit = vi.fn(async (signal?: AbortSignal) => {
|
||||
if (exited) return true
|
||||
if (signal === undefined) {
|
||||
await done.catch(() => {})
|
||||
return true
|
||||
}
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const onAbort = (): void => { resolve(false) }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
void done.then(
|
||||
() => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolve(true)
|
||||
},
|
||||
() => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolve(true)
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
const handle: SubprocessHandle = {
|
||||
pid: options.pid ?? 4321,
|
||||
stdin: toChild,
|
||||
stdout: fromChild,
|
||||
stderr: undefined,
|
||||
collected: {},
|
||||
done,
|
||||
terminate,
|
||||
waitForExit,
|
||||
}
|
||||
return {
|
||||
handle,
|
||||
peer,
|
||||
fromChild,
|
||||
toChild,
|
||||
settle,
|
||||
fail,
|
||||
terminate,
|
||||
waitForExit,
|
||||
}
|
||||
}
|
||||
|
||||
function runSpec(
|
||||
child: FakeChild,
|
||||
overrides: Partial<CursorRunSpec> = {},
|
||||
): CursorRunSpec {
|
||||
return {
|
||||
cwd: process.cwd(),
|
||||
executable: EXECUTABLE,
|
||||
env: {},
|
||||
disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
|
||||
force: false,
|
||||
trust: false,
|
||||
spawn: () => child.handle,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function publishRun(
|
||||
child = fakeChild(),
|
||||
signal = new AbortController().signal,
|
||||
specOverrides: Partial<CursorRunSpec> = {},
|
||||
) {
|
||||
const starting = startCursorRun(request(undefined, signal), runSpec(child, specOverrides))
|
||||
await nextTask()
|
||||
child.peer.send(initEvent())
|
||||
return { child, run: await starting }
|
||||
}
|
||||
|
||||
function startedWire(): { readonly child: FakeChild; readonly wire: CursorStreamWire } {
|
||||
const child = fakeChild()
|
||||
const wire = new CursorStreamWire(child.handle.stdout!)
|
||||
wire.start()
|
||||
return { child, wire }
|
||||
}
|
||||
|
||||
describe('task admission and command construction', () => {
|
||||
it('accepts one or more text blocks and rejects empty, non-text, or option-shaped tasks', () => {
|
||||
expect(textTask([{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }])).toBe('ab')
|
||||
expect(() => textTask([])).toThrow('must contain only text blocks')
|
||||
expect(() => textTask([{ type: 'reasoning', text: 'x' }]))
|
||||
.toThrow('must contain only text blocks')
|
||||
expect(() => textTask([{ type: 'text', text: ' ' }])).toThrow('must not be empty')
|
||||
expect(() => textTask([{ type: 'text', text: '--force me' }]))
|
||||
.toThrow('must not begin with "-"')
|
||||
})
|
||||
|
||||
it('refuses a Windows batch shim so task text never reaches a shell', () => {
|
||||
expect(assertDirectlySpawnable('C:\\bin\\cursor-agent.exe', 'win32'))
|
||||
.toBe('C:\\bin\\cursor-agent.exe')
|
||||
expect(assertDirectlySpawnable('/usr/bin/cursor-agent', 'linux'))
|
||||
.toBe('/usr/bin/cursor-agent')
|
||||
// A shim is only unusable where it needs cmd.exe.
|
||||
expect(assertDirectlySpawnable('/usr/bin/cursor-agent.cmd', 'linux'))
|
||||
.toBe('/usr/bin/cursor-agent.cmd')
|
||||
for (const shim of ['C:\\bin\\cursor-agent.cmd', 'C:\\bin\\cursor-agent.BAT']) {
|
||||
expect(() => assertDirectlySpawnable(shim, 'win32')).toThrow('is a batch shim')
|
||||
}
|
||||
// The omitted platform reads the host, so the expectation follows it.
|
||||
const underHostPlatform = (): string => assertDirectlySpawnable('C:\\bin\\cursor-agent.cmd')
|
||||
if (process.platform === 'win32') {
|
||||
expect(underHostPlatform).toThrow('is a batch shim')
|
||||
} else {
|
||||
expect(underHostPlatform()).toBe('C:\\bin\\cursor-agent.cmd')
|
||||
}
|
||||
})
|
||||
|
||||
it('builds the fixed print-mode argv and adds only selected permissions', () => {
|
||||
const base = { executable: EXECUTABLE, cwd: '/work', force: false, trust: false }
|
||||
expect(cursorAgentArgv(base, 'ship it')).toEqual([
|
||||
EXECUTABLE,
|
||||
'--print',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--workspace',
|
||||
'/work',
|
||||
'ship it',
|
||||
])
|
||||
expect(cursorAgentArgv({ ...base, force: true, trust: true }, 'ship it')).toEqual([
|
||||
EXECUTABLE,
|
||||
'--print',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--workspace',
|
||||
'/work',
|
||||
'--force',
|
||||
'--trust',
|
||||
'ship it',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('package contracts', () => {
|
||||
it('registers one fixed descriptor, validates config, and unregisters on HMR', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentRuntime)
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
const fiber = await ctx.plugin(cursor, {})
|
||||
expect(ctx.subagents.getProvider('cursor')).toMatchObject({
|
||||
name: 'cursor',
|
||||
capabilities: {
|
||||
outputSchema: false,
|
||||
depthLimit: false,
|
||||
toolFilter: false,
|
||||
persona: false,
|
||||
},
|
||||
inheritsParentContext: false,
|
||||
})
|
||||
expect(ctx.subagents.list()).toEqual(['cursor'])
|
||||
await fiber.dispose()
|
||||
expect(ctx.subagents.list()).toEqual([])
|
||||
|
||||
for (const disposeGraceMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
|
||||
await expect(ctx.plugin(cursor, { disposeGraceMs }))
|
||||
.rejects.toThrow('disposeGraceMs must be a positive finite number')
|
||||
}
|
||||
await expect(ctx.plugin(cursor, { disposeGraceMs: MAX_TIMER_DELAY_MS + 1 }))
|
||||
.rejects.toThrow(`disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('requires a parent session cwd before resolving or spawning anything', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentRuntime)
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
const resolveExecutable = vi.spyOn(ctx.subprocess, 'resolveExecutable')
|
||||
const spawn = vi.spyOn(ctx.subprocess, 'spawn')
|
||||
await ctx.plugin(cursor, {})
|
||||
|
||||
await expect(ctx.subagents.start('cursor', {
|
||||
prompt: [{ type: 'text', text: 'task' }],
|
||||
parent: {
|
||||
id: 'parent-without-cwd',
|
||||
session: { header: {} },
|
||||
} as unknown as Agent,
|
||||
signal: new AbortController().signal,
|
||||
})).rejects.toThrow(
|
||||
'subagent-cursor: no working directory for the child — delegate from a parent session that has one',
|
||||
)
|
||||
expect(resolveExecutable).not.toHaveBeenCalled()
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps the namespace export shape and package-owned empty invariant', async () => {
|
||||
expect('default' in cursor).toBe(false)
|
||||
expect(cursor.name).toBe('subagent-cursor')
|
||||
expect(cursor.inject).toEqual(['subagents', 'subprocess'])
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
expect(loader.unwrapExports(cursor)).toBe(cursor)
|
||||
|
||||
const dispose = vi.fn()
|
||||
const register = vi.fn((
|
||||
_packageName: string,
|
||||
_installer: InvariantInstaller,
|
||||
) => dispose)
|
||||
const ctx = { invariants: { register } } as unknown as Context
|
||||
await expect(invariant.apply(ctx)).resolves.toBe(dispose)
|
||||
expect(register).toHaveBeenCalledWith(
|
||||
'@deepseek-ai/dsh-subagent-cursor',
|
||||
expect.any(Function),
|
||||
)
|
||||
const install = register.mock.calls[0]![1]
|
||||
await install(new Context(), (message) => { throw new Error(message) })
|
||||
expect(invariant.name).toBe('subagent-cursor-invariant')
|
||||
expect(invariant.inject).toEqual(['invariants'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('CursorStreamWire', () => {
|
||||
it('gates on init, keeps the terminal answer, and ignores unrelated events', async () => {
|
||||
const { child, wire } = startedWire()
|
||||
wire.start()
|
||||
child.peer.send(initEvent())
|
||||
await expect(wire.ready()).resolves.toEqual({
|
||||
sessionId: 'chat-1',
|
||||
model: 'cursor-model',
|
||||
})
|
||||
child.peer.send(
|
||||
{ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'task' }] } },
|
||||
{ type: 'tool_call', subtype: 'started', call_id: 'c1', tool_call: {} },
|
||||
{ type: 'system', subtype: 'usage', tokens: 12 },
|
||||
assistantEvent([{ type: 'text', text: 'thinking out loud' }]),
|
||||
{ type: 'newer_cli_event', payload: 1 },
|
||||
)
|
||||
await nextTask()
|
||||
expect(wire.collectOutput()).toEqual([{ type: 'text', text: 'thinking out loud' }])
|
||||
child.peer.send(resultEvent())
|
||||
await expect(wire.awaitResult()).resolves.toEqual({
|
||||
output: [{ type: 'text', text: 'the final answer' }],
|
||||
stopReason: 'completed',
|
||||
})
|
||||
})
|
||||
|
||||
it('reads events split across chunks and skips blank lines', async () => {
|
||||
const { child, wire } = startedWire()
|
||||
const frame = JSON.stringify(initEvent())
|
||||
child.peer.raw(`\n \n${frame.slice(0, 10)}`)
|
||||
await nextTask()
|
||||
child.peer.raw(`${frame.slice(10)}\n`)
|
||||
await expect(wire.ready()).resolves.toMatchObject({ sessionId: 'chat-1' })
|
||||
expect(wire.collectOutput()).toEqual([])
|
||||
})
|
||||
|
||||
it('reports an absent model rather than inventing one', async () => {
|
||||
const { child, wire } = startedWire()
|
||||
child.peer.send(initEvent({ model: 42 }))
|
||||
await expect(wire.ready()).resolves.toEqual({
|
||||
sessionId: 'chat-1',
|
||||
model: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the last non-empty assistant message and drops non-text blocks', async () => {
|
||||
const { child, wire } = startedWire()
|
||||
child.peer.send(
|
||||
initEvent(),
|
||||
assistantEvent([{ type: 'text', text: 'first' }]),
|
||||
assistantEvent([{ type: 'image', source: {} }]),
|
||||
assistantEvent([]),
|
||||
assistantEvent([{ type: 'text', text: 'second' }, { type: 'image', source: {} }]),
|
||||
)
|
||||
await nextTask()
|
||||
expect(wire.collectOutput()).toEqual([{ type: 'text', text: 'second' }])
|
||||
})
|
||||
|
||||
it('fails closed on every unusable terminal result', async () => {
|
||||
for (const [overrides, detail] of [
|
||||
[{ subtype: 'error' }, 'terminal result subtype error'],
|
||||
[{ subtype: 7 }, 'terminal result subtype unknown'],
|
||||
[{ is_error: true }, 'success result was marked as an error'],
|
||||
[{ result: 12 }, 'success result was marked as an error'],
|
||||
[{ result: ' ' }, 'success result was marked as an error'],
|
||||
] as const) {
|
||||
const { child, wire } = startedWire()
|
||||
child.peer.send(initEvent(), resultEvent(overrides))
|
||||
await expect(wire.awaitResult()).rejects.toThrow(detail)
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses a result that arrives without an announced session', async () => {
|
||||
const { child, wire } = startedWire()
|
||||
child.peer.send(resultEvent())
|
||||
await expect(wire.ready()).rejects.toThrow('without announcing a session')
|
||||
await expect(wire.awaitResult()).rejects.toThrow('without announcing a session')
|
||||
})
|
||||
|
||||
it('treats malformed stdout as a protocol failure', async () => {
|
||||
for (const [line, detail] of [
|
||||
['not json at all', 'a stdout line that is not JSON'],
|
||||
['[1,2]', 'an invalid event'],
|
||||
['null', 'an invalid event'],
|
||||
[JSON.stringify({ type: 'system', subtype: 'init', session_id: '' }), 'an invalid init session id'],
|
||||
[JSON.stringify({ type: 'assistant', message: 'text' }), 'an invalid assistant message'],
|
||||
[JSON.stringify(assistantEvent('not an array')), 'an invalid assistant message content'],
|
||||
] as const) {
|
||||
const { child, wire } = startedWire()
|
||||
child.peer.raw(`${line}\n`)
|
||||
await expect(wire.awaitResult()).rejects.toThrow(detail)
|
||||
}
|
||||
})
|
||||
|
||||
it('fails pending gates on stream error, end of stream, and close', async () => {
|
||||
const broken = startedWire()
|
||||
broken.child.fromChild.emit('error', new Error('stdout broke'))
|
||||
await expect(broken.wire.awaitResult()).rejects.toThrow('stdout broke')
|
||||
|
||||
const ended = startedWire()
|
||||
ended.child.fromChild.end()
|
||||
await expect(ended.wire.awaitResult()).rejects.toThrow('ended without a terminal result')
|
||||
|
||||
const closed = startedWire()
|
||||
closed.wire.close()
|
||||
closed.wire.close()
|
||||
await expect(closed.wire.ready()).rejects.toThrow('event stream closed')
|
||||
|
||||
// A completed run keeps its result across teardown and end of stream.
|
||||
const done = startedWire()
|
||||
done.child.peer.send(initEvent(), resultEvent())
|
||||
await expect(done.wire.awaitResult()).resolves.toMatchObject({ stopReason: 'completed' })
|
||||
done.wire.close()
|
||||
done.child.fromChild.end()
|
||||
await expect(done.wire.awaitResult()).resolves.toMatchObject({ stopReason: 'completed' })
|
||||
})
|
||||
|
||||
it('is safe to close before it is started', async () => {
|
||||
const child = fakeChild()
|
||||
const wire = new CursorStreamWire(child.handle.stdout!)
|
||||
wire.close()
|
||||
await expect(wire.ready()).rejects.toThrow('event stream closed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('run lifecycle and quiescence', () => {
|
||||
it('spawns the fixed command, publishes after init, and disposes once', async () => {
|
||||
const child = fakeChild()
|
||||
const spawn = vi.fn(() => child.handle)
|
||||
const starting = startCursorRun(
|
||||
request(),
|
||||
runSpec(child, { spawn, force: true, trust: true, disposeGraceMs: 40, env: { CURSOR_API_KEY: 'fake' } }),
|
||||
)
|
||||
await nextTask()
|
||||
expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
|
||||
argv: [
|
||||
EXECUTABLE,
|
||||
'--print',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--workspace',
|
||||
process.cwd(),
|
||||
'--force',
|
||||
'--trust',
|
||||
'do the task',
|
||||
],
|
||||
cwd: process.cwd(),
|
||||
graceMs: 40,
|
||||
env: { CURSOR_API_KEY: 'fake' },
|
||||
}))
|
||||
child.peer.send(initEvent())
|
||||
const run = await starting
|
||||
expect(run.id).toMatch(/^[0-9a-f-]{36}$/)
|
||||
expect(run.localAgent).toBeUndefined()
|
||||
|
||||
child.peer.send(resultEvent())
|
||||
await expect(run.result).resolves.toEqual({
|
||||
output: [{ type: 'text', text: 'the final answer' }],
|
||||
stopReason: 'completed',
|
||||
})
|
||||
const first = run.dispose()
|
||||
expect(run.dispose()).toBe(first)
|
||||
await first
|
||||
expect(child.terminate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('closes stdin so an unattended prompt cannot stall the child', async () => {
|
||||
const { child, run } = await publishRun()
|
||||
expect(child.toChild.writableEnded).toBe(true)
|
||||
child.peer.send(resultEvent())
|
||||
await run.result
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('settles a cancelled run as aborted with the output collected so far', async () => {
|
||||
const controller = new AbortController()
|
||||
const child = fakeChild({ exitOnTerminate: false })
|
||||
const { run } = await publishRun(child, controller.signal)
|
||||
child.peer.send(assistantEvent([{ type: 'text', text: 'partial work' }]))
|
||||
await nextTask()
|
||||
controller.abort(new Error('parent stopped waiting'))
|
||||
await expect(run.result).resolves.toEqual({
|
||||
output: [{ type: 'text', text: 'partial work' }],
|
||||
stopReason: 'aborted',
|
||||
})
|
||||
child.settle({ exitCode: null, signal: 'SIGTERM' })
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('flattens a child exit and a protocol failure after publication', async () => {
|
||||
const exited = await publishRun()
|
||||
exited.child.settle({ exitCode: 2, signal: null })
|
||||
await expect(exited.run.result).resolves.toEqual({
|
||||
output: [],
|
||||
stopReason: 'error',
|
||||
})
|
||||
await exited.run.dispose()
|
||||
|
||||
const onError = vi.fn<(error: Error, stopReason: SubagentStopReason) => void>()
|
||||
const malformed = await publishRun(fakeChild(), new AbortController().signal, { onError })
|
||||
malformed.child.peer.raw('garbage\n')
|
||||
await expect(malformed.run.result).resolves.toMatchObject({ stopReason: 'error' })
|
||||
expect(onError).toHaveBeenCalledTimes(1)
|
||||
const [reported, reportedReason] = onError.mock.calls[0]!
|
||||
expect(reported.message).toContain('not JSON')
|
||||
expect(reportedReason).toBe('error')
|
||||
await malformed.run.dispose()
|
||||
})
|
||||
|
||||
it('rejects before spawn when the request is already aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const child = fakeChild()
|
||||
const spawn = vi.fn(() => child.handle)
|
||||
await expect(startCursorRun(request(undefined, controller.signal), runSpec(child, { spawn })))
|
||||
.rejects.toThrow('aborted before cursor-agent startup')
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rolls the child back when startup fails or is aborted before publication', async () => {
|
||||
const failed = fakeChild()
|
||||
const failing = startCursorRun(request(), runSpec(failed))
|
||||
await nextTask()
|
||||
failed.peer.raw('not json\n')
|
||||
await expect(failing).rejects.toThrow('not JSON')
|
||||
expect(failed.terminate).toHaveBeenCalledTimes(1)
|
||||
|
||||
const controller = new AbortController()
|
||||
const aborted = fakeChild()
|
||||
const aborting = startCursorRun(request(undefined, controller.signal), runSpec(aborted))
|
||||
await nextTask()
|
||||
controller.abort()
|
||||
await expect(aborting).rejects.toThrow('aborted before run publication')
|
||||
expect(aborted.terminate).toHaveBeenCalledTimes(1)
|
||||
|
||||
const exited = fakeChild()
|
||||
const exiting = startCursorRun(request(), runSpec(exited))
|
||||
await nextTask()
|
||||
exited.settle({ exitCode: 3, signal: null })
|
||||
await expect(exiting).rejects.toThrow('exited before the run settled')
|
||||
|
||||
// A failed spawn reports pid -1, so rollback has no tree to signal.
|
||||
const broken = fakeChild({ pid: -1, doneError: new Error('spawn observer failed') })
|
||||
await expect(startCursorRun(request(), runSpec(broken)))
|
||||
.rejects.toThrow('spawn observer failed')
|
||||
expect(broken.terminate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a startup failure whose cleanup also failed as an aggregate', async () => {
|
||||
const child = fakeChild()
|
||||
child.handle.waitForExit = vi.fn(() => Promise.reject(new Error('tree never exited')))
|
||||
const starting = startCursorRun(request(), runSpec(child))
|
||||
await nextTask()
|
||||
child.peer.raw('not json\n')
|
||||
const failure = await starting.catch((error: unknown) => error)
|
||||
expect(failure).toBeInstanceOf(AggregateError)
|
||||
expect((failure as AggregateError).message)
|
||||
.toContain('startup failed and cursor-agent cleanup also failed')
|
||||
})
|
||||
|
||||
it('keeps overlapping runs isolated', async () => {
|
||||
const first = await publishRun()
|
||||
const second = await publishRun()
|
||||
first.child.peer.send(resultEvent({ result: 'first answer' }))
|
||||
second.child.peer.send(resultEvent({ subtype: 'error' }))
|
||||
await expect(first.run.result).resolves.toMatchObject({
|
||||
output: [{ type: 'text', text: 'first answer' }],
|
||||
stopReason: 'completed',
|
||||
})
|
||||
await expect(second.run.result).resolves.toMatchObject({ stopReason: 'error' })
|
||||
await first.run.dispose()
|
||||
await second.run.dispose()
|
||||
})
|
||||
|
||||
it('uses the registered provider config, the resolved executable, and logs flattened errors', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentRuntime)
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
const child = fakeChild()
|
||||
const resolveExecutable = vi.spyOn(ctx.subprocess, 'resolveExecutable')
|
||||
.mockResolvedValue(EXECUTABLE)
|
||||
const spawn = vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue(child.handle)
|
||||
const warnings: string[] = []
|
||||
ctx.logger.warn = ((message: unknown) => {
|
||||
warnings.push(String(message))
|
||||
}) as typeof ctx.logger.warn
|
||||
await ctx.plugin(cursor, {
|
||||
env: { CURSOR_API_KEY: 'fake' },
|
||||
disposeGraceMs: 25,
|
||||
force: true,
|
||||
})
|
||||
const starting = ctx.subagents.start('cursor', {
|
||||
prompt: [{ type: 'text', text: 'task' }],
|
||||
parent: fakeParent,
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
await nextTask()
|
||||
child.peer.send(initEvent())
|
||||
const run = await starting
|
||||
child.settle({ exitCode: 1, signal: null })
|
||||
await expect(run.result).resolves.toMatchObject({ stopReason: 'error' })
|
||||
expect(resolveExecutable).toHaveBeenCalledWith(
|
||||
'cursor-agent',
|
||||
{ CURSOR_API_KEY: 'fake' },
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
expect(spawn).toHaveBeenCalledTimes(1)
|
||||
const spawned = spawn.mock.calls[0]![0]
|
||||
expect(spawned.argv).toContain('--force')
|
||||
expect(spawned.env).toEqual({ CURSOR_API_KEY: 'fake' })
|
||||
expect(spawned.graceMs).toBe(25)
|
||||
expect(spawned.cwd).toBe(process.cwd())
|
||||
expect(warnings).toEqual([
|
||||
expect.stringContaining('subagent-cursor: child run failed (error):'),
|
||||
])
|
||||
await run.dispose().catch(() => {})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('disposeCursorChild', () => {
|
||||
it('closes the stream, terminates, and waits for the managed tree', async () => {
|
||||
const child = fakeChild()
|
||||
const wire = new CursorStreamWire(child.handle.stdout!)
|
||||
wire.start()
|
||||
await disposeCursorChild(wire, child.handle)
|
||||
expect(child.terminate).toHaveBeenCalledTimes(1)
|
||||
expect(child.waitForExit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not finish disposal before the managed tree exits', async () => {
|
||||
const child = fakeChild({ exitOnTerminate: false })
|
||||
const wire = new CursorStreamWire(child.handle.stdout!)
|
||||
let settled = false
|
||||
const disposing = disposeCursorChild(wire, child.handle).then(() => { settled = true })
|
||||
await nextTask()
|
||||
expect(settled).toBe(false)
|
||||
child.settle()
|
||||
await disposing
|
||||
expect(settled).toBe(true)
|
||||
})
|
||||
|
||||
it('skips signalling a failed spawn and contains its observer rejection', async () => {
|
||||
const child = fakeChild({ pid: -1, doneError: new Error('spawn failed') })
|
||||
const wire = new CursorStreamWire(child.handle.stdout!)
|
||||
await expect(disposeCursorChild(wire, child.handle)).resolves.toBeUndefined()
|
||||
expect(child.terminate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a direct-child observer failure from a live tree', async () => {
|
||||
const child = fakeChild({ exitOnTerminate: false })
|
||||
const wire = new CursorStreamWire(child.handle.stdout!)
|
||||
const disposing = disposeCursorChild(wire, child.handle)
|
||||
child.fail(new Error('observer failed'))
|
||||
await expect(disposing).rejects.toThrow('observer failed')
|
||||
})
|
||||
})
|
||||
42
packages/subagent/subagent-cursor/tsconfig.json
Normal file
42
packages/subagent/subagent-cursor/tsconfig.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../subagent"
|
||||
},
|
||||
{
|
||||
"path": "../../subprocess/subprocess"
|
||||
},
|
||||
{
|
||||
"path": "../../util/timeout"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import { foldContextOf } from '@deepseek-ai/dsh-session-projection'
|
||||
import type { SessionProjectionRegistry } from '@deepseek-ai/dsh-session-projection'
|
||||
import type { SessionProjectionCache } from '@deepseek-ai/dsh-session-projection-cache'
|
||||
import { SubagentError } from './error.ts'
|
||||
@@ -395,7 +396,7 @@ async function resolveColdIdentity(
|
||||
}
|
||||
let identity: SubagentIdentityProjection | null | undefined
|
||||
try {
|
||||
identity = projections.restore({}, inspected.events, 0).snapshot.values.subagent
|
||||
identity = projections.restore({}, inspected.events, 0, foldContextOf(inspected.meta)).snapshot.values.subagent
|
||||
} catch {
|
||||
// The restore folds EVERY registered unit over this child's log, so any
|
||||
// unit's fold or schema can reject damaged payloads — deterministic data
|
||||
|
||||
@@ -2,9 +2,13 @@ import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import SessionProjectionRegistry, { foldContextOf } from '@deepseek-ai/dsh-session-projection'
|
||||
import SubagentRuntime from '../src/index.ts'
|
||||
import { subagentTimingProjectionDefinition } from '../src/projection.ts'
|
||||
/** The unit under test ignores the fold context; this session inherits no fork prefix. */
|
||||
const applyTiming = (state: Parameters<typeof subagentTimingProjectionDefinition.apply>[0], event: SessionEvent) =>
|
||||
subagentTimingProjectionDefinition.apply(state, event, foldContextOf({}))
|
||||
|
||||
|
||||
function event(type: SessionEvent['type'], seq: number, time: number): SessionEvent {
|
||||
return { type, seq, time, data: {} } as SessionEvent
|
||||
@@ -12,7 +16,7 @@ function event(type: SessionEvent['type'], seq: number, time: number): SessionEv
|
||||
|
||||
function fold(events: SessionEvent[]) {
|
||||
let state = subagentTimingProjectionDefinition.init()
|
||||
for (const item of events) state = subagentTimingProjectionDefinition.apply(state, item)
|
||||
for (const item of events) state = applyTiming(state, item)
|
||||
return subagentTimingProjectionDefinition.view(state)
|
||||
}
|
||||
|
||||
@@ -59,19 +63,19 @@ describe('subagent timing projection', () => {
|
||||
|
||||
it('ignores completed pre-descriptor turns and unrelated events', () => {
|
||||
const initial = subagentTimingProjectionDefinition.init()
|
||||
expect(subagentTimingProjectionDefinition.apply(
|
||||
expect(applyTiming(
|
||||
initial,
|
||||
event('assistant/chunk', 0, 1),
|
||||
)).toBe(initial)
|
||||
expect(subagentTimingProjectionDefinition.apply(
|
||||
expect(applyTiming(
|
||||
initial,
|
||||
event('turn/end', 1, 2),
|
||||
)).toBe(initial)
|
||||
const descriptor = subagentTimingProjectionDefinition.apply(
|
||||
const descriptor = applyTiming(
|
||||
initial,
|
||||
event('subagent/descriptor', 2, 3),
|
||||
)
|
||||
expect(subagentTimingProjectionDefinition.apply(
|
||||
expect(applyTiming(
|
||||
descriptor,
|
||||
event('turn/end', 3, 4),
|
||||
)).toBe(descriptor)
|
||||
|
||||
Reference in New Issue
Block a user