Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
40539a6
fix(daemon): full ACP tool transcript and richer usage telemetry
mrcfps Jul 24, 2026
980bfd4
fix(daemon): prefer ACP kind over noncanonical tool names
mrcfps Jul 24, 2026
eb4911d
fix(daemon): fail-closed redaction for unknown ACP tool payloads
mrcfps Jul 24, 2026
859f1bd
fix(daemon): flush open ACP tools as errors on session fail
mrcfps Jul 24, 2026
d592a37
fix(daemon): redact ACP Bash stdout in tool transcripts
mrcfps Jul 24, 2026
8437d4b
fix(daemon): redact Linux home paths in Langfuse Bash tool spans
mrcfps Jul 24, 2026
367a193
fix(daemon): redact custom ACP tool names before Langfuse telemetry
mrcfps Jul 24, 2026
3f5416b
test(e2e): force-click unstable comment targets in preview iframe
mrcfps Jul 24, 2026
5eba609
fix(daemon): merge partial usage frames in reverse analytics scan
mrcfps Jul 24, 2026
67dfa84
fix(daemon): flush open ACP tools before AMR no-output fail
mrcfps Jul 24, 2026
527eb10
fix(daemon): merge partial primary usage and flush tools on ACP abort
mrcfps Jul 24, 2026
49272eb
fix(daemon): hash path-like ACP toolCallIds before telemetry
mrcfps Jul 24, 2026
1180633
fix(daemon): always hash ACP adapter toolCallIds for telemetry
mrcfps Jul 24, 2026
137e1c2
fix(daemon): keep reverse usage scan open for missing cache fields
mrcfps Jul 24, 2026
7c35252
fix(daemon): retain flushed ACP tool IDs against late terminals
mrcfps Jul 24, 2026
953fd8d
fix(daemon): block kind:other Bash-like tool names
mrcfps Jul 24, 2026
d0958e5
fix(daemon): fail closed on Bash names without execute kind
mrcfps Jul 24, 2026
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
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,10 @@ deploy/agent-bins/

# Looper harness attachments (bug screenshots etc.) — agent scratch, not repo content
.looper-attachments/

# Deepwork local progress (git-local; OpenCode-readable via .ignore)
.slim/deepwork/

# Local agent worktrees (must stay git-ignored so pnpm guard does not scan them)
.slim/worktrees/
.slim/worktrees.json
3 changes: 3 additions & 0 deletions .ignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Keep deepwork progress readable by OpenCode while git-ignored
!.slim/deepwork/
!.slim/deepwork/**
75 changes: 65 additions & 10 deletions apps/daemon/src/agent-protocol/acp/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,15 +128,40 @@ export function promotedOpenCodeSessionErrorPayload(data: unknown, fallbackMessa
export interface FormattedUsage {
input_tokens?: number;
output_tokens?: number;
/** OpenAI-like inclusive cache-read subset (input already includes cache). */
cached_read_tokens?: number;
/** Anthropic-like additive cache-read (input is uncached remainder). */
cache_read_input_tokens?: number;
/** Generic / OpenAI-like cache write alias. */
cache_creation_tokens?: number;
/** Anthropic-like cache creation field name. */
cache_creation_input_tokens?: number;
/** OpenAI-like cache write alias used by some ACP adapters. */
cached_write_tokens?: number;
thought_tokens?: number;
total_tokens?: number;
}

function firstFiniteNumber(src: Record<string, unknown>, keys: string[]): number | undefined {
for (const key of keys) {
const value = src[key];
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
return value;
}
}
return undefined;
}

