Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/fix-claude-context-compact.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"helmor": patch
---

Wire Claude `/context` and `/compact` slash commands through to the Agent SDK — the wrapped prompt previously hid them from the SDK's root-command detection — and surface the compact lifecycle as system notices with a spinner while compacting and a check when done.
84 changes: 84 additions & 0 deletions sidecar/src/claude/compact-filter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, expect, test } from "bun:test";
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { shouldSuppressCompactContextUsageMessage } from "./compact-filter.js";

/** Build a synthetic `## Context Usage` assistant message — the redundant
* table the Claude Agent SDK emits after `/compact`. */
function syntheticContextUsageMessage(
text = "## Context Usage\ntokens low",
): SDKMessage {
return {
type: "assistant",
message: {
model: "<synthetic>",
content: [{ type: "text", text }],
},
} as unknown as SDKMessage;
}

function realAssistantMessage(text: string): SDKMessage {
return {
type: "assistant",
message: {
model: "claude-sonnet-4",
content: [{ type: "text", text }],
},
} as unknown as SDKMessage;
}

describe("shouldSuppressCompactContextUsageMessage", () => {
test("drops synthetic ## Context Usage message for /compact", () => {
expect(
shouldSuppressCompactContextUsageMessage(
syntheticContextUsageMessage(),
"/compact",
),
).toBe(true);
});

test("keeps synthetic ## Context Usage message for /context (useful output)", () => {
expect(
shouldSuppressCompactContextUsageMessage(
syntheticContextUsageMessage(),
"/context all",
),
).toBe(false);
});

test("keeps synthetic ## Context Usage message when no root slash command", () => {
expect(
shouldSuppressCompactContextUsageMessage(
syntheticContextUsageMessage(),
null,
),
).toBe(false);
});

test("keeps real (non-synthetic) assistant message even for /compact", () => {
expect(
shouldSuppressCompactContextUsageMessage(
realAssistantMessage("## Context Usage\ntokens low"),
"/compact",
),
).toBe(false);
});

test("keeps synthetic assistant message whose text is not ## Context Usage for /compact", () => {
expect(
shouldSuppressCompactContextUsageMessage(
syntheticContextUsageMessage("Unrelated summary"),
"/compact",
),
).toBe(false);
});

test("keeps non-assistant messages for /compact", () => {
const systemMessage = {
type: "system",
subtype: "compact_boundary",
} as unknown as SDKMessage;
expect(
shouldSuppressCompactContextUsageMessage(systemMessage, "/compact"),
).toBe(false);
});
});
35 changes: 35 additions & 0 deletions sidecar/src/claude/compact-filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";

/// Detect the synthetic `## Context Usage` assistant message the SDK emits
/// after `/compact` — redundant once `compact_boundary` confirms the compact.
function isCompactContextUsageMessage(message: SDKMessage): boolean {
if (message.type !== "assistant") {
return false;
}
const payload = (
message as { message?: { model?: string; content?: unknown } }
).message;
if (payload?.model !== "<synthetic>") {
return false;
}
const content = payload.content;
if (!Array.isArray(content) || content.length === 0) {
return false;
}
const first = content[0] as { type?: string; text?: string };
return (
first.type === "text" &&
typeof first.text === "string" &&
first.text.startsWith("## Context Usage")
);
}

/// Only drop the table for `/compact` — `/context` produces it on purpose.
export function shouldSuppressCompactContextUsageMessage(
message: SDKMessage,
rootSlashCommand: string | null,
): boolean {
return (
rootSlashCommand === "/compact" && isCompactContextUsageMessage(message)
);
}
57 changes: 57 additions & 0 deletions sidecar/src/claude/session-manager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, test } from "bun:test";
import { normalizeRootSlashCommand } from "./session-manager.js";

