refactor(agent): unify sourced message delivery

This commit is contained in:
_Kerman
2026-07-24 22:38:50 +08:00
parent 009d113e0e
commit 992cf894af
197 changed files with 1890 additions and 2132 deletions
+16 -17
View File
@@ -12,8 +12,9 @@
import { readFileSync } from 'node:fs'
import type { Context } from 'cordis'
import z from 'schemastery'
import type { AdditionalContext, Agent, PromptDecision } from '@deepseek-ai/dsh-agent'
import type { Agent, PromptDecision } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { UserMessageData } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-persistence'
import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import {
@@ -122,11 +123,12 @@ export function apply(ctx: Context, config: Config): void {
/**
* Run every command hook configured for `point` whose matcher selects
* `matchQuery`, with the per-event `payload` on stdin, and fold the results.
* Writes a `hook/invoked`/`hook/result` pair per hook into the session when one
* is available (the mid-turn points always have an open turn). Returns the
* merged outcome (a neutral, already-most-restrictive view) for the caller to
* map onto its seam decision. `matchQuery` is the event's matcher subject
* (tool name, session source, …); `''` for events that ignore matchers.
* Writes a `hook/invoked`/`hook/result` pair per hook when `opts.turn` names
* an open turn. Pre-turn `UserPromptSubmit` and detached lifecycle points
* omit the pair. Returns the merged outcome (a neutral,
* already-most-restrictive view) for the caller to map onto its seam
* decision. `matchQuery` is the event's matcher subject (tool name, session
* source, …); `''` for events that ignore matchers.
*/
async function runPoint(
point: string,
@@ -183,14 +185,14 @@ export function apply(ctx: Context, config: Config): void {
// TODO(hook-continue-false): `merged.stop` is logged but needs a run-level halt seam.
/** Build additional model context from hook output, or return undefined when empty. */
function contextFrom(merged: MergedHookOutcome): AdditionalContext | undefined {
function contextFrom(merged: MergedHookOutcome): UserMessageData | undefined {
if (merged.additionalContext.length === 0) return undefined
const content: ContentBlock[] = merged.additionalContext.map(text => ({ type: 'text', text }))
return { content, source: PLUGIN_SOURCE }
}
/** Prepend one context without flattening downstream provenance or metadata. */
function prependContext(ours: AdditionalContext, theirs: AdditionalContext[] | undefined): AdditionalContext[] {
function prependContext(ours: UserMessageData, theirs: UserMessageData[] | undefined): UserMessageData[] {
return [ours, ...theirs ?? []]
}
@@ -201,7 +203,7 @@ export function apply(ctx: Context, config: Config): void {
detached.track(runPoint('SessionStart', source, sessionStartPayload(ctx, agent, source), { agent, signal: detached.signal })
.then((merged) => {
const context = contextFrom(merged)
if (context) agent.inject(context.content, { source: context.source })
if (context) agent.inject({ content: context.content, source: context.source })
})
.catch((error: unknown) => {
ctx.logger.warn(`hooks-claude: SessionStart hook failed: ${String(error)}`)
@@ -211,8 +213,7 @@ export function apply(ctx: Context, config: Config): void {
// --- UserPromptSubmit → PromptDecision. The prompt text is the payload; no
// matcher subject (CC ignores matchers for this event). ---
ctx.on('agent/prompt-submit', async (agent, content, _source, signal, next): Promise<PromptDecision> => {
const turn = lastTurn(agent)
const merged = await runPoint('UserPromptSubmit', '', promptPayload(ctx, agent, content), { agent, turn, signal })
const merged = await runPoint('UserPromptSubmit', '', promptPayload(ctx, agent, content), { agent, signal })
if (merged.decision === 'deny') {
return { kind: 'block', reason: merged.reason ?? 'blocked by UserPromptSubmit hook' }
}
@@ -266,7 +267,7 @@ export function apply(ctx: Context, config: Config): void {
if (merged.decision === 'deny') {
// A blocking Stop hook forces continuation.
const text = merged.reason ?? 'continue: blocked by Stop hook'
agent.steer([{ type: 'text', text }], { source: PLUGIN_SOURCE })
agent.steer({ content: [{ type: 'text', text }], source: PLUGIN_SOURCE })
}
})
@@ -277,7 +278,7 @@ export function apply(ctx: Context, config: Config): void {
detached.track(runPoint('SubagentStart', SUBAGENT_TYPE, subagentPayload(ctx, 'SubagentStart', info, child), { ...child ? { agent: child } : {}, signal: detached.signal })
.then((merged) => {
const context = contextFrom(merged)
if (context && child) child.inject(context.content, { source: context.source })
if (context && child) child.inject({ content: context.content, source: context.source })
})
.catch((error: unknown) => { ctx.logger.warn(`hooks-claude: SubagentStart hook failed: ${String(error)}`) }))
})
@@ -301,13 +302,11 @@ const SUBAGENT_TYPE = 'general-purpose'
// --- Per-event stdin payloads (the CC DIALECT shape). Field names match CC's
// hook input schema; this is the part a bridge owns. ---
/** The last (open or just-closed) turn number in the agent's log, or 0. */
/** The last open turn number in the agent's log, or 0 without an agent. */
function lastTurn(agent: Agent | undefined): number {
if (!agent) return 0
const last = [...agent.session.events].findLast(e => e.type === 'turn/start')
/* v8 ignore next -- the `: 0` arm is a defensive fallback: lastTurn is only
called from the mid-turn seams (prompt-submit/pre-/post-execute/continuation),
which always run inside an open turn, so `last` is always a turn/start here. */
/* v8 ignore next -- agent-present callers are tool/stop seams inside an open turn. */
return last?.type === 'turn/start' ? last.data.turn : 0
}