Merge branch 'codex/20260831-fix-history-quota-errors-6c2a91e4-fix-history-quota-errors-6c2a91e4'
This commit is contained in:
commit
69832e0ba6
14 files changed
+480
-49
No files matched your search
@@ -0,0 +1,104 @@
|
|||||||
|
# Task: Fix Coding history loading and quota errors
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
- Task ID: 20260831-fix-history-quota-errors-6c2a91e4
|
||||||
|
- Mode: Feature
|
||||||
|
- Branch: codex/20260831-fix-history-quota-errors-6c2a91e4-fix-history-quota-errors-6c2a91e4
|
||||||
|
- Worktree: /Users/inmanx/Documents/makelore-fix-history-quota-errors-6c2a91e4
|
||||||
|
- Base commit: 38f85f6b5e4dc4e2c5e5b9f8f4506554cfd578f5
|
||||||
|
- Owner: codex
|
||||||
|
- Status: Ready for integration
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Restore access to the full persisted active-branch history of Makelore Code
|
||||||
|
conversations after Pi compaction, while keeping history display bounded in
|
||||||
|
the Renderer and preserving the user's scroll anchor when older content is
|
||||||
|
prepended.
|
||||||
|
- Recognize the observed upstream `403 token_point_balance_exhausted` contract
|
||||||
|
as a non-retryable Token Point quota failure and show actionable, safe
|
||||||
|
Chinese feedback instead of the generic `处理失败` summary.
|
||||||
|
- Add focused Main/projector/Renderer regression coverage and run the required
|
||||||
|
typecheck, lint, and production build verification.
|
||||||
|
|
||||||
|
## Intent And Constraints
|
||||||
|
|
||||||
|
- Preserve Pi `0.84.2` as the sole runtime and keep Pi session/wire shapes
|
||||||
|
inside Electron Main. Renderer continues to consume product-neutral
|
||||||
|
Snapshot/Patch data through the Host API.
|
||||||
|
- Full visible history must not change the compacted model context or expose
|
||||||
|
the private compaction summary. Accepted/uncertain mutations remain
|
||||||
|
non-replayed.
|
||||||
|
- Quota classification must be narrow enough not to reinterpret unrelated
|
||||||
|
`403`, `429`, or Provider failures, and raw upstream text must not leak into
|
||||||
|
Renderer state.
|
||||||
|
- The ready-for-integration progress-message task overlaps the timeline source
|
||||||
|
and test files but has no semantic conflict; keep changes localized to the
|
||||||
|
history-window and scrolling behavior.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
- Pi session hydration now projects the complete active branch in chronological
|
||||||
|
order instead of dropping entries before the latest compaction boundary.
|
||||||
|
Compaction summaries remain private, and multiple durable compaction markers
|
||||||
|
reconcile against live IDs from newest to newest.
|
||||||
|
- The Renderer still mounts only the latest 120 nodes initially, automatically
|
||||||
|
prepends earlier nodes in 100-node steps near the top, and offsets
|
||||||
|
`scrollTop` by the added height so the current reading position stays fixed.
|
||||||
|
- The exact `403 token_point_balance_exhausted` / `词元点数余额不足` response is
|
||||||
|
classified as quota exhaustion, forwarded as non-retryable HTTP 402, and
|
||||||
|
projected as `CODING_PROVIDER_QUOTA_EXHAUSTED` with the safe message
|
||||||
|
`词元点数余额不足,请充值后重试。`.
|
||||||
|
- Live failures and persisted Pi assistant failures share the same redacting
|
||||||
|
classifier. Reopened conversations restore only the safe Provider notice;
|
||||||
|
raw upstream request IDs are not copied into the public Snapshot.
|
||||||
|
- The collapsed process summary displays the actionable public failure message
|
||||||
|
for both live runs and reopened persisted failures instead of generic
|
||||||
|
`处理失败`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `pnpm exec vitest run tests/unit/pi-session-projector.test.ts tests/unit/coding-conversation-timeline.test.tsx tests/unit/ai-proxy-routes.test.ts tests/unit/pi-event-projector.test.ts`
|
||||||
|
- Passed: 4 files, 45 tests.
|
||||||
|
- `pnpm run typecheck`
|
||||||
|
- Passed.
|
||||||
|
- `pnpm run lint:check`
|
||||||
|
- Passed with 0 errors and 5 existing warnings in `src/pages/Home/index.tsx`
|
||||||
|
and `src/pages/Makelore/index.tsx`.
|
||||||
|
- `pnpm test`
|
||||||
|
- Passed: 211 regular files / 1,730 tests plus the isolated pressure file /
|
||||||
|
1 test; 3 tests skipped.
|
||||||
|
- `pnpm run build:vite`
|
||||||
|
- Passed for Renderer, Electron Main, Preload, and release utility worker;
|
||||||
|
existing Vite dynamic-import and chunk-size warnings remain.
|
||||||
|
- `node ./node_modules/@playwright/test/cli.js test tests/e2e/pi-coding-first-chat.spec.ts`
|
||||||
|
- Passed: 2 Electron tests, including persisted quota feedback, automatic
|
||||||
|
older-history loading, and scroll-anchor preservation.
|
||||||
|
- `git diff --check`
|
||||||
|
- Passed.
|
||||||
|
|
||||||
|
## Follow-ups
|
||||||
|
|
||||||
|
- Integration should account for the ready progress-message task because both
|
||||||
|
tasks intentionally touch `CodingConversationTimeline.tsx` and its unit/E2E
|
||||||
|
coverage; the edited concerns are separate.
|
||||||
|
|
||||||
|
## Promotion Candidates
|
||||||
|
|
||||||
|
- Target canonical documents: `README.md` and the integrated current-state
|
||||||
|
summary.
|
||||||
|
- Proposal: state that Pi compaction changes model context but does not remove
|
||||||
|
visible active-branch history, and that recognized Provider quota failures
|
||||||
|
remain actionable after reopening a conversation.
|
||||||
|
- Evidence: focused projector/Renderer/proxy tests, the 1,731-test full unit
|
||||||
|
run including pressure coverage, the production build, and the two focused
|
||||||
|
Electron E2E scenarios all pass.
|
||||||
|
- Future impact: troubleshooting and future history work should preserve the
|
||||||
|
distinction between Pi context retention and product-visible session
|
||||||
|
history, and should reuse the redacted Provider-failure projection.
|
||||||
|
- Semantic conflicts: none known; the peer progress-message task overlaps the
|
||||||
|
timeline files but not these semantics.
|
||||||
|
- Human confirmation required: no product-direction choice is known to be
|
||||||
|
required, but the integration owner must decide whether this detail belongs
|
||||||
|
in canonical user-facing documentation.
|
||||||
@@ -86,7 +86,8 @@ function getForwardedOneApiStatus(status: number, bodyText: string): number {
|
|||||||
if (status === 502 && isAIGatewayUserContextMissing(bodyText)) {
|
if (status === 502 && isAIGatewayUserContextMissing(bodyText)) {
|
||||||
return 401;
|
return 401;
|
||||||
}
|
}
|
||||||
if (status === 429 && getAIGatewayErrorKind(bodyText) === 'quota_exhausted') {
|
if ((status === 403 || status === 429)
|
||||||
|
&& getAIGatewayErrorKind(bodyText) === 'quota_exhausted') {
|
||||||
return 402;
|
return 402;
|
||||||
}
|
}
|
||||||
if (status === 429 && isAIGatewayUpstreamSaturated(bodyText)) {
|
if (status === 429 && isAIGatewayUpstreamSaturated(bodyText)) {
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import type {
|
|||||||
ConversationToolNode,
|
ConversationToolNode,
|
||||||
PublicUsage,
|
PublicUsage,
|
||||||
} from '../contracts';
|
} from '../contracts';
|
||||||
import { isAIGatewayUserContextMissing } from '../../../shared/ai-gateway-error-details';
|
|
||||||
import type { PiRpcEvent } from './rpc-client';
|
import type { PiRpcEvent } from './rpc-client';
|
||||||
|
import { projectPiProviderFailure } from './provider-failure';
|
||||||
import { subagentDetailsOfResult } from '../subagent-protocol';
|
import { subagentDetailsOfResult } from '../subagent-protocol';
|
||||||
import {
|
import {
|
||||||
isProductToolName,
|
isProductToolName,
|
||||||
@@ -45,17 +45,6 @@ function retryFailure(message: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function providerFailure(message: unknown) {
|
|
||||||
if (typeof message === 'string' && isAIGatewayUserContextMissing(message)) {
|
|
||||||
return {
|
|
||||||
code: 'CODING_PROVIDER_AUTH_REQUIRED' as const,
|
|
||||||
message: '模型服务身份上下文无效,请重试;若仍失败请重新登录。',
|
|
||||||
recoverable: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return retryFailure('模型服务请求失败,请稍后重试。');
|
|
||||||
}
|
|
||||||
|
|
||||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||||
? value as Record<string, unknown>
|
? value as Record<string, unknown>
|
||||||
@@ -294,7 +283,7 @@ export class PiEventProjector {
|
|||||||
...run,
|
...run,
|
||||||
status: 'error',
|
status: 'error',
|
||||||
terminalReason: 'failed',
|
terminalReason: 'failed',
|
||||||
error: providerFailure(event.finalError),
|
error: projectPiProviderFailure(event.finalError),
|
||||||
},
|
},
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
@@ -485,7 +474,7 @@ export class PiEventProjector {
|
|||||||
...snapshot.run,
|
...snapshot.run,
|
||||||
status: 'error',
|
status: 'error',
|
||||||
terminalReason: 'failed',
|
terminalReason: 'failed',
|
||||||
error: providerFailure(assistant.errorMessage),
|
error: projectPiProviderFailure(assistant.errorMessage),
|
||||||
},
|
},
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { CodingRuntimePublicError } from '../contracts';
|
||||||
|
import { isAIGatewayUserContextMissing } from '../../../shared/ai-gateway-error-details';
|
||||||
|
import { getAIGatewayErrorKind } from '../../../shared/ai-gateway-error-kind';
|
||||||
|
|
||||||
|
export function projectPiProviderFailure(message: unknown): CodingRuntimePublicError {
|
||||||
|
if (typeof message === 'string' && isAIGatewayUserContextMissing(message)) {
|
||||||
|
return {
|
||||||
|
code: 'CODING_PROVIDER_AUTH_REQUIRED',
|
||||||
|
message: '模型服务身份上下文无效,请重试;若仍失败请重新登录。',
|
||||||
|
recoverable: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (typeof message === 'string' && getAIGatewayErrorKind(message) === 'quota_exhausted') {
|
||||||
|
return {
|
||||||
|
code: 'CODING_PROVIDER_QUOTA_EXHAUSTED',
|
||||||
|
message: '词元点数余额不足,请充值后重试。',
|
||||||
|
recoverable: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
code: 'CODING_RUNTIME_START_FAILED',
|
||||||
|
message: '模型服务请求失败,请稍后重试。',
|
||||||
|
recoverable: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
} from './event-projector';
|
} from './event-projector';
|
||||||
import { isProductToolName, productToolDetailsOfResult } from '../product-tool-protocol';
|
import { isProductToolName, productToolDetailsOfResult } from '../product-tool-protocol';
|
||||||
import { subagentDetailsOfResult } from '../subagent-protocol';
|
import { subagentDetailsOfResult } from '../subagent-protocol';
|
||||||
|
import { projectPiProviderFailure } from './provider-failure';
|
||||||
|
|
||||||
export interface PiSessionSnapshotInput {
|
export interface PiSessionSnapshotInput {
|
||||||
snapshot: ConversationSnapshot;
|
snapshot: ConversationSnapshot;
|
||||||
@@ -106,23 +107,6 @@ function activePath(entriesValue: unknown, leafId: unknown): SessionEntryRecord[
|
|||||||
return path.reverse();
|
return path.reverse();
|
||||||
}
|
}
|
||||||
|
|
||||||
function retainedTail(path: SessionEntryRecord[]): SessionEntryRecord[] {
|
|
||||||
const compactionIndex = path.findLastIndex((entry) => entry.type === 'compaction');
|
|
||||||
if (compactionIndex < 0) return path;
|
|
||||||
const compaction = path[compactionIndex];
|
|
||||||
const firstKeptEntryId = compaction.firstKeptEntryId;
|
|
||||||
if (typeof firstKeptEntryId !== 'string') unreadable();
|
|
||||||
const firstKeptIndex = path.findIndex(
|
|
||||||
(entry, index) => index < compactionIndex && entry.id === firstKeptEntryId,
|
|
||||||
);
|
|
||||||
if (firstKeptIndex < 0) unreadable();
|
|
||||||
return [
|
|
||||||
compaction,
|
|
||||||
...path.slice(firstKeptIndex, compactionIndex),
|
|
||||||
...path.slice(compactionIndex + 1),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function contentBlocks(
|
async function contentBlocks(
|
||||||
messageId: string,
|
messageId: string,
|
||||||
content: unknown,
|
content: unknown,
|
||||||
@@ -265,6 +249,19 @@ async function projectEntries(
|
|||||||
...(stopReason ? { stopReason } : {}),
|
...(stopReason ? { stopReason } : {}),
|
||||||
};
|
};
|
||||||
nodes.push(node);
|
nodes.push(node);
|
||||||
|
if (message.stopReason === 'error') {
|
||||||
|
const failure = projectPiProviderFailure(message.errorMessage);
|
||||||
|
if (failure.code === 'CODING_PROVIDER_AUTH_REQUIRED'
|
||||||
|
|| failure.code === 'CODING_PROVIDER_QUOTA_EXHAUSTED') {
|
||||||
|
nodes.push({
|
||||||
|
kind: 'notice',
|
||||||
|
id: `${messageId}:provider-error`,
|
||||||
|
code: failure.code,
|
||||||
|
level: 'error',
|
||||||
|
message: failure.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!Array.isArray(message.content)) continue;
|
if (!Array.isArray(message.content)) continue;
|
||||||
for (const value of message.content) {
|
for (const value of message.content) {
|
||||||
const block = asRecord(value);
|
const block = asRecord(value);
|
||||||
@@ -310,6 +307,16 @@ function reconcileLiveIds(
|
|||||||
liveNodes: ConversationNode[],
|
liveNodes: ConversationNode[],
|
||||||
): ConversationNode[] {
|
): ConversationNode[] {
|
||||||
const used = new Set<string>();
|
const used = new Set<string>();
|
||||||
|
const durableCompactions = durableNodes.filter((node) => node.kind === 'compaction');
|
||||||
|
const liveCompactions = liveNodes.filter((node) => node.kind === 'compaction');
|
||||||
|
const compactionMatches = new Map<string, Extract<ConversationNode, { kind: 'compaction' }>>();
|
||||||
|
for (
|
||||||
|
let durableIndex = durableCompactions.length - 1, liveIndex = liveCompactions.length - 1;
|
||||||
|
durableIndex >= 0 && liveIndex >= 0;
|
||||||
|
durableIndex -= 1, liveIndex -= 1
|
||||||
|
) {
|
||||||
|
compactionMatches.set(durableCompactions[durableIndex].id, liveCompactions[liveIndex]);
|
||||||
|
}
|
||||||
return durableNodes.map((node) => {
|
return durableNodes.map((node) => {
|
||||||
if (node.kind === 'message') {
|
if (node.kind === 'message') {
|
||||||
const live = liveNodes.find((candidate) => candidate.kind === 'message'
|
const live = liveNodes.find((candidate) => candidate.kind === 'message'
|
||||||
@@ -338,10 +345,8 @@ function reconcileLiveIds(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (node.kind === 'compaction') {
|
if (node.kind === 'compaction') {
|
||||||
const live = liveNodes.findLast(
|
const live = compactionMatches.get(node.id);
|
||||||
(candidate) => candidate.kind === 'compaction' && !used.has(candidate.id),
|
if (!live) return node;
|
||||||
);
|
|
||||||
if (!live || live.kind !== 'compaction') return node;
|
|
||||||
used.add(live.id);
|
used.add(live.id);
|
||||||
return { ...node, id: live.id, runId: live.runId };
|
return { ...node, id: live.id, runId: live.runId };
|
||||||
}
|
}
|
||||||
@@ -375,7 +380,7 @@ export async function projectPiSessionSnapshot(
|
|||||||
const response = asRecord(input.entries);
|
const response = asRecord(input.entries);
|
||||||
const state = asRecord(input.state);
|
const state = asRecord(input.state);
|
||||||
if (!response || !state) unreadable();
|
if (!response || !state) unreadable();
|
||||||
const path = retainedTail(activePath(response.entries, response.leafId));
|
const path = activePath(response.entries, response.leafId);
|
||||||
const durableNodes = await projectEntries(path, input);
|
const durableNodes = await projectEntries(path, input);
|
||||||
const nodes = reconcileLiveIds(durableNodes, input.snapshot.nodes);
|
const nodes = reconcileLiveIds(durableNodes, input.snapshot.nodes);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ export type AIGatewayErrorKind = 'quota_exhausted' | 'authentication_invalid';
|
|||||||
export function isTokenBalanceExhausted(message: string | null | undefined): boolean {
|
export function isTokenBalanceExhausted(message: string | null | undefined): boolean {
|
||||||
const normalized = message?.trim().toLowerCase() ?? '';
|
const normalized = message?.trim().toLowerCase() ?? '';
|
||||||
return normalized.includes('token_balance_exhausted')
|
return normalized.includes('token_balance_exhausted')
|
||||||
|
|| normalized.includes('token_point_balance_exhausted')
|
||||||
|| normalized.includes('token balance exhausted')
|
|| normalized.includes('token balance exhausted')
|
||||||
|| normalized.includes('balance exhausted');
|
|| normalized.includes('balance exhausted')
|
||||||
|
|| normalized.includes('词元点数余额不足');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAIGatewayErrorKind(message: string | null | undefined): AIGatewayErrorKind | null {
|
export function getAIGatewayErrorKind(message: string | null | undefined): AIGatewayErrorKind | null {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export type CodingRuntimeErrorCode =
|
|||||||
| 'CODING_RUNTIME_READY_TIMEOUT'
|
| 'CODING_RUNTIME_READY_TIMEOUT'
|
||||||
| 'CODING_RUNTIME_PROTOCOL_ERROR'
|
| 'CODING_RUNTIME_PROTOCOL_ERROR'
|
||||||
| 'CODING_PROVIDER_AUTH_REQUIRED'
|
| 'CODING_PROVIDER_AUTH_REQUIRED'
|
||||||
|
| 'CODING_PROVIDER_QUOTA_EXHAUSTED'
|
||||||
| 'CODING_MODEL_UNAVAILABLE'
|
| 'CODING_MODEL_UNAVAILABLE'
|
||||||
| 'CODING_SESSION_UNREADABLE'
|
| 'CODING_SESSION_UNREADABLE'
|
||||||
| 'CODING_STORAGE_WRITE_FAILED'
|
| 'CODING_STORAGE_WRITE_FAILED'
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ const ERROR_CODES = new Set([
|
|||||||
'CODING_RUNTIME_READY_TIMEOUT',
|
'CODING_RUNTIME_READY_TIMEOUT',
|
||||||
'CODING_RUNTIME_PROTOCOL_ERROR',
|
'CODING_RUNTIME_PROTOCOL_ERROR',
|
||||||
'CODING_PROVIDER_AUTH_REQUIRED',
|
'CODING_PROVIDER_AUTH_REQUIRED',
|
||||||
|
'CODING_PROVIDER_QUOTA_EXHAUSTED',
|
||||||
'CODING_MODEL_UNAVAILABLE',
|
'CODING_MODEL_UNAVAILABLE',
|
||||||
'CODING_SESSION_UNREADABLE',
|
'CODING_SESSION_UNREADABLE',
|
||||||
'CODING_STORAGE_WRITE_FAILED',
|
'CODING_STORAGE_WRITE_FAILED',
|
||||||
|
|||||||
@@ -1265,6 +1265,19 @@ function processItemIsFailed(item: ProcessItem): boolean {
|
|||||||
return item.kind === 'notice' && item.node.level === 'error';
|
return item.kind === 'notice' && item.node.level === 'error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function persistedProviderFailureMessage(items: ProcessItem[]): string | undefined {
|
||||||
|
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||||
|
const item = items[index];
|
||||||
|
if (item?.kind === 'notice'
|
||||||
|
&& item.node.level === 'error'
|
||||||
|
&& (item.node.code === 'CODING_PROVIDER_AUTH_REQUIRED'
|
||||||
|
|| item.node.code === 'CODING_PROVIDER_QUOTA_EXHAUSTED')) {
|
||||||
|
return item.node.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function processGroupIsFailed(
|
function processGroupIsFailed(
|
||||||
items: ProcessItem[],
|
items: ProcessItem[],
|
||||||
run: ConversationRunState,
|
run: ConversationRunState,
|
||||||
@@ -1326,10 +1339,13 @@ const ProcessGroup = memo(function ProcessGroup({
|
|||||||
? runningDurationLabel(run, now)
|
? runningDurationLabel(run, now)
|
||||||
: settledDurationLabel(run)
|
: settledDurationLabel(run)
|
||||||
: null;
|
: null;
|
||||||
|
const failureMessage = failed
|
||||||
|
? (latest ? run.error?.message : undefined) ?? persistedProviderFailureMessage(items)
|
||||||
|
: undefined;
|
||||||
const summary = active
|
const summary = active
|
||||||
? `处理中${duration ? ` ${duration}` : ''}`
|
? `处理中${duration ? ` ${duration}` : ''}`
|
||||||
: failed
|
: failed
|
||||||
? `处理失败${duration ? ` · ${duration}` : ''}`
|
? `${failureMessage ?? '处理失败'}${duration ? ` · ${duration}` : ''}`
|
||||||
: `已处理${duration ? ` ${duration}` : ''}`;
|
: `已处理${duration ? ` ${duration}` : ''}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1491,11 +1507,33 @@ export const CodingConversationTimeline = memo(function CodingConversationTimeli
|
|||||||
const nodes = useCodingConversationStore(selectNodes);
|
const nodes = useCodingConversationStore(selectNodes);
|
||||||
const run = useCodingConversationStore(selectRun);
|
const run = useCodingConversationStore(selectRun);
|
||||||
const [visibleLimit, setVisibleLimit] = useState(INITIAL_WINDOW);
|
const [visibleLimit, setVisibleLimit] = useState(INITIAL_WINDOW);
|
||||||
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const stickToBottomRef = useRef(true);
|
||||||
|
const prependAnchorRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null);
|
||||||
const windowStart = Math.max(0, nodes.length - visibleLimit);
|
const windowStart = Math.max(0, nodes.length - visibleLimit);
|
||||||
const visibleNodes = nodes.slice(windowStart);
|
const visibleNodes = nodes.slice(windowStart);
|
||||||
const turns = useMemo(() => timelineTurns(visibleNodes), [visibleNodes]);
|
const turns = useMemo(() => timelineTurns(visibleNodes), [visibleNodes]);
|
||||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const stickToBottomRef = useRef(true);
|
const loadEarlier = useCallback(() => {
|
||||||
|
if (windowStart === 0 || prependAnchorRef.current) return;
|
||||||
|
const element = scrollRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
prependAnchorRef.current = {
|
||||||
|
scrollHeight: element.scrollHeight,
|
||||||
|
scrollTop: element.scrollTop,
|
||||||
|
};
|
||||||
|
stickToBottomRef.current = false;
|
||||||
|
setVisibleLimit((current) => Math.min(nodes.length, current + WINDOW_STEP));
|
||||||
|
}, [nodes.length, windowStart]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const anchor = prependAnchorRef.current;
|
||||||
|
if (!anchor) return;
|
||||||
|
prependAnchorRef.current = null;
|
||||||
|
const element = scrollRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
element.scrollTop = anchor.scrollTop + (element.scrollHeight - anchor.scrollHeight);
|
||||||
|
}, [visibleLimit]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!stickToBottomRef.current) return;
|
if (!stickToBottomRef.current) return;
|
||||||
@@ -1513,6 +1551,7 @@ export const CodingConversationTimeline = memo(function CodingConversationTimeli
|
|||||||
onScroll={(event) => {
|
onScroll={(event) => {
|
||||||
const element = event.currentTarget;
|
const element = event.currentTarget;
|
||||||
stickToBottomRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 64;
|
stickToBottomRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 64;
|
||||||
|
if (element.scrollTop <= 48) loadEarlier();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="mx-auto flex w-full max-w-[50rem] flex-col gap-8">
|
<div className="mx-auto flex w-full max-w-[50rem] flex-col gap-8">
|
||||||
@@ -1521,7 +1560,7 @@ export const CodingConversationTimeline = memo(function CodingConversationTimeli
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="mx-auto min-h-9 rounded-lg px-4 text-xs transition-transform duration-150 ease-out active:scale-[0.96]"
|
className="mx-auto min-h-9 rounded-lg px-4 text-xs transition-transform duration-150 ease-out active:scale-[0.96]"
|
||||||
onClick={() => setVisibleLimit((current) => current + WINDOW_STEP)}
|
onClick={loadEarlier}
|
||||||
>
|
>
|
||||||
加载更早内容
|
加载更早内容
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -143,6 +143,12 @@ async function installCodingFirstChatHost(
|
|||||||
: null,
|
: null,
|
||||||
modelResolution: featureComplete ? 'resolved' : 'required',
|
modelResolution: featureComplete ? 'resolved' : 'required',
|
||||||
};
|
};
|
||||||
|
const historyConversation = {
|
||||||
|
...conversation,
|
||||||
|
id: 'conversation-pi-history',
|
||||||
|
title: 'History and quota',
|
||||||
|
updatedAt: '2026-08-23T23:58:00.000Z',
|
||||||
|
};
|
||||||
const snapshot = {
|
const snapshot = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
conversation: {
|
conversation: {
|
||||||
@@ -353,6 +359,39 @@ async function installCodingFirstChatHost(
|
|||||||
error: { code: 'CODING_RUNTIME_START_FAILED', message: 'Worker stopped', recoverable: true },
|
error: { code: 'CODING_RUNTIME_START_FAILED', message: 'Worker stopped', recoverable: true },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
const historySnapshot = {
|
||||||
|
...snapshot,
|
||||||
|
conversation: {
|
||||||
|
...snapshot.conversation,
|
||||||
|
id: historyConversation.id,
|
||||||
|
title: historyConversation.title,
|
||||||
|
},
|
||||||
|
nodes: [
|
||||||
|
...Array.from({ length: 150 }, (_, index) => ({
|
||||||
|
kind: 'message',
|
||||||
|
id: `history-message-${index}`,
|
||||||
|
role: 'user',
|
||||||
|
status: 'complete',
|
||||||
|
blocks: [{
|
||||||
|
kind: 'text',
|
||||||
|
id: `history-message-${index}:content:0`,
|
||||||
|
text: `History message ${index}`,
|
||||||
|
status: 'complete',
|
||||||
|
}],
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
kind: 'notice',
|
||||||
|
id: 'history-quota-notice',
|
||||||
|
code: 'CODING_PROVIDER_QUOTA_EXHAUSTED',
|
||||||
|
level: 'error',
|
||||||
|
message: '词元点数余额不足,请充值后重试。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
run: { status: 'idle' },
|
||||||
|
queue: { items: [] },
|
||||||
|
pendingInteractions: [],
|
||||||
|
worker: { status: 'ready', generation: 1 },
|
||||||
|
};
|
||||||
const respond = (json: unknown, status = 200) => ({
|
const respond = (json: unknown, status = 200) => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
data: { status, ok: status >= 200 && status < 300, json },
|
data: { status, ok: status >= 200 && status < 300, json },
|
||||||
@@ -453,7 +492,7 @@ async function installCodingFirstChatHost(
|
|||||||
if (path === `/api/coding/projects/conversations?projectId=${project.id}`) {
|
if (path === `/api/coding/projects/conversations?projectId=${project.id}`) {
|
||||||
return respond({
|
return respond({
|
||||||
conversations: featureComplete
|
conversations: featureComplete
|
||||||
? [conversation, secondConversation]
|
? [conversation, secondConversation, historyConversation]
|
||||||
: state.conversationCreated
|
: state.conversationCreated
|
||||||
? [conversation]
|
? [conversation]
|
||||||
: [],
|
: [],
|
||||||
@@ -478,6 +517,9 @@ async function installCodingFirstChatHost(
|
|||||||
if (path === `/api/coding/conversations/${secondConversation.id}/snapshot`) {
|
if (path === `/api/coding/conversations/${secondConversation.id}/snapshot`) {
|
||||||
return respond({ snapshot: secondSnapshot });
|
return respond({ snapshot: secondSnapshot });
|
||||||
}
|
}
|
||||||
|
if (path === `/api/coding/conversations/${historyConversation.id}/snapshot`) {
|
||||||
|
return respond({ snapshot: historySnapshot });
|
||||||
|
}
|
||||||
if (path === `/api/coding/conversations/${conversation.id}/prompt` && method === 'POST') {
|
if (path === `/api/coding/conversations/${conversation.id}/prompt` && method === 'POST') {
|
||||||
return respond({
|
return respond({
|
||||||
acceptance: {
|
acceptance: {
|
||||||
@@ -890,6 +932,29 @@ test('PI feature UI isolates Conversations and exposes queue, interaction, model
|
|||||||
await expect(page.getByRole('textbox')).toHaveValue('');
|
await expect(page.getByRole('textbox')).toHaveValue('');
|
||||||
await page.getByRole('button', { name: '恢复' }).click();
|
await page.getByRole('button', { name: '恢复' }).click();
|
||||||
|
|
||||||
|
await builderConversations.getByRole('button', { name: 'History and quota' }).click();
|
||||||
|
const historyProcess = page.getByTestId('coding-process-group');
|
||||||
|
await expect(historyProcess.locator('summary').first())
|
||||||
|
.toContainText('词元点数余额不足,请充值后重试。');
|
||||||
|
await expect(page.getByText('History message 0')).toHaveCount(0);
|
||||||
|
await expect(page.getByText('History message 31')).toHaveCount(1);
|
||||||
|
const historyTimeline = page.getByTestId('coding-conversation-timeline');
|
||||||
|
const beforeHeight = await historyTimeline.evaluate((element) => {
|
||||||
|
element.scrollTop = 0;
|
||||||
|
const height = element.scrollHeight;
|
||||||
|
element.dispatchEvent(new Event('scroll', { bubbles: true }));
|
||||||
|
return height;
|
||||||
|
});
|
||||||
|
await expect(page.getByText('History message 0')).toHaveCount(1);
|
||||||
|
const anchoredScroll = await historyTimeline.evaluate((element) => ({
|
||||||
|
scrollHeight: element.scrollHeight,
|
||||||
|
scrollTop: element.scrollTop,
|
||||||
|
}));
|
||||||
|
expect(anchoredScroll.scrollTop).toBeGreaterThan(0);
|
||||||
|
expect(Math.abs(
|
||||||
|
anchoredScroll.scrollTop - (anchoredScroll.scrollHeight - beforeHeight),
|
||||||
|
)).toBeLessThanOrEqual(2);
|
||||||
|
|
||||||
await expect(page.getByText(/编程工具|分享|取消分享|回滚|恢复回滚|待办|全局运行时/)).toHaveCount(0);
|
await expect(page.getByText(/编程工具|分享|取消分享|回滚|恢复回滚|待办|全局运行时/)).toHaveCount(0);
|
||||||
const state = await readState(electronApp);
|
const state = await readState(electronApp);
|
||||||
expect(state.captured.some((request) => request.path.endsWith('/model') && request.method === 'POST')).toBe(true);
|
expect(state.captured.some((request) => request.path.endsWith('/model') && request.method === 'POST')).toBe(true);
|
||||||
|
|||||||
@@ -274,6 +274,40 @@ describe('ai proxy routes', () => {
|
|||||||
expect(response.body()).toBe(JSON.stringify({ error: 'user quota is not enough' }));
|
expect(response.body()).toBe(JSON.stringify({ error: 'user quota is not enough' }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('maps the observed token-point balance 403 to a non-retryable quota response', async () => {
|
||||||
|
seedWorksSquareAIGatewayCredential({
|
||||||
|
accessToken: 'ws-ai-token',
|
||||||
|
expiresIn: 3600,
|
||||||
|
oneApiBaseUrl: 'https://one-api.example.com/v1',
|
||||||
|
});
|
||||||
|
const body = JSON.stringify({
|
||||||
|
error: {
|
||||||
|
message: '词元点数余额不足',
|
||||||
|
code: 'token_point_balance_exhausted',
|
||||||
|
type: 'one_api_error',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const fetchMock = vi.fn().mockResolvedValueOnce(
|
||||||
|
new Response(body, {
|
||||||
|
status: 403,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
const response = createResponse();
|
||||||
|
|
||||||
|
await handleAiProxyRoutes(
|
||||||
|
createRequest('POST', { model: 'deepseek-v4-flash', messages: [] }),
|
||||||
|
response.res,
|
||||||
|
new URL('http://127.0.0.1:13210/api/ai-proxy/v1/chat/completions'),
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledOnce();
|
||||||
|
expect(response.statusCode).toBe(402);
|
||||||
|
expect(response.body()).toBe(body);
|
||||||
|
});
|
||||||
|
|
||||||
it('maps rolling-window quota exhaustion to a non-retryable response status', async () => {
|
it('maps rolling-window quota exhaustion to a non-retryable response status', async () => {
|
||||||
seedWorksSquareAIGatewayCredential({
|
seedWorksSquareAIGatewayCredential({
|
||||||
accessToken: 'ws-ai-token',
|
accessToken: 'ws-ai-token',
|
||||||
|
|||||||
@@ -631,7 +631,83 @@ describe('CodingConversationTimeline', () => {
|
|||||||
.toBeVisible();
|
.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('windows long timelines and loads earlier nodes only on demand', async () => {
|
it('shows an actionable non-retryable quota failure in the process summary', async () => {
|
||||||
|
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||||
|
const { CodingConversationTimeline } = await import(
|
||||||
|
'@/pages/Chat/CodingConversationTimeline'
|
||||||
|
);
|
||||||
|
const base = createProductSnapshot('conversation-quota-failure', 1);
|
||||||
|
const snapshot = {
|
||||||
|
...base,
|
||||||
|
run: {
|
||||||
|
status: 'idle' as const,
|
||||||
|
runId: 'run-quota-failure',
|
||||||
|
startedAt: 1_000,
|
||||||
|
settledAt: 6_000,
|
||||||
|
terminalReason: 'failed' as const,
|
||||||
|
error: {
|
||||||
|
code: 'CODING_PROVIDER_QUOTA_EXHAUSTED' as const,
|
||||||
|
message: '词元点数余额不足,请充值后重试。',
|
||||||
|
recoverable: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nodes: [{
|
||||||
|
kind: 'tool' as const,
|
||||||
|
id: 'tool-quota-failure',
|
||||||
|
toolCallId: 'call-quota-failure',
|
||||||
|
toolName: 'bash',
|
||||||
|
title: '执行命令',
|
||||||
|
inputText: '',
|
||||||
|
status: 'error' as const,
|
||||||
|
output: [],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
codingConversationStore.getState().applySnapshotEvent({
|
||||||
|
type: 'snapshot',
|
||||||
|
conversationId: 'conversation-quota-failure',
|
||||||
|
workerGeneration: 1,
|
||||||
|
seq: snapshot.cursor.seq,
|
||||||
|
snapshot,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<CodingConversationTimeline conversationId="conversation-quota-failure" />);
|
||||||
|
|
||||||
|
expect(within(screen.getByTestId('coding-process-group'))
|
||||||
|
.getByText('词元点数余额不足,请充值后重试。 · 5 秒')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an actionable persisted Provider failure after reopening a Conversation', async () => {
|
||||||
|
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||||
|
const { CodingConversationTimeline } = await import(
|
||||||
|
'@/pages/Chat/CodingConversationTimeline'
|
||||||
|
);
|
||||||
|
const base = createProductSnapshot('conversation-persisted-quota-failure', 1);
|
||||||
|
const snapshot = {
|
||||||
|
...base,
|
||||||
|
run: { status: 'idle' as const },
|
||||||
|
nodes: [{
|
||||||
|
kind: 'notice' as const,
|
||||||
|
id: 'persisted-quota-notice',
|
||||||
|
code: 'CODING_PROVIDER_QUOTA_EXHAUSTED',
|
||||||
|
level: 'error' as const,
|
||||||
|
message: '词元点数余额不足,请充值后重试。',
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
codingConversationStore.getState().applySnapshotEvent({
|
||||||
|
type: 'snapshot',
|
||||||
|
conversationId: 'conversation-persisted-quota-failure',
|
||||||
|
workerGeneration: 1,
|
||||||
|
seq: snapshot.cursor.seq,
|
||||||
|
snapshot,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<CodingConversationTimeline conversationId="conversation-persisted-quota-failure" />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('coding-process-group').querySelector('summary'))
|
||||||
|
.toHaveTextContent('词元点数余额不足,请充值后重试。');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('windows long timelines, loads near the top, and preserves the scroll anchor', async () => {
|
||||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||||
const { CodingConversationTimeline } = await import(
|
const { CodingConversationTimeline } = await import(
|
||||||
'@/pages/Chat/CodingConversationTimeline'
|
'@/pages/Chat/CodingConversationTimeline'
|
||||||
@@ -659,8 +735,17 @@ describe('CodingConversationTimeline', () => {
|
|||||||
|
|
||||||
expect(screen.queryByText('Notice 0')).not.toBeInTheDocument();
|
expect(screen.queryByText('Notice 0')).not.toBeInTheDocument();
|
||||||
expect(screen.getByText('Notice 30')).toBeInTheDocument();
|
expect(screen.getByText('Notice 30')).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole('button', { name: '加载更早内容' }));
|
const timeline = screen.getByTestId('coding-conversation-timeline');
|
||||||
expect(screen.getByText('Notice 0')).toBeInTheDocument();
|
Object.defineProperty(timeline, 'scrollHeight', {
|
||||||
|
configurable: true,
|
||||||
|
get: () => screen.queryByText('Notice 0') ? 1_600 : 1_000,
|
||||||
|
});
|
||||||
|
timeline.scrollTop = 0;
|
||||||
|
|
||||||
|
fireEvent.scroll(timeline);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Notice 0')).toBeInTheDocument());
|
||||||
|
expect(timeline.scrollTop).toBe(600);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not commit the selected timeline when a hidden Conversation streams', async () => {
|
it('does not commit the selected timeline when a hidden Conversation streams', async () => {
|
||||||
|
|||||||
@@ -472,6 +472,40 @@ describe('Pi event projector', () => {
|
|||||||
expect(JSON.stringify(snapshot)).not.toContain('secret request detail');
|
expect(JSON.stringify(snapshot)).not.toContain('secret request detail');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('projects token-point balance exhaustion as a non-retryable Provider quota failure', async () => {
|
||||||
|
const projector = new PiEventProjector({ createId: () => 'provider-quota-error-a' });
|
||||||
|
let snapshot = emptySnapshot();
|
||||||
|
const providerFailure = {
|
||||||
|
role: 'assistant',
|
||||||
|
content: [],
|
||||||
|
stopReason: 'error',
|
||||||
|
errorMessage: '403: {"error":{"message":"词元点数余额不足","code":"token_point_balance_exhausted","request_id":"secret-request"}}',
|
||||||
|
timestamp: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
snapshot = apply(snapshot, await projector.project(snapshot, {
|
||||||
|
type: 'message_end',
|
||||||
|
message: providerFailure,
|
||||||
|
}));
|
||||||
|
snapshot = apply(snapshot, await projector.project(snapshot, {
|
||||||
|
type: 'agent_end',
|
||||||
|
messages: [providerFailure],
|
||||||
|
willRetry: false,
|
||||||
|
}));
|
||||||
|
snapshot = apply(snapshot, await projector.project(snapshot, { type: 'agent_settled' }));
|
||||||
|
|
||||||
|
expect(snapshot.run).toMatchObject({
|
||||||
|
status: 'idle',
|
||||||
|
terminalReason: 'failed',
|
||||||
|
error: {
|
||||||
|
code: 'CODING_PROVIDER_QUOTA_EXHAUSTED',
|
||||||
|
message: '词元点数余额不足,请充值后重试。',
|
||||||
|
recoverable: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(snapshot)).not.toContain('secret-request');
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps an exhausted Works user-context retry redacted and Provider-owned', async () => {
|
it('keeps an exhausted Works user-context retry redacted and Provider-owned', async () => {
|
||||||
const projector = new PiEventProjector({ createId: () => 'provider-retry-error-a' });
|
const projector = new PiEventProjector({ createId: () => 'provider-retry-error-a' });
|
||||||
let snapshot = emptySnapshot();
|
let snapshot = emptySnapshot();
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ describe('Pi session projector', () => {
|
|||||||
expect(snapshot.cursor).toEqual({ workerGeneration: 1, seq: 7 });
|
expect(snapshot.cursor).toEqual({ workerGeneration: 1, seq: 7 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies retained-tail compaction and reconciles durable entries without replacing live IDs', async () => {
|
it('projects the full active history around compaction and reconciles durable entries without replacing live IDs', async () => {
|
||||||
const live: ConversationSnapshot = {
|
const live: ConversationSnapshot = {
|
||||||
...baseSnapshot(),
|
...baseSnapshot(),
|
||||||
nodes: [
|
nodes: [
|
||||||
@@ -371,10 +371,11 @@ describe('Pi session projector', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(snapshot.nodes.map(({ kind, id }) => ({ kind, id }))).toEqual([
|
expect(snapshot.nodes.map(({ kind, id }) => ({ kind, id }))).toEqual([
|
||||||
{ kind: 'compaction', id: 'live-compaction-a' },
|
{ kind: 'message', id: 'entry:entry-old-user' },
|
||||||
{ kind: 'message', id: 'live-user-a' },
|
{ kind: 'message', id: 'live-user-a' },
|
||||||
{ kind: 'message', id: 'live-assistant-a' },
|
{ kind: 'message', id: 'live-assistant-a' },
|
||||||
{ kind: 'tool', id: 'live-tool-a' },
|
{ kind: 'tool', id: 'live-tool-a' },
|
||||||
|
{ kind: 'compaction', id: 'live-compaction-a' },
|
||||||
{ kind: 'message', id: 'entry:entry-after-user' },
|
{ kind: 'message', id: 'entry:entry-after-user' },
|
||||||
]);
|
]);
|
||||||
expect(snapshot.nodes).toContainEqual(expect.objectContaining({
|
expect(snapshot.nodes).toContainEqual(expect.objectContaining({
|
||||||
@@ -401,10 +402,55 @@ describe('Pi session projector', () => {
|
|||||||
compaction: 'idle',
|
compaction: 'idle',
|
||||||
recalculating: true,
|
recalculating: true,
|
||||||
});
|
});
|
||||||
expect(JSON.stringify(snapshot)).not.toContain('Summarized old question');
|
expect(JSON.stringify(snapshot)).toContain('Summarized old question');
|
||||||
expect(JSON.stringify(snapshot)).not.toContain('summary must stay hidden');
|
expect(JSON.stringify(snapshot)).not.toContain('summary must stay hidden');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('restores persisted Provider quota failures as safe actionable notices', async () => {
|
||||||
|
const snapshot = await projectPiSessionSnapshot({
|
||||||
|
snapshot: baseSnapshot(),
|
||||||
|
workerGeneration: 2,
|
||||||
|
state: { sessionId: 'pi-session-a', isStreaming: false, isCompacting: false },
|
||||||
|
entries: {
|
||||||
|
leafId: 'entry-quota-error',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
type: 'message',
|
||||||
|
id: 'entry-quota-user',
|
||||||
|
parentId: null,
|
||||||
|
message: { role: 'user', content: 'Continue', timestamp: 1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'message',
|
||||||
|
id: 'entry-quota-error',
|
||||||
|
parentId: 'entry-quota-user',
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: [],
|
||||||
|
stopReason: 'error',
|
||||||
|
errorMessage: '403: {"message":"词元点数余额不足","code":"token_point_balance_exhausted","request_id":"secret-request"}',
|
||||||
|
timestamp: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(snapshot.nodes).toContainEqual(expect.objectContaining({
|
||||||
|
kind: 'message',
|
||||||
|
id: 'entry:entry-quota-error',
|
||||||
|
status: 'error',
|
||||||
|
}));
|
||||||
|
expect(snapshot.nodes).toContainEqual({
|
||||||
|
kind: 'notice',
|
||||||
|
id: 'entry:entry-quota-error:provider-error',
|
||||||
|
code: 'CODING_PROVIDER_QUOTA_EXHAUSTED',
|
||||||
|
level: 'error',
|
||||||
|
message: '词元点数余额不足,请充值后重试。',
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(snapshot)).not.toContain('secret-request');
|
||||||
|
});
|
||||||
|
|
||||||
it('projects persisted images through attachment storage without retaining base64', async () => {
|
it('projects persisted images through attachment storage without retaining base64', async () => {
|
||||||
const rawImage = 'A'.repeat(1024 * 1024);
|
const rawImage = 'A'.repeat(1024 * 1024);
|
||||||
const projected = await projectPiSessionSnapshot({
|
const projected = await projectPiSessionSnapshot({
|
||||||
|
|||||||
Reference in new issue
Block a user