/**
* Normalises an ACP agent's raw `usage` object (camelCase keys) into the
* snake_case `FormattedUsage` shape used by the daemon event stream. Returns
* `null` when the input is not a recognisable usage object or has no known
* fields.
* Normalises an ACP agent's raw `usage` object (camelCase and/or snake_case
* keys) into the snake_case `FormattedUsage` shape used by the daemon event
* stream. Returns `null` when the input is not a recognisable usage object or
* has no known fields.
*
* Cache field names are preserved by family so
* `scanRunEventsForUsageAnalytics` can classify anthropic (additive) vs
* openai (inclusive) correctly. Do not collapse Anthropic
* `cache_read_input_tokens` into `cached_read_tokens`.
*
* @param usage - The raw `result.usage` value from a `session/prompt` response.
* @returns A `FormattedUsage` object with at least one field, or `null`.
Expand All @@ -145,13 +170,43 @@ export function formatUsage(usage: unknown): FormattedUsage | null {
const src = asObject(usage);
if (!src) return null;
const out: FormattedUsage = {};
if (typeof src.inputTokens === 'number') out.input_tokens = src.inputTokens;
if (typeof src.outputTokens === 'number') out.output_tokens = src.outputTokens;
if (typeof src.cachedReadTokens === 'number') {
out.cached_read_tokens = src.cachedReadTokens;
const inputTokens = firstFiniteNumber(src, ['inputTokens', 'input_tokens']);
const outputTokens = firstFiniteNumber(src, ['outputTokens', 'output_tokens']);
// Prefer source-family keys independently so mixed payloads keep both
// semantics when present; otherwise map only within the matching family.
const anthropicCacheRead = firstFiniteNumber(src, [
'cache_read_input_tokens',
'cacheReadInputTokens',
]);
const openAiCacheRead = firstFiniteNumber(src, [
'cachedReadTokens',
'cached_read_tokens',
]);
const anthropicCacheCreation = firstFiniteNumber(src, [
'cache_creation_input_tokens',
'cacheCreationInputTokens',
]);
const openAiCacheCreation = firstFiniteNumber(src, [
'cacheCreationTokens',
'cache_creation_tokens',
]);
const openAiCachedWrite = firstFiniteNumber(src, [
'cached_write_tokens',
'cachedWriteTokens',
]);
const thoughtTokens = firstFiniteNumber(src, ['thoughtTokens', 'thought_tokens']);
const totalTokens = firstFiniteNumber(src, ['totalTokens', 'total_tokens']);
if (inputTokens !== undefined) out.input_tokens = inputTokens;
if (outputTokens !== undefined) out.output_tokens = outputTokens;
if (anthropicCacheRead !== undefined) out.cache_read_input_tokens = anthropicCacheRead;
if (openAiCacheRead !== undefined) out.cached_read_tokens = openAiCacheRead;
if (anthropicCacheCreation !== undefined) {
out.cache_creation_input_tokens = anthropicCacheCreation;
}
if (typeof src.thoughtTokens === 'number') out.thought_tokens = src.thoughtTokens;
if (typeof src.totalTokens === 'number') out.total_tokens = src.totalTokens;
if (openAiCacheCreation !== undefined) out.cache_creation_tokens = openAiCacheCreation;
if (openAiCachedWrite !== undefined) out.cached_write_tokens = openAiCachedWrite;
if (thoughtTokens !== undefined) out.thought_tokens = thoughtTokens;
if (totalTokens !== undefined) out.total_tokens = totalTokens;
return Object.keys(out).length > 0 ? out : null;
}
/**
Expand Down
212 changes: 153 additions & 59 deletions apps/daemon/src/agent-protocol/acp/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,18 @@ import {
} from './rpc.js';
import {
acpRawEventShape,
isAcpCompletedStatus,
isAcpTerminalFailureStatus,
acpToolCallId,
isAcpArtifactWriteLabel,
isAcpArtifactWriteUpdate,
acpArtifactWritePath,
isAcpTerminalToolStatus,
isAcpThinkOnlyTool,
isAcpRecognizedKind,
acpArtifactWritePathRanked,
acpToolName,
acpToolInput,
acpToolResultContent,
acpSafeToolResultContent,
promotedAmrRetryStatusPayload,
promotedAmrStderrPayload,
} from './updates.js';
Expand Down Expand Up @@ -97,7 +103,8 @@ export interface AttachAcpSessionOptions {
* 5. Streams `session/update` events to the `send` callback, translating:
* - `agent_thought_chunk` → `thinking_start` / `thinking_delta`
* - `agent_message_chunk` → `text_delta` (with DSML and tool-call text suppression)
* - `tool_call` / `tool_call_update` → deferred `tool_use` / `tool_result` pairs
* - `tool_call` / `tool_call_update` → full `tool_use` / `tool_result` transcript
* (all tools; write tools keep Claude-shaped names + `file_path` for artifact count)
* - status updates → `agent.status` events
* 6. Handles `session/request_permission` calls by auto-approving.
* 7. On prompt completion, flushes suppression buffers, emits usage, and closes stdin.
Expand Down Expand Up @@ -180,12 +187,71 @@ export function attachAcpSession({
closedBlocks: 0,
};
const acpArtifactWriteToolCallIds = new Set<string>();
// Per artifact-write tool call, accumulate the best concrete file path seen
// across its frames and whether we have already mirrored it into canonical
// tool_use/tool_result events. Emission is deferred to the terminal frame so
// a `locations`/`rawInput` path that ACP only sends on a later update is used
// for classification, instead of locking in a first-frame guess.
const acpArtifactRunEventState = new Map<string, { path: string | null; emitted: boolean }>();
// Per toolCallId: accumulate name/input/path/result across partial ACP frames
// and emit exactly one tool_use + one tool_result at terminal status (or on
// prompt flush for still-open tools). Think-only tools are tracked but never
// transcribed and never flip emittedConcreteToolEvent. Entries stay after
// emit so a repeated terminal frame cannot re-create + re-emit.
type AcpToolNameSource = 'kind' | 'other';
type AcpToolRunState = {
name: string;
nameSource: AcpToolNameSource;
input: Record<string, unknown>;
path: string | null;
pathRank: number;
resultContent: string;
/** Sticky: once true, never cleared by later status-only frames. */
thinkOnly: boolean;
firstSeenAt: number;
emitted: boolean;
};
const acpToolRunEventState = new Map<string, AcpToolRunState>();

