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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,21 @@ describe('isContextOverflowErrorData', () => {
isContextOverflowErrorData({ message: 'Rate limit exceeded: too many tokens per minute' }),
).toBe(false);
});

it('treats Codex remote compact encrypted-content 400 as rebuildable', () => {
expect(
isContextOverflowErrorData({
message:
'Error running remote compact task: { "type": "error", "error": { "code": "invalid_encrypted_content" } }',
}),
).toBe(true);
expect(
isContextOverflowErrorData({
message:
'Encrypted content could not be decrypted or parsed. code=invalid_encrypted_content',
}),
).toBe(false);
});
});

describe('shouldRebuildPiNativeSession', () => {
Expand Down Expand Up @@ -219,6 +234,43 @@ describe('createContextOverflowRollover', () => {
};
}

it('rebuilds Codex remote compact encrypted-content failures without a context-overflow reason key', async () => {
const deps = makeDeps([
msg('user', '先做 A', 'u1', 1),
msg('assistant', '做完 A', 'a1', 2),
msg('user', '再做 B', 'u2', 3),
]);
deps.getSessionRow.mockResolvedValue({
status: 'active',
agentKind: 'codex',
remoteHostId: null,
clearedAt: null,
sdkSessionId: 'thread-1',
contextTokens: 12_000,
contextWindow: 200_000,
model: 'gpt-5.6-sol',
providerId: 'openai',
});
const rollover = createContextOverflowRollover(deps);
rollover.claim('s1');
await expect(
rollover.tryRecover('s1', {
message:
'Error running remote compact task: { "type": "error", "error": { "code": "invalid_encrypted_content" } }',
}),
).resolves.toBe(true);
expect(deps.commitRebuild).toHaveBeenCalledWith(
's1',
expect.any(String),
expect.objectContaining({
reason: 'context-overflow',
sourceUserClientId: 'u2',
sourceAgentKind: 'codex',
}),
);
expect(deps.replayUserMessage).toHaveBeenCalledWith('s1', '再做 B');
});

it('rebuilds once, injects handoff, and wire-replays the same user content', async () => {
const deps = makeDeps([
msg('user', '先做 A', 'u1', 1),
Expand Down Expand Up @@ -339,6 +391,27 @@ describe('createContextOverflowRollover', () => {
expect(deps.replayUserMessage).not.toHaveBeenCalled();
});

it('rebuilds before send when the trailing error is a Codex remote compact encrypted-content 400', async () => {
const compactError =
'Error running remote compact task: { "type": "error", "error": { "code": "invalid_encrypted_content" } }';
const deps = makeDeps([msg('user', '继续', 'u1'), msg('error', compactError, 'e1')]);
deps.getSessionRow.mockResolvedValue({
status: 'active',
agentKind: 'codex',
remoteHostId: null,
clearedAt: null,
sdkSessionId: 'thread-1',
contextTokens: 12_000,
contextWindow: 200_000,
model: 'gpt-5.6-sol',
providerId: 'openai',
});
const rollover = createContextOverflowRollover(deps);
await expect(rollover.prepareUnhealthySession('s1')).resolves.toBe(true);
expect(deps.commitRebuild).toHaveBeenCalled();
expect(deps.replayUserMessage).not.toHaveBeenCalled();
});

it('rebuilds before send when Grok context is already over the real window', async () => {
const deps = makeDeps([msg('user', '继续', 'u1'), msg('assistant', '好', 'a1')]);
deps.getSessionRow.mockResolvedValue({
Expand Down
10 changes: 8 additions & 2 deletions apps/desktop/src/main/maker-ipc/contextOverflowRollover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
* 规划函数是纯的,host 只负责关 live handle、落库、注入交接、wire 重放失败那条 user 消息。
*/

import { CONTEXT_OVERFLOW_REASON, isContextOverflowErrorMessage } from '@cindy/maker-core';
import {
CONTEXT_OVERFLOW_REASON,
isContextOverflowErrorMessage,
isRemoteCompactEncryptedContentError,
} from '@cindy/maker-core';
import {
projectAgentFacingText,
readAgentInputReferences,
Expand Down Expand Up @@ -38,7 +42,9 @@ export function isContextOverflowErrorData(data: unknown): boolean {
const rec = data as { reason?: unknown; message?: unknown; sdkError?: unknown };
if (rec.reason === CONTEXT_OVERFLOW_REASON) return true;
return [rec.message, rec.sdkError].some(
(value) => typeof value === 'string' && isContextOverflowErrorMessage(value),
(value) =>
typeof value === 'string' &&
(isContextOverflowErrorMessage(value) || isRemoteCompactEncryptedContentError(value)),
);
}

Expand Down
5 changes: 4 additions & 1 deletion docs/dev-rules/maker-core-and-agent-behavior.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ controller/进程内;重启后没有 live handle 时不凭估算换窗。Orc
fail closed。compact 失败触发的换窗同样 fail closed,不得自动 replay 已有副作用的用户消息。
PI 的 `pi-prompt-timeout` 是唯一保留的 timeout 交接入口;Claude Code/Codex 的普通
timeout 不得触发自动换窗或 replay。Codex 当前没有与 Claude `AutoCompactController` 对等的 host
自动 `/compact` 注入路径;未来若增加,仍须遵守同一评估和交接边界。手动压缩入口不受此规则影响,
自动 `/compact` 注入路径;未来若增加,仍须遵守同一评估和交接边界。Codex 订阅远端压缩若因
`invalid_encrypted_content` 硬失败(`Error running remote compact task`),视为官方 compact
确定性失败,走同一套 host-controlled rollover;单独的 `invalid_encrypted_content`(HTTP 静默剥
推理密文范围)不得当成换窗。手动压缩入口不受此规则影响,
手动 compact 失败不得锁存换窗。

> **适用范围与增量原则**:Agent 能力归属(下节 1)与代码优先确定性(下节 2)按增量
Expand Down
14 changes: 13 additions & 1 deletion packages/maker-core/src/agents/codex/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ import {
overloadRetryDelayMs,
parseOverloadError,
} from '../shared/overload-error.js';
import { CONTEXT_OVERFLOW_REASON } from '../shared/context-overflow-error.js';
import { isRemoteCompactEncryptedContentError } from '../shared/remote-compact-encrypted-error.js';
import { buildCodexEnv } from './env-builder.js';
import {
buildCodexCapabilityConfigOverrides,
Expand Down Expand Up @@ -9049,9 +9051,19 @@ export class CodexAgent extends BaseAgent {
// Already reported above as the authoritative terminal outcome; the
// interrupt-derived message must not overwrite it.
} else if (turn.error?.message) {
const message = turn.error.message;
const extra =
typeof turn.error.additionalDetails === 'string' ? turn.error.additionalDetails : '';
const classifyText = extra ? `${message}\n${extra}` : message;
eventQueue.push({
type: 'error',
data: { message: turn.error.message, isTerminal: true },
data: {
message,
isTerminal: true,
...(isRemoteCompactEncryptedContentError(classifyText)
? { reason: CONTEXT_OVERFLOW_REASON }
: {}),
},
source: 'codex',
});
} else if (turn.status === 'failed') {
Expand Down
35 changes: 35 additions & 0 deletions packages/maker-core/src/agents/codex/translator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,41 @@ describe('translateErrorNotification', () => {
expect(events[0]!.data).not.toHaveProperty('reason');
});

it('Codex 远端 compact 密文 400 走 context-overflow,交给 host 换窗而不是原样重试', async () => {
const rt = newCodexRuntimeState();
const q = createAsyncQueue<AgentEvent>();
translateErrorNotification(
makeParams({
willRetry: false,
message:
'Error running remote compact task: { "type": "error", "error": { "code": "invalid_encrypted_content", "message": "The encrypted content cind...9ln0 could not be verified." } }',
}),
q,
makeCtx(rt),
);
const events = await collect(q);
expect(events[0]!.data).toMatchObject({
reason: 'context-overflow',
isTerminal: true,
});
});

it('单独的 invalid_encrypted_content 不冒充超限换窗', async () => {
const rt = newCodexRuntimeState();
const q = createAsyncQueue<AgentEvent>();
translateErrorNotification(
makeParams({
willRetry: false,
message:
'Encrypted content could not be decrypted or parsed. code=invalid_encrypted_content',
}),
q,
makeCtx(rt),
);
const events = await collect(q);
expect(events[0]!.data).not.toHaveProperty('reason');
});

it('上下文超限终止错误带 context-overflow reason(#1429): 原样重试必败, renderer 靠它换恢复动作', async () => {
const rt = newCodexRuntimeState();
const q = createAsyncQueue<AgentEvent>();
Expand Down
10 changes: 9 additions & 1 deletion packages/maker-core/src/agents/codex/translator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
CONTEXT_OVERFLOW_REASON,
isContextOverflowErrorMessage,
} from '../shared/context-overflow-error.js';
import { isRemoteCompactEncryptedContentError } from '../shared/remote-compact-encrypted-error.js';
import { commandExecutionDisplayInput, type CommandExecutionDisplayInput } from './command-display.js';
import { codexErrorInfoTag } from './app-server/protocol.js';
import {
Expand Down Expand Up @@ -577,9 +578,16 @@ export function translateErrorNotification(
// 它隐藏 Retry 并给出压缩 / 新开会话入口。结构化 contextWindowExceeded 优先,
// 文案匹配仅兼容旧版 app-server;与 capacity 互斥时 overload 优先 —— 它还驱动
// 退避重投接管,语义更具体。
const additionalDetails =
typeof params.error?.additionalDetails === 'string' ? params.error.additionalDetails : '';
const overflowClassifyText = additionalDetails
? `${safeMessage}\n${additionalDetails}`
: safeMessage;
const contextOverflowReason =
!isCapacityError &&
(errorInfoTag === 'contextWindowExceeded' || isContextOverflowErrorMessage(safeMessage))
(errorInfoTag === 'contextWindowExceeded' ||
isContextOverflowErrorMessage(safeMessage) ||
isRemoteCompactEncryptedContentError(overflowClassifyText))
? { reason: CONTEXT_OVERFLOW_REASON }
: {};
if (!params.willRetry && isCapacityError) {
Expand Down
1 change: 1 addition & 0 deletions packages/maker-core/src/agents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export {
CONTEXT_OVERFLOW_REASON,
isContextOverflowErrorMessage,
} from './shared/context-overflow-error.js';
export { isRemoteCompactEncryptedContentError } from './shared/remote-compact-encrypted-error.js';
export { isDeterministicHostCompactFailure } from './shared/auto-compact-controller.js';
// ErrorBanner 用人话替换 LiteLLM / Responses 空壳流中断,不驱动自动续跑。
export {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";

import { isRemoteCompactEncryptedContentError } from "./remote-compact-encrypted-error.js";

const COMPACT_ENCRYPTED =
'Error running remote compact task: { "type": "error", "error": { "type": "invalid_request_error", "code": "invalid_encrypted_content", "message": "The encrypted content cind...9ln0 could not be verified. Reason: Encrypted content could not be decrypted or parsed." } }';

describe("isRemoteCompactEncryptedContentError", () => {
it("matches Codex remote compact 400 with invalid_encrypted_content", () => {
expect(isRemoteCompactEncryptedContentError(COMPACT_ENCRYPTED)).toBe(true);
});

it("does not treat standalone encrypted-content failures as compact rollover", () => {
expect(
isRemoteCompactEncryptedContentError(
"Encrypted content could not be decrypted or parsed. code=invalid_encrypted_content",
),
).toBe(false);
});

it("does not match other compact failures", () => {
expect(
isRemoteCompactEncryptedContentError(
"Error running remote compact task: timeout",
),
).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Codex 订阅远端压缩(remote compact)把解不开的 `encrypted_content` 送给上游后的硬失败。
*
* 与 HTTP 静默剥推理密文的分工:单独的 `invalid_encrypted_content` 仍由 proxy 剥
* `reasoning.encrypted_content` 透明重试。远端 compact 是 Codex 内部硬失败、无本地回退,
* 且压缩块密文不能剥;原样重试必再撞同一个 400。恢复动作与满窗 / host compact 确定性
* 失败相同:host-controlled rollover(换窗),不要求用户开新任务或 Fork。
*
* 必须同时命中 compact 入口文案和密文错误码,避免把供应商切换时的推理密文 400 误当成换窗。
*/
export function isRemoteCompactEncryptedContentError(message: string): boolean {
if (!message) return false;
return (
/invalid_encrypted_content/i.test(message) &&
/remote compact/i.test(message)
);
}