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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,24 @@ 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 `<system-reminder>` 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 `<total_tokens>N tokens left</total_tokens>` 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 `<system-reminder>` 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

- **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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down
14 changes: 9 additions & 5 deletions src/api/aiSdkClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions src/api/llmClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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<T extends OpenAI.Chat.ChatCompletionUserMessageParam | OpenAI.Chat.ChatCompletionToolMessageParam>(
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
}
Loading
Loading