const buildToolUseInput = (st: AcpToolRunState): Record<string, unknown> => {
const input = { ...st.input };
if (st.path) input.file_path = st.path;
else delete input.file_path;
return input;
};

const emitTerminalToolPair = (toolCallId: string, st: AcpToolRunState, isError: boolean) => {
if (st.emitted) return;
st.emitted = true;
// Think/reason frames are activity noise for AMR no-output detection and
// must not appear as concrete tool_use/tool_result events.
if (st.thinkOnly) return;
send('agent', {
type: 'tool_use',
id: toolCallId,
Comment thread
mrcfps marked this conversation as resolved.
Outdated
name: st.name,
input: buildToolUseInput(st),
// Wall-clock start of the first ACP frame for this toolCallId so analytics
// can compute real duration even though tool_use is emitted at terminal.
startedAt: st.firstSeenAt,
});
send('agent', {
type: 'tool_result',
toolUseId: toolCallId,
// Bash/execute stdout can dump private files (cat .env). Langfuse only
// lexically masks Bash, so redact before the canonical transcript ships.
content: acpSafeToolResultContent(st.name, st.resultContent),
isError,
Comment thread
mrcfps marked this conversation as resolved.
});
// Concrete only on terminal tool_result for a real (non-think) tool.
emittedConcreteToolEvent = true;
};

// Flush tools that never received a terminal `tool_call_update`. Clean
// completion uses isError=false (best-effort close); fail paths use
// isError=true so Langfuse/PostHog and the persisted transcript keep the
// open tool as an errored result instead of dropping it entirely.
const flushOpenAcpTools = (isError = false) => {
for (const [toolCallId, st] of acpToolRunEventState) {
if (st.emitted) continue;
emitTerminalToolPair(toolCallId, st, isError);
}
acpToolRunEventState.clear();
Comment thread
mrcfps marked this conversation as resolved.
Outdated
};

const stageWatchdogDisabled = stageTimeoutMs <= 0;
const resetStageTimer = (label: string) => {
Expand Down Expand Up @@ -228,6 +294,9 @@ export function attachAcpSession({

const failWithPayload = (payload: unknown) => {
if (finished) return;
// Emit pending tools as errored before terminal state so deferred
// tool_use pairs are not lost on timeout / process death / RPC error.
flushOpenAcpTools(true);
finished = true;
fatal = true;
clearStageTimer();
Expand All @@ -240,6 +309,9 @@ export function attachAcpSession({
options: { forceModelUnavailable?: boolean; details?: unknown; retryable?: boolean } = {},
) => {
if (finished) return;
// Emit pending tools as errored before terminal state so deferred
// tool_use pairs are not lost on timeout / process death / RPC error.
flushOpenAcpTools(true);
finished = true;
fatal = true;
clearStageTimer();
Expand Down Expand Up @@ -408,8 +480,24 @@ export function attachAcpSession({
nextId += 1;
};

// Best-effort: emit provider usage before terminal fail/success so failed
// ACP runs still contribute token fields to PostHog/Langfuse when the agent
// returned a usage object on the prompt result.
const emitUsageIfPresent = (usageSource?: unknown) => {
const usage = formatUsage(usageSource);
if (!usage) return;
send('agent', {
type: 'usage',
usage,
durationMs: Date.now() - runStartedAt,
});
};

const finishCleanPrompt = (usageSource?: unknown) => {
if (finished) return;
// Flush any tools still open when the prompt completes so traces stay
// complete (one tool_use + tool_result per id).
flushOpenAcpTools();
Comment thread
mrcfps marked this conversation as resolved.
const flushedToolText = toolCallTextSuppressor.flush();
noteToolCallTextSuppression('tool_call_xml_flush');
const flushedText = flushedToolText ? (dsmlArtifactSuppressor?.strip(flushedToolText) ?? flushedToolText) : '';
Expand All @@ -419,14 +507,7 @@ export function attachAcpSession({
noteArtifactTextSuppression('artifact_flush');
emitToolCallTextSuppressionSummary();
emitArtifactTextSuppressionSummary();
const usage = formatUsage(usageSource);
if (usage) {
send('agent', {
type: 'usage',
usage,
durationMs: Date.now() - runStartedAt,
});
}
emitUsageIfPresent(usageSource);
finished = true;
clearStageTimer();
stdin.end();
Expand Down Expand Up @@ -633,51 +714,62 @@ export function attachAcpSession({
if (toolCallId && isAcpArtifactWriteLabel(update)) {
acpArtifactWriteToolCallIds.add(toolCallId);
}
// Mirror artifact-write tool calls into the daemon's canonical
// tool_use/tool_result event shape so `countNewArtifacts`
// (run-artifacts.ts) can see ACP file writes. Without this, every ACP
// agent (AMR, Hermes, Kilo, Kiro, Devin, Vibe, …) reported
// run_finished.artifact_count: 0 even when the run wrote artifacts,
// because the ACP adapter emitted only text/status/thinking events and
// never the tool_use/tool_result pair the counter scans for.
//
// This path only feeds the NO-PROJECT fallback (project runs use the
// filesystem snapshot). Two correctness rules, both learned the hard
// way in review:
// 1. Defer emission to the TERMINAL frame and accumulate the best
// concrete path across frames — ACP often sends `locations` only
// on the completing update, and emitting on the first frame would
// lock in a wrong/empty guess that a later path can't correct.
// 2. Never fabricate an artifact extension. `isArtifactPath` is what
// decides whether a write counts; feeding it a real path lets it
// correctly EXCLUDE non-artifact edits (`config.json`, `README.md`)
// and INCLUDE real artifacts. A write that never carries a concrete
// path stays keyed on its (extension-less) toolCallId, so it is
// simply not counted rather than inflating the metric with a
// synthetic `.html` — under-counting a truly opaque write is
// acceptable; a false-positive artifact is not.
// Full ACP tool transcript → canonical tool_use/tool_result events for
// Langfuse/PostHog and `countNewArtifacts` (run-artifacts.ts). Every
// non-think tool is mirrored once at terminal status (accumulate
// partial frames first). Write tools keep Claude-shaped Write/Edit
// names and a real `file_path` when present. Never use toolCallId as
// file_path.
if (toolCallId) {
Comment thread
mrcfps marked this conversation as resolved.
const isWriteCall =
isAcpArtifactWriteLabel(update) || acpArtifactWriteToolCallIds.has(toolCallId);
if (isWriteCall) {
let st = acpArtifactRunEventState.get(toolCallId);
if (!st) {
st = { path: null, emitted: false };
acpArtifactRunEventState.set(toolCallId, st);
const nextName = acpToolName(update);
const nextInput = acpToolInput(update);
const nextPath = acpArtifactWritePathRanked(update);
const nextResult = acpToolResultContent(update);
const nextThinkOnly = isAcpThinkOnlyTool(update);
const kindRaw = typeof update.kind === 'string' ? update.kind.trim() : '';
const nameFromKind = Boolean(kindRaw && isAcpRecognizedKind(kindRaw));
let st = acpToolRunEventState.get(toolCallId);
if (!st) {
st = {
name: nextName,
nameSource: nameFromKind ? 'kind' : 'other',
input: nextInput,
path: nextPath?.path ?? null,
pathRank: nextPath?.rank ?? 0,
resultContent: nextResult,
thinkOnly: nextThinkOnly,
firstSeenAt: Date.now(),
emitted: false,
};
acpToolRunEventState.set(toolCallId, st);
} else if (!st.emitted) {
// Kind-locked names must not be overwritten by later title-only frames
// (e.g. kind:read then title "Update cache" must stay Read).
if (nameFromKind) {
st.name = nextName;
st.nameSource = 'kind';
} else if (st.nameSource !== 'kind') {
// Prefer a more specific name over the generic fallback.
if (nextName !== 'Tool' || st.name === 'Tool') st.name = nextName;
}
if (!st.path) st.path = acpArtifactWritePath(update);
const failed = isAcpTerminalFailureStatus(update);
if (!st.emitted && (failed || isAcpCompletedStatus(update))) {
st.emitted = true;
send('agent', {
type: 'tool_use',
id: toolCallId,
name: 'Write',
input: { file_path: st.path ?? toolCallId },
});
send('agent', { type: 'tool_result', toolUseId: toolCallId, isError: failed });
emittedConcreteToolEvent = true;
// Shallow merge rawInput; later keys win.
st.input = { ...st.input, ...nextInput };
// Upgrade path when a higher-precedence source appears.
if (nextPath && (!st.path || nextPath.rank >= st.pathRank)) {
st.path = nextPath.path;
st.pathRank = nextPath.rank;
}
// Keep last non-empty result payload (terminal may be status-only).
if (nextResult) st.resultContent = nextResult;
// Sticky think-only: once classified, never clear on later frames
// (terminal status-only frames have no title and would otherwise
// flip thinkOnly false and emit a fake concrete tool).
if (nextThinkOnly) st.thinkOnly = true;
}
if (isAcpTerminalToolStatus(update)) {
const failed = isAcpTerminalFailureStatus(update);
emitTerminalToolPair(toolCallId, st, failed);
// Keep the entry (emitted=true) so a repeated terminal cannot re-emit.
}
}
if (isAcpArtifactWriteUpdate(update, acpArtifactWriteToolCallIds)) {
Expand Down Expand Up @@ -770,6 +862,8 @@ export function attachAcpSession({
if (!emittedVisibleTextChunk && !emittedConcreteToolEvent && modelUnavailableErrorCode) {
const outputTokens = usage?.output_tokens;
const hadCompletionTokens = typeof outputTokens === 'number' && outputTokens > 0;
// Emit usage before fail so analytics still sees provider tokens.
emitUsageIfPresent(result.usage);
Comment thread
mrcfps marked this conversation as resolved.
if (hadCompletionTokens || emittedToolCall || emittedTextChunk) {
fail(
'ACP session completed after reporting model activity, but did not produce visible assistant text, concrete tool results, or artifacts.',
Expand Down
Loading
Loading