Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions src/daemon/providers/canonical.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
* CanonicalToolCall type already captures everything needed.
*/

import type { ProviderEvent } from "./interface.js";
import { type ProviderEvent, isSubagentEvent } from "./interface.js";

// ── Types ─────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -264,9 +264,19 @@ export class CanonicalHistoryAccumulator {
* On turn_done, the completed assistant turn is appended to history.
*/
handleEvent(event: ProviderEvent): void {
// Subagent text/thinking is not primary conversation content — recording
// it would corrupt cross-provider history (#82). Session already filters
// these before feeding the accumulator; this guards standalone callers.
if (isSubagentEvent(event)) return;
switch (event.type) {
case "text_done":
this.#currentText = event.content;
// A turn can span several assistant messages (text → tool → text →
// final text); each fires its own text_done. Append every block —
// assigning would keep only the last one and drop all interleaved
// reasoning from the canonical history (#82).
this.#currentText = this.#currentText
? `${this.#currentText}\n\n${event.content}`
: event.content;
break;

case "thinking_delta":
Expand Down
60 changes: 30 additions & 30 deletions src/daemon/providers/claude/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,6 @@ export class ClaudeProvider implements SessionProvider {
#currentCanUseTool: TurnOpts["canUseTool"] | null = null;
#currentSender: AuthContext | null = null;

// PreToolUse hook data — queued by hook, consumed by canUseTool
// Maps tool_name → queue of { toolUseId, agentId } for FIFO matching
#pendingToolUse: Map<string, Array<{ toolUseId: string; agentId?: string }>> = new Map();

#init: ClaudeProviderInit;

constructor(init: ClaudeProviderInit) {
Expand All @@ -127,7 +123,6 @@ export class ClaudeProvider implements SessionProvider {
this.#hasQueried = false;
this.#backingRecoveryAttempted = false;
this.#lastPushedContent = null;
this.#pendingToolUse.clear();
}

// ── AgentProvider interface ───────────────────────────────────────────────
Expand Down Expand Up @@ -190,7 +185,6 @@ export class ClaudeProvider implements SessionProvider {
}

async teardown(): Promise<void> {
this.#pendingToolUse.clear(); // clear before closing so stale entries don't survive a model switch
this.#inputQueue?.close();
this.#abortController?.abort();
if (this.#consumerTask) {
Expand Down Expand Up @@ -290,20 +284,13 @@ export class ClaudeProvider implements SessionProvider {
hooks: {
PreToolUse: [{
hooks: [async (rawInput) => {
const input = rawInput as PreToolUseHookInput & { agent_id?: string };
const input = rawInput as PreToolUseHookInput;
init.store.audit(
this.#currentSender?.sub ?? "unknown",
"session.tool_call",
sessionId,
`tool=${input.tool_name}`,
);
// Capture tool_use_id + agent_id so canUseTool can correlate them.
if (input.tool_use_id) {
const entry = { toolUseId: input.tool_use_id, agentId: input.agent_id };
const queue = this.#pendingToolUse.get(input.tool_name) ?? [];
queue.push(entry);
this.#pendingToolUse.set(input.tool_name, queue);
}
// Compression rewrite.
if (init.config && init.compressionRegistry) {
const rewritten = rewriteBashToolInput({
Expand Down Expand Up @@ -345,21 +332,22 @@ export class ClaudeProvider implements SessionProvider {
}],
},

canUseTool: async (toolName, input) => {
canUseTool: async (toolName, input, options) => {
const toolId = randomUUID();
const approvalId = randomUUID();
const inputObj = input as Record<string, unknown>;

// Pop the PreToolUse-captured data for this tool (FIFO by name).
const pending = this.#pendingToolUse.get(toolName);
const captured = pending?.shift();
if (pending && pending.length === 0) this.#pendingToolUse.delete(toolName);

if (!captured?.toolUseId) {
// Correlate by the SDK's own tool_use_id, passed directly to this
// callback. Never reconstruct it from a PreToolUse-fed name-keyed
// FIFO: the SDK skips canUseTool for auto-allowed tools
// (allowedTools / project permissions.allow), so any allow rule
// desyncs such a queue and mis-correlates every later tool call
// in the session (issue #81).
const sdkToolUseId = options?.toolUseID;
if (!sdkToolUseId) {
return { behavior: "deny" as const, message: "Unable to correlate tool use id" };
}
const sdkToolUseId = captured.toolUseId;
const sdkAgentId = captured.agentId;
const sdkAgentId = options?.agentID;

// Emit tool_start — Session creates the SessionMessage.
this.#emit({
Expand Down Expand Up @@ -495,6 +483,8 @@ export function translateSDKMessage(
}

// Text content (tool_use blocks are handled via canUseTool → tool_start).
// Tag with parent_tool_use_id so subagent text is never mistaken for
// primary assistant output downstream (issue #82).
const content = msg.message.content as unknown as Array<Record<string, unknown>>;
const textParts: string[] = [];
for (const block of content) {
Expand All @@ -503,39 +493,49 @@ export function translateSDKMessage(
}
}
if (textParts.length > 0) {
emit({ type: "text_done", content: textParts.join("") });
emit({
type: "text_done",
content: textParts.join(""),
parentToolUseId: assistantMsg.parent_tool_use_id ?? null,
});
}
break;
}

case "stream_event": {
const event = (msg as {
const streamMsg = msg as {
event?: {
type?: string;
index?: number;
content_block?: { type?: string };
delta?: { type?: string; text?: string; thinking?: string };
};
}).event;
parent_tool_use_id?: string | null;
};
const event = streamMsg.event;
if (!event) break;
// Subagent stream events carry the spawning tool call's id — tag every
// text/thinking emission so consumers can keep them out of the primary
// conversation (issue #82).
const parentToolUseId = streamMsg.parent_tool_use_id ?? null;

if (event.type === "content_block_start" && event.content_block?.type === "thinking") {
// Signal a new thinking block — Session creates the message.
emit({ type: "thinking_delta", content: "", blockIndex: event.index });
emit({ type: "thinking_delta", content: "", blockIndex: event.index, parentToolUseId });
break;
}

if (event.type === "content_block_delta" && event.delta) {
if (event.delta.type === "text_delta" && event.delta.text) {
emit({ type: "text_delta", content: event.delta.text });
emit({ type: "text_delta", content: event.delta.text, parentToolUseId });
} else if (event.delta.type === "thinking_delta" && event.delta.thinking) {
emit({ type: "thinking_delta", content: event.delta.thinking, blockIndex: event.index });
emit({ type: "thinking_delta", content: event.delta.thinking, blockIndex: event.index, parentToolUseId });
}
break;
}

if (event.type === "content_block_stop") {
emit({ type: "thinking_done", blockIndex: event.index });
emit({ type: "thinking_done", blockIndex: event.index, parentToolUseId });
}
break;
}
Expand Down
32 changes: 27 additions & 5 deletions src/daemon/providers/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,17 @@ export interface NormalizedTurnResult {

// ── Provider event stream ─────────────────────────────────────────────────────

/** Normalized event emitted by any provider. Session maps these to SessionMessages. */
/** Normalized event emitted by any provider. Session maps these to SessionMessages.
*
* Text/thinking events carry `parentToolUseId` when they were produced by a
* subagent (the id of the tool call that spawned it). `null`/absent = primary
* agent. Consumers must not record non-primary text as primary conversation
* content — see issue #82. */
export type ProviderEvent =
| { type: "text_delta"; content: string }
| { type: "text_done"; content: string }
| { type: "thinking_delta"; content: string; blockIndex?: number }
| { type: "thinking_done"; blockIndex?: number }
| { type: "text_delta"; content: string; parentToolUseId?: string | null }
| { type: "text_done"; content: string; parentToolUseId?: string | null }
| { type: "thinking_delta"; content: string; blockIndex?: number; parentToolUseId?: string | null }
| { type: "thinking_done"; blockIndex?: number; parentToolUseId?: string | null }
/** Fired when a tool call starts (from the provider's canUseTool gate).
* Carries the provider-internal tool_use_id so Session can correlate messages. */
| {
Expand All @@ -119,6 +124,23 @@ export type ProviderEvent =
| { type: "turn_done"; result: NormalizedTurnResult }
| { type: "error"; message: string };

/**
* True when a text/thinking ProviderEvent was produced by a subagent
* (`parentToolUseId` set). Such events must never be recorded as primary
* conversation content — see issue #82. Centralised so the canonical
* accumulator and Session's event consumer can't drift as new subagent-aware
* event types are added.
*/
export function isSubagentEvent(event: ProviderEvent): boolean {
return (
(event.type === "text_delta" ||
event.type === "text_done" ||
event.type === "thinking_delta" ||
event.type === "thinking_done") &&
event.parentToolUseId != null
);
}

// ── TurnRun ───────────────────────────────────────────────────────────────────

export interface TurnRun {
Expand Down
9 changes: 8 additions & 1 deletion src/daemon/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

import { ClaudeProvider } from "./providers/claude/index.js";
import { CanonicalHistoryAccumulator } from "./providers/canonical.js";
import type { ProviderEvent, NormalizedTurnResult, TurnRun, ToolApprovalFn, SessionProvider } from "./providers/interface.js";
import { type ProviderEvent, type NormalizedTurnResult, type TurnRun, type ToolApprovalFn, type SessionProvider, isSubagentEvent } from "./providers/interface.js";
import { randomUUID } from "node:crypto";
import type {
AuthContext,
Expand Down Expand Up @@ -2000,6 +2000,13 @@ export class Session {
}

async #handleProviderEvent(event: ProviderEvent): Promise<void> {
// Subagent text/thinking (parentToolUseId set) is not part of the primary
// conversation: streaming it into the primary assistant message corrupts
// both the visible transcript and the canonical history, and a subagent
// text_done would clobber the primary message mid-stream (#82). The
// subagent's work still surfaces via its tool_call messages and the
// spawning tool's result. Shared with the canonical accumulator's guard.
if (isSubagentEvent(event)) return;
switch (event.type) {
case "text_delta": {
if (!this.#activeAssistantMsg) {
Expand Down
Loading
Loading