From 0769bc54608b52a88d54233b90d0e19b325dcd3a Mon Sep 17 00:00:00 2001 From: "matterai-app[bot]" <150888575+matterai-app[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:35:43 +0530 Subject: [PATCH 1/4] feat: Claude Code-style request layout and prompt-cache fixes - Static system prompt (role definition, tool guide, harness section) so the prompt cache hits across sessions and projects. - The first user message opens with blocks for AGENTS.md and a git snapshot; the environment follows as its own system message. - notes are appended to each later user message and tool round rather than sent as system messages a gateway would hoist. - AI SDK providers get the environment folded into the first user turn. - Each assistant message replays its own reasoning as reasoning_content. - Reminders and environment are re-sent after compaction. - Remove tool-result pruning past 40% of the window: it rewrote history from the first tool result and capped the cached prefix at the first message. History is now append-only until auto-compaction. --- src/api/aiSdkClient.ts | 14 ++- src/api/llmClient.ts | 50 ++++++++ src/core/agent.ts | 250 +++++++++++++++++++------------------ src/prompts/system.ts | 116 ++++++++++++----- test/agent-context.test.ts | 129 ++++++++++++++----- test/compaction.test.ts | 5 +- 6 files changed, 377 insertions(+), 187 deletions(-) diff --git a/src/api/aiSdkClient.ts b/src/api/aiSdkClient.ts index b4f7857..f8dc7bf 100644 --- a/src/api/aiSdkClient.ts +++ b/src/api/aiSdkClient.ts @@ -18,7 +18,7 @@ import { import type OpenAI from "openai" import { parseToolCallArguments } from "../utils/jsonRepair.js" -import { REASONING_DETAILS_FIELD, type LLMClient } from "./llmClient.js" +import { foldSystemMessages, REASONING_DETAILS_FIELD, type LLMClient } from "./llmClient.js" // `ReasoningPart` isn't re-exported from "ai"; derive it from the exported // assistant content union so we don't depend on a transitive package path. @@ -51,8 +51,11 @@ export class AiSdkClient implements LLMClient { const isAnthropic = model.provider === "anthropic" // Replay stored thinking blocks only on Anthropic, where they round-trip - // with their signatures; other providers ignore the side-channel. - const aiMessages = toModelMessages(messages, isAnthropic) + // with their signatures; other providers ignore the side-channel. The + // environment system message is folded into the user turn before it: + // Anthropic only accepts a leading system prompt, and arbitrary + // OpenAI-compatible endpoints may reject a system message mid-conversation. + const aiMessages = toModelMessages(foldSystemMessages(messages, () => true), isAnthropic) if (isAnthropic) applyCacheBreakpoint(aiMessages) const aiTools = toAiTools(tools) @@ -250,8 +253,9 @@ function toModelMessages( out.push({ role: "tool", content: [result] }) break } - // "system"/"developer"/"function" roles don't appear in OrbCode history - // (the system prompt is passed separately) — ignore defensively. + // "system" messages are folded beforehand (see createMessage) and the + // system prompt is passed separately; "developer"/"function" roles + // don't appear in OrbCode history — ignore defensively. } } return out diff --git a/src/api/llmClient.ts b/src/api/llmClient.ts index 0c6d322..76acd87 100644 --- a/src/api/llmClient.ts +++ b/src/api/llmClient.ts @@ -37,6 +37,13 @@ export interface LLMClient { */ export const REASONING_DETAILS_FIELD = "_reasoningDetails" +/** + * The OpenAI-compatible field carrying an assistant message's reasoning text. + * Stored on the message and replayed unchanged on the OpenAI path; the AI SDK + * client replays `REASONING_DETAILS_FIELD` instead and ignores this one. + */ +export const REASONING_CONTENT_FIELD = "reasoning_content" + /** Drop the reasoning side-channel from assistant messages bound for an OpenAI * `/chat/completions` request (it isn't a valid input field there). */ export function stripReasoningDetails( @@ -50,3 +57,46 @@ export function stripReasoningDetails( return rest as unknown as OpenAI.Chat.ChatCompletionMessageParam }) } + +/** + * Fold mid-conversation system messages selected by `shouldFold` into the + * message before them (the user message or last tool result), or into a new + * user message when there is none. Used for content a destination can't take + * as a system turn in place: gateways that hoist every system message into the + * leading system prompt, and providers that only accept a leading one. A + * hoisted note that changes each step would rewrite the start of every request + * and defeat the prompt cache; folded in place, the request prefix stays + * byte-stable. + */ +export function foldSystemMessages( + messages: OpenAI.Chat.ChatCompletionMessageParam[], + shouldFold: (text: string) => boolean, +): OpenAI.Chat.ChatCompletionMessageParam[] { + const out: OpenAI.Chat.ChatCompletionMessageParam[] = [] + for (const message of messages) { + const text = message.role === "system" ? systemText(message.content) : "" + if (message.role !== "system" || !shouldFold(text)) { + out.push(message) + continue + } + const previous = out[out.length - 1] + if (previous?.role === "user" || previous?.role === "tool") { + out[out.length - 1] = appendText(previous, text) + } else { + out.push({ role: "user", content: text }) + } + } + return out +} + +function systemText(content: OpenAI.Chat.ChatCompletionSystemMessageParam["content"]): string { + return typeof content === "string" ? content : content.map((part) => part.text).join("") +} + +function appendText( + message: T, + text: string, +): T { + if (typeof message.content === "string") return { ...message, content: `${message.content}\n\n${text}` } + return { ...message, content: [...message.content, { type: "text", text }] } as T +} diff --git a/src/core/agent.ts b/src/core/agent.ts index 88c12aa..568ad5c 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -8,10 +8,22 @@ import { attachmentSummary, formatAttachmentContext, } from "../attachments.js" -import { REASONING_DETAILS_FIELD, type LLMClient } from "../api/llmClient.js" +import { + foldSystemMessages, + REASONING_CONTENT_FIELD, + REASONING_DETAILS_FIELD, + type LLMClient, +} from "../api/llmClient.js" import { getModel } from "../api/models.js" import { createLLMClient } from "../api/provider.js" -import { buildSystemPrompt } from "../prompts/system.js" +import { + buildContextReminders, + buildEnvironmentMessage, + buildSystemPrompt, + isTokensLeftNote, + systemReminder, + tokensLeftNote, +} from "../prompts/system.js" import { describeToolCall, disposeSearchFiles, @@ -20,7 +32,6 @@ import { getApprovalKind, type ToolContext, } from "../tools/index.js" -import { walkFiles } from "../tools/executors/listFiles.js" import { previewFileChange } from "../tools/executors/files.js" import { extractFigmaUrls, figmaFetch } from "../tools/executors/figma.js" import { stripSearchPageMetadataForDisplay } from "../tools/executors/searchFiles/format.js" @@ -44,8 +55,8 @@ import { } from "./checkpoints.js" import { HookRunner, type HooksConfig } from "./hooks.js" import { McpManager } from "../mcp/manager.js" -import { loadMemoryFiles } from "../memory/loader.js" -import { loadSkills } from "../skills/loader.js" +import { loadMemoryFiles, renderMemorySection } from "../memory/loader.js" +import { loadSkills, renderSkillCatalog } from "../skills/loader.js" import { renderLinkedReposSection } from "../config/links.js" import { unifiedDiff } from "../utils/diff.js" import { parseToolCallArguments } from "../utils/jsonRepair.js" @@ -95,17 +106,11 @@ const MAX_STREAM_RETRIES = 3 const STALE_WRITE_TOLERANCE_MS = 2000 // --- Context management ----------------------------------------------------- -/** Start stubbing stale tool results once context passes this fraction of the window. */ -const PRUNE_TRIGGER_FRACTION = 0.4 -/** The most recent tool results are always sent verbatim. */ +// History is append-only between compactions: rewriting an earlier message +// changes the request prefix and throws away the provider's prompt cache from +// that point on. +/** A size-limited summary request still sends the most recent tool results verbatim. */ const KEEP_RECENT_TOOL_RESULTS = 4 -/** The prune boundary only advances in batches this large, so the request prefix - * (and the gateway's prompt cache) stays stable between prunes. */ -const PRUNE_BATCH = 6 -/** Results shorter than this are not worth stubbing. */ -const PRUNE_MIN_CHARS = 1500 -/** Tools whose output is bulky and can simply be re-fetched. */ -const PRUNABLE_TOOLS = new Set(["read_file", "search_files", "list_files", "Bash", "execute_command", "web_fetch", "web_search"]) /** Summarize the history before a step once context passes this fraction of the window. */ const AUTO_COMPACT_FRACTION = 0.8 /** Rough chars-per-token for content the gateway hasn't measured yet. */ @@ -293,23 +298,38 @@ function isParallelReadOnlyCall(toolCall: PendingToolCall): boolean { } } -function getGitSummary(cwd: string): string { +function gitOutput(cwd: string, command: string): string | undefined { try { - const branch = execSync("git rev-parse --abbrev-ref HEAD", { cwd, stdio: ["ignore", "pipe", "ignore"] }) - .toString() - .trim() - const status = execSync("git status --short", { cwd, stdio: ["ignore", "pipe", "ignore"] }) - .toString() - .trimEnd() - .split("\n") - .slice(0, 20) - .join("\n") - return `## Git Repository Information\n- Current Branch: ${branch}\n${status ? `\n### Working Tree Changes\n${status}` : "- Working tree clean"}` + return execSync(command, { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trimEnd() } catch { - return "" + return undefined } } +/** Git snapshot for the first message's context reminder; empty outside a repo. */ +function getGitSummary(cwd: string): string { + const branch = gitOutput(cwd, "git rev-parse --abbrev-ref HEAD") + if (branch === undefined) return "" + const mainBranch = gitOutput(cwd, "git symbolic-ref --short refs/remotes/origin/HEAD")?.replace(/^origin\//, "") + const user = gitOutput(cwd, "git config user.name") + const status = (gitOutput(cwd, "git status --short") ?? "").split("\n").filter(Boolean) + const commits = gitOutput(cwd, "git log --oneline -n 5") + return [ + "# gitStatus", + "This is the git status at the start of the conversation. Note that this status is a snapshot in time, and will not update during the conversation.", + "", + `Current branch: ${branch}`, + ...(mainBranch ? ["", `Main branch (you will usually use this for PRs): ${mainBranch}`] : []), + ...(user ? ["", `Git user: ${user}`] : []), + "", + "Status:", + status.length === 0 + ? "(clean)" + : status.slice(0, 20).join("\n") + (status.length > 20 ? `\n… (${status.length - 20} more)` : ""), + ...(commits ? ["", "Recent commits:", commits] : []), + ].join("\n") +} + /** * The user's message is wrapped in tags internally so the TUI can * identify user-authored text when replaying a session. The wrapper is only an @@ -498,6 +518,10 @@ export class Agent { private options: AgentOptions private client: LLMClient private systemPrompt: string + /** AGENTS.md instructions and skills catalog, sent as conversation context + * (not in the system prompt) so the system prompt stays cacheable. */ + private memorySection = "" + private skillCatalog = "" private messages: OpenAI.Chat.ChatCompletionMessageParam[] = [] private transcript: SessionTranscriptEntry[] = [] private transcriptReasoning = "" @@ -536,8 +560,6 @@ export class Agent { private pendingStartContext = "" /** guards Stop-hook forced continuation against infinite loops */ private stopHookActive = false - /** tool results at message indexes below this are sent as short stubs (see pruneStaleToolResults) */ - private prunedBefore = 0 /** set after a failed auto-compaction so it isn't retried on every step of * the same turn; cleared at the start of each turn and on any success */ private autoCompactFailed = false @@ -603,15 +625,14 @@ export class Agent { } this.sessionApproveEdits = options.autoApproveEdits this.mcp = options.mcp - // Build the system prompt with AGENTS.md memory files and the skills - // catalog injected, so the model sees project/user instructions and - // knows which skills it can invoke. An explicit override (from - // `orbcode -s `) bypasses the default entirely. - const memoryFiles = options.systemPromptOverride ? [] : loadMemoryFiles(options.cwd) - const skills = options.systemPromptOverride ? new Map() : loadSkills(options.cwd) - this.systemPrompt = options.systemPromptOverride - ? options.systemPromptOverride - : buildSystemPrompt(options.cwd, { memoryFiles, skills }) + // AGENTS.md instructions and the skills catalog reach the model with the + // first message (see contextReminders / environmentMessage). An explicit + // override (from `orbcode -s `) bypasses them and the default prompt. + if (!options.systemPromptOverride) { + this.memorySection = renderMemorySection(loadMemoryFiles(options.cwd)) + this.skillCatalog = renderSkillCatalog(loadSkills(options.cwd)) + } + this.systemPrompt = options.systemPromptOverride ?? buildSystemPrompt() this.client = options.client ?? createLLMClient({ @@ -721,7 +742,9 @@ export class Agent { if (restored.length > 0) { this.messages.push({ role: "user", - content: `System reminder: The user restored these files to an earlier state, so any edits you made to them since are gone:\n${restored.join("\n")}`, + content: systemReminder( + `The user restored these files to an earlier state, so any edits you made to them since are gone:\n${restored.join("\n")}`, + ), }) } this.persist() @@ -751,7 +774,6 @@ export class Agent { this.measuredMessages = Math.min(this.measuredMessages, this.messages.length) this.stopHookActive = false this.autoCompactFailed = false - this.prunedBefore = Math.min(this.prunedBefore, this.messages.length) this.repeatTracker.clear() // Persist even when nothing is left, so the rewound turns don't come // back on /resume. @@ -862,7 +884,6 @@ export class Agent { this.pendingStartContext = "" this.sessionStarted = false this.stopHookActive = false - this.prunedBefore = 0 this.autoCompactFailed = false this.measuredMessages = 0 this.repeatTracker.clear() @@ -938,84 +959,55 @@ export class Agent { return this.sessionStarted || this.messages.length > 0 } - private buildEnvironmentDetails(): string { - const files = walkFiles(this.options.cwd, true, 200) - const git = getGitSummary(this.options.cwd) - const now = new Date() - const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone - const timeZoneOffset = -now.getTimezoneOffset() / 60 - const timeZoneOffsetHours = Math.floor(Math.abs(timeZoneOffset)) - const timeZoneOffsetMinutes = Math.abs(Math.round((Math.abs(timeZoneOffset) - timeZoneOffsetHours) * 60)) - const timeZoneOffsetStr = `${timeZoneOffset >= 0 ? "+" : "-"}${timeZoneOffsetHours}:${timeZoneOffsetMinutes.toString().padStart(2, "0")}` - const linkedRepos = renderLinkedReposSection(this.options.cwd) - return `# Environment Details - -## Current Workspace Directory (${this.options.cwd}) Files -${files.join("\n") || "(empty directory)"} -${files.length >= 200 ? "\n(File list truncated.)" : ""} - -${git} -${linkedRepos ? `\n${linkedRepos}` : ""} -## Current Time -Current time in ISO 8601 UTC format: ${now.toISOString()} -User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` + /** blocks that open the first user message. */ + private contextReminders(): string { + return buildContextReminders(this.memorySection, getGitSummary(this.options.cwd)) } - /** Conversation history with internal markers stripped, ready for the model. */ - private outgoingMessages(): OpenAI.Chat.ChatCompletionMessageParam[] { - const toolNames = new Map() - if (this.prunedBefore > 0) { - for (const message of this.messages) { - if (message.role !== "assistant") continue - for (const call of message.tool_calls ?? []) { - if (call.type === "function") toolNames.set(call.id, call.function.name) - } - } + /** System message that follows the first user message. */ + private environmentMessage(): OpenAI.Chat.ChatCompletionSystemMessageParam { + return { + role: "system", + content: buildEnvironmentMessage({ + cwd: this.options.cwd, + isGitRepo: gitOutput(this.options.cwd, "git rev-parse --is-inside-work-tree") === "true", + modelName: getModel(this.options.modelId).name, + skillCatalog: this.skillCatalog, + linkedRepos: renderLinkedReposSection(this.options.cwd), + }), } - return this.messages.map((message, index) => { - if (message.role === "tool" && index < this.prunedBefore) { - const name = toolNames.get(message.tool_call_id) ?? "" - const text = contentToText(message.content) - if (PRUNABLE_TOOLS.has(name) && text.length >= PRUNE_MIN_CHARS) { - const lines = text.split("\n").length - return { - ...message, - content: `[Earlier ${name} result (${lines} lines) removed to save context. Re-run the call if you still need it.]`, - } - } - return message - } - return message.role === "user" - ? { - ...message, - content: - typeof message.content === "string" - ? stripUserQueryTags(message.content) - : message.content.map((part) => - part.type === "text" ? { ...part, text: stripUserQueryTags(part.text) } : part, - ), - } - : message - }) + } + + /** System message reporting the context window left, sent after each user message and tool round. */ + private tokensLeftMessage(): OpenAI.Chat.ChatCompletionSystemMessageParam { + const window = getModel(this.options.modelId).contextWindow + return { role: "system", content: tokensLeftNote(window - this.estimatedContextTokens()) } } /** - * Once context is large, stop resending old bulky tool results verbatim. The - * stored history is untouched (sessions keep everything); only the outgoing - * copy is stubbed. The boundary only advances in batches so the request prefix - * stays identical between advances and the gateway's prompt cache keeps hitting. + * Conversation history with internal markers stripped, ready for the model. + * The environment stays a system message after the first user message; the + * per-step notes are folded into the message before them, + * since a gateway that hoists system messages into the leading prompt would + * otherwise rewrite the start of every request. */ - private pruneStaleToolResults(): void { - const window = getModel(this.options.modelId).contextWindow - if (this.contextTokens < window * PRUNE_TRIGGER_FRACTION) return - const toolIndexes: number[] = [] - this.messages.forEach((message, index) => { - if (message.role === "tool") toolIndexes.push(index) - }) - if (toolIndexes.length <= KEEP_RECENT_TOOL_RESULTS) return - const boundary = toolIndexes[toolIndexes.length - KEEP_RECENT_TOOL_RESULTS] - const newlyStale = toolIndexes.filter((index) => index >= this.prunedBefore && index < boundary).length - if (newlyStale >= PRUNE_BATCH) this.prunedBefore = boundary + private outgoingMessages(): OpenAI.Chat.ChatCompletionMessageParam[] { + return foldSystemMessages( + this.messages.map((message) => + message.role === "user" + ? { + ...message, + content: + typeof message.content === "string" + ? stripUserQueryTags(message.content) + : message.content.map((part) => + part.type === "text" ? { ...part, text: stripUserQueryTags(part.text) } : part, + ), + } + : message, + ), + isTokensLeftNote, + ) } /** Note appended to a tool result when the model keeps repeating the same call. */ @@ -1109,8 +1101,10 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` .map((attachment) => ``) .join("\n")}\n` } - if (!this.firstMessageSent) { - userContent = `${this.buildEnvironmentDetails()}\n\n${userContent}` + const isFirstMessage = !this.firstMessageSent + if (isFirstMessage) { + const reminders = this.contextReminders() + if (reminders) userContent = `${reminders}\n\n${userContent}` this.firstMessageSent = true } // SessionStart context sits above the prompt; UserPromptSubmit context @@ -1142,6 +1136,7 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` ] : userContent, }) + this.messages.push(isFirstMessage ? this.environmentMessage() : this.tokensLeftMessage()) // Persist immediately so a hard kill before the first model response // still leaves the user's prompt on disk. this.persist() @@ -1224,7 +1219,7 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` // Keep the conversation consistent: note the interruption for the model. this.messages.push({ role: "user", - content: "System reminder: The user interrupted this response before it finished.", + content: systemReminder("The user interrupted this response before it finished."), }) } else { onEvent({ type: "error", message: sanitizeErrorMessage(error) }) @@ -1258,7 +1253,7 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` if (result.blocked && !this.stopHookActive) { this.stopHookActive = true const reason = result.blockReason || "A Stop hook asked you to keep going." - this.messages.push({ role: "user", content: `System reminder (Stop hook): ${reason}` }) + this.messages.push({ role: "user", content: systemReminder(`Stop hook: ${reason}`) }) return true } return false @@ -1493,27 +1488,34 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` * verbatim after it, so the new request isn't lost in the summary. */ private replaceHistoryWithSummary(summary: string, continueWork: boolean): void { - const last = this.messages[this.messages.length - 1] + // The unanswered user message, if any, is the last one apart from + // trailing harness system messages (which are regenerated below). + let lastIndex = this.messages.length - 1 + while (lastIndex >= 0 && this.messages[lastIndex]?.role === "system") lastIndex-- + const last = this.messages[lastIndex] const pending = continueWork && last?.role === "user" ? last : undefined + const reminders = this.contextReminders() this.messages = [ { role: "user", content: + (reminders ? `${reminders}\n\n` : "") + `# Conversation Summary\n\nThe conversation history was compacted. Summary of everything so far:\n\n${summary}` + (continueWork ? "\n\nContinue the work described above from where it left off. Do not restart or repeat steps that are already done." : ""), }, + this.environmentMessage(), ...(pending ? [pending] : []), ] // The summary is small; the next usage report replaces this estimate. this.contextTokens = this.messages.reduce((total, message) => total + messageTokenEstimate(message), 0) this.measuredMessages = this.messages.length + if (pending) this.messages.push(this.tokensLeftMessage()) this.autoCompactFailed = false // Checkpoints index into the history that was just replaced. deleteBackups(this.taskId, this.checkpoints) this.checkpoints = [] - this.prunedBefore = 0 this.repeatTracker.clear() } @@ -1621,6 +1623,7 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` // A fresh segment can re-open if the model interleaves reasoning with content. let reasoningOpen = false let reasoningStart = 0 + let reasoningText = "" let reasoningDetails: unknown // Once a reasoning-done row is committed to the transcript we can't roll it // back, so a mid-stream retry after that point isn't clean. @@ -1645,6 +1648,7 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` assistantText = "" reasoningOpen = false reasoningStart = 0 + reasoningText = "" reasoningDetails = undefined toolCallsByIndex.clear() nextSyntheticIndex = 10000 @@ -1652,7 +1656,6 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` return true } - this.pruneStaleToolResults() const stream = this.streamWithRetry( () => this.client.createMessage(this.systemPrompt, this.outgoingMessages(), getActiveTools(this.mcp), signal), signal, @@ -1673,6 +1676,7 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` reasoningOpen = true reasoningStart = Date.now() } + reasoningText += chunk.text onEvent({ type: "reasoning-delta", text: chunk.text }) break case "reasoning_details": @@ -1728,6 +1732,13 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` function: { name: tc.name, arguments: tc.arguments || "{}" }, })) } + // Replay this step's reasoning verbatim on every later request, as other + // OpenAI-compatible clients do: the model sees its earlier thinking and the + // message never changes, so the provider's prompt cache keeps matching. + // Left off when there is none (strict endpoints reject unknown fields). + if (reasoningText) { + ;(assistantMessage as unknown as Record)[REASONING_CONTENT_FIELD] = reasoningText + } // Stash reasoning blocks (opaque) so the next turn can replay them. The // field is persisted with the session and stripped on the OpenAI path. if (reasoningDetails !== undefined) { @@ -1782,6 +1793,7 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` for (let index = batchEnd; index < toolCalls.length; index++) { await runToolCall(toolCalls[index]) } + if (!completed) this.messages.push(this.tokensLeftMessage()) // Persist after every model step: a hard kill mid-turn (crash, closed // terminal, kill signal) loses at most the in-flight tool call instead // of the entire turn's accumulated history. diff --git a/src/prompts/system.ts b/src/prompts/system.ts index 5783a63..45bad9c 100644 --- a/src/prompts/system.ts +++ b/src/prompts/system.ts @@ -1,14 +1,11 @@ import * as os from "node:os"; import { getShell, isCmdShell } from "../utils/shell.js"; -import type { MemoryFile } from "../memory/types.js"; -import { renderMemorySection } from "../memory/loader.js"; -import type { Skill } from "../skills/types.js"; -import { renderSkillCatalog } from "../skills/loader.js"; // Role definition and tool guide ported verbatim from the Orbital extension -// (agent mode roleDefinition + applyDiffToolDescription). Only the system -// information section is adapted from the IDE to the CLI environment. +// (agent mode roleDefinition + applyDiffToolDescription). Per-session context +// is not part of the system prompt; see buildEnvironmentMessage / +// buildContextReminders and the harness section that explains them. function shellDescription(): string { const shell = getShell(); @@ -238,39 +235,96 @@ Replace the entire TODO list with an updated checklist reflecting the current st IMPORTANT: Use attempt_completion tool when you have completed the task. This signals that you are done. `; -function getSystemInfoSection(cwd: string): string { - return `# System Information +const harnessSection = `# Harness -- Operating System: ${process.platform === "darwin" ? `macOS ${os.release()}` : `${process.platform} ${os.release()}`} -- Default Shell: ${shellDescription()} -- Home Directory: ${os.homedir()} -- Current Workspace Directory: ${cwd} +The role definition and tool guide above describe context in generic terms; this is how OrbCode CLI actually delivers it: -The Current Workspace Directory is the directory the user launched OrbCode CLI from, and is therefore the default directory for all tool operations. Commands run in the current workspace directory unless a different cwd is passed; changing directories inside a command does not modify the workspace directory. When the user initially gives you a task, a listing of filepaths in the current workspace directory will be included in the Environment Details section. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current workspace directory, you can use \`ls\` or \`find\` through Bash. Prefer a non-recursive \`ls\` for generic directories where you don't need the nested structure, like the Desktop.`; +- Per-session context is sent as conversation messages, not in this system prompt. The first user message may open with blocks holding the "Project & User Instructions (AGENTS.md)" section and the git status at session start. A system message right after it holds the Environment details: primary working directory (the Current Workspace Directory), platform, shell, model, linked repositories, the "Available Skills" catalog and today's date. +- blocks, the Environment details and notes come from the harness, not the user. Heed them, but don't mention them in your response to the user. +- Each later user message and each round of tool results ends with N tokens left: the room left in your context window. When the conversation grows long, older context is summarized automatically, so you don't need to wrap up early or hand off mid-task. +- Text you output outside of tool calls is shown to the user as GitHub-flavored markdown in a terminal. + +# Workspace + +The Current Workspace Directory is the directory the user launched OrbCode CLI from, and is therefore the default directory for all tool operations. Commands run in the current workspace directory unless a different cwd is passed; changing directories inside a command does not modify the workspace directory. No file listing is attached; explore the project with the shell (\`ls\`, \`rg --files\`, \`git ls-files\`) rather than guessing at its layout. Prefer a non-recursive \`ls\` for generic directories where you don't need the nested structure, like the Desktop.`; + +/** + * The static system prompt. Everything per-session (cwd, git state, AGENTS.md, + * skills, date) is sent as conversation messages instead, so this prefix is + * byte-identical across sessions and projects and the prompt cache keeps hitting. + */ +export function buildSystemPrompt(): string { + return [roleDefinition, toolGuide, harnessSection].join("\n\n"); } -export interface SystemPromptOptions { - /** AGENTS.md memory files to inject (lowest precedence first). */ - memoryFiles?: MemoryFile[]; - /** Skills catalog to advertise to the model. */ - skills?: Map; +export interface EnvironmentOptions { + cwd: string; + isGitRepo: boolean; + modelName: string; + /** Rendered skills catalog (empty when there are none). */ + skillCatalog?: string; + /** Rendered linked-repositories section (empty when there are none). */ + linkedRepos?: string; + now?: Date; } -export function buildSystemPrompt( - cwd: string, - options: SystemPromptOptions = {}, -): string { - const memorySection = options.memoryFiles - ? renderMemorySection(options.memoryFiles) - : ""; - const skillSection = options.skills ? renderSkillCatalog(options.skills) : ""; +/** Environment details, sent as a system message right after the first user message. */ +export function buildEnvironmentMessage(options: EnvironmentOptions): string { + const now = options.now ?? new Date(); + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const offset = -now.getTimezoneOffset(); + const offsetStr = `${offset >= 0 ? "+" : "-"}${Math.floor(Math.abs(offset) / 60)}:${(Math.abs(offset) % 60).toString().padStart(2, "0")}`; + const pad = (n: number) => n.toString().padStart(2, "0"); + const today = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; return [ - roleDefinition, - toolGuide, - getSystemInfoSection(cwd), - memorySection, - skillSection, + `# Environment +You have been invoked in the following environment: + - Primary working directory: ${options.cwd} + - Is a git repository: ${options.isGitRepo} + - Home directory: ${os.homedir()} + - Platform: ${process.platform} + - Shell: ${shellDescription()} + - OS Version: ${process.platform === "darwin" ? `macOS (Darwin ${os.release()})` : `${os.type()} ${os.release()}`} + +You are powered by the model ${options.modelName}.`, + options.linkedRepos, + options.skillCatalog, + `Today's date is ${today}. User time zone: ${timeZone}, UTC${offsetStr}.`, ] .filter(Boolean) .join("\n\n"); } + +/** Wrap harness-provided context the model should heed but not echo. */ +export function systemReminder(text: string): string { + return `\n${text}\n`; +} + +/** Context prepended to the first user message: AGENTS.md instructions and the git snapshot. */ +export function buildContextReminders(memorySection: string, gitStatus: string): string { + const reminders: string[] = []; + if (memorySection) { + reminders.push( + systemReminder( + `Codebase and user instructions are shown below. Be sure to adhere to these instructions. IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written.\n\n${memorySection}`, + ), + ); + } + if (gitStatus) { + reminders.push( + systemReminder( + `As you answer the user's questions, you can use the following context:\n${gitStatus}\n\nThis context was attached automatically; it isn't part of the user's message.`, + ), + ); + } + return reminders.join("\n"); +} + +/** Remaining context-window budget, appended to each user message and tool round. */ +export function tokensLeftNote(tokensLeft: number): string { + return `${Math.max(0, Math.round(tokensLeft))} tokens left`; +} + +export function isTokensLeftNote(text: string): boolean { + return text.startsWith(""); +} diff --git a/test/agent-context.test.ts b/test/agent-context.test.ts index 9831517..6771a0f 100644 --- a/test/agent-context.test.ts +++ b/test/agent-context.test.ts @@ -5,7 +5,7 @@ import * as os from "node:os" import * as path from "node:path" import type OpenAI from "openai" -import type { LLMClient } from "../src/api/llmClient.js" +import { foldSystemMessages, type LLMClient } from "../src/api/llmClient.js" import type { ApiStreamChunk } from "../src/api/stream.js" import { Agent } from "../src/core/agent.js" @@ -15,6 +15,8 @@ interface ScriptedStep { /** tool calls to emit, or none for a final text answer */ tools?: { name: string; args: Record }[] text?: string + /** reasoning streamed before the step's visible output */ + reasoning?: string /** reported context size for this step */ inputTokens?: number } @@ -41,6 +43,7 @@ class ScriptedClient implements LLMClient { return } const step = this.steps[this.index++] ?? { text: "done" } + if (step.reasoning) yield { type: "reasoning", text: step.reasoning } if (step.tools) { yield { type: "native_tool_calls", @@ -128,33 +131,6 @@ test("an edit between repeats resets the loop counter", async () => { for (const text of toolTexts(client.requests.at(-1)!.messages)) assert.doesNotMatch(text, /identical/) }) -test("stale bulky tool results are stubbed once context is large; recent ones stay verbatim", async () => { - // 232k window: 100k input tokens is past the 40% prune trigger. - const steps: ScriptedStep[] = Array.from({ length: 12 }, (_, i) => ({ tools: [readRegion(i)], inputTokens: 100_000 })) - steps.push({ text: "done", inputTokens: 100_000 }) - const client = new ScriptedClient(steps) - const { agent } = makeAgent(client) - await agent.runTurn("read a lot") - const texts = toolTexts(client.requests.at(-1)!.messages) - assert.equal(texts.length, 12) - const stubbed = texts.filter((t) => t.startsWith("[Earlier read_file result")) - assert.ok(stubbed.length >= 6, `expected at least one batch stubbed, got ${stubbed.length}`) - // The four most recent results are never stubbed. - for (const t of texts.slice(-4)) assert.doesNotMatch(t, /^\[Earlier/) - // Stubbed results are the oldest ones, contiguous from the start. - const firstFull = texts.findIndex((t) => !t.startsWith("[Earlier")) - assert.equal(stubbed.length, firstFull) -}) - -test("small contexts are never pruned", async () => { - const steps: ScriptedStep[] = Array.from({ length: 10 }, (_, i) => ({ tools: [readRegion(i)], inputTokens: 5_000 })) - steps.push({ text: "done", inputTokens: 5_000 }) - const client = new ScriptedClient(steps) - const { agent } = makeAgent(client) - await agent.runTurn("read a lot") - for (const t of toolTexts(client.requests.at(-1)!.messages)) assert.doesNotMatch(t, /^\[Earlier/) -}) - test("history is auto-compacted when the context nears the window, and the turn continues", async () => { const client = new ScriptedClient([ { tools: [readRegion(0)], inputTokens: 10_000 }, @@ -168,12 +144,13 @@ test("history is auto-compacted when the context nears the window, and the turn assert.ok(events.some((e) => /Conversation compacted/.test(e))) const compactRequests = client.requests.filter((r) => r.toolCount === 0) assert.equal(compactRequests.length, 1) - // The request after compaction starts from a single summary message. + // The request after compaction starts from the summary, then the re-sent environment. const afterIdx = client.requests.findIndex((r) => r.toolCount === 0) + 1 const after = client.requests[afterIdx].messages - assert.equal(after.length, 1) + assert.deepEqual(after.map((m) => m.role), ["user", "system"]) assert.match(String(after[0].content), /SUMMARY-OF-WORK/) assert.match(String(after[0].content), /Continue the work described above/) + assert.match(String(after[1].content), /^# Environment/) }) test("a failing auto-compaction is reported once and does not derail the turn", async () => { @@ -231,3 +208,95 @@ test("whitespace-only content before a tool call is not kept as an assistant mes const sentAssistant = client.requests.at(-1)!.messages.find((m) => m.role === "assistant")! assert.equal(sentAssistant.content, null) }) + +test("the environment is a system message after the first user message; token notes are folded; each request extends the previous one", async () => { + const client = new ScriptedClient([ + { tools: [readRegion(0)], inputTokens: 10_000 }, + { tools: [readRegion(1)], inputTokens: 11_000 }, + { text: "done", inputTokens: 12_000 }, + { text: "again", inputTokens: 13_000 }, + ]) + const { agent } = makeAgent(client) + await agent.runTurn("first") + await agent.runTurn("second") + for (const request of client.requests) { + // Gateways hoist system messages into the leading prompt, so the only one + // sent must never change: the environment, as Claude Code sends it. + const systemIndexes = request.messages.flatMap((m, i) => (m.role === "system" ? [i] : [])) + assert.deepEqual(systemIndexes, [1]) + assert.match(String(request.messages[1].content), /^# Environment/) + } + assertEachRequestExtendsThePrevious(client) + assert.match(String(client.requests[0].messages[0].content), /first$/) + const last = client.requests[client.requests.length - 1].messages.at(-1)! + assert.match(String(last.content), /second\n\n\d+ tokens left<\/total_tokens>$/) +}) + +test("each step's reasoning is replayed unchanged as reasoning_content on later requests", async () => { + const client = new ScriptedClient([ + { reasoning: "look at the file first", tools: [readRegion(0)], inputTokens: 10_000 }, + { tools: [readRegion(1)], inputTokens: 11_000 }, + { reasoning: "enough context now", text: "done", inputTokens: 12_000 }, + { text: "again", inputTokens: 13_000 }, + ]) + const { agent } = makeAgent(client) + await agent.runTurn("first") + await agent.runTurn("second") + const reasoning = (messages: Messages) => + messages + .filter((m) => m.role === "assistant") + .map((m) => (m as unknown as Record).reasoning_content) + assert.deepEqual(reasoning(client.requests[1].messages), ["look at the file first"]) + // A step without reasoning carries no field; earlier messages never change. + assert.deepEqual(reasoning(client.requests[3].messages), ["look at the file first", undefined, "enough context now"]) + assertEachRequestExtendsThePrevious(client) +}) + +test("foldSystemMessages moves selected system messages into the turn before them", () => { + const messages: Messages = [ + { role: "user", content: "hi" }, + { role: "system", content: "# Environment" }, + { role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "x", arguments: "{}" } }] }, + { role: "tool", tool_call_id: "c1", content: "out" }, + { role: "system", content: "5 tokens left" }, + ] + const notesOnly = foldSystemMessages(messages, (text) => text.startsWith("")) + assert.deepEqual(notesOnly.map((m) => m.role), ["user", "system", "assistant", "tool"]) + assert.equal(notesOnly[3].content, "out\n\n5 tokens left") + // The AI SDK client folds everything: no system message survives. + const all = foldSystemMessages(messages, () => true) + assert.deepEqual(all.map((m) => m.role), ["user", "assistant", "tool"]) + assert.equal(all[0].content, "hi\n\n# Environment") +}) + +/** Gateways cache by prefix: every request must start with the previous one + * (only its last message may grow, as harness notes are appended to it). */ +function assertEachRequestExtendsThePrevious(client: ScriptedClient): void { + for (let i = 1; i < client.requests.length; i++) { + const previous = client.requests[i - 1].messages + const current = client.requests[i].messages + const changed = previous + .slice(0, -1) + .findIndex((message, index) => JSON.stringify(message) !== JSON.stringify(current[index])) + assert.equal(changed, -1, `request ${i} rewrote message ${changed} of ${previous.length}`) + assert.ok( + String(contentText(current[previous.length - 1])).startsWith(String(contentText(previous[previous.length - 1]))), + `request ${i} rewrote the previous last message`, + ) + } +} + +function contentText(message: Messages[number]): string { + const content = (message as { content?: unknown }).content + return typeof content === "string" ? content : JSON.stringify(content) +} + +test("a long session past 40% of the window never rewrites earlier history, so the prompt cache keeps growing", async () => { + // 232k window: 100k input tokens per step is well into large-context territory. + const steps: ScriptedStep[] = Array.from({ length: 12 }, (_, i) => ({ tools: [readRegion(i)], inputTokens: 100_000 })) + steps.push({ text: "done", inputTokens: 100_000 }) + const client = new ScriptedClient(steps) + const { agent } = makeAgent(client) + await agent.runTurn("read a lot") + assertEachRequestExtendsThePrevious(client) +}) diff --git a/test/compaction.test.ts b/test/compaction.test.ts index 858fa60..6b90063 100644 --- a/test/compaction.test.ts +++ b/test/compaction.test.ts @@ -112,9 +112,10 @@ test("a resumed session past 80% compacts before its first request and keeps the await agent.runTurn("now add tests") assert.equal(client.requests[0].tools, 0, "first request is the summary") const next = client.requests[1].messages - assert.equal(next.length, 2) + assert.deepEqual(next.map((m) => m.role), ["user", "system", "user"]) assert.match(String(next[0].content), /SUMMARY/) - assert.match(String(next[1].content), /now add tests/) + assert.match(String(next[1].content), /^# Environment/) + assert.match(String(next[2].content), /now add tests\n\n\d+ tokens left<\/total_tokens>$/) }) test("tool output added since the last usage report counts toward the threshold", async () => { From fbc8ce591d140d0eddd157db5bf6a083b10d2710 Mon Sep 17 00:00:00 2001 From: "matterai-app[bot]" <150888575+matterai-app[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:35:55 +0530 Subject: [PATCH 2/4] feat(ui): grouped tool rows and turn-wide spinner stats - Consecutive read-only tool calls (reads, searches, listings, LSP, web, read-only shell commands) collapse into one row whose latest call swaps in place; edits, errors and mutating commands keep their own rows. ctrl+o expands groups alongside thinking. - The spinner shows elapsed time for the whole turn and live output tokens, labelled by the running tool (Reading, Searching, Running, Editing, Fetching). --- package.json | 2 +- src/ui/App.tsx | 79 +++++++++++++++++++++-- src/ui/components/Spinner.tsx | 39 ++++++++++-- src/ui/components/rows.tsx | 117 ++++++++++++++++++++++++++++++++++ test/tool-group.test.tsx | 98 ++++++++++++++++++++++++++++ test/ui-viewport.test.tsx | 4 +- 6 files changed, 327 insertions(+), 12 deletions(-) create mode 100644 test/tool-group.test.tsx diff --git a/package.json b/package.json index 13e9ddd..7185060 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "test:resume": "node --import tsx --test test/resume-cost.test.ts", "test:compaction": "node --import tsx --test test/compaction.test.ts test/agent-context.test.ts test/files.test.ts", "test:effort": "node --import tsx --test test/model-effort.test.ts test/model-persistence.test.ts", - "test:ui": "bun test test/ui-viewport.test.tsx test/session-picker.test.tsx test/rewind-picker.test.tsx test/resume-confirm.test.tsx test/model-picker.test.tsx test/effort-picker.test.tsx", + "test:ui": "bun test test/ui-viewport.test.tsx test/session-picker.test.tsx test/rewind-picker.test.tsx test/resume-confirm.test.tsx test/model-picker.test.tsx test/effort-picker.test.tsx test/tool-group.test.tsx", "prepublishOnly": "npm run typecheck && npm run build" }, "dependencies": { diff --git a/src/ui/App.tsx b/src/ui/App.tsx index ee970e6..2fc555c 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -115,10 +116,12 @@ import { type ResumeCostEstimate, } from "../core/resumeCost.js"; import { + appendRow, diffViewHeight, formatToolName, formatUserBlock, RowView, + toolGroupHeading, type Row, } from "./components/rows.js"; import { LinkManager } from "./components/LinkManager.js"; @@ -614,6 +617,25 @@ export function App({ const agentRef = useRef(null); const expandReasoningRef = useRef(false); + // Turn-wide spinner stats: elapsed time and output tokens. Tokens are the + // provider-reported count so far plus ~4 chars/token for the live stream. + const [turnStartedAt, setTurnStartedAt] = useState(() => Date.now()); + const turnTokensRef = useRef(0); + const turnCommittedTokensRef = useRef(0); + const turnStreamedCharsRef = useRef(0); + const countStreamedChars = useCallback((chars: number) => { + turnStreamedCharsRef.current += chars; + turnTokensRef.current = + turnCommittedTokensRef.current + + Math.round(turnStreamedCharsRef.current / 4); + }, []); + useLayoutEffect(() => { + if (!busy) return; + setTurnStartedAt(Date.now()); + turnTokensRef.current = 0; + turnCommittedTokensRef.current = 0; + turnStreamedCharsRef.current = 0; + }, [busy]); const reasoningBufferRef = useRef(""); const textBufferRef = useRef(""); // taskId for which a title fetch has already been started (once per task). @@ -762,7 +784,9 @@ export function App({ }, []); const pushRow = useCallback((row: DistributiveOmit) => { - setRows((prev) => [...prev, { ...row, id: rowId() } as Row]); + setRows((prev) => + appendRow(prev, { ...row, id: rowId() } as Row, expandReasoningRef.current), + ); }, []); // Wipe the visible transcript — a clean slate for /clear, /new and /resume. @@ -785,6 +809,7 @@ export function App({ (event: AgentEvent) => { switch (event.type) { case "reasoning-delta": + countStreamedChars(event.text.length); reasoningBufferRef.current += event.text; setStreamingReasoning(reasoningBufferRef.current); setBusyLabel("Thinking"); @@ -801,6 +826,7 @@ export function App({ setBusyLabel("Working"); break; case "text-delta": + countStreamedChars(event.text.length); textBufferRef.current += event.text; setStreamingText(textBufferRef.current); setBusyLabel("Working"); @@ -827,7 +853,7 @@ export function App({ setBusyLabel("Working"); break; case "tool-start": - setBusyLabel("Working"); + setBusyLabel(toolActivityLabel(event.name)); break; case "tool-end": setBusyLabel("Working"); @@ -844,6 +870,10 @@ export function App({ setTasks(event.todos); break; case "usage": + // The provider's count replaces the streamed-character estimate. + turnCommittedTokensRef.current += event.outputTokens; + turnStreamedCharsRef.current = 0; + turnTokensRef.current = turnCommittedTokensRef.current; setContextTokens(event.inputTokens + event.outputTokens); setTotalCost(event.totalCost); // A usage chunk arrives once per LLM response, so the plan/usage @@ -2226,7 +2256,9 @@ export function App({ // new expansion state. setRows((prev) => prev.map((row) => - row.kind === "reasoning" ? { ...row, expanded } : row, + row.kind === "reasoning" || row.kind === "tool-group" + ? { ...row, expanded } + : row, ), ); // The terminal adapter replaces the retained screen rows in place. @@ -2606,7 +2638,12 @@ export function App({ )} {streamingReasoning && ( - + {streamingReasoningDisplay} @@ -2679,6 +2716,8 @@ export function App({ clearTimeout(timer) }, [showTip]) - const seconds = Math.floor((Date.now() - startedAt) / 1000) + const tokens = tokensRef?.current ?? 0 return ( - {FRAMES[frame]} {label} - ({seconds}s · esc to interrupt) + {FRAMES[frame]} {label}… + + {" "}({formatElapsed(Date.now() - startedAt)} + {tokens > 0 ? ` · ↓ ${formatTokenCount(tokens)} tokens` : ""} · esc to interrupt) + {tipVisible && └── TIP: {tip}} diff --git a/src/ui/components/rows.tsx b/src/ui/components/rows.tsx index 93b9255..63eaebf 100644 --- a/src/ui/components/rows.tsx +++ b/src/ui/components/rows.tsx @@ -3,6 +3,7 @@ import { Box, Text } from "../primitives.js" import type { AttachmentSummary } from "../../attachments.js" import { COLORS } from "../../branding.js" +import { isReadOnlyCommand } from "../../tools/readOnlyCommand.js" import { renderMarkdown } from "../markdown.js" import { Header } from "./Header.js" @@ -20,10 +21,98 @@ export type Row = isError: boolean diff?: string } + /** A run of consecutive read-only tool calls collapsed into one summary line. */ + | { kind: "tool-group"; id: string; tools: GroupedTool[]; expanded: boolean } | { kind: "info"; id: string; text: string } | { kind: "error"; id: string; text: string } | { kind: "completion"; id: string; text: string } +export interface GroupedTool { + name: string + summary: string +} + +/** Read-only exploration tools whose output the user rarely needs to see. */ +const GROUPABLE_TOOLS = new Set([ + "read_file", + "search_files", + "codebase_search", + "list_files", + "list_code_definition_names", + "lsp", + "check_background", + "web_search", + "web_fetch", +]) + +/** Successful, diff-less observation calls collapse into the surrounding tool group. */ +export function isGroupableTool(row: { name: string; summary: string; isError: boolean; diff?: string }): boolean { + if (row.isError || row.diff) return false + if (GROUPABLE_TOOLS.has(row.name)) return true + return (row.name === "Bash" || row.name === "execute_command") && isReadOnlyCommand(row.summary) +} + +/** + * Append a row to the transcript, folding a groupable tool call into the tool + * group directly before it (or starting a new one). Any other row — assistant + * text, an edit, an error — ends the group, so each group covers one + * uninterrupted stretch of exploration. + */ +export function appendRow(rows: Row[], row: Row, expanded: boolean): Row[] { + if (row.kind !== "tool" || !isGroupableTool(row)) return [...rows, row] + const tool: GroupedTool = { name: row.name, summary: row.summary } + const last = rows[rows.length - 1] + if (last?.kind === "tool-group") { + return [...rows.slice(0, -1), { ...last, tools: [...last.tools, tool] }] + } + return [...rows, { kind: "tool-group", id: row.id, tools: [tool], expanded }] +} + +type ToolTally = { singular: string; plural: string; count: number } + +/** "Read 3 files, searched for 2 patterns, ran 1 command" */ +export function toolGroupHeading(tools: GroupedTool[]): string { + const tallies = new Map() + const add = (key: string, singular: string, pluralForm: string, count = 1) => { + const tally = tallies.get(key) ?? { singular, plural: pluralForm, count: 0 } + tally.count += count + tallies.set(key, tally) + } + for (const tool of tools) { + switch (tool.name) { + case "read_file": { + const files = /across (\d+) files?$/.exec(tool.summary) + add("read", "read 1 file", "read # files", files ? Number(files[1]) : 1) + break + } + case "search_files": + case "codebase_search": + add("search", "searched for 1 pattern", "searched for # patterns") + break + case "list_files": + case "list_code_definition_names": + add("list", "listed 1 directory", "listed # directories") + break + case "Bash": + case "execute_command": + add("run", "ran 1 command", "ran # commands") + break + case "web_search": + add("web", "searched the web once", "searched the web # times") + break + case "web_fetch": + add("fetch", "fetched 1 page", "fetched # pages") + break + default: + add(tool.name, `used ${formatToolName(tool.name)} once`, `used ${formatToolName(tool.name)} # times`) + } + } + const heading = [...tallies.values()] + .map((tally) => (tally.count === 1 ? tally.singular : tally.plural.replace("#", String(tally.count)))) + .join(", ") + return heading.charAt(0).toUpperCase() + heading.slice(1) +} + const TOOL_DISPLAY_NAMES: Record = { update_todo_list: "Update Tasks", lsp: "LSP", @@ -96,6 +185,10 @@ interface DiffViewProps { maxWidth?: number } +function oneLine(text: string): string { + return text.replace(/\s*\n\s*/g, " ") +} + function truncateLine(text: string, maxWidth: number | undefined): string { if (!maxWidth || text.length <= maxWidth) return text if (maxWidth <= 1) return "…".slice(0, maxWidth) @@ -274,6 +367,30 @@ export const RowView = React.memo(function RowView({ row, width }: { row: Row; w )} ) + case "tool-group": { + // One line per entry so the height estimate stays exact; the ⎿ line + // swaps to the latest call while collapsed. + const lineWidth = Math.max(1, width - 4) + const latest = row.tools[row.tools.length - 1] + return ( + + + ● + {toolGroupHeading(row.tools)} + {!row.expanded && (ctrl+o to expand)} + + {row.expanded + ? row.tools.map((tool, i) => ( + + {truncateLine(` ⎿ ${formatToolName(tool.name)} ${oneLine(tool.summary)}`, lineWidth + 4)} + + )) + : latest && ( + {truncateLine(` ⎿ ${oneLine(latest.summary)}`, lineWidth + 4)} + )} + + ) + } case "info": return ( diff --git a/test/tool-group.test.tsx b/test/tool-group.test.tsx new file mode 100644 index 0000000..97d9640 --- /dev/null +++ b/test/tool-group.test.tsx @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import React, { act } from "react"; +import { testRender } from "@opentui/react/test-utils"; + +import { Box } from "../src/ui/primitives.js"; +import { + appendRow, + RowView, + toolGroupHeading, + type Row, +} from "../src/ui/components/rows.js"; +import { + formatElapsed, + formatTokenCount, + Spinner, +} from "../src/ui/components/Spinner.js"; + +let nextId = 0; +function tool(name: string, summary: string, extra: Partial> = {}): Row { + return { kind: "tool", id: `t${nextId++}`, name, summary, resultPreview: "out", isError: false, ...extra }; +} + +function build(rows: Row[]): Row[] { + return rows.reduce((acc, row) => appendRow(acc, row, false), []); +} + +test("consecutive read-only tools collapse into one group row", () => { + const rows = build([ + tool("read_file", "src/a.ts"), + tool("search_files", "/foo/ in src"), + tool("Bash", "git status --short"), + tool("read_file", "3 regions across 2 files"), + ]); + assert.equal(rows.length, 1); + const group = rows[0]!; + assert.equal(group.kind, "tool-group"); + assert.equal(group.id, "t0", "the group keeps its first entry's id so its React key is stable"); + if (group.kind !== "tool-group") return; + assert.equal(toolGroupHeading(group.tools), "Read 3 files, searched for 1 pattern, ran 1 command"); +}); + +test("edits, errors, mutating commands and assistant text end the group", () => { + const rows = build([ + tool("read_file", "src/a.ts"), + tool("file_edit", "src/a.ts", { diff: "@@ -1 +1 @@\n-a\n+b" }), + tool("read_file", "src/b.ts"), + tool("read_file", "src/c.ts", { isError: true }), + tool("Bash", "npm install"), + { kind: "assistant", id: "x", text: "done" }, + tool("list_files", "src"), + ]); + assert.deepEqual( + rows.map((row) => row.kind), + ["tool-group", "tool", "tool-group", "tool", "tool", "assistant", "tool-group"], + ); +}); + +test("a collapsed group shows only the latest call under its heading", async () => { + const [group] = build([tool("read_file", "src/a.ts"), tool("read_file", "src/b.ts")]); + const screen = await testRender( + + + , + { width: 80, height: 4 }, + ); + try { + await screen.renderOnce(); + const frame = screen.captureCharFrame(); + assert.match(frame, /● Read 2 files \(ctrl\+o to expand\)/); + assert.match(frame, /⎿ src\/b\.ts/); + assert.doesNotMatch(frame, /src\/a\.ts/); + } finally { + act(() => screen.renderer.destroy()); + } +}); + +test("the spinner shows turn-wide elapsed time and output tokens", async () => { + const screen = await testRender( + + + , + { width: 100, height: 2 }, + ); + try { + await screen.renderOnce(); + assert.match(screen.captureCharFrame(), /Reading… \(2m 13s · ↓ 4\.2k tokens · esc to interrupt\)/); + } finally { + act(() => screen.renderer.destroy()); + } +}); + +test("elapsed and token formats", () => { + assert.equal(formatElapsed(45_900), "45s"); + assert.equal(formatElapsed(3_840_000), "1h 4m"); + assert.equal(formatTokenCount(840), "840"); + assert.equal(formatTokenCount(1_250_000), "1.3M"); +}); diff --git a/test/ui-viewport.test.tsx b/test/ui-viewport.test.tsx index 1f7246a..566e0b7 100644 --- a/test/ui-viewport.test.tsx +++ b/test/ui-viewport.test.tsx @@ -56,7 +56,7 @@ test("delays slash-command tips on the thinking indicator", async () => { await screen.renderOnce(); const frame = screen.captureCharFrame(); - assert.match(frame, /Thinking \(0s · esc to interrupt\)/); + assert.match(frame, /Thinking… \(0s · esc to interrupt\)/); assert.doesNotMatch(frame, /└── TIP: /); assert.equal(TIP_DELAY_MS, 2_000); } finally { @@ -384,7 +384,7 @@ test("renders Working spinner below streaming response text while busy", async ( await screen.renderOnce(); const frame = screen.captureCharFrame(); assert.match(frame, /● Streaming response content\.\.\./); - assert.match(frame, /Working \(0s · esc to interrupt\)/); + assert.match(frame, /Working… \(0s · esc to interrupt\)/); } finally { act(() => screen.renderer.destroy()); } From 714c159a2de028b6bebd3ce44108607b793b29f6 Mon Sep 17 00:00:00 2001 From: "matterai-app[bot]" <150888575+matterai-app[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:36:02 +0530 Subject: [PATCH 3/4] release: v7.0.0 - Claude Code-style request layout: static system prompt, context in the first user message, the environment as its own system message, notes appended in place, reasoning replayed as reasoning_content - Prompt cache keeps growing in long sessions: history is append-only until auto-compaction (no more tool-result pruning) - Grouped tool rows that swap to the latest call; spinner with turn-wide elapsed time and live output tokens --- CHANGELOG.md | 17 +++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aba69a5..19c506f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [7.0.0] - 2026-10-09 + +### Added + +- **Grouped exploration tools.** Consecutive read-only calls (file reads, searches, listings, LSP, web search/fetch, read-only shell commands) collapse into one row such as `● Read 3 files, searched for 1 pattern, ran 1 command`, with a `⎿` line that swaps to the latest call as each finishes. `ctrl+o` expands groups alongside thinking. Edits, errors, mutating commands and assistant text still render as their own rows and start a new group. Resumed sessions are grouped the same way. Covered by `test/tool-group.test.tsx`. +- **Turn-wide spinner stats.** The spinner shows elapsed time for the whole turn (no longer resetting when the label changes) and output tokens counting up live, e.g. `Reading… (2m 13s · ↓ 4.2k tokens · esc to interrupt)`. The label follows the running tool (Reading, Searching, Running, Editing, Fetching). + +### Changed + +- **Request layout, matching Claude Code.** The system prompt is now static (role definition, tool guide, and a harness section explaining how context is delivered), so the prompt cache hits across sessions and projects. Per-session context moved into the conversation: the first user message opens with `` blocks for AGENTS.md instructions and a git snapshot (branch, main branch, git user, status, recent commits), and a separate system message right after it carries the environment (working directory, platform, shell, OS, model, linked repos, skills catalog, date). Each later user message and each round of tool results ends with a `N tokens left` note; these change every step, so they are appended to the message before them rather than sent as system messages, which a gateway would merge into the leading prompt. Direct providers through the AI SDK (Anthropic, custom OpenAI-compatible endpoints) get the environment folded into the first user turn, since they may reject a mid-conversation system message. The 200-file workspace listing is no longer attached; the model explores with the shell. After compaction, the reminders and environment are re-sent with the summary instead of being lost. +- **Reasoning is replayed.** Each assistant message keeps the reasoning the model streamed for it and sends it back unchanged as `reasoning_content` on every later request, as other OpenAI-compatible clients do. The model sees its earlier thinking and the message never changes, so the prompt cache keeps matching. Previously the gateway injected a stored copy into only the newest message. +- Interrupt, Stop-hook and file-restore notes to the model now use `` tags. + +### Fixed + +- **Prompt cache stopped growing in long sessions.** Once context passed 40% of the window (~93k tokens on GLM 5.3), OrbCode replaced old tool results in the request with short stubs. That rewrote the conversation from its first tool result onward, so the provider could only reuse the cached system prompt, tools and first message (~20k tokens) and billed the rest at the uncached rate. History is now append-only until auto-compaction at 80%, so each request extends the previous one and the cached prefix keeps growing. Covered by `test/agent-context.test.ts`, which checks that no request rewrites earlier history in a long session. + ## [6.9.8] - 2026-10-09 ### Added diff --git a/package-lock.json b/package-lock.json index b332798..62c35c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@matterailab/orbcode", - "version": "6.9.7", + "version": "7.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@matterailab/orbcode", - "version": "6.9.7", + "version": "7.0.0", "license": "MIT", "dependencies": { "@ai-sdk/anthropic": "^3.0.85", diff --git a/package.json b/package.json index 7185060..576c7b4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@matterailab/orbcode", - "version": "6.9.8", + "version": "7.0.0", "description": "OrbCode CLI — agentic coding in your terminal, by MatterAI", "type": "module", "bin": { From 465b5cdd869d8a19c31df7b5e7142f03a3288066 Mon Sep 17 00:00:00 2001 From: code-crusher Date: Fri, 9 Oct 2026 20:43:00 +0530 Subject: [PATCH 4/4] feat(ui): make thinking live-only and drop the static reasoning row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completed thinking no longer leaves a "✦ Thought for Ns" summary line in the transcript — the live stream already showed the reasoning while it streamed. The reasoning Row variant, its RowView case, formatDuration, and the ctrl+o thinking expansion are removed; ctrl+o now only expands grouped tool rows, and the shortcut hint reads "ctrl+o expand". --- CHANGELOG.md | 1 + src/core/agent.ts | 9 ++++--- src/ui/App.tsx | 46 ++++++++++++------------------------ src/ui/components/Header.tsx | 2 +- src/ui/components/rows.tsx | 22 ----------------- 5 files changed, 21 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19c506f..2074029 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Request layout, matching Claude Code.** The system prompt is now static (role definition, tool guide, and a harness section explaining how context is delivered), so the prompt cache hits across sessions and projects. Per-session context moved into the conversation: the first user message opens with `` blocks for AGENTS.md instructions and a git snapshot (branch, main branch, git user, status, recent commits), and a separate system message right after it carries the environment (working directory, platform, shell, OS, model, linked repos, skills catalog, date). Each later user message and each round of tool results ends with a `N tokens left` note; these change every step, so they are appended to the message before them rather than sent as system messages, which a gateway would merge into the leading prompt. Direct providers through the AI SDK (Anthropic, custom OpenAI-compatible endpoints) get the environment folded into the first user turn, since they may reject a mid-conversation system message. The 200-file workspace listing is no longer attached; the model explores with the shell. After compaction, the reminders and environment are re-sent with the summary instead of being lost. - **Reasoning is replayed.** Each assistant message keeps the reasoning the model streamed for it and sends it back unchanged as `reasoning_content` on every later request, as other OpenAI-compatible clients do. The model sees its earlier thinking and the message never changes, so the prompt cache keeps matching. Previously the gateway injected a stored copy into only the newest message. - Interrupt, Stop-hook and file-restore notes to the model now use `` tags. +- **Thinking is live-only.** Reasoning is shown while it streams and leaves nothing behind: the static `✦ Thought for 43s (ctrl+o to show thinking)` summary row is gone, and `ctrl+o` no longer expands past thinking — completed thinking never renders, in this session or a resumed one. `ctrl+o` now only expands/collapses grouped tool rows. ### Fixed diff --git a/src/core/agent.ts b/src/core/agent.ts index 568ad5c..e869c15 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -1618,15 +1618,14 @@ export class Agent { let assistantText = "" // A reasoning segment is "open" from its first delta until visible content // (text or a tool call) begins. We emit reasoning-done at that transition so - // "Thought for Ns" reflects only the thinking time — not the answer that - // follows — and the live "Thinking" block stops before the answer streams. - // A fresh segment can re-open if the model interleaves reasoning with content. + // the live "Thinking" block stops before the answer streams. A fresh + // segment can re-open if the model interleaves reasoning with content. let reasoningOpen = false let reasoningStart = 0 let reasoningText = "" let reasoningDetails: unknown - // Once a reasoning-done row is committed to the transcript we can't roll it - // back, so a mid-stream retry after that point isn't clean. + // Once reasoning-done has been emitted the thinking segment is closed out + // and can't be rolled back, so a mid-stream retry after that point isn't clean. let reasoningRowCommitted = false const finalizeReasoning = () => { if (reasoningOpen) { diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 2fc555c..0367bef 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -616,7 +616,8 @@ export function App({ }, []); const agentRef = useRef(null); - const expandReasoningRef = useRef(false); + // ctrl+o expansion state for grouped tool rows. + const expandToolsRef = useRef(false); // Turn-wide spinner stats: elapsed time and output tokens. Tokens are the // provider-reported count so far plus ~4 chars/token for the live stream. const [turnStartedAt, setTurnStartedAt] = useState(() => Date.now()); @@ -785,7 +786,7 @@ export function App({ const pushRow = useCallback((row: DistributiveOmit) => { setRows((prev) => - appendRow(prev, { ...row, id: rowId() } as Row, expandReasoningRef.current), + appendRow(prev, { ...row, id: rowId() } as Row, expandToolsRef.current), ); }, []); @@ -815,12 +816,8 @@ export function App({ setBusyLabel("Thinking"); break; case "reasoning-done": - pushRow({ - kind: "reasoning", - text: reasoningBufferRef.current, - durationMs: event.durationMs, - expanded: expandReasoningRef.current, - }); + // The live "Thinking" block ends here; completed thinking is never + // rendered, so there is no row to commit. reasoningBufferRef.current = ""; setStreamingReasoning(""); setBusyLabel("Working"); @@ -1141,14 +1138,9 @@ export function App({ // Replay the exact display transcript when available. Agent also builds // a best-effort tool/result history for sessions from the legacy schema. for (const entry of resumedAgent.displayTranscript) { - if (entry.kind === "reasoning") { - pushRow({ - ...entry, - expanded: expandReasoningRef.current, - }); - } else { - pushRow(entry); - } + // Thinking is shown live only; replayed reasoning entries render nothing. + if (entry.kind === "reasoning") continue; + pushRow(entry); } pushRow({ kind: "info", @@ -1291,11 +1283,8 @@ export function App({ setTasks(result.todos); setContextTokens(agent.lastContextTokens); for (const entry of agent.displayTranscript) { - if (entry.kind === "reasoning") { - pushRow({ ...entry, expanded: expandReasoningRef.current }); - } else { - pushRow(entry); - } + if (entry.kind === "reasoning") continue; + pushRow(entry); } setPromptPrefill({ id: Date.now(), text: result.text }); } @@ -2250,15 +2239,12 @@ export function App({ }); } if (key.ctrl && input === "o") { - const expanded = !expandReasoningRef.current; - expandReasoningRef.current = expanded; - // Re-render the whole transcript (including past thinking) with the - // new expansion state. + const expanded = !expandToolsRef.current; + expandToolsRef.current = expanded; + // Re-render the transcript's grouped tool rows with the new state. setRows((prev) => prev.map((row) => - row.kind === "reasoning" || row.kind === "tool-group" - ? { ...row, expanded } - : row, + row.kind === "tool-group" ? { ...row, expanded } : row, ), ); // The terminal adapter replaces the retained screen rows in place. @@ -3087,7 +3073,7 @@ function estimateRowLines(row: Row, width: number): number { wrappedAt("/help all commands", secondCellWidth), ); const shortcuts = wrappedAt( - "shift+tab approvals · ctrl+o thinking · esc interrupt · ctrl+d/c exit", + "shift+tab approvals · ctrl+o expand · esc interrupt · ctrl+d/c exit", panelWidth, ); // Action/footer top margins plus Header's bottom margin add three rows. @@ -3100,8 +3086,6 @@ function estimateRowLines(row: Row, width: number): number { case "assistant": // Blank messages render nothing (see rows.tsx). return row.text.trim() ? 1 + wrapped(`● ${row.text}`) : 0; - case "reasoning": - return row.expanded ? 2 + wrapped(row.text, w - 2) : 2; case "tool": { const heading = `${formatToolName(row.name)} ${row.summary}`; let h = 1 + wrapped(heading); diff --git a/src/ui/components/Header.tsx b/src/ui/components/Header.tsx index 3bd8967..dddd721 100644 --- a/src/ui/components/Header.tsx +++ b/src/ui/components/Header.tsx @@ -84,7 +84,7 @@ export function Header({ cwd, modelName }: { cwd: string; modelName: string }) { - shift+tab approvals · ctrl+o thinking · esc interrupt · ctrl+d/c exit + shift+tab approvals · ctrl+o expand · esc interrupt · ctrl+d/c exit diff --git a/src/ui/components/rows.tsx b/src/ui/components/rows.tsx index 63eaebf..34f0256 100644 --- a/src/ui/components/rows.tsx +++ b/src/ui/components/rows.tsx @@ -11,7 +11,6 @@ export type Row = | { kind: "header"; id: string; cwd: string; modelName: string } | { kind: "user"; id: string; text: string; attachments?: AttachmentSummary[] } | { kind: "assistant"; id: string; text: string } - | { kind: "reasoning"; id: string; text: string; durationMs: number; expanded: boolean } | { kind: "tool" id: string @@ -263,11 +262,6 @@ export function DiffView({ diff, maxLines = MAX_DIFF_LINES, maxWidth }: DiffView ) } -export function formatDuration(durationMs: number): string { - const seconds = durationMs / 1000 - return seconds >= 10 ? `${Math.round(seconds)}s` : `${seconds.toFixed(1)}s` -} - /** Build a padded user block exactly as wide as the transcript. */ export function formatUserBlock(text: string, width: number, attachments: AttachmentSummary[] = []): string { const lineWidth = Math.max(1, width) @@ -328,22 +322,6 @@ export const RowView = React.memo(function RowView({ row, width }: { row: Row; w ) - case "reasoning": - return ( - - - ✦ Thought for {formatDuration(row.durationMs)} - {!row.expanded && (ctrl+o to show thinking)} - - {row.expanded && ( - - - {row.text.trim()} - - - )} - - ) case "tool": return (