Merge remote-tracking branch 'origin/master' into worktree-llm-tool-order

This commit is contained in:
imccyu
2026-07-07 21:35:28 +08:00
95 changed files with 2351 additions and 310 deletions

View File

@@ -39,6 +39,8 @@ import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientpr
* hook before any step ran — ACP has no "rejected" reason, and a
* blocked prompt is, from the client's view, the prompt not being
* carried out; `cancelled` is the closest legal wire reason)
* @param reason - the harness turn-end reason to translate.
* @returns the legal ACP wire value per the mapping above.
*/
export function turnEndToStopReason(reason: TurnEndReason): StopReason {
switch (reason.kind) {
@@ -71,6 +73,8 @@ export function turnEndToStopReason(reason: TurnEndReason): StopReason {
* `reasoning` is surfaced via `agent_thought_chunk`
* streaming rather than as a message block, and `tool-call`/`tool-result`
* are handled by the tool-call update path.
* @param block - the harness content block to translate.
* @returns the ACP block, or `undefined` for a kind with no message-content mapping.
*/
export function harnessBlockToAcpContent(block: ContentBlock): AcpContentBlock | undefined {
switch (block.type) {
@@ -89,6 +93,8 @@ export function harnessBlockToAcpContent(block: ContentBlock): AcpContentBlock |
* concatenated verbatim; resource links become explicit textual references so
* baseline ACP clients can point at files without the bridge silently dropping
* that context.
* @param prompt - the ACP prompt blocks to flatten.
* @returns the concatenated text, with resource links rendered as bracketed references.
*/
export function acpPromptToText(prompt: readonly AcpContentBlock[]): string {
return prompt
@@ -109,6 +115,8 @@ export function acpPromptToText(prompt: readonly AcpContentBlock[]): string {
* Whether an ACP prompt contains content the bridge cannot accept. Baseline ACP
* requires `text` and `resource_link`; richer inline payloads (`resource`,
* image, audio, …) are rejected rather than silently dropped.
* @param prompt - the ACP prompt blocks to inspect.
* @returns `true` when any block is neither `text` nor `resource_link`.
*/
export function promptHasUnsupportedContent(prompt: readonly AcpContentBlock[]): boolean {
return prompt.some(block => block.type !== 'text' && block.type !== 'resource_link')

View File

@@ -701,6 +701,8 @@ export function apply(ctx: Context, config: AcpConfig): void {
* Build per-agent options from the plugin config, omitting absent fields
* (exactOptionalPropertyTypes: never assign `undefined` to an optional key).
* Exported for unit coverage of both the present and absent branches.
* @param config - the plugin config carrying the optional model name.
* @returns the per-agent options, with `model` present only when configured.
*/
export function agentOptions(config: AcpConfig): { model?: string } {
return {
@@ -764,6 +766,16 @@ function validateMcpServers(params: { mcpServers?: unknown[] }): void {
*
* Other event types (turn/step boundaries, context/message, …) produce
* no client update.
* @param sessionId - the ACP session id stamped on every emitted notification.
* @param event - the harness session event to translate.
* @param notify - sink for each produced `session/update` notification; called
* zero or more times per event (best-effort UI feed, never load-bearing).
* @param presenter - resolves tool-owned render intent for tool events;
* defaults to the generic-fallback {@link nullToolPresenter}.
* @param terminal - the connection's terminal-rendering context; defaults to
* disabled (the plain-text console-block fallback).
* @param options - `includeUserMessages` (default `true`): live streaming
* passes `false` so a prompt the client just sent is not echoed back.
*/
export function streamSessionEventUpdate(
sessionId: SessionId,
@@ -825,6 +837,8 @@ export function streamSessionEventUpdate(
* harness status triple IS `PlanEntryStatus`). The ACP client REPLACES its whole
* plan on each `plan` update, matching the harness's whole-list-replace
* semantics, so no per-entry diffing is needed.
* @param todos - the harness todo list (the whole list, not a diff).
* @returns the ACP plan body, one entry per todo.
*/
export function todosToPlan(todos: TodoItem[]): Plan {
return { entries: todos.map((todo): PlanEntry => ({ content: todo.content, priority: 'medium', status: todo.status })) }
@@ -885,7 +899,16 @@ export class ToolPresenter {
private readonly onError: (message: string) => void = () => {},
) {}
/** Pending-state render intent for a `tool/call`; remembers `(name, args, card)` for the matching result. */
/**
* Pending-state render intent for a `tool/call`; remembers `(name, args, card)`
* for the matching result.
* @param callId - the call id the matching `tool/result` will look up.
* @param name - the tool name, resolved against the registry for `presentCall`.
* @param argsJson - the raw arguments JSON from the event; parsed for the view
* (a non-JSON string is surfaced raw).
* @returns the tool-owned view, or the generic fallback (title = tool name,
* kind `other`, parsed args as raw input) when the tool defines none or threw.
*/
call(callId: CallId, name: string, argsJson: string): ToolCallView {
const args = parseToolArguments(argsJson)
let present: ToolCallView | undefined
@@ -905,7 +928,18 @@ export class ToolPresenter {
return view
}
/** Completed-state render intent for a `tool/result`; consumes the remembered `(name, args, card)`. */
/**
* Completed-state render intent for a `tool/result`; consumes the remembered
* `(name, args, card)`.
* @param callId - the id of the matching `tool/call`; an unknown or late id
* falls back to the raw content.
* @param content - the result's content blocks (the fallback and fill-in body).
* @param isError - whether the result is an error, forwarded to `presentResult`.
* @param meta - the result's machine-readable meta, forwarded when present.
* @returns the tool-owned view — an orphaned `terminal` result (no terminal
* call side) and a content-less `generic` are normalized — or the raw-content
* generic card when the tool defines no `presentResult` or threw.
*/
result(callId: CallId, content: ContentBlock[], isError: boolean, meta?: unknown): ToolResultView {
const call = this.pending.get(callId)
this.pending.delete(callId)

View File

@@ -36,6 +36,10 @@ import Loader from '@cordisjs/plugin-loader'
* the SAME directory (the keyless replay tree). Other modes — including no
* snapshot mode at all — use the path as-is. Returns an absolute path resolved
* from `cwd`.
* @param configPath - the requested config path (absolute, or relative to `cwd`).
* @param snapshotMode - the bin's `$DSH_SNAPSHOT` value; only `'replay'` swaps the basename.
* @param cwd - the base a relative `configPath` resolves against.
* @returns the absolute path of the config to boot.
*/
export function resolveConfigPath(
configPath: string, snapshotMode: string | undefined, cwd: string = process.cwd(),
@@ -54,6 +58,9 @@ export function resolveConfigPath(
* them via the `!!js` tag. A present-but-unreadable `.env` is a real
* misconfiguration: surface it via `warn` (one line, default stderr) rather
* than silently running with the wrong environment.
* @param binName - the diagnostic prefix on the warn line.
* @param dir - the directory whose `.env` to load.
* @param warn - sink for the one-line misconfiguration diagnostic.
*/
export function loadEnv(
binName: string, dir: string = process.cwd(),
@@ -90,6 +97,9 @@ export interface FailLoudProcess {
* STDERR (never stdout — for the ACP bin that channel carries JSON-RPC) and
* guarantees `exit(1)`. Install before `boot()`. Returns the uninstaller
* (tests use it; the bins run until exit and never do).
* @param binName - the diagnostic prefix on the fatal-failure line.
* @param proc - the process slice to register on; tests inject a fake.
* @returns the uninstaller that removes the rejection handler.
*/
export function installFailLoud(binName: string, proc: FailLoudProcess = process): () => void {
const handler = (err: unknown): void => {
@@ -108,6 +118,8 @@ export function installFailLoud(binName: string, proc: FailLoudProcess = process
* entry is the one legitimate fiber-less state: `Entry.refresh()` deliberately
* skips `init()` for it — a valid "plugin turned off" config, not a failed
* import — so it is excluded.
* @param ctx - the settled context whose loader entries to audit.
* @param binName - the diagnostic prefix on the thrown error.
*/
export function assertEntriesLoaded(ctx: Context, binName: string): void {
const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled)
@@ -139,6 +151,10 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void {
* active under `node --expose-internals`; a consumer running a built bin must
* pass that flag (or install the plugins where node hoists them). Relative
* specifiers resolve against the config directory with no flag.
* @param binName - the diagnostic prefix for load-failure errors.
* @param absoluteConfigPath - the config to include; must already be absolute
* (see {@link resolveConfigPath}).
* @returns the root context once every entry has started.
*/
export async function boot(binName: string, absoluteConfigPath: string): Promise<Context> {
const ctx = new Context()

View File

@@ -63,6 +63,10 @@ function isTTYPair(input: Readable, output: Writable): boolean {
* directly with fakes. Returns nothing — all registration is via `ctx.on`/
* `ctx.effect`, so fiber disposal tears every listener and the readline
* interface down.
* @param ctx - the context supplying the `agents` service and the event feeds.
* @param config - the plugin config; defaults are re-applied here for direct
* callers that bypass Loader validation.
* @param runtime - the process-I/O seam (line source, render sink, exit hook).
*/
export function createStdioChat(ctx: Context, config: Config, runtime: StdioRuntime): void {
// Default here too (not just via schemastery's `.default()`): this helper is