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
19 changes: 19 additions & 0 deletions apps/desktop/src/main/__tests__/session-error-presentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import assert from 'node:assert/strict';
import { describe, it } from 'node:test';

import { describeSessionErrorReason } from '../../renderer/session-error-presentation.js';
import { sessionEventErrorMessage } from '../../renderer/model-connection-errors.js';
import { deriveFailedTurnRecovery, describeTurnErrorClass } from '../../renderer/session-status-presentation.js';

describe('provider capacity presentation', () => {
Expand All @@ -41,3 +42,21 @@ describe('provider capacity presentation', () => {
assert.doesNotMatch(recovery.label, /直接重试/);
});
});

describe('context compaction failure presentation', () => {
it('shows actionable malformed-summary guidance', () => {
const message = sessionEventErrorMessage({
type: 'error',
id: 'error-1',
turnId: 'turn-1',
ts: 1,
recoverable: false,
reason: 'context_budget_exhausted',
message: 'Turn failed: context_budget_exhausted',
details: { contextBudgetExhaustedDetail: 'malformed_summary_missing_section' },
});

assert.match(message, /上下文压缩/);
assert.match(message, /上下文窗口设置|切换模型|开启新任务/);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,19 @@ describe('failed turn recovery presentation', () => {
);
});

it('offers configuration or task recovery after runtime context exhaustion', () => {
assert.deepEqual(
deriveFailedTurnRecovery(
{ ...outputFreeFailure, errorClass: 'context_budget_exhausted' },
'zh',
),
{
action: 'check_connection',
label: '检查模型的上下文窗口设置、切换模型,或开启新任务',
},
);
});

it('keeps the generic retry fallback for an unknown output-free failure', () => {
assert.deepEqual(
deriveFailedTurnRecovery({ ...outputFreeFailure, errorClass: 'unknown_failure' }, 'en'),
Expand Down
8 changes: 5 additions & 3 deletions apps/desktop/src/renderer/locales/conversation-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,8 @@ export interface DesktopConversationCopy {
turnError: {
unknown: string;
contextOverflow: string;
contextBudgetExhausted: string;
malformedSummary: string;
timeout: string;
auth: string;
providerBilling: string;
Expand All @@ -339,7 +341,7 @@ export interface DesktopConversationCopy {
permission: string;
restarted: string;
sandboxBoundaryClosed: string;
recovery: Record<'safeResume' | 'stepCap' | 'toolError' | 'connection' | 'partial' | 'toolRecord' | 'retry' | 'capacity' | 'contextOverflow' | 'sandboxBoundaryClosed', string>;
recovery: Record<'safeResume' | 'stepCap' | 'toolError' | 'connection' | 'partial' | 'toolRecord' | 'retry' | 'capacity' | 'contextOverflow' | 'contextBudgetExhausted' | 'sandboxBoundaryClosed', string>;
};
}

Expand Down Expand Up @@ -636,7 +638,7 @@ const COPY = {
reauth: { label: '上次连接测试鉴权失败', tooltip: '最近一次连接测试返回鉴权失败(401 / 403),密钥可能已过期或被吊销。这不会拦截发送,但若发送失败请到 设置 · 模型 重新登录。' },
testError: { label: '上次连接测试失败', tooltip: '最近一次连接测试因网络 / 超时 / 5xx 失败。这不会拦截发送,但若问题持续请到 设置 · 模型 检查 Base URL / 代理。' },
},
turnError: { unknown: '未知错误', contextOverflow: '上下文窗口已超出限制', timeout: '请求超时', auth: '鉴权失败', providerBilling: '模型服务计费受限', providerCapacity: '模型服务暂时满载,请稍后重试或切换模型', rateLimit: '触发模型速率限制', network: '网络错误', provider: '模型服务返回错误', stepCap: '达到工具步骤上限', tool: '工具调用失败', permission: '等待权限确认', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭', recovery: { safeResume: '检查当前状态后,可尝试安全恢复', stepCap: '任务可能尚未完成,可以继续', toolError: '先检查工具结果,再决定是否重试', connection: '先检查模型连接或登录状态', partial: '已保留部分输出,可从这里继续', toolRecord: '工具记录已保留,重试前先看结果', retry: '没有执行工具,可直接重试', capacity: '模型服务暂时满载,请等待几分钟或切换模型后重试', contextOverflow: '上下文仍超出限制,请减少附件或开启新任务', sandboxBoundaryClosed: '访问范围没有放开,重试本轮后可重新决定' } },
turnError: { unknown: '未知错误', contextOverflow: '上下文窗口已超出限制', contextBudgetExhausted: '上下文已达到上限,当前任务无法继续', malformedSummary: '上下文压缩未能生成有效摘要。请检查模型的上下文窗口设置、切换模型,或开启新任务。', timeout: '请求超时', auth: '鉴权失败', providerBilling: '模型服务计费受限', providerCapacity: '模型服务暂时满载,请稍后重试或切换模型', rateLimit: '触发模型速率限制', network: '网络错误', provider: '模型服务返回错误', stepCap: '达到工具步骤上限', tool: '工具调用失败', permission: '等待权限确认', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭', recovery: { safeResume: '检查当前状态后,可尝试安全恢复', stepCap: '任务可能尚未完成,可以继续', toolError: '先检查工具结果,再决定是否重试', connection: '先检查模型连接或登录状态', partial: '已保留部分输出,可从这里继续', toolRecord: '工具记录已保留,重试前先看结果', retry: '没有执行工具,可直接重试', capacity: '模型服务暂时满载,请等待几分钟或切换模型后重试', contextOverflow: '上下文仍超出限制,请减少附件或开启新任务', contextBudgetExhausted: '检查模型的上下文窗口设置、切换模型,或开启新任务', sandboxBoundaryClosed: '访问范围没有放开,重试本轮后可重新决定' } },
},
en: {
actions: { stopFailedTitle: 'Failed to stop', stopFailedFallback: 'The task action failed. Try again later.', refreshSessionsFailedTitle: 'Failed to refresh tasks', refreshSessionsFailedFallback: 'The task list could not be refreshed. Try again later.', conversationErrorTitle: 'Task error', conversationErrorFallback: 'The task run failed. Try again later.', regenerateStartedTitle: 'Regeneration started', regenerateStartedDescription: 'Generating a new response', branchCreatedTitle: 'Branch created', branchCreatedDescription: (name) => `New task: ${name}`, revisionStartedTitle: 'Edit draft ready', revisionStartedDescription: 'The original task is kept; sending creates a new version', revisionReadyTitle: 'Ready to edit and resend', revisionReadyDescription: 'Rewound to before that message; edit and send when ready', revisionUnavailableTitle: 'This message cannot be edited yet', revisionAttachmentsUnsupported: 'Edit & resend does not yet support historical attachments. Copy the text into a new message instead.', revisionTransformedTextUnsupported: 'Edit & resend does not yet support messages sent with an explicit skill. Copy the text and select the skill again instead.', revisionDraftAttachmentConflict: 'The composer already has pending attachments. Send or remove them before editing a sent message.', revisionCommandUnsupported: 'You cannot run /compact, /side, or orchestration commands while editing a sent message. Cancel the edit first.', revisionAlreadyActive: 'Another message is already being edited. Send or cancel that edit first.', revisionCancelLabel: 'Cancel', revisionBannerTitle: 'Editing sent message', revisionBannerDetail: '· New version on send', revisionUnchanged: 'Nothing changed. Use Regenerate if you only want a new answer.', operationFailedTitle: 'Action failed', operationFailedFallback: 'The task action failed. Try again later.', attachmentFailedTitle: 'Failed to add attachment', tryAgain: 'Try again later.', modelReboundTitle: 'Switched to an available model', modelReboundDescription: (modelId) => `The previous connection is unavailable${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: 'Failed to load task', returnLatest: 'Return to latest', scrollMainToBottom: 'Scroll main conversation to bottom' },
Expand Down Expand Up @@ -867,7 +869,7 @@ const COPY = {
reauth: { label: 'Last connection test failed authentication', tooltip: 'The latest test returned 401 / 403. Sending is not blocked, but sign in again under Settings · Models if it fails.' },
testError: { label: 'Last connection test failed', tooltip: 'The latest test failed because of a network, timeout, or 5xx error. Sending is not blocked; check Base URL or proxy settings if it persists.' },
},
turnError: { unknown: 'Unknown error', contextOverflow: 'Context window exceeded', timeout: 'Request timed out', auth: 'Authentication failed', providerBilling: 'Provider billing required', providerCapacity: 'The model service is temporarily at capacity. Wait and retry, or switch models.', rateLimit: 'Model rate limit reached', network: 'Network error', provider: 'Model service error', stepCap: 'Tool-step limit reached', tool: 'Tool call failed', permission: 'Waiting for permission', restarted: 'The app restarted before the previous turn completed', sandboxBoundaryClosed: 'The app restarted, so the pending request to reach outside the workspace was closed as denied', recovery: { safeResume: 'Inspect the current state, then try safe recovery', stepCap: 'The task may be incomplete; continue from here', toolError: 'Inspect the tool result before retrying', connection: 'Check the model connection or sign-in status', partial: 'Partial output was retained; continue from here', toolRecord: 'Tool history was retained; inspect it before retrying', retry: 'No tools ran; retry directly', capacity: 'The model service is at capacity. Wait a few minutes or switch models before retrying.', contextOverflow: 'Context is still too large; reduce attachments or start a new task', sandboxBoundaryClosed: 'Access was not widened; retry the turn to decide again' } },
turnError: { unknown: 'Unknown error', contextOverflow: 'Context window exceeded', contextBudgetExhausted: 'The context limit was reached and this task cannot continue', malformedSummary: 'Context compaction could not produce a valid summary. Check the model context-window setting, switch models, or start a new task.', timeout: 'Request timed out', auth: 'Authentication failed', providerBilling: 'Provider billing required', providerCapacity: 'The model service is temporarily at capacity. Wait and retry, or switch models.', rateLimit: 'Model rate limit reached', network: 'Network error', provider: 'Model service error', stepCap: 'Tool-step limit reached', tool: 'Tool call failed', permission: 'Waiting for permission', restarted: 'The app restarted before the previous turn completed', sandboxBoundaryClosed: 'The app restarted, so the pending request to reach outside the workspace was closed as denied', recovery: { safeResume: 'Inspect the current state, then try safe recovery', stepCap: 'The task may be incomplete; continue from here', toolError: 'Inspect the tool result before retrying', connection: 'Check the model connection or sign-in status', partial: 'Partial output was retained; continue from here', toolRecord: 'Tool history was retained; inspect it before retrying', retry: 'No tools ran; retry directly', capacity: 'The model service is at capacity. Wait a few minutes or switch models before retrying.', contextOverflow: 'Context is still too large; reduce attachments or start a new task', contextBudgetExhausted: 'Check the model context-window setting, switch models, or start a new task', sandboxBoundaryClosed: 'Access was not widened; retry the turn to decide again' } },
},
} satisfies UiCatalog<DesktopConversationCopy>;

Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/src/renderer/model-connection-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ export function sessionEventErrorMessage(
if (isNoRealConnectionEvent(event)) {
return noRealConnectionSetupDescription(noRealConnectionReasonFromEvent(event), locale);
}
const contextBudgetDetail =
event.details && !Array.isArray(event.details)
? event.details.contextBudgetExhaustedDetail
: undefined;
if (typeof contextBudgetDetail === 'string' && contextBudgetDetail.startsWith('malformed_summary_')) {
return getDesktopConversationCopy(locale).turnError.malformedSummary;
}
const reasonDescription = describeSessionErrorReason(event.reason, locale);
if (reasonDescription) return reasonDescription;
const fallback = getDesktopConversationCopy(locale).actions.conversationErrorFallback;
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/renderer/session-error-presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export function describeSessionErrorReason(reason: string | undefined, locale: U
switch (reason?.toLowerCase()) {
case 'context_overflow':
return copy.contextOverflow;
case 'context_budget_exhausted':
return copy.contextBudgetExhausted;
case 'timeout':
return copy.timeout;
case 'auth':
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/renderer/session-status-presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,5 +180,8 @@ export function deriveFailedTurnRecovery(input: FailedTurnRecoveryInput, locale:
if (lower === 'context_overflow') {
return { action: 'continue', label: copy.contextOverflow };
}
if (lower === 'context_budget_exhausted') {
return { action: 'check_connection', label: copy.contextBudgetExhausted };
}
return { action: 'retry', label: copy.retry };
}
3 changes: 3 additions & 0 deletions packages/core/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1205,6 +1205,9 @@ export type ContextCompactionOutcome =
export type ContextBudgetExhaustedDetail =
| 'no_safe_completed_span'
| 'summarizer_failed'
| 'malformed_summary_missing_section'
| 'malformed_summary_truncated'
| 'malformed_summary_too_small_for_fold'
| 'head_anchor_exceeds_capacity';

export type CompleteStopReason = CompleteEvent['stopReason'];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,65 @@ test('projects a failed Turn message from the canonical terminal event', async (
});
});

test('projects context-budget exhaustion detail from the canonical terminal event', async () => {
await withStores(async (root, stores) => {
const { sessionId, rootAdmissions } = await createRunningRoot(root, stores);
const context = {
sessionId,
invocationId: 'run-1',
runId: 'run-1',
turnId: 'turn-1',
source: 'test',
startedAt: 10,
request: {
sessionId,
invocationId: 'run-1',
runId: 'run-1',
turnId: 'turn-1',
text: 'hello',
source: 'test',
},
newId: () => 'unused',
now: () => 12,
} as const;
const terminalEvent = mapSessionEventToRuntimeEvent(
{
type: 'complete',
id: 'terminal-context-budget-1',
turnId: 'turn-1',
ts: 13,
stopReason: 'context_budget_exhausted',
contextBudgetExhaustedDetail: 'malformed_summary_missing_section',
},
context,
createSessionEventMapMemory(),
);
await stores.runtimeEventStore.appendRuntimeEvent(sessionId, 'run-1', terminalEvent);
await stores.agentRunStore.updateRun(sessionId, 'run-1', {
status: 'failed',
updatedAt: 13,
completedAt: 13,
failureClass: 'context_budget_exhausted',
});

const reader = new CanonicalSessionProjectionReader({
stores,
rootAdmissions,
messages: {
projection: () => ({ hostEpoch: 'epoch-1', queueRevision: 0, steering: [], followup: [] }),
},
});
const canonical = await reader.read(sessionId);
assert.equal(canonical?.rootTurn?.status, 'failed');
if (canonical?.rootTurn?.status === 'failed') {
assert.equal(
canonical.rootTurn.contextBudgetExhaustedDetail,
'malformed_summary_missing_section',
);
}
});
});

test('propagates canonical Store read failures during candidate preflight', async () => {
await withStores(async (root, stores) => {
const session = await stores.sessionStore.create(sessionInput(root));
Expand Down
16 changes: 16 additions & 0 deletions packages/runtime-host/src/__tests__/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,13 @@ describe('Runtime Host bootstrap protocol', () => {
assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 47);
});

test('publishes a new compatibility epoch for context-budget failure detail', () => {
// Epoch 50 is already used by WorkHub coordination summaries on main.
// The context-budget detail therefore needs its own strictly newer
// handshake boundary so peers cannot accept the wrong closed shape.
assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 50);
});

test('adds credential rotation without changing existing credential inputs', () => {
const issueInput = {
principalKind: 'remote_owner',
Expand Down Expand Up @@ -1607,6 +1614,15 @@ describe('Runtime Host bootstrap protocol', () => {
};

assert.deepEqual(decodeHostFrame(response), response);
const withContextDetail = {
...response,
result: {
...response.result,
failureClass: 'context_budget_exhausted',
contextBudgetExhaustedDetail: 'malformed_summary_missing_section' as const,
},
};
assert.deepEqual(decodeHostFrame(withContextDetail), withContextDetail);
assert.throws(
() =>
decodeHostFrame({
Expand Down
40 changes: 40 additions & 0 deletions packages/runtime-host/src/__tests__/session-projector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,46 @@ test('reseeds the latest provider retry when the active Turn still carries one',
assert.equal(seeded[0] && 'phase' in seeded[0] ? seeded[0].phase : undefined, 'scheduled');
});

test('projects structured context-budget failure detail to the Desktop event', () => {
const projector = new RuntimeHostSessionProjector(
snapshot(),
createRuntimeHostSessionProjectionSeed([], snapshot()),
() => 10,
);

const events = projector.accept({
kind: 'subscription.session_projection',
hostEpoch: 'host-1',
subscriptionId: 'subscription-1',
sequence: 1,
snapshot: snapshot({
projectionRevision: 2,
rootTurn: {
sessionId: 'session-1',
turnId: 'turn-1',
runId: 'run-1',
status: 'failed',
terminalEventId: 'terminal-1',
failureClass: 'context_budget_exhausted',
contextBudgetExhaustedDetail: 'malformed_summary_missing_section',
},
}),
}).events;

assert.deepEqual(events, [
{
type: 'error',
id: 'terminal-1',
turnId: 'turn-1',
ts: 10,
recoverable: false,
reason: 'context_budget_exhausted',
message: 'Turn failed: context_budget_exhausted',
details: { contextBudgetExhaustedDetail: 'malformed_summary_missing_section' },
},
]);
});

test('emits a live provider retry when the snapshot overlay appears, then drops it after content', () => {
const projector = new RuntimeHostSessionProjector(
snapshot(),
Expand Down
3 changes: 3 additions & 0 deletions packages/runtime-host/src/adapter/session-projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,9 @@ export class RuntimeHostSessionProjector {
recoverable: false,
reason: root.failureClass,
message: root.failureMessage ?? `Turn failed: ${root.failureClass}`,
...(root.contextBudgetExhaustedDetail
? { details: { contextBudgetExhaustedDetail: root.contextBudgetExhaustedDetail } }
: {}),
});
} else {
events.push({
Expand Down
4 changes: 3 additions & 1 deletion packages/runtime-host/src/protocol/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const;
export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const;
// Increment when the same protocol version no longer guarantees safe Client-Host
// interoperability. Mismatches are rejected before domain commands are admitted.
export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 50 as const;
export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 51 as const;
// 51: Failed Turn snapshots preserve the structured context-budget exhaustion
// detail. Epoch-50 peers reject the optional field on the closed snapshot shape.
// 50: WorkHub can append durable coordination summaries and admit tool-free
// answers through its reserved Coordination Session authority.
// 49: WorkHub resolves one durable Coordination Session per Runtime Host.
Expand Down
Loading