From d64cff5857498ee55968828b56e6aa368c280dc1 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Wed, 21 Oct 2026 02:37:31 +0200 Subject: [PATCH 1/3] mason: preserve turn-scoped housekeeping reply notes Add native turnEndNote.tools policy and a shared deterministic history transform for OAuth OpenCode 1, OpenCode 2 and Pi. Require at least 350 outgoing visible text characters and all-housekeeping tool calls. Replay every earlier qualifying note; omit only a note directly before another user message to avoid Anthropic's invalid placement. Cover per-host N/N+1/N+2 serialized history prefixes, settings projection, OAuth/API exclusions, tool-name mapping, length boundaries, cache lookback, effort controls and signing. Add three mutation controls: exact dispatch catches all three; direct marked mutations each fail only their named test (nine companions green). Broad replay found unrelated custody flakes on the latest-point control; all-tools and length controls had no collateral. Verified pinned Bun 1.3.14 remote typecheck, build, focused Core/OpenCode/Pi tests, format/lint and Pi host mapping. Full unit gates encountered the offline packed OpenCode registry refusal and unrelated Pi native-relay timeouts; no unrelated tests or deadlines were changed. --- docs/configuration.md | 9 + mutations.toml | 48 ++++ packages/core/src/accounts.ts | 2 + packages/core/src/claude-code.ts | 13 +- packages/core/src/index.ts | 1 + packages/core/src/native-account-view.ts | 1 + packages/core/src/native-migration-source.ts | 7 + packages/core/src/native-vault-seed.ts | 1 + packages/core/src/tests/turn-end-note.test.ts | 209 +++++++++++++++ packages/core/src/turn-end-note.ts | 129 +++++++++ packages/opencode/CHANGELOG.md | 1 + packages/opencode/src/index.ts | 2 + packages/opencode/src/tests/index.test.ts | 83 ++++++ .../src/tests/opencode2-adapter.test.ts | 122 +++++++++ .../opencode/src/tests/turn-end-note.test.ts | 245 ++++++++++++++++++ packages/opencode/src/transform.ts | 9 + packages/opencode/src/v2/wire.ts | 2 + packages/pi/src/convert.ts | 27 +- packages/pi/src/stream.ts | 1 + packages/pi/src/tests/stream.test.ts | 71 +++++ packages/pi/src/tests/turn-end-note.test.ts | 200 ++++++++++++++ 21 files changed, 1179 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/tests/turn-end-note.test.ts create mode 100644 packages/core/src/turn-end-note.ts create mode 100644 packages/opencode/src/tests/turn-end-note.test.ts create mode 100644 packages/pi/src/tests/turn-end-note.test.ts diff --git a/docs/configuration.md b/docs/configuration.md index e11dc799e..694b3b766 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -20,11 +20,20 @@ The imported preference fields are listed below. Values such as quota thresholds | `dump`, `logging` | `enabled` request dumps; `level`: `error`, `warn`, `info`, `debug`, `trace` | | `claudeFast` | `enabled`: supported Opus fast mode | | `thinkingBinding` | `prefixMismatchBehavior`: `account-default`, `error`, `drop_block` | +| `turnEndNote` | `tools`: host tool names for the reply-then-housekeeping note; default `["board", "ctx_reduce"]`, `[]` disables it | | `costZeroing` | `enabled`: OpenCode OAuth price display, true by default | | `relay` | `enabled`, `url`, `transport` (`http` / `websocket`; when missing, inferred from the URL: `wss://` uses `websocket`, `https://` uses `http`), `fallbackToDirect`; its token is private credential state | [Cache](cache.md), [models](models.md) and [transports](transports.md) describe the effects. `quota.showToasts: true` opts into OpenCode quota notifications. Local credential state and per-account runtime metadata are separate from preferences; quota, profile, validation and Prime state are not all in the same file. +### Reply-then-housekeeping note + +On OAuth routes for Fable 5.1, Opus 5.5, Sonnet 5.5 and Haiku 5.5, the plugin adds a turn-scoped system note after the results of an assistant's housekeeping calls when its visible reply is at least 350 characters and **all** its calls are in `turnEndNote.tools`. It asks Claude not to repeat the reply it already delivered. The 350-character minimum is a safety margin for short mid-work steps, not a configurable setting. Only non-whitespace text blocks count, joined without extra separators and measured as sent; thinking does not count. API-key routes, including API-key proxies, are unchanged. + +Use the tool names the host calls, such as `board`, not the OpenCode wire spelling `mcp_Board`. For a custom list, set `"turnEndNote": { "tools": ["board", "my_cleanup"] }` in the live native settings; there is no menu control. An unset list uses `board` and `ctx_reduce`; an empty list turns the note off. Changing the list can change earlier notes, so keep it stable during a conversation to preserve prompt caching. + +The plugin reconstructs every earlier qualifying note with identical text and placement on every request, independently of the current tool declarations. It does not add a cache breakpoint or effort configuration to the note. There is one exception: Anthropic rejects a note immediately followed by a user message, so the plugin omits just that note if the host appends a user message without an intervening assistant message. Bytes change at that point only; earlier notes are not moved or rewritten. + ## Fallback accounts and session affinity Fallbacks are separate OAuth accounts or Anthropic-compatible API-key routes in the active custody source. By default the main account is tried first, then eligible OAuth fallbacks in order. `fallback-first` reverses that preference. `sticky-balanced` assigns a new session using remaining quota above configured minimums in five-hour, seven-day and model-scoped windows. diff --git a/mutations.toml b/mutations.toml index 69100b890..ffe06260a 100644 --- a/mutations.toml +++ b/mutations.toml @@ -7,6 +7,54 @@ prebuild = [ { name = "opencode-current-source", command = ["bun", "run", "--cwd", "packages/opencode", "build"] }, ] +[[control]] +id = "turn-end-note-replays-earlier-points" +guards = "Every qualifying history point must retain its turn-scoped note, not just the latest result." +file = "packages/core/src/turn-end-note.ts" +old = " if (visible.length < TURN_END_NOTE_MIN_VISIBLE_CHARACTERS) continue" +new = " if (index !== messages.length - 1) continue // NON-VACUITY BREAK: inject only at the latest point\n if (visible.length < TURN_END_NOTE_MIN_VISIBLE_CHARACTERS) continue" +test_file = "packages/core/src/tests/turn-end-note.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/core/src/tests/turn-end-note.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["turn-end note history > replays every earlier note with an identical serialized prefix"] +expect_message = "error: expect(received).toBe(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"] +broad_report = "tmp/mutations/core.xml" +broad_id = "{name}" + +[[control]] +id = "turn-end-note-requires-all-tools" +guards = "A working tool mixed with housekeeping must prevent the turn-end note." +file = "packages/core/src/turn-end-note.ts" +old = " if (!calls.length || !calls.every((call) => allowed.has(call.name)))\n continue" +new = " if (!calls.length) continue // NON-VACUITY BREAK: drop the all-tools check" +test_file = "packages/core/src/tests/turn-end-note.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/core/src/tests/turn-end-note.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["turn-end note history > requires all tool calls to be housekeeping"] +expect_message = "error: expect(received).toBe(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"] +broad_report = "tmp/mutations/core.xml" +broad_id = "{name}" + +[[control]] +id = "turn-end-note-requires-long-reply" +guards = "Short mid-work text must not receive a reply-complete note; the safety margin is 350 visible characters." +file = "packages/core/src/turn-end-note.ts" +old = " if (visible.length < TURN_END_NOTE_MIN_VISIBLE_CHARACTERS) continue" +new = " if (visible.length === 0) continue // NON-VACUITY BREAK: drop the 350-character check" +test_file = "packages/core/src/tests/turn-end-note.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/core/src/tests/turn-end-note.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["turn-end note history > requires at least 350 exact outgoing visible characters"] +expect_message = "error: expect(received).toBe(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"] +broad_report = "tmp/mutations/core.xml" +broad_id = "{name}" + [[control]] id = "gzip-resets-cch-before-compression" guards = "The compressed body must carry cch=00000 rather than the identity body's signature." diff --git a/packages/core/src/accounts.ts b/packages/core/src/accounts.ts index 8ffd4a1cc..38058788c 100644 --- a/packages/core/src/accounts.ts +++ b/packages/core/src/accounts.ts @@ -322,6 +322,8 @@ export type AccountStorage = { thinkingBinding?: { prefixMismatchBehavior?: 'account-default' | 'error' | 'drop_block' } + /** Host tool names for reply-then-housekeeping notes; absent uses board/ctx_reduce, [] disables. */ + turnEndNote?: { tools: string[] } /** * Zero out Anthropic OAuth model costs in the provider hook. Default: enabled * (OAuth usage is quota-based, not per-token billed, so costs show as $0). diff --git a/packages/core/src/claude-code.ts b/packages/core/src/claude-code.ts index fa98901dc..3360d3b95 100644 --- a/packages/core/src/claude-code.ts +++ b/packages/core/src/claude-code.ts @@ -13,6 +13,7 @@ import { sendsPerTurnControlBeta, } from './mid-conversation-output-config.ts' import { isClaudeHaiku55Model } from './models.ts' +import { TURN_END_NOTE_BETA } from './turn-end-note.ts' export type ProviderAccountUuid = string & { readonly __providerAccountUuid: unique symbol @@ -397,7 +398,7 @@ export const CLAUDE_CODE_FULL_AGENT_BETAS = [ 'prompt-caching-scope-2026-01-05', 'mid-conversation-tool-changes-2026-07-01', 'advanced-tool-use-2025-11-20', - 'mid-conversation-system-clear-at-2026-08-21', + TURN_END_NOTE_BETA, 'effort-2025-11-24', 'thinking-binding-controls-2026-08-01', 'thinking-display-updates-2026-08-18', @@ -460,6 +461,16 @@ export function selectClaudeCodeBetas( ? [...CLAUDE_CODE_QUOTA_BETAS] : [...CLAUDE_CODE_TITLE_BETAS] + if ( + Array.isArray(body?.messages) && + body.messages.some( + (message) => + isRecord(message) && message.clear_at === 'next_user_message', + ) && + !selected.includes(TURN_END_NOTE_BETA) + ) + selected.push(TURN_END_NOTE_BETA) + // Claude Code 2.1.302 puts claude-code-20250219 after the prompt-caching // scope beta on Haiku 5.5 requests instead of first. if (body && isClaudeHaiku55Model(body.model)) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 668391580..6da9c059e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -311,3 +311,4 @@ export { export * from './start.ts' export * from './sticky-routing.ts' export * from './thinking-binding.ts' +export * from './turn-end-note.ts' diff --git a/packages/core/src/native-account-view.ts b/packages/core/src/native-account-view.ts index 7fdc7b7dd..038c17930 100644 --- a/packages/core/src/native-account-view.ts +++ b/packages/core/src/native-account-view.ts @@ -419,6 +419,7 @@ const settingFields: Readonly> = { logging: ['level'], claudeFast: ['enabled'], thinkingBinding: ['prefixMismatchBehavior'], + turnEndNote: ['tools'], costZeroing: ['enabled'], cacheKeep: ['enabled', 'always', 'startHour', 'endHour', 'subagents'], prime: ['enabled'], diff --git a/packages/core/src/native-migration-source.ts b/packages/core/src/native-migration-source.ts index 66da24f96..e33a908ab 100644 --- a/packages/core/src/native-migration-source.ts +++ b/packages/core/src/native-migration-source.ts @@ -123,6 +123,7 @@ const settingFields: Record = { logging: ['level'], claudeFast: ['enabled'], thinkingBinding: ['prefixMismatchBehavior'], + turnEndNote: ['tools'], costZeroing: ['enabled'], cacheKeep: ['enabled', 'always', 'startHour', 'endHour', 'subagents'], prime: ['enabled'], @@ -176,6 +177,12 @@ export function validateNativeMigrationSettings( ].includes(key) ) { if (typeof value[key] !== 'boolean') refuse() + } else if (section === 'turnEndNote' && key === 'tools') { + if ( + !Array.isArray(value[key]) || + !value[key].every((name) => typeof name === 'string') + ) + refuse() } else if (key === 'url') { if (typeof value[key] !== 'string' || !isValidApiBaseURL(value[key])) refuse() diff --git a/packages/core/src/native-vault-seed.ts b/packages/core/src/native-vault-seed.ts index 3dcf7c3ef..3a693bcbe 100644 --- a/packages/core/src/native-vault-seed.ts +++ b/packages/core/src/native-vault-seed.ts @@ -48,6 +48,7 @@ const preferenceFields: Record = { // first settings write may not yet know which vault accounts have those UUIDs. killswitch: ['enabled', 'main'], claudeFast: ['enabled'], + turnEndNote: ['tools'], prime: ['enabled'], logging: ['level'], dump: ['enabled'], diff --git a/packages/core/src/tests/turn-end-note.test.ts b/packages/core/src/tests/turn-end-note.test.ts new file mode 100644 index 000000000..8a8830ba6 --- /dev/null +++ b/packages/core/src/tests/turn-end-note.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, test } from 'bun:test' +import { selectClaudeCodeBetas } from '../claude-code.ts' +import { nativeAccountSettings } from '../native-account-view.ts' +import { + projectNativeMigrationSource, + validateNativeMigrationSettings, +} from '../native-migration-source.ts' +import { applyTurnEndNotes, supportsTurnEndNote } from '../turn-end-note.ts' + +const note = { + role: 'system', + clear_at: 'next_user_message', + content: + 'Your reply above has already reached the user. The tool call after it was housekeeping and changed nothing they need to know. Do not repeat, summarize or rewrite that reply. If no new work remains, end the turn without restating anything.', +} +function pair(id: string, names = ['board'], text = 'A'.repeat(350)) { + return [ + { + role: 'assistant', + content: [ + { type: 'text', text }, + ...names.map((name, i) => ({ + type: 'tool_use', + id: `${id}_${i}`, + name, + input: {}, + })), + ], + }, + { + role: 'user', + content: names.map((_, i) => ({ + type: 'tool_result', + tool_use_id: `${id}_${i}`, + content: 'Done', + })), + }, + ] +} +const request = (messages: unknown[]) => ({ + model: 'claude-opus-5-5', + messages, +}) + +describe('turn-end note history', () => { + test('replays every earlier note with an identical serialized prefix', () => { + const history = [{ role: 'user', content: 'Question' }, ...pair('a')] + const first = request(structuredClone(history)) + expect(applyTurnEndNotes(first)).toBe(1) + history.push( + { role: 'assistant', content: [{ type: 'text', text: 'Done' }] }, + { role: 'user', content: 'Next' }, + ...pair('b', ['ctx_reduce']), + ) + const second = request(structuredClone(history)) + expect(applyTurnEndNotes(second)).toBe(2) + expect( + JSON.stringify(second.messages.slice(0, first.messages.length)), + ).toBe(JSON.stringify(first.messages)) + expect(second.messages.at(-1)).toEqual(note) + }) + + test('requires at least 350 exact outgoing visible characters', () => { + expect( + applyTurnEndNotes(request(pair('a', ['board'], 'a'.repeat(349)))), + ).toBe(0) + const body = request(pair('a', ['board'], 'a'.repeat(350))) + expect(applyTurnEndNotes(body)).toBe(1) + expect(body.messages.at(-1)).toEqual(note) + const split = pair('b') + split[0]!.content = [ + { type: 'text', text: 'x'.repeat(175) }, + { type: 'text', text: ' \n' }, + { type: 'text', text: 'y'.repeat(174) }, + { type: 'tool_use', id: 'b_0', name: 'board', input: {} }, + ] as any + expect(applyTurnEndNotes(request(split))).toBe(0) + ;(split[0]!.content[2] as any).text += 'y' + expect(applyTurnEndNotes(request(split))).toBe(1) + }) + + test('requires all tool calls to be housekeeping', () => { + const body = request(pair('a', ['board', 'bash'])) + expect(applyTurnEndNotes(body)).toBe(0) + expect(body.messages).not.toContainEqual(note) + }) + + test('requires actual visible text and matching results, not just thinking or calls', () => { + for (const text of ['', ' \t\n']) + expect(applyTurnEndNotes(request(pair('a', ['board'], text)))).toBe(0) + const thinking = pair('a') + thinking[0]!.content = [ + { type: 'thinking', thinking: 'private' }, + { type: 'tool_use', id: 'a_0', name: 'board', input: {} }, + ] as any + expect(applyTurnEndNotes(request(thinking))).toBe(0) + expect(applyTurnEndNotes(request([pair('a')[0], pair('b')[1]]))).toBe(0) + expect( + applyTurnEndNotes( + request([ + { role: 'assistant', content: [{ type: 'text', text: 'Answer' }] }, + { role: 'user', content: 'Next' }, + ]), + ), + ).toBe(0) + }) + + test('supports only verified families and each injected note has its main beta', () => { + for (const model of [ + 'claude-opus-5-5', + 'claude-sonnet-5-5-20261001[1m]', + 'claude-fable-5-1', + 'claude-haiku-5-5', + ]) { + const body = { ...request(pair('a')), model } + expect(supportsTurnEndNote(model)).toBe(true) + expect(applyTurnEndNotes(body)).toBe(1) + expect(selectClaudeCodeBetas(body).split(',')).toContain( + 'mid-conversation-system-clear-at-2026-08-21', + ) + } + for (const model of [ + 'claude-opus-5', + 'claude-sonnet-4-5', + 'claude-mythos-5-1', + 'unknown', + undefined, + ]) { + expect(supportsTurnEndNote(model)).toBe(false) + expect(applyTurnEndNotes({ ...request(pair('a')), model })).toBe(0) + } + }) + + test('keeps the note byte shape free of cache and effort fields', () => { + const body = request(pair('a')) + applyTurnEndNotes(body) + expect(body.messages.at(-1)).toEqual(note) + expect( + selectClaudeCodeBetas( + { ...body, max_tokens: 1, stream: false }, + [], + 'auxiliary', + ).split(','), + ).toContain('mid-conversation-system-clear-at-2026-08-21') + }) + + test('empty configuration disables; stateless host-name mapping allows custom tools', () => { + expect(applyTurnEndNotes(request(pair('a')), { tools: [] })).toBe(0) + const body = request(pair('a', ['Cleanup'])) + expect( + applyTurnEndNotes(body, { + tools: ['cleanup'], + mapToolName: (name) => name[0]!.toUpperCase() + name.slice(1), + }), + ).toBe(1) + expect(body.messages.at(-1)).toEqual(note) + }) + + test('the next-user exception removes only the offending note', () => { + const history = [{ role: 'user', content: 'Question' }, ...pair('a')] + const first = request(structuredClone(history)) + applyTurnEndNotes(first) + const second = request([ + ...history, + { role: 'user', content: 'Next' }, + ...pair('b'), + ]) + applyTurnEndNotes(second) + expect(second.messages.slice(0, 3)).toEqual(first.messages.slice(0, 3)) + expect(second.messages[3]).toEqual({ role: 'user', content: 'Next' }) + expect(second.messages.at(-1)).toEqual(note) + expect( + second.messages.filter((message: any) => message.clear_at), + ).toHaveLength(1) + }) + + test('recognizes the assistant across empty effort controls without altering them', () => { + const [assistant, results] = pair('a') + const effort = { + role: 'system', + content: [], + output_config: { effort: 'low' }, + } + const body = request([assistant, effort, results]) + expect(applyTurnEndNotes(body)).toBe(1) + expect(body.messages).toEqual([assistant, effort, results, note]) + }) +}) + +test('turnEndNote settings survive secret-free native projection and migration, including empty arrays', () => { + for (const tools of [[], ['board', 'custom']]) { + const settings = { turnEndNote: { tools, secret: 'not-public' } } + expect(nativeAccountSettings(settings)).toEqual({ turnEndNote: { tools } }) + expect(() => validateNativeMigrationSettings(settings)).not.toThrow() + const migrated = projectNativeMigrationSource({ + host: 'opencode', + storageId: 'a'.repeat(64), + configDigest: null, + config: settings, + state: {}, + hostAuth: {}, + }) + expect(migrated.settings.turnEndNote).toEqual({ tools }) + } + for (const tools of ['board', [1], null]) + expect(() => + validateNativeMigrationSettings({ turnEndNote: { tools } }), + ).toThrow() +}) diff --git a/packages/core/src/turn-end-note.ts b/packages/core/src/turn-end-note.ts new file mode 100644 index 000000000..9a4e5c1c2 --- /dev/null +++ b/packages/core/src/turn-end-note.ts @@ -0,0 +1,129 @@ +import { + isClaudeFable51Model, + isClaudeHaiku55Model, + isClaudeOpus55Model, + isClaudeSonnet55Model, +} from './models.ts' + +export const TURN_END_NOTE_BETA = 'mid-conversation-system-clear-at-2026-08-21' +export const TURN_END_NOTE_MIN_VISIBLE_CHARACTERS = 350 +export const TURN_END_NOTE_CONTENT = + 'Your reply above has already reached the user. The tool call after it was housekeeping and changed nothing they need to know. Do not repeat, summarize or rewrite that reply. If no new work remains, end the turn without restating anything.' + +const DEFAULT_TOOLS = ['board', 'ctx_reduce'] as const + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +/** Families enabled for turn-end notes under Claude Code's clear-at beta. */ +export function supportsTurnEndNote(model: unknown): boolean { + return ( + isClaudeFable51Model(model) || + isClaudeHaiku55Model(model) || + isClaudeOpus55Model(model) || + isClaudeSonnet55Model(model) + ) +} + +/** + * Call only on OAuth histories, after effort-marker validation and stripping. + * Map configured host names with the same stateless mapping used for history, + * never with the currently declared tool set: declarations can change later. + */ +export function applyTurnEndNotes( + body: Record, + options: { + tools?: readonly string[] + mapToolName?: (name: string) => string + visibleText?: (message: Record) => string + } = {}, +): number { + if (!supportsTurnEndNote(body.model) || !Array.isArray(body.messages)) + return 0 + const allowed = new Set( + (options.tools ?? DEFAULT_TOOLS).map( + (name) => options.mapToolName?.(name) ?? name, + ), + ) + if (!allowed.size) return 0 + + const messages: unknown[] = body.messages + const rewritten: unknown[] = [] + let inserted = 0 + for (let index = 0; index < messages.length; index++) { + const results = messages[index] + rewritten.push(results) + let preceding = index - 1 + // Effort controls may sit between a tool call and its results. They have no + // visible content and must not hide the assistant/result pair. + while (preceding >= 0) { + const control = messages[preceding] + if ( + !isRecord(control) || + control.role !== 'system' || + !Array.isArray(control.content) || + control.content.length !== 0 || + !isRecord(control.output_config) + ) + break + preceding-- + } + const assistant = messages[preceding] + if ( + !isRecord(assistant) || + assistant.role !== 'assistant' || + !Array.isArray(assistant.content) || + !isRecord(results) || + results.role !== 'user' || + !Array.isArray(results.content) + ) + continue + + const visible = options.visibleText + ? options.visibleText(assistant) + : assistant.content + .filter( + (block: unknown) => + isRecord(block) && + block.type === 'text' && + typeof block.text === 'string' && + block.text.trim().length > 0, + ) + .map((block) => block.text) + .join('') + if (visible.length < TURN_END_NOTE_MIN_VISIBLE_CHARACTERS) continue + const calls = assistant.content.filter( + (block: unknown) => isRecord(block) && block.type === 'tool_use', + ) + if (!calls.length || !calls.every((call) => allowed.has(call.name))) + continue + const resultIds = new Set( + results.content + .filter( + (block: unknown) => isRecord(block) && block.type === 'tool_result', + ) + .map((block) => block.tool_use_id), + ) + if ( + !calls.every( + (call) => typeof call.id === 'string' && resultIds.has(call.id), + ) + ) + continue + + // Anthropic rejects a turn-scoped note directly before a user message. This + // is the only suffix-dependent exception: omit just this note, leaving every + // other history position unchanged. Never move or rewrite an earlier note. + const next = messages[index + 1] + if (isRecord(next) && next.role === 'user') continue + rewritten.push({ + role: 'system', + clear_at: 'next_user_message', + content: TURN_END_NOTE_CONTENT, + }) + inserted++ + } + if (inserted) body.messages = rewritten + return inserted +} diff --git a/packages/opencode/CHANGELOG.md b/packages/opencode/CHANGELOG.md index 07f07406d..c12f99425 100644 --- a/packages/opencode/CHANGELOG.md +++ b/packages/opencode/CHANGELOG.md @@ -10,6 +10,7 @@ This package is a CortexKit-maintained fork of the original `@ex-machina/opencod ### Patch Changes +- Prevent repeated long replies after housekeeping calls on supported OAuth models in OpenCode 1, OpenCode 2 and Pi. Add a turn-scoped reminder after tool results when the reply has at least 350 visible characters and every call is in `turnEndNote.tools` (default `board` and `ctx_reduce`; an empty list disables it). Replay earlier reminders unchanged for prompt caching, except when a reminder would sit directly before a user message; Anthropic rejects that placement. API-key routes are unchanged. - Back off client refusal recovery through Opus 4.8 for 10, 20, then 40 completed responses, repeating at 40 and always retrying the source. Ten consecutive successful source responses reset the next round to 10; tool-use responses count. Model changes and session deletion clear state, as does an OpenCode restart, so a restarted session may try the original model once more. Server-first and legacy modes share this in-memory policy. Remove the mismatched immediate source-model warms and waits, keeping ordinary active-model CacheKeep, account binding, and the Opus standby cache anchor. - Stop silently dropping a trailing assistant message that has content. When OpenCode's history briefly ends on the previous answer, the plugin used to delete that answer and resend the earlier question, repeating completed work. Only provably empty trailing assistant messages are still removed; any other trailing assistant content now fails locally with a non-retryable `invalid_request_error` 400 on both OAuth and API-key routes, before anything is sent to the model. diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 9f3ad2a1b..4d7f86a02 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -7128,6 +7128,8 @@ const anthropicAuthPlugin = async ( await getRequestStorage(), ), midConversationEffortEnabled: true, + turnEndNoteEnabled: true, + turnEndNote: (await getRequestStorage())?.turnEndNote, midConversationEffortPlan: effortPlanHeader, midConversationEffortResolvedPlan: resolvedEffortPlan, hybridStandbyAnchor: standbyCacheAnchor, diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 6fe231228..8459326a9 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -9373,6 +9373,89 @@ describe('auth.loader', () => { }) }) + test('native turnEndNote settings affect OAuth only, not the API-key proxy fallback', async () => { + await useTempAccountFile( + createFallbackStorage({ + quota: { + enabled: false, + minimumRemaining: { five_hour: 10 }, + failClosedOnUnknownQuota: true, + }, + accounts: [ + { + id: 'paid', + type: 'api', + apiKey: 'synthetic-paid', + baseURL: 'https://proxy.invalid', + authHeader: 'authorization-bearer', + }, + ], + }), + ) + await updatePoolSettings((settings) => ({ + ...settings, + turnEndNote: { tools: ['my_cleanup'] }, + })) + const bodies: any[] = [] + globalThis.fetch = mock( + withNativeAdmission((input: any, init: any) => { + if (extractUrl(input).includes('/api/oauth/usage')) + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 100 }, + seven_day: { utilization: 50 }, + }), + ), + ) + bodies.push(JSON.parse(String(init?.body))) + return Promise.resolve( + new Response(null, { status: bodies.length === 1 ? 429 : 200 }), + ) + }), + ) as unknown as typeof fetch + const plugin = await getPlugin() + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'sk-ant-oat01-main-access', + refresh: 'main-refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) + await result.fetch(MESSAGES_URL, { + method: 'POST', + body: JSON.stringify({ + model: 'claude-opus-5-5', + messages: [ + { role: 'user', content: 'Question' }, + { + role: 'assistant', + content: [ + { type: 'text', text: 'A'.repeat(350) }, + { type: 'tool_use', name: 'my_cleanup', id: 'a', input: {} }, + ], + }, + { + role: 'user', + content: [ + { type: 'tool_result', tool_use_id: 'a', content: 'Done' }, + ], + }, + ], + }), + }) + expect(bodies).toHaveLength(2) + expect(bodies[0].messages.at(-1)).toMatchObject({ + role: 'system', + clear_at: 'next_user_message', + }) + expect(bodies[1].messages).toHaveLength(3) + expect(bodies[1].messages.at(-1).role).toBe('user') + }) + test('routes to API-key fallback after main OAuth returns 429 and strips server fallback fields from the custom provider request', async () => { delete process.env.OPENCODE_ANTHROPIC_AUTH_FALLBACK_MODE await useTempAccountFile( diff --git a/packages/opencode/src/tests/opencode2-adapter.test.ts b/packages/opencode/src/tests/opencode2-adapter.test.ts index 44f718850..688ad5308 100644 --- a/packages/opencode/src/tests/opencode2-adapter.test.ts +++ b/packages/opencode/src/tests/opencode2-adapter.test.ts @@ -51,6 +51,7 @@ import { type NativeAnthropicSource, NO_ACCOUNT_REFUSAL, } from '../v2/adapter' +import { createNativeWirePolicy } from '../v2/wire' // A synthetic Core source: every account, credential and policy answer comes // from here, and every call the adapter makes into it is recorded. @@ -429,6 +430,127 @@ const bodyOf = async (request: Request) => JSON.parse(await request.clone().text()) as InspectedBody describe('OpenCode 2 native Anthropic adapter: request shape', () => { + test('qualifying reply histories on an API proxy are byte-identical despite OAuth note settings', async () => { + const { source } = makeSource({ + options: { turnEndNoteEnabled: true }, + authorize: () => + apiKey( + 'synthetic-api-key', + { custody: 'api', receipt: 'api' }, + 'https://proxy.invalid', + ), + }) + const host = new Host(createNativeAnthropicAdapter({ source }).adapter) + const payload = { + model: 'claude-opus-5-5', + messages: [ + { role: 'user', content: 'Question' }, + { + role: 'assistant', + content: [ + { type: 'text', text: 'A'.repeat(350) }, + { type: 'tool_use', name: 'board', id: 'a', input: {} }, + ], + }, + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'a', content: 'Done' }], + }, + ], + } + const { final } = await host.run( + scopeOf({ modelID: payload.model }), + hostRequest(payload), + ) + expect(await final.text()).toBe(JSON.stringify(payload)) + }) + + test('turn-end note N N+1 N+2 retain signed history prefixes through the native wire policy', async () => { + const wire = createNativeWirePolicy() + const { source } = makeSource({ + requestOptions: (input) => + wire.requestOptions( + input as any, + { + policyStorage: { + turnEndNote: { tools: ['board'] }, + claudeCache: { enabled: true, mode: 'automatic' }, + }, + } as any, + ), + }) + const host = new Host(createNativeAnthropicAdapter({ source }).adapter) + const note = { + role: 'system', + clear_at: 'next_user_message', + content: + 'Your reply above has already reached the user. The tool call after it was housekeeping and changed nothing they need to know. Do not repeat, summarize or rewrite that reply. If no new work remains, end the turn without restating anything.', + } + const pair = (id: string) => [ + { + role: 'assistant', + content: [ + { type: 'text', text: 'A'.repeat(350) }, + { type: 'tool_use', name: 'board', id, input: {} }, + ], + }, + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: id, content: 'Done' }], + }, + ] + const messages: unknown[] = [ + { role: 'user', content: 'Question' }, + ...pair('a'), + ] + const outputs: any[] = [] + for (const id of ['a', 'b', 'c']) { + if (id !== 'a') + messages.push( + { role: 'assistant', content: [{ type: 'text', text: 'Done.' }] }, + { role: 'user', content: `Next ${id}` }, + ...pair(id), + ) + const payload = { + model: 'claude-opus-5-5', + stream: true, + max_tokens: 16000, + messages, + tools: + id === 'a' + ? [{ name: 'board', input_schema: { type: 'object' } }] + : [], + } + const { final } = await host.run( + scopeOf({ modelID: payload.model }), + hostRequest(payload), + ) + const text = await final.clone().text() + expect(await signRequestBody(text)).toBe(text) + const body = JSON.parse(text) + expect(body.messages.at(-1)).toEqual(note) + expect(final.headers.get('anthropic-beta')?.split(',')).toContain( + 'mid-conversation-system-clear-at-2026-08-21', + ) + for (const previous of outputs) { + expect( + JSON.stringify(body.messages.slice(0, previous.messages.length)), + ).toBe(JSON.stringify(previous.messages)) + const { messages: _m, system: _s, tools: _t, ...fields } = body + const { messages: _pm, system: _ps, tools: _pt, ...initial } = previous + expect(fields).toEqual(initial) + expect(body.system.slice(1)).toEqual(previous.system.slice(1)) + expect( + body.system[0].text.replace(/cch=[0-9a-f]{5}/, 'cch=00000'), + ).toBe(previous.system[0].text.replace(/cch=[0-9a-f]{5}/, 'cch=00000')) + } + outputs.push(body) + } + expect( + outputs[2].messages.filter((message: any) => message.clear_at), + ).toHaveLength(3) + }) + test('OAuth send carries the Claude Code wire shape, signed body and betas', async () => { const { source } = makeSource() const native = createNativeAnthropicAdapter({ source }) diff --git a/packages/opencode/src/tests/turn-end-note.test.ts b/packages/opencode/src/tests/turn-end-note.test.ts new file mode 100644 index 000000000..6c11e2a2a --- /dev/null +++ b/packages/opencode/src/tests/turn-end-note.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, test } from 'bun:test' +import { signRequestBody } from '@cortexkit/anthropic-auth-core' +import { rewriteRequestBody, setOAuthHeaders } from '../transform' + +const note = { + role: 'system', + clear_at: 'next_user_message', + content: + 'Your reply above has already reached the user. The tool call after it was housekeeping and changed nothing they need to know. Do not repeat, summarize or rewrite that reply. If no new work remains, end the turn without restating anything.', +} + +function reply(id: string, names = ['board'], text = 'A'.repeat(350)) { + return [ + { + role: 'assistant', + content: [ + { type: 'text', text }, + ...names.map((name, i) => ({ + type: 'tool_use', + id: `${id}_${i}`, + name, + input: {}, + })), + ], + }, + { + role: 'user', + content: names.map((_, i) => ({ + type: 'tool_result', + tool_use_id: `${id}_${i}`, + content: 'Done.', + })), + }, + ] +} + +async function build( + messages: unknown[], + options: NonNullable[1]> = {}, + model = 'claude-opus-5-5', + tools = ['board', 'ctx_reduce'], +) { + const text = await rewriteRequestBody( + JSON.stringify({ + model, + max_tokens: 16000, + stream: true, + messages, + tools: tools.map((name) => ({ name, input_schema: { type: 'object' } })), + }), + { turnEndNoteEnabled: true, ...options }, + ) + expect(await signRequestBody(text)).toBe(text) + return JSON.parse(text) +} + +describe('OpenCode turn-end notes', () => { + test('inserts the exact note after results, before signing, with its beta', async () => { + const body = await build([ + { role: 'user', content: 'Question' }, + ...reply('a'), + ]) + expect(body.messages.at(-1)).toEqual(note) + expect(body.messages[1].content[1].name).toBe('mcp_Board') + const headers = new Headers() + setOAuthHeaders(headers, 'sk-ant-oat01-synthetic', { body }) + expect(headers.get('anthropic-beta')?.split(',')).toContain( + 'mid-conversation-system-clear-at-2026-08-21', + ) + }) + + test('N N+1 N+2 retain the serialized history prefix through all earlier notes', async () => { + const history = [{ role: 'user', content: 'Question' }, ...reply('a')] + const first = await build(history) + expect(first.messages.at(-1)).toEqual(note) + history.push( + { role: 'assistant', content: [{ type: 'text', text: 'No new work.' }] }, + { role: 'user', content: 'Next question' }, + ...reply('b', ['ctx_reduce']), + ) + const second = await build(history, {}, undefined, ['unrelated']) + history.push( + { role: 'assistant', content: [{ type: 'text', text: 'Finished.' }] }, + { role: 'user', content: 'Third question' }, + ...reply('c'), + ) + const third = await build(history, {}, undefined, []) + for (const later of [second, third]) { + expect( + JSON.stringify(later.messages.slice(0, first.messages.length)), + ).toBe(JSON.stringify(first.messages)) + const { messages: _m, system: _s, tools: _t, ...fields } = later + const { messages: _fm, system: _fs, tools: _ft, ...initialFields } = first + expect(fields).toEqual(initialFields) + expect(later.system.slice(1)).toEqual(first.system.slice(1)) + expect(later.system[0].text.replace(/cch=[0-9a-f]{5}/, 'cch=00000')).toBe( + first.system[0].text.replace(/cch=[0-9a-f]{5}/, 'cch=00000'), + ) + } + expect( + JSON.stringify(third.messages.slice(0, second.messages.length)), + ).toBe(JSON.stringify(second.messages)) + expect( + third.messages.filter((m: { clear_at?: string }) => m.clear_at), + ).toHaveLength(3) + }) + + test.each(['', ' \n\t'])( + 'no visible text means no note (%j), including on older turns', + async (text) => { + const round = reply('a', ['board'], text) + ;(round[0] as any).content.push({ type: 'text', text: ' \n\t' }) + const history = [{ role: 'user', content: 'Question' }, ...round] + const first = await build(history) + history.push( + { role: 'assistant', content: [{ type: 'text', text: 'Done' }] }, + { role: 'user', content: 'Next' }, + ) + const second = await build(history) + for (const body of [first, second]) + expect(body.messages).not.toContainEqual(note) + }, + ) + + test.each([349, 350])( + 'visible reply boundary: %i characters', + async (length) => { + const body = await build([ + { role: 'user', content: 'Question' }, + ...reply('a', ['board'], 'a'.repeat(length)), + ]) + expect(body.messages.some((m: { clear_at?: string }) => m.clear_at)).toBe( + length === 350, + ) + }, + ) + + test('mixed housekeeping and working tools never qualify', async () => { + const body = await build([ + { role: 'user', content: 'Question' }, + ...reply('a', ['board', 'bash']), + ]) + expect(body.messages).not.toContainEqual(note) + }) + + test('disabled, API-key and unsupported requests have no note; custom host names work', async () => { + const history = [{ role: 'user', content: 'Question' }, ...reply('a')] + for (const options of [ + { turnEndNote: { tools: [] } }, + { turnEndNoteEnabled: false }, + ]) { + expect((await build(history, options)).messages).not.toContainEqual(note) + } + expect( + (await build(history, {}, 'claude-sonnet-4-5')).messages, + ).not.toContainEqual(note) + const custom = [ + { role: 'user', content: 'Question' }, + ...reply('a', ['my_cleanup']), + ] + expect( + ( + await build(custom, { turnEndNote: { tools: ['my_cleanup'] } }) + ).messages.at(-1), + ).toEqual(note) + expect( + (await build(history, { turnEndNote: { tools: ['mcp_Board'] } })) + .messages, + ).not.toContainEqual(note) + }) + + test('a directly following user suppresses only that note, not later notes', async () => { + const history = [{ role: 'user', content: 'Question' }, ...reply('a')] + const first = await build(history) + const later = await build([ + ...history, + { role: 'user', content: 'Next' }, + ...reply('b'), + ]) + expect(later.messages.slice(0, first.messages.length - 1)).toEqual( + first.messages.slice(0, -1), + ) + expect(later.messages[3]).toEqual({ role: 'user', content: 'Next' }) + expect(later.messages.at(-1)).toEqual(note) + expect( + later.messages.filter((m: { clear_at?: string }) => m.clear_at), + ).toHaveLength(1) + }) + + test.each(['explicit', 'hybrid', 'automatic'] as const)( + 'cache mode %s never anchors a note', + async (mode) => { + const body = await build( + [{ role: 'user', content: 'Question' }, ...reply('a')], + { cache1hEnabled: true, cache1hMode: mode }, + ) + expect(body.messages.at(-1)).toEqual(note) + }, + ) + + test('hybrid lookback counts the note but never anchors it', async () => { + const history = [ + { role: 'user', content: 'Question' }, + ...reply('a'), + { + role: 'assistant', + content: Array.from({ length: 18 }, () => ({ + type: 'text', + text: 'continued', + })), + }, + { role: 'user', content: 'Next' }, + ] + const body = await build(history, { + cache1hEnabled: true, + cache1hMode: 'hybrid', + }) + expect(body.messages[3]).toEqual(note) + expect(body.messages[2].content[0].cache_control).toEqual({ + type: 'ephemeral', + ttl: '1h', + }) + expect(body.messages.at(-1).content[0].cache_control).toEqual({ + type: 'ephemeral', + ttl: '1h', + }) + }) + + test('native effort controls between calls and results compose with notes', async () => { + const [assistant, results] = reply('a') + const effort = { + role: 'system', + content: [], + output_config: { effort: 'low' }, + } + const body = await build([ + { role: 'user', content: 'Question' }, + assistant, + effort, + results, + ]) + expect(body.messages[2]).toEqual(effort) + expect(body.messages.at(-1)).toEqual(note) + }) +}) diff --git a/packages/opencode/src/transform.ts b/packages/opencode/src/transform.ts index f420fe7b2..92f411f0e 100644 --- a/packages/opencode/src/transform.ts +++ b/packages/opencode/src/transform.ts @@ -3,6 +3,7 @@ import { applyClaudeCodeHeaders, applyClaudeCodeMetadata, applyThinkingBindingControls, + applyTurnEndNotes, assertNotCustodyTombstone, type BillingLineageFields, buildBillingHeaderValue, @@ -985,6 +986,8 @@ function messageContentBlockCount(message: unknown) { const content = message.content if (Array.isArray(content)) return content.length if (content == null) return 0 + // A turn-end note has string content: it consumes one lookback block, even + // though hybrid anchors are placed on user boundaries, never on the note. return 1 } @@ -1519,6 +1522,8 @@ export async function rewriteRequestBody( midConversationEffortEnabled?: boolean midConversationEffortPlan?: string midConversationEffortResolvedPlan?: OpenCodeEffortMarkerPlan + turnEndNoteEnabled?: boolean + turnEndNote?: { tools: string[] } perf?: RewritePerfCallback hybridStandbyAnchor?: HybridMessageCacheAnchor serverSideFallbackEnabled?: boolean @@ -1604,6 +1609,10 @@ export async function rewriteRequestBody( parsed, options.thinkingPrefixMismatchBehavior ?? 'account-default', ) + if (options.turnEndNoteEnabled) { + // History still carries host tool names here, before mcp_ wire prefixing. + applyTurnEndNotes(parsed, { tools: options.turnEndNote?.tools }) + } const billingStart = rewriteNowMs() const billingHeader = diff --git a/packages/opencode/src/v2/wire.ts b/packages/opencode/src/v2/wire.ts index 68f9c34c0..16816cd9d 100644 --- a/packages/opencode/src/v2/wire.ts +++ b/packages/opencode/src/v2/wire.ts @@ -146,6 +146,8 @@ export function createNativeWirePolicy() { // would insert each change twice and alter earlier history, so the // cached prompt prefix would stop matching. midConversationEffortEnabled: false, + turnEndNoteEnabled: true, + turnEndNote: snapshot.policyStorage.turnEndNote, serverSideFallbackEnabled: resolveContentFilterFallbackMode( process.env.OPENCODE_ANTHROPIC_AUTH_CONTENT_FILTER_FALLBACK, diff --git a/packages/pi/src/convert.ts b/packages/pi/src/convert.ts index f945a450b..df949b3d8 100644 --- a/packages/pi/src/convert.ts +++ b/packages/pi/src/convert.ts @@ -2,6 +2,7 @@ import { applyClaudeCodeMetadata, applyMidConversationOutputConfig, applyThinkingBindingControls, + applyTurnEndNotes, assertNoMeaningfulTrailingAssistant, buildBillingHeaderValue, type Cache1hMode, @@ -205,6 +206,7 @@ function convertTextAndImages( function convertMessages( messages: Message[], targetModelId: string, + visibleReplies: WeakMap, ): AnthropicRequestBody['messages'] { const result: AnthropicRequestBody['messages'] = [] @@ -241,9 +243,12 @@ function convertMessages( } const blocks: Array> = [] + let visibleText = '' for (const block of message.content) { if (block.type === 'text' && block.text.trim()) { - blocks.push({ type: 'text', text: sanitize(block.text) }) + const text = sanitize(block.text) + blocks.push({ type: 'text', text }) + visibleText += text } else if (block.type === 'thinking') { const thinking = block as ThinkingContent const signature = thinking.thinkingSignature @@ -317,7 +322,14 @@ function convertMessages( }) } } - if (blocks.length) result.push({ role: 'assistant', content: blocks }) + if (blocks.length) { + const converted = { role: 'assistant', content: blocks } + result.push(converted) + // Converted thinking can become text for replay, but it was never a + // visible reply. Count only actual text blocks, using the exact outgoing + // strings collected above, regardless of the current tool declarations. + visibleReplies.set(converted, visibleText) + } continue } @@ -579,6 +591,7 @@ export async function buildAnthropicRequest( controls: { effortTransitions?: readonly MidConversationEffortTransition[] thinkingPrefixMismatchBehavior?: ThinkingPrefixMismatchBehavior + turnEndNote?: { tools: string[] } } = {}, ): Promise<{ body: AnthropicRequestBody @@ -604,7 +617,8 @@ export async function buildAnthropicRequest( // first so those losses cannot hide a meaningful final assistant turn. Check // the Anthropic messages again after conversion removes empty user messages. assertNoMeaningfulTrailingAssistant(context.messages) - const messages = convertMessages(context.messages, modelId) + const visibleReplies = new WeakMap() + const messages = convertMessages(context.messages, modelId, visibleReplies) stripEmptyTrailingAssistantMessages(messages) const system = [ { @@ -730,6 +744,13 @@ export async function buildAnthropicRequest( } applyMidConversationOutputConfig(body, controls.effortTransitions ?? []) + if (identity) { + applyTurnEndNotes(body, { + tools: controls.turnEndNote?.tools, + mapToolName: toClaudeCodeToolName, + visibleText: (message) => visibleReplies.get(message) ?? '', + }) + } addEphemeralCacheControl(body) applyCacheMode(body, cache.enabled, cache.mode) if (identity) { diff --git a/packages/pi/src/stream.ts b/packages/pi/src/stream.ts index 21eaa37d9..a1785431c 100644 --- a/packages/pi/src/stream.ts +++ b/packages/pi/src/stream.ts @@ -598,6 +598,7 @@ async function sendAnthropicRequest(options: { identity, { effortTransitions: options.apiAccount ? [] : options.effortTransitions, + turnEndNote: storage.turnEndNote, thinkingPrefixMismatchBehavior: options.apiAccount ? 'account-default' : getThinkingPrefixMismatchBehavior(storage), diff --git a/packages/pi/src/tests/stream.test.ts b/packages/pi/src/tests/stream.test.ts index c46eb9ba5..223429d8a 100644 --- a/packages/pi/src/tests/stream.test.ts +++ b/packages/pi/src/tests/stream.test.ts @@ -201,6 +201,77 @@ describe('Pi API fallback routing helpers', () => { expect(sockets[0]).toStartWith('wss://relay.invalid/ws?') }) + test('native turnEndNote settings reach the signed Pi OAuth request and its beta', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'pi-turn-end-note-')) + const storagePath = join(tempDir, 'anthropic-auth.json') + process.env.PI_ANTHROPIC_AUTH_FILE = storagePath + await saveNativePiFixture( + { + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + turnEndNote: { tools: ['my_cleanup'] }, + accounts: [], + }, + storagePath, + ) + let bodyText = '' + let headers = new Headers() + globalThis.fetch = mock( + async (input: string | URL | Request, init?: RequestInit) => { + if (input.toString().includes('/api/claude_cli/bootstrap')) + return new Response( + JSON.stringify({ + oauth_account: { account_uuid: 'pi-note-account' }, + }), + ) + bodyText = String(init?.body) + headers = new Headers(init?.headers) + return new Response( + 'event: message_start\ndata: {"type":"message_start","message":{"usage":{"input_tokens":1,"output_tokens":0}}}\n\nevent: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}\n\nevent: message_stop\ndata: {"type":"message_stop"}\n\n', + ) + }, + ) as unknown as typeof fetch + const stream = streamCortexKitAnthropic( + { ...anthropicModel, id: 'claude-opus-5-5' }, + { + tools: [], + messages: [ + { role: 'user', content: 'Question', timestamp: 0 }, + { + role: 'assistant', + content: [ + { type: 'text', text: 'A'.repeat(350) }, + { type: 'toolCall', id: 'a', name: 'my_cleanup', arguments: {} }, + ], + timestamp: 0, + }, + { + role: 'toolResult', + toolCallId: 'a', + content: [{ type: 'text', text: 'Done' }], + isError: false, + timestamp: 0, + }, + ], + } as any, + { apiKey: 'sk-ant-oat-pi-note', sessionId: 'ses_pi_note' }, + ) + for await (const _event of stream) { + /* Drain the provider stream. */ + } + const body = JSON.parse(bodyText) + expect(body.messages.at(-1)).toEqual({ + role: 'system', + clear_at: 'next_user_message', + content: + 'Your reply above has already reached the user. The tool call after it was housekeeping and changed nothing they need to know. Do not repeat, summarize or rewrite that reply. If no new work remains, end the turn without restating anything.', + }) + expect(await signRequestBody(bodyText)).toBe(bodyText) + expect(headers.get('anthropic-beta')?.split(',')).toContain( + 'mid-conversation-system-clear-at-2026-08-21', + ) + }) + test('sends Fable 5.1 thinking binding controls after compacted signed history', async () => { tempDir = await mkdtemp(join(tmpdir(), 'pi-fable-51-binding-')) const storagePath = join(tempDir, 'anthropic-auth.json') diff --git a/packages/pi/src/tests/turn-end-note.test.ts b/packages/pi/src/tests/turn-end-note.test.ts new file mode 100644 index 000000000..e26aa102c --- /dev/null +++ b/packages/pi/src/tests/turn-end-note.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, test } from 'bun:test' +import { + applyClaudeCodeHeaders, + type ClaudeCodeIdentity, + type ProviderAccountUuid, + signRequestBody, +} from '@cortexkit/anthropic-auth-core' +import { type Context, type Message, Type } from '@earendil-works/pi-ai' +import { buildAnthropicRequest } from '../convert' + +const note = { + role: 'system', + clear_at: 'next_user_message', + content: + 'Your reply above has already reached the user. The tool call after it was housekeeping and changed nothing they need to know. Do not repeat, summarize or rewrite that reply. If no new work remains, end the turn without restating anything.', +} +const identity: ClaudeCodeIdentity = { + deviceId: 'd'.repeat(64), + accountUuid: '00000000-0000-4000-8000-000000000001' as ProviderAccountUuid, + sessionId: '11111111-2222-4333-8444-555555555555', +} +const user = (content: string): Message => ({ + role: 'user', + content, + timestamp: 0, +}) +const answer = (text: string): Message => + ({ + role: 'assistant', + content: [{ type: 'text', text }], + timestamp: 0, + }) as Message +function pair(id: string, name = 'board', text = 'A'.repeat(350)): Message[] { + return [ + { + ...answer(text), + content: [ + { type: 'text', text }, + { type: 'toolCall', id, name, arguments: {} }, + ], + } as Message, + { + role: 'toolResult', + toolCallId: id, + toolName: name, + content: [{ type: 'text', text: 'Done' }], + isError: false, + timestamp: 0, + }, + ] +} +async function build( + messages: Message[], + controls: NonNullable[6]> = {}, + oauth = true, + model = 'claude-opus-5-5', + names = ['board', 'ctx_reduce'], + mode: 'automatic' | 'hybrid' | 'explicit' = 'automatic', +) { + const result = await buildAnthropicRequest( + model, + { + messages, + tools: names.map((name) => ({ + name, + description: name, + parameters: Type.Object({}), + })), + } as Context, + undefined, + { enabled: true, mode }, + false, + oauth ? identity : undefined, + controls, + ) + expect(await signRequestBody(result.bodyText)).toBe(result.bodyText) + return { ...result, wire: JSON.parse(result.bodyText) } +} + +describe('Pi turn-end notes', () => { + test('N N+1 N+2 preserve every earlier note, including after tool declarations change', async () => { + const history = [user('Question'), ...pair('a')] + const first = await build(history) + expect(first.wire.messages.at(-1)).toEqual(note) + history.push( + answer('No new work.'), + user('Next'), + ...pair('b', 'ctx_reduce'), + ) + const second = await build(history, {}, true, undefined, ['unrelated']) + history.push(answer('Done.'), user('Third'), ...pair('c')) + const third = await build(history, {}, true, undefined, []) + for (const later of [second, third]) { + expect( + JSON.stringify( + later.wire.messages.slice(0, first.wire.messages.length), + ), + ).toBe(JSON.stringify(first.wire.messages)) + const { messages: _m, system: _s, tools: _t, ...fields } = later.wire + const { messages: _fm, system: _fs, tools: _ft, ...initial } = first.wire + expect(fields).toEqual(initial) + expect(later.wire.system.slice(1)).toEqual(first.wire.system.slice(1)) + expect( + later.wire.system[0].text.replace(/cch=[0-9a-f]{5}/, 'cch=00000'), + ).toBe(first.wire.system[0].text.replace(/cch=[0-9a-f]{5}/, 'cch=00000')) + } + expect( + JSON.stringify(third.wire.messages.slice(0, second.wire.messages.length)), + ).toBe(JSON.stringify(second.wire.messages)) + expect( + third.wire.messages.filter((m: { clear_at?: string }) => m.clear_at), + ).toHaveLength(3) + const headers = new Headers() + applyClaudeCodeHeaders(headers, '', { body: third.body, identity }) + expect(headers.get('anthropic-beta')?.split(',')).toContain( + 'mid-conversation-system-clear-at-2026-08-21', + ) + }) + + test.each([349, 350])( + 'visible reply boundary: %i characters', + async (length) => { + const body = await build([ + user('Question'), + ...pair('a', 'board', 'a'.repeat(length)), + ]) + expect( + body.wire.messages.some((m: { clear_at?: string }) => m.clear_at), + ).toBe(length === 350) + }, + ) + + test('custom host spellings use the same stable mapping as calls', async () => { + const result = await build([user('Question'), ...pair('a', 'bash')], { + turnEndNote: { tools: ['bash'] }, + }) + expect(result.wire.messages[1].content[1].name).toBe('Bash') + expect(result.wire.messages.at(-1)).toEqual(note) + }) + + test('API-key, unsupported and explicitly empty lists stay unchanged', async () => { + const history = [user('Question'), ...pair('a')] + for (const result of [ + await build(history, {}, false), + await build(history, {}, true, 'claude-sonnet-4-5'), + await build(history, { turnEndNote: { tools: [] } }), + ]) { + expect(result.wire.messages).not.toContainEqual(note) + } + }) + + test('thinking downgraded to text is not a visible reply', async () => { + const history = [user('Question'), ...pair('a')] + const assistant = history[1] as Extract + assistant.content[0] = { + type: 'thinking', + thinking: 'Private reasoning'.repeat(50), + } + expect((await build(history)).wire.messages).not.toContainEqual(note) + }) + + test.each(['', ' \n\t'])( + 'empty or whitespace-only replies do not qualify: %j', + async (text) => { + expect( + (await build([user('Question'), ...pair('a', 'board', text)])).wire + .messages, + ).not.toContainEqual(note) + }, + ) + + test('a following user removes only the offending note', async () => { + const history = [user('Question'), ...pair('a')] + const first = await build(history) + const later = await build([...history, user('Next'), ...pair('b')]) + expect(later.wire.messages.slice(0, 3)).toEqual( + first.wire.messages.slice(0, 3), + ) + expect(later.wire.messages[3]).toEqual({ role: 'user', content: 'Next' }) + expect(later.wire.messages.at(-1)).toEqual(note) + expect( + later.wire.messages.filter((m: { clear_at?: string }) => m.clear_at), + ).toHaveLength(1) + }) + + test.each(['automatic', 'hybrid', 'explicit'] as const)( + 'notes have no cache or effort fields in %s mode', + async (mode) => { + const result = await build( + [user('Question'), ...pair('a')], + {}, + true, + undefined, + undefined, + mode, + ) + expect(result.wire.messages.at(-1)).toEqual(note) + }, + ) +}) From 0ae0c889f0f66db62d8882e3d96945881135149c Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Wed, 21 Oct 2026 03:50:30 +0200 Subject: [PATCH 2/3] docs: record the turn-end note in the root changelog packages/opencode/CHANGELOG.md holds inherited upstream history; release notes come from the root CHANGELOG.md. --- CHANGELOG.md | 2 ++ packages/opencode/CHANGELOG.md | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aab0ff1f9..6640dc21d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ This repo is a CortexKit-maintained Anthropic auth monorepo for OpenCode and Pi. - A successful token refresh no longer fails a request just because a quota check finished while it was refreshing. - Valid tokens no longer wait for proactive refreshes; requests waiting for a rejected-token refresh fail retryably after two seconds while the refresh continues. +- Prevent repeated long replies after housekeeping calls on supported OAuth models in OpenCode 1, OpenCode 2 and Pi. Add a turn-scoped reminder after tool results when the reply has at least 350 visible characters and every call is in `turnEndNote.tools` (default `board` and `ctx_reduce`; an empty list disables it). Replay earlier reminders unchanged for prompt caching, except when a reminder would sit directly before a user message; Anthropic rejects that placement. API-key routes are unchanged. + ### Dependencies diff --git a/packages/opencode/CHANGELOG.md b/packages/opencode/CHANGELOG.md index c12f99425..07f07406d 100644 --- a/packages/opencode/CHANGELOG.md +++ b/packages/opencode/CHANGELOG.md @@ -10,7 +10,6 @@ This package is a CortexKit-maintained fork of the original `@ex-machina/opencod ### Patch Changes -- Prevent repeated long replies after housekeeping calls on supported OAuth models in OpenCode 1, OpenCode 2 and Pi. Add a turn-scoped reminder after tool results when the reply has at least 350 visible characters and every call is in `turnEndNote.tools` (default `board` and `ctx_reduce`; an empty list disables it). Replay earlier reminders unchanged for prompt caching, except when a reminder would sit directly before a user message; Anthropic rejects that placement. API-key routes are unchanged. - Back off client refusal recovery through Opus 4.8 for 10, 20, then 40 completed responses, repeating at 40 and always retrying the source. Ten consecutive successful source responses reset the next round to 10; tool-use responses count. Model changes and session deletion clear state, as does an OpenCode restart, so a restarted session may try the original model once more. Server-first and legacy modes share this in-memory policy. Remove the mismatched immediate source-model warms and waits, keeping ordinary active-model CacheKeep, account binding, and the Opus standby cache anchor. - Stop silently dropping a trailing assistant message that has content. When OpenCode's history briefly ends on the previous answer, the plugin used to delete that answer and resend the earlier question, repeating completed work. Only provably empty trailing assistant messages are still removed; any other trailing assistant content now fails locally with a non-retryable `invalid_request_error` 400 on both OAuth and API-key routes, before anything is sent to the model. From 026fe85ceae2d94a22c11ca9d125c3de24b980fd Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Wed, 21 Oct 2026 03:51:44 +0200 Subject: [PATCH 3/3] docs: clarify turn-end note comments --- packages/core/src/accounts.ts | 6 +++++- packages/core/src/turn-end-note.ts | 6 +++++- packages/pi/src/convert.ts | 3 ++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/core/src/accounts.ts b/packages/core/src/accounts.ts index 38058788c..ceba19a99 100644 --- a/packages/core/src/accounts.ts +++ b/packages/core/src/accounts.ts @@ -322,7 +322,11 @@ export type AccountStorage = { thinkingBinding?: { prefixMismatchBehavior?: 'account-default' | 'error' | 'drop_block' } - /** Host tool names for reply-then-housekeeping notes; absent uses board/ctx_reduce, [] disables. */ + /** + * Tool names (as the host names them) whose calls after a long reply get a + * system note telling the model not to repeat that reply. Unset means the + * `board` and `ctx_reduce` tools; an empty list turns the note off. + */ turnEndNote?: { tools: string[] } /** * Zero out Anthropic OAuth model costs in the provider hook. Default: enabled diff --git a/packages/core/src/turn-end-note.ts b/packages/core/src/turn-end-note.ts index 9a4e5c1c2..ac3836698 100644 --- a/packages/core/src/turn-end-note.ts +++ b/packages/core/src/turn-end-note.ts @@ -16,7 +16,11 @@ function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value) } -/** Families enabled for turn-end notes under Claude Code's clear-at beta. */ +/** + * Model families that get turn-end notes. The note is a turn-scoped system + * message (`clear_at: "next_user_message"`), which needs the + * mid-conversation-system-clear-at beta that Claude Code sends for these models. + */ export function supportsTurnEndNote(model: unknown): boolean { return ( isClaudeFable51Model(model) || diff --git a/packages/pi/src/convert.ts b/packages/pi/src/convert.ts index df949b3d8..27cbbb9ed 100644 --- a/packages/pi/src/convert.ts +++ b/packages/pi/src/convert.ts @@ -327,7 +327,8 @@ function convertMessages( result.push(converted) // Converted thinking can become text for replay, but it was never a // visible reply. Count only actual text blocks, using the exact outgoing - // strings collected above, regardless of the current tool declarations. + // strings collected above, so the note decision for this message stays + // the same on every later request. visibleReplies.set(converted, visibleText) } continue