describe("normalizeRootSlashCommand", () => {
test("bare /compact -> /compact", () => {
expect(normalizeRootSlashCommand("/compact")).toBe("/compact");
});

test("/compact with args -> /compact (args dropped)", () => {
expect(normalizeRootSlashCommand("/compact focus on auth")).toBe(
"/compact",
);
});

test("bare /context -> /context all", () => {
expect(normalizeRootSlashCommand("/context")).toBe("/context all");
});

test("/context with args -> /context all", () => {
expect(normalizeRootSlashCommand("/context show usage")).toBe(
"/context all",
);
});

test("wrapped /compact (helmor preamble + User request marker) -> /compact", () => {
const wrapped = `<helmor_context>\nYou are running inside Helmor...\n</helmor_context>\n\nUser request:\n/compact`;
expect(normalizeRootSlashCommand(wrapped)).toBe("/compact");
});

test("wrapped /context -> /context all", () => {
const wrapped = `<helmor_context>\nframing\n</helmor_context>\n\nUser request:\n/context`;
expect(normalizeRootSlashCommand(wrapped)).toBe("/context all");
});

test("prompt with linked-dirs block AND helmor preamble -> command still extracted", () => {
const wrapped = `<helmor_context>\nframing\n</helmor_context>\n[Linked directories — you have read/write access:\n- /other]\n\nUser request:\n/compact`;
expect(normalizeRootSlashCommand(wrapped)).toBe("/compact");
});

test("/goal is not a recognized root command -> null", () => {
expect(normalizeRootSlashCommand("/goal fix all bugs")).toBe(null);
});

test("normal prompt -> null", () => {
expect(normalizeRootSlashCommand("fix the bug in auth.ts")).toBe(null);
});

test("wrapped normal prompt -> null", () => {
const wrapped = `<helmor_context>\nframing\n</helmor_context>\n\nUser request:\nfix the bug in auth.ts`;
expect(normalizeRootSlashCommand(wrapped)).toBe(null);
});

test("command-like text not at the end (mid-prompt) -> null", () => {
const wrapped = `<helmor_context>\nframing\n</helmor_context>\n\nUser request:\nPlease run /compact for me`;
expect(normalizeRootSlashCommand(wrapped)).toBe(null);
});
});
43 changes: 39 additions & 4 deletions sidecar/src/claude/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
parseTitleAndBranchWithDiagnostics,
TITLE_GENERATION_TIMEOUT_MS,
} from "../title.js";
import { shouldSuppressCompactContextUsageMessage } from "./compact-filter.js";
import { loadProjectMcpServers } from "./project-mcp.js";

/**
Expand All @@ -67,6 +68,29 @@ const SLASH_COMMANDS_TIMEOUT_MS = 20_000;
*/
const CONTEXT_USAGE_TIMEOUT_MS = 30_000;

/**
* Helmor wraps every user prompt as `{helmor preamble}\n\nUser request:\n{prompt}`
* (and may also prepend a `[Linked directories …]` block). The Claude Agent SDK
* only recognises a root slash command when the prompt it receives starts with
* `/<name>`, so a wrapped `/compact` or `/context` is treated as literal text
* and silently does nothing. Strip the wrapping and return the bare command the
* SDK needs; return `null` for any non-command prompt so the normal path runs.
*/
export function normalizeRootSlashCommand(prompt: string): string | null {
const marker = "\n\nUser request:\n";
const markerIndex = prompt.lastIndexOf(marker);
const text = (
markerIndex === -1 ? prompt : prompt.slice(markerIndex + marker.length)
).trim();
if (text === "/context" || text.startsWith("/context ")) {
return "/context all";
}
if (text === "/compact" || text.startsWith("/compact ")) {
return "/compact";
}
return null;
}

/**
* Resolve the Claude Code native binary for `pathToClaudeCodeExecutable`.
* Prefers `HELMOR_CLAUDE_CODE_BIN_PATH` (release), then the platform
Expand Down Expand Up @@ -408,10 +432,14 @@ export class ClaudeSessionManager implements SessionManager {
directories: additionalDirectories,
cwd: cwd ?? "(none)",
});
const promptWithContext = prependLinkedDirectoriesContext(
prompt,
additionalDirectories,
);
// Root slash commands (/compact, /context) must reach the SDK bare — any
// prepended context breaks the SDK's root-command detection. Strip the
// Helmor wrapping; for normal prompts keep the linked-dirs hint.
const rootSlashCommand = normalizeRootSlashCommand(prompt);
const promptForSdk = rootSlashCommand ?? prompt;
const promptWithContext = rootSlashCommand
? promptForSdk
: prependLinkedDirectoriesContext(promptForSdk, additionalDirectories);

const { text, imagePaths } = parseImageRefs(promptWithContext, images);
const promptSource = createPushable<SDKUserMessage>();
Expand Down Expand Up @@ -698,6 +726,13 @@ export class ClaudeSessionManager implements SessionManager {
// `end` here would violate the "exactly one terminal event" contract.
if (this.turns.isAbortRequested(sessionId)) return;
logger.sdkEvent(requestId, message);
// /compact: drop the redundant synthetic `## Context Usage` reply —
// `compact_boundary` already confirms the compact.
if (
shouldSuppressCompactContextUsageMessage(message, rootSlashCommand)
) {
continue;
}
if (message.type === "rate_limit_event") {
lastRateLimitInfo = (
message as { rate_limit_info?: RateLimitOverageInfo }
Expand Down
4 changes: 2 additions & 2 deletions src-tauri/src/agents/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -724,7 +724,7 @@ fn custom_provider_options(
provider_key: Some(model.provider_key),
effort_levels: claude_effort_levels(),
supports_fast_mode: false,
supports_context_usage: false,
supports_context_usage: true,
})
.collect()
}
Expand Down Expand Up @@ -1072,7 +1072,7 @@ mod tests {
sections[0].options[6].effort_levels,
vec!["low", "medium", "high", "xhigh", "max"]
);
assert!(!sections[0].options[6].supports_context_usage);
assert!(sections[0].options[6].supports_context_usage);
assert_eq!(sections[1].id, "codex");
}

Expand Down
65 changes: 58 additions & 7 deletions src-tauri/src/pipeline/accumulator/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ use super::types::{
};
use streaming::StreamingBlock;

fn is_claude_compact_lifecycle_event(value: &Value) -> bool {
let subtype = value.get("subtype").and_then(Value::as_str);
matches!(subtype, Some("compact_boundary"))
|| (subtype == Some("status")
&& value.get("status").and_then(Value::as_str) == Some("compacting"))
}

#[cfg(test)]
mod tests;

Expand Down Expand Up @@ -144,6 +151,7 @@ pub struct StreamAccumulator {
/// parent Task/Agent tool call instead of flashing as a top-level bubble.
pub(super) cur_streaming_parent_id: Option<String>,
local_bash_task_refs: HashSet<String>,
compact_lifecycle_id: Option<String>,
// ── Codex state ──────────────────────────────────────────────────
/// Per-item delta accumulation for Codex App Server streaming.
codex_items: HashMap<String, codex::CodexItemState>,
Expand Down Expand Up @@ -353,6 +361,7 @@ impl StreamAccumulator {
cur_asst_block_count: 0,
cur_streaming_parent_id: None,
local_bash_task_refs: HashSet::new(),
compact_lifecycle_id: None,
codex_items: codex::new_item_states(),
codex_partial_idx: None,
codex_turn_started_at: None,
Expand Down Expand Up @@ -1245,13 +1254,15 @@ impl StreamAccumulator {
}

fn handle_claude_system(&mut self, raw_line: &str, value: &Value) {
// Subtypes listed in `event_filter::SUPPRESSED_SYSTEM_SUBTYPES`
// never enter `collected[]` — they don't cross IPC, don't render,
// don't waste downstream work. Edit that file to toggle.
if let Some(subtype) = value.get("subtype").and_then(Value::as_str) {
if crate::pipeline::event_filter::is_suppressed_system_subtype(subtype) {
return;
}
// Suppressed system events never enter `collected[]` — they don't cross
// IPC, don't render, don't waste downstream work. Edit
// `event_filter` to toggle.
if crate::pipeline::event_filter::is_suppressed_system_event(value) {
return;
}
if is_claude_compact_lifecycle_event(value) {
self.collect_compact_lifecycle(raw_line, value);
return;
}
// `local_bash` task_* events duplicate the accompanying Bash tool.
// Claude omits `task_type` on the later notification, so remember
Expand Down Expand Up @@ -1351,6 +1362,46 @@ impl StreamAccumulator {
self.cur_asst_block_count = 0;
}

fn collect_compact_lifecycle(&mut self, raw: &str, parsed: &Value) {
let subtype = parsed.get("subtype").and_then(Value::as_str);
let id = match subtype {
Some("status") => {
let id = parsed
.get("uuid")
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
self.compact_lifecycle_id = Some(id.clone());
id
}
Some("compact_boundary") => self
.compact_lifecycle_id
.take()
.or_else(|| {
parsed
.get("uuid")
.and_then(Value::as_str)
.map(str::to_string)
})
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
_ => uuid::Uuid::new_v4().to_string(),
};

self.collect_or_replace(raw, parsed, MessageRole::System, Some(id.clone()));

if subtype == Some("compact_boundary") {
if let Some(pos) = self.turns.iter().rposition(|turn| turn.id == id) {
self.turns[pos].content_json = raw.to_string();
} else {
self.turns.push(CollectedTurn {
id,
role: MessageRole::System,
content_json: raw.to_string(),
});
}
}
}

pub(super) fn collect_message(
&mut self,
raw: &str,
Expand Down
9 changes: 9 additions & 0 deletions src-tauri/src/pipeline/adapter/labels.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,15 @@ pub(super) fn build_system_notice(parsed: Option<&Value>, msg_id: &str) -> Optio
})
}
"compact_boundary" => Some(build_compact_boundary_notice(parsed, msg_id)),
"status" if parsed.get("status").and_then(Value::as_str) == Some("compacting") => {
Some(MessagePart::SystemNotice {
id: notice_part_id(msg_id),
severity: NoticeSeverity::Info,
label: "Compacting context".to_string(),
body: None,
})
}
"status" => None,
"codex_compacting" => Some(MessagePart::SystemNotice {
id: notice_part_id(msg_id),
severity: NoticeSeverity::Info,
Expand Down
Loading
Loading