Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
103 commits
Select commit Hold shift + click to select a range
a0aa52a
feat(usage): add a Usage tab with provider limits and usage history
KarthickMahalingam Oct 28, 2026
c0891e4
fix(types): add apiProvider and modelId to ClineApiReqInfo
KarthickMahalingam Oct 28, 2026
a7ba4ad
test(visual): add Usage tab baselines
KarthickMahalingam Oct 28, 2026
0774432
fix(usage): wrap long text instead of clipping it at the reflow width
KarthickMahalingam Oct 28, 2026
010a565
fix(usage): address review feedback on quotas, cache hit rate, and retry
KarthickMahalingam Oct 28, 2026
56a7085
fix(usage): reconcile condense cost and record the resolved model
KarthickMahalingam Oct 28, 2026
a2cf471
fix(usage): use the provider registry in a test; refresh baselines fo…
KarthickMahalingam Oct 28, 2026
dafd502
fix(usage): cancel Copilot quota lookups with the request, unique req…
KarthickMahalingam Oct 28, 2026
cda3475
test(usage): require exactly one quota call, and avoid a racy dynamic…
KarthickMahalingam Oct 28, 2026
f263acf
style: format Task.spec
KarthickMahalingam Oct 28, 2026
ae50b61
fix(usage): do not join a cancelled quota request; ignore non-finite …
KarthickMahalingam Oct 28, 2026
9191d4a
fix(usage): clear a stale usageByModel when messages are replaced; cl…
KarthickMahalingam Oct 28, 2026
56d07b1
test: use providerIdentifiers in the usageByModel store test
KarthickMahalingam Oct 28, 2026
017237c
fix(usage): cancel profile loading and the Copilot token stage; add s…
KarthickMahalingam Oct 28, 2026
ed9657f
fix(usage): compute metadata from the persisted (merged) messages; as…
KarthickMahalingam Oct 28, 2026
f19d08a
refactor(usage): remove guards made unreachable by the abortable waits
KarthickMahalingam Oct 28, 2026
094138b
Merge branch 'main' into feat/usage-tab
KarthickMahalingam Oct 29, 2026
cb58245
Merge branch 'main' into feat/usage-tab
KarthickMahalingam Oct 29, 2026
ab61e55
test(usage): pin the cache lifetime and hostile base URLs; report a C…
KarthickMahalingam Oct 29, 2026
a6a594c
feat(usage): translate the names of fixed provider-limit windows
KarthickMahalingam Oct 29, 2026
3074d68
Merge branch 'main' into feat/usage-tab
KarthickMahalingam Oct 30, 2026
361be13
test(usage): cover the day-count window name
KarthickMahalingam Oct 30, 2026
1586ed0
Merge branch 'main' into feat/usage-tab
KarthickMahalingam Oct 30, 2026
bcb4e40
Merge branch 'main' into feat/usage-tab
KarthickMahalingam Oct 31, 2026
dc9d128
fix(usage): record the serving handler per request; OpenRouter total …
KarthickMahalingam Oct 31, 2026
ef34663
Merge branch 'main' into feat/usage-tab
KarthickMahalingam Oct 31, 2026
f616c70
fix(lint): hold the served model id in an object, not a let that is a…
KarthickMahalingam Oct 31, 2026
34335c4
fix(usage): skip non-object stored requests; abort quota requests on …
KarthickMahalingam Oct 31, 2026
a7b0743
fix(usage): query the active profile's own credentials; deadline for …
KarthickMahalingam Oct 31, 2026
d66e03d
fix(ci): add the new export to the handler mock; drop the unused load…
KarthickMahalingam Oct 31, 2026
89025cc
Merge branch 'main' into feat/usage-tab
KarthickMahalingam Oct 31, 2026
e544e9c
fix(usage): keep another key's cache entry; abort on sidebar dispose;…
KarthickMahalingam Oct 31, 2026
3c06690
Merge branch 'main' into feat/usage-tab
KarthickMahalingam Oct 31, 2026
51fbd59
Merge branch 'main' into feat/usage-tab
KarthickMahalingam Oct 31, 2026
c27fe1d
fix(usage): inert CSV counters; an explicit usage clear wins over ano…
KarthickMahalingam Oct 31, 2026
e65ba25
fix(usage): one selection for the totals and the CSV; stronger assert…
KarthickMahalingam Oct 31, 2026
e545685
fix(usage): leave out times a date cannot represent; the cost-only re…
KarthickMahalingam Nov 1, 2026
adf0204
fix(usage): a bounded range ends with today; times whose local day is…
KarthickMahalingam Nov 1, 2026
f4d70a4
test(usage): build the ordinary-date fixture from a fixed UTC time
KarthickMahalingam Nov 1, 2026
54ff15b
Merge branch 'main' into feat/usage-tab
KarthickMahalingam Nov 1, 2026
78467a9
Merge branch 'main' into feat/usage-tab
KarthickMahalingam Nov 2, 2026
b11511e
Merge branch 'main' into feat/usage-tab
KarthickMahalingam Nov 3, 2026
466964c
fix(usage): the CSV export goes through the host's save dialog, like …
KarthickMahalingam Nov 3, 2026
72be8df
fix(usage): range radio group with arrow keys, an empty workspace mat…
KarthickMahalingam Nov 3, 2026
34d1a66
test: cover the remaining patch lines (non-Error export failure, expo…
KarthickMahalingam Nov 3, 2026
6c320db
fix(usage): a cancelled MiniMax lookup does not try the next URL; cov…
KarthickMahalingam Nov 3, 2026
216a065
fix(usage): a provider-limit request without an id is aborted on disp…
KarthickMahalingam Nov 3, 2026
670f209
fix(usage): the host neutralises every formula cell of the CSV it wri…
KarthickMahalingam Nov 3, 2026
c2415e4
test(usage): Linux baseline for the long-text case
KarthickMahalingam Nov 3, 2026
89acc9a
fix(usage): neutralise a formula cell behind a byte order mark or zer…
KarthickMahalingam Nov 3, 2026
1866ed6
Merge branch 'main' into feat/usage-tab
KarthickMahalingam Nov 4, 2026
1af28da
test(usage): cover the cancelled-between-URLs check of the MiniMax lo…
KarthickMahalingam Nov 4, 2026
affbb79
Merge remote-tracking branch 'origin/feat/usage-tab' into feat/usage-tab
KarthickMahalingam Nov 4, 2026
b0c5540
fix: one task save at a time, from the message write to the history w…
KarthickMahalingam Nov 4, 2026
1d2bc06
test: no new any in the save-ordering tests (typed access helper)
KarthickMahalingam Nov 4, 2026
d291482
fix(usage): an already-aborted profile load consumes the rejection of…
KarthickMahalingam Nov 4, 2026
4731548
test(usage): the already-aborted load test discriminates (a plain fun…
KarthickMahalingam Nov 4, 2026
64ba1be
fix: a message write no longer waits for the metadata stage of the sa…
KarthickMahalingam Nov 4, 2026
51802a7
i18n(pt-BR): complete the quota reset phrase
KarthickMahalingam Nov 4, 2026
7483985
fix(usage): a quota request that was cancelled before it started asks…
KarthickMahalingam Nov 4, 2026
22f677b
fix(usage): NanoGPT and Z.ai skip a window with neither a figure nor …
KarthickMahalingam Nov 4, 2026
6c7ea7b
ci: rerun the extension host tests (the restart persistence test did …
KarthickMahalingam Nov 4, 2026
6a5506d
fix(usage): a degraded NanoGPT window says so in its label; a test of…
KarthickMahalingam Nov 4, 2026
4d6ef34
fix(usage): profile reads start nothing for a cancelled request (lazy…
KarthickMahalingam Nov 4, 2026
539ec8a
test: no raw provider identifier in the profile loading test (lint)
KarthickMahalingam Nov 4, 2026
1481a4f
fix(usage): a click on Refresh is not swallowed by the focus request …
KarthickMahalingam Nov 4, 2026
0f841da
ci: rerun the visual tests (the welcome border contrast check raced a…
KarthickMahalingam Nov 4, 2026
dfa565b
fix(usage): a lost message rewrite is reported to its caller; replaci…
KarthickMahalingam Nov 5, 2026
0bfe08f
Merge branch 'main' into feat/usage-tab
KarthickMahalingam Nov 5, 2026
3ce8ebc
fix(usage): no provider requests while Settings indexes its tabs; the…
KarthickMahalingam Nov 5, 2026
8109f45
test: wait for the indexing to finish instead of a fixed delay; the l…
KarthickMahalingam Nov 5, 2026
f57be39
Merge branch 'main' into feat/usage-tab
KarthickMahalingam Nov 5, 2026
0883ccf
test(usage): the rendered top tasks are checked in order, from the la…
KarthickMahalingam Nov 5, 2026
419199f
test(usage): the rendered top tasks are checked with their complete t…
KarthickMahalingam Nov 5, 2026
e7a8777
fix(usage): the profile reads take the request's abort signal at the …
KarthickMahalingam Nov 5, 2026
45c79e8
Merge branch 'main' into feat/usage-tab
KarthickMahalingam Nov 5, 2026
823d8e5
fix(usage): a Kimi Code profile with no recorded method is an OAuth o…
KarthickMahalingam Nov 5, 2026
a8e5ffe
test(usage): the mock of the profile read declares its parameters, so…
KarthickMahalingam Nov 5, 2026
15c5081
fix(usage): a request is priced with the protocol of the handler that…
KarthickMahalingam Nov 5, 2026
f62f9c3
fix(usage): the provider limits request applies the organization allo…
KarthickMahalingam Nov 5, 2026
261349c
perf(usage): the provider limits request reads the allow list without…
KarthickMahalingam Nov 5, 2026
4cd148c
Merge branch 'main' into feat/usage-tab
KarthickMahalingam Nov 6, 2026
a42498e
fix(history): an edit that cannot be applied after a checkpoint resto…
KarthickMahalingam Nov 6, 2026
dbe6cac
fix(usage,history): the CSV export is inert for semicolon and tab imp…
KarthickMahalingam Nov 6, 2026
fc6ca5c
test: no comma expression in the CSV parser of the tests (lint)
KarthickMahalingam Nov 6, 2026
07a612d
fix(usage): neutralize formulas after line breaks inside CSV cells in…
KarthickMahalingam Nov 6, 2026
4da346b
fix(usage): read all profiles for the provider limits in one locked r…
KarthickMahalingam Nov 6, 2026
a73732b
fix(usage): remember the folder of the last usage export; start the t…
KarthickMahalingam Nov 6, 2026
2377fe1
test(usage): use providerIdentifiers in the bulk profile read test (l…
KarthickMahalingam Nov 6, 2026
cdad28d
fix(usage): provider-limit figures are data the webview translates in…
KarthickMahalingam Nov 6, 2026
2c7ac88
fix(usage): abort queued profile reads when their deadline is up; tel…
KarthickMahalingam Nov 7, 2026
2398387
fix(usage): a closing-note failure is not a failed rewind; a failed o…
KarthickMahalingam Nov 7, 2026
2882542
test(usage): no message argument to expect in the webview tests (tsc)…
KarthickMahalingam Nov 7, 2026
c189fa2
fix(checkpoints): await the restart after a failed rewind, tell the u…
KarthickMahalingam Nov 9, 2026
d508e90
Merge branch 'main' into feat/usage-tab
KarthickMahalingam Nov 9, 2026
e80c422
fix(checkpoints): await and verify the restart after a rewind that wo…
KarthickMahalingam Nov 9, 2026
108a349
Merge origin/feat/usage-tab
KarthickMahalingam Nov 9, 2026
ca7198d
ci: rerun (Windows ShadowCheckpointService hook timeout; the file is …
KarthickMahalingam Nov 9, 2026
5fc57fb
fix(checkpoints,usage): one restart for a delete, only for the restor…
KarthickMahalingam Nov 9, 2026
494d614
fix(usage): serve a cached Copilot quota only to its own session; fre…
KarthickMahalingam Nov 9, 2026
6157aff
fix(usage): show a task's own name in the top list; fix the French re…
KarthickMahalingam Nov 9, 2026
09d01a2
fix(usage): neutralize spaced formulas in CSV cells and after separat…
KarthickMahalingam Nov 9, 2026
db67a63
fix(usage): tell the user when the usage export is too large; fix the…
KarthickMahalingam Nov 9, 2026
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
35 changes: 35 additions & 0 deletions packages/types/src/__tests__/history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,38 @@ describe("historyItemSchema", () => {
expect(historyItemSchema.parse(baseItem)).not.toHaveProperty("title")
})
})

describe("usageByModel on history items", () => {
const base = { id: "task-1", number: 1, ts: 1, task: "t", tokensIn: 1, tokensOut: 1, totalCost: 0 }

it("preserves every field of a per-model entry", () => {
const entry = {
apiProvider: "provider-a",
modelId: "model-a",
requests: 3,
tokensIn: 100,
tokensOut: 50,
cacheReads: 40,
cacheWrites: 10,
cost: 0.25,
}
expect(historyItemSchema.parse({ ...base, usageByModel: [entry, { ...entry, modelId: "model-b" }] })).toEqual({
...base,
usageByModel: [entry, { ...entry, modelId: "model-b" }],
})
})

it("accepts an entry with no provider, model or cache counts, and an item with no breakdown", () => {
const bare = { requests: 1, tokensIn: 2, tokensOut: 3, cost: 0 }
expect(historyItemSchema.parse({ ...base, usageByModel: [bare] }).usageByModel).toEqual([bare])
expect(historyItemSchema.parse(base).usageByModel).toBeUndefined()
})

it.each([
["a missing request count", { tokensIn: 1, tokensOut: 1, cost: 0 }],
["a non-numeric cost", { requests: 1, tokensIn: 1, tokensOut: 1, cost: "free" }],
["a non-numeric token count", { requests: 1, tokensIn: "1", tokensOut: 1, cost: 0 }],
])("rejects %s", (_name, entry) => {
expect(historyItemSchema.safeParse({ ...base, usageByModel: [entry] }).success).toBe(false)
})
})
1 change: 1 addition & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@ export const globalSettingsSchema = z.object({
lastSettingsExportPath: z.string().optional(),
lastTaskExportPath: z.string().optional(),
lastImageSavePath: z.string().optional(),
lastUsageExportPath: z.string().optional(),

/**
* Path to worktree to auto-open after switching workspaces.
Expand Down
19 changes: 19 additions & 0 deletions packages/types/src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,23 @@ export const pendingTaskActionSchema = z.discriminatedUnion("kind", [

export type PendingTaskAction = z.infer<typeof pendingTaskActionSchema>

/**
* Token/cost totals for one provider+model within a task. A task can span
* several (the user may switch profiles mid-task), so history keeps a list.
*/
export const usageByModelEntrySchema = z.object({
apiProvider: z.string().optional(),
modelId: z.string().optional(),
requests: z.number(),
tokensIn: z.number(),
tokensOut: z.number(),
cacheReads: z.number().optional(),
cacheWrites: z.number().optional(),
cost: z.number(),
})

export type UsageByModelEntry = z.infer<typeof usageByModelEntrySchema>

export const historyItemSchema = z.object({
id: z.string(),
rootTaskId: z.string().optional(),
Expand All @@ -39,6 +56,8 @@ export const historyItemSchema = z.object({
cacheWrites: z.number().optional(),
cacheReads: z.number().optional(),
totalCost: z.number(),
/** Per provider+model breakdown, recorded as requests happen. Absent on tasks created before this was tracked. */
usageByModel: z.array(usageByModelEntrySchema).optional(),
size: z.number().optional(),
workspace: z.string().optional(),
mode: z.string().optional(),
Expand Down
85 changes: 85 additions & 0 deletions packages/types/src/providers/vscode-llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,93 @@ export const vsCodeLmModelsMessageTypes = [
"githubCopilotManageAccount",
"githubCopilotSignInResult",
"githubCopilotModels",
"requestProviderQuotas",
"cancelProviderQuotas",
"providerQuotas",
] as const

/** The fixed name of a window, so the webview can show it in the user's language. */
export type ProviderQuotaWindowName = {
key: "weekly" | "daily" | "monthly" | "unlimited" | "hours" | "days" | "credits" | "tokens"
count?: number
}

/**
* The figure of a window, as data, so the webview writes the words around it in the user's language. The amounts are
* already formatted (a currency, a count): they are not translated. `valueLabel` stays as the English fallback.
*/
export type ProviderQuotaValue =
| { kind: "usedOf"; used: string; limit: string }
| { kind: "percentUsed"; percent: number }
| { kind: "spentMonth" | "spentTotal" | "left"; amount: string }
| { kind: "notReported" }
/** `window` is the English name of the window, for one that has no `name`. */
| { kind: "creditsOf"; window: string; name?: ProviderQuotaWindowName; used: string; total: string }
| { kind: "windowState"; window: string; name?: ProviderQuotaWindowName; state: string }
| { kind: "windowDegraded"; window: string; name?: ProviderQuotaWindowName }

/**
* One limit/balance a provider reports (e.g. Copilot premium credits for the
* month, an OpenRouter key limit, a DeepSeek prepaid balance).
*/
export interface ProviderQuotaWindow {
/** Stable key, e.g. "premium", "key_limit", "balance". */
id: string
/** Pre-formatted primary figure, e.g. "39,389.3 / 140,000 used", "$12.40 left". */
valueLabel: string
/**
* Set when the label is just the name of a fixed window ("Weekly", "5-hour", "Unlimited"), so the webview can
* show it in the user's language. `valueLabel` stays as the English fallback.
*/
name?: ProviderQuotaWindowName
/** Set when the figure has words around it ("$5.00 left", "12 / 20 used"): the webview writes them in the user's language. */
value?: ProviderQuotaValue
/** 0–100 when the provider reports a bounded allowance. */
usedPercent?: number
/** Epoch ms. */
resetAt?: number
}

export type ProviderQuota =
| {
providerId: string
ok: true
plan?: string
windows: ProviderQuotaWindow[]
fetchedAt: number
}
| {
providerId: string
ok: false
reason: "signedOut" | "noCopilot" | "noKey" | "unsupported" | "error"
message?: string
fetchedAt: number
}

/**
* The signed-in account's GitHub Copilot premium-request allowance, as reported by
* `GET /copilot_internal/user` (the same source Copilot Chat's own usage panel reads).
*/
export interface GitHubCopilotQuota {
/** e.g. "business", "individual". */
plan?: string
unlimited: boolean
/** Total credits for the period; undefined when unlimited or not reported. */
entitlement?: number
/** Credits used this period, `entitlement - quota_remaining` (one decimal, like GitHub's panel). */
used?: number
/** 0–100. */
usedPercent?: number
/** Epoch ms when the allowance resets. */
resetAt?: number
/** Epoch ms when this snapshot was fetched. */
fetchedAt: number
}

export type GitHubCopilotQuotaResult =
| { ok: true; quota: GitHubCopilotQuota }
| { ok: false; reason: "signedOut" | "noCopilot" | "error"; message?: string }

export const vsCodeLmModelsMessageTypeSchema = z.enum(vsCodeLmModelsMessageTypes)

export const VsCodeLmModelsMessageType = vsCodeLmModelsMessageTypeSchema.enum
Expand Down
16 changes: 15 additions & 1 deletion packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { RouterModelsMessageType, type ModelInfo, type ModelRecord, type RouterM
import { LmStudioModelsMessageType } from "./providers/lm-studio.js"
import { OllamaModelsMessageType } from "./providers/ollama.js"
import { OpenAiModelsMessageType } from "./providers/openai.js"
import { VsCodeLmModelsMessageType } from "./providers/vscode-llm.js"
import { VsCodeLmModelsMessageType, type ProviderQuota } from "./providers/vscode-llm.js"
import type { OpenAiCodexRateLimitInfo } from "./providers/openai-codex-rate-limits.js"
import type { SkillMetadata } from "./skills.js"
import type { RuleMetadata } from "./rules.js"
Expand Down Expand Up @@ -50,6 +50,7 @@ export interface ExtensionMessage {
| typeof VsCodeLmModelsMessageType.vsCodeLmModels
| typeof VsCodeLmModelsMessageType.githubCopilotModels
| typeof VsCodeLmModelsMessageType.githubCopilotSignInResult
| typeof VsCodeLmModelsMessageType.providerQuotas
| "settingsSaveResult"
| "vsCodeLmApiAvailable"
| "updatePrompt"
Expand Down Expand Up @@ -160,6 +161,9 @@ export interface ExtensionMessage {
modelInfo?: ModelInfo
}[]
githubCopilotAccount?: string | null
providerQuotas?: ProviderQuota[]
/** True when the quotas could not be loaded at all (as opposed to "no provider has a usage API"). */
providerQuotasFailed?: boolean
mcpServers?: McpServer[]
commits?: GitCommit[]
listApiConfig?: ProviderSettingsEntry[]
Expand Down Expand Up @@ -546,6 +550,7 @@ export interface WebviewMessage {
| "renameTaskWithId"
| "importSettings"
| "exportSettings"
| "exportUsageCsv"
| "resetState"
| typeof RouterModelsMessageType.flushRouterModels
| typeof RouterModelsMessageType.requestRouterModels
Expand All @@ -557,6 +562,8 @@ export interface WebviewMessage {
| typeof VsCodeLmModelsMessageType.githubCopilotSignIn
| typeof VsCodeLmModelsMessageType.githubCopilotReconnect
| typeof VsCodeLmModelsMessageType.githubCopilotManageAccount
| typeof VsCodeLmModelsMessageType.requestProviderQuotas
| typeof VsCodeLmModelsMessageType.cancelProviderQuotas
| "openImage"
| "saveImage"
| "openFile"
Expand Down Expand Up @@ -995,6 +1002,13 @@ export interface ClineApiReqInfo {
cancelReason?: ClineApiReqCancelReason
streamingFailedMessage?: string
apiProtocol?: "anthropic" | "openai"
/**
* The provider and model that actually served this request, recorded at the
* time of the call. Usage reporting reads these instead of the *current*
* profile, so switching providers later doesn't relabel past requests.
*/
apiProvider?: string
modelId?: string
}

export type ClineApiReqCancelReason = "streaming_failed" | "user_cancelled"
56 changes: 56 additions & 0 deletions src/core/checkpoints/ConversationRewindError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { t } from "../../i18n"

/**
* The files were put back to the checkpoint, but the conversation could not be rewound to it (the messages could not be
* saved, and were left as they were). The task is started again from what is saved, so the workspace and the task are
* not left apart with the task stopped; the caller still has to tell the user, and may keep an edit for it.
*/
export class ConversationRewindError extends Error {
/** Whether the task was started again. When it was not, nothing will take a pending edit: the caller hands it back. */
readonly restarted: boolean

/** `restartFailure` is given when starting the task again failed too (what failed is in its `error`). */
constructor(
override readonly cause: unknown,
readonly restartFailure?: { error: unknown },
) {
super(cause instanceof Error ? cause.message : String(cause))
this.name = "ConversationRewindError"
this.restarted = restartFailure === undefined
}
}

/**
* The files were restored and the conversation was rewound, but the task could not be started again (or was not
* rehydrated). Nothing will take a pending edit, so the caller clears it and hands it back to the user.
*/
export class TaskNotRestartedError extends Error {
constructor(override readonly cause: unknown) {
super(cause instanceof Error ? cause.message : String(cause))
this.name = "TaskNotRestartedError"
}
}

/** Whether the task that a restore left behind has not been started again, so that nothing will apply a pending edit. */
export function taskWasNotRestarted(error: unknown): boolean {
return error instanceof TaskNotRestartedError || (error instanceof ConversationRewindError && !error.restarted)
}

/**
* What the user is told when a checkpoint restore failed. A restore that put the files back but could not rewind the
* conversation says so: the files did change, and the task was started again with its previous conversation (or, when
* that failed too, it has to be started by the user).
*/
export function checkpointRestoreFailureMessage(error: unknown): string {
const reason = error instanceof Error ? error.message : String(error)
if (error instanceof TaskNotRestartedError) return t("common:errors.checkpoint_restart_failed", { error: reason })
if (!(error instanceof ConversationRewindError))
return t("common:errors.checkpoint_restore_failed", { error: reason })
if (error.restarted) return t("common:errors.checkpoint_rewind_failed", { error: reason })
// Both failures are told: why the conversation was not rewound, and why the task was not started again.
const restartError = error.restartFailure?.error
return t("common:errors.checkpoint_rewind_restart_failed", {
error: reason,
restartError: restartError instanceof Error ? restartError.message : String(restartError),
})
}
91 changes: 91 additions & 0 deletions src/core/checkpoints/__tests__/ConversationRewindError.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { t } from "../../../i18n"
import {
checkpointRestoreFailureMessage,
ConversationRewindError,
TaskNotRestartedError,
taskWasNotRestarted,
} from "../ConversationRewindError"

vi.mock("../../../i18n", () => ({
t: vi.fn((key: string, options?: { error?: string }) => `${key}: ${options?.error}`),
}))

describe("ConversationRewindError", () => {
it("is restarted unless the restart failed too", () => {
expect(new ConversationRewindError(new Error("x")).restarted).toBe(true)
const failed = new ConversationRewindError(new Error("x"), { error: new Error("y") })
expect(failed.restarted).toBe(false)
expect(failed.restartFailure).toEqual({ error: new Error("y") })
})
})

describe("checkpointRestoreFailureMessage", () => {
it.each([
["an Error", new Error("Missing commit"), "Missing commit"],
["a plain value", "Invalid reference", "Invalid reference"],
])("says a failed restore as such, for %s", (_name, error, reason) => {
expect(checkpointRestoreFailureMessage(error)).toBe(`common:errors.checkpoint_restore_failed: ${reason}`)
})

it.each([
["an Error", new Error("Failed to save the messages"), "Failed to save the messages"],
["a plain value", "disk full", "disk full"],
])(
"says that the files were restored, and the conversation was not rewound, when the cause is %s",
(_name, cause, reason) => {
expect(checkpointRestoreFailureMessage(new ConversationRewindError(cause))).toBe(
`common:errors.checkpoint_rewind_failed: ${reason}`,
)
},
)

it.each([
["an Error", new Error("cannot rehydrate"), "cannot rehydrate"],
["a plain value", "no history", "no history"],
])(
"tells why the task could not be started again too, when the restart failure is %s",
(_name, restartFailure, restartError) => {
const error = new ConversationRewindError(new Error("Failed to save the messages"), {
error: restartFailure,
})

expect(checkpointRestoreFailureMessage(error)).toBe(
"common:errors.checkpoint_rewind_restart_failed: Failed to save the messages",
)
expect(t).toHaveBeenLastCalledWith("common:errors.checkpoint_rewind_restart_failed", {
error: "Failed to save the messages",
restartError,
})
},
)

it("says that the task has to be opened from the history when it could not be started again after a rewind that worked", () => {
expect(checkpointRestoreFailureMessage(new TaskNotRestartedError(new Error("cannot rehydrate")))).toBe(
"common:errors.checkpoint_restart_failed: cannot rehydrate",
)
})
})

describe("TaskNotRestartedError and taskWasNotRestarted", () => {
it.each([
["a restart that failed after a rewind that worked", new TaskNotRestartedError("x"), true],
["a failed rewind whose restart failed too", new ConversationRewindError(new Error("x"), { error: "y" }), true],
["a failed rewind whose task was restarted", new ConversationRewindError(new Error("x")), false],
["any other failure", new Error("Missing commit"), false],
["something that is not an error", "Invalid reference", false],
])("for %s: %s", (_name, error, expected) => {
expect(taskWasNotRestarted(error)).toBe(expected)
})

it.each([
["an Error", new Error("cannot rehydrate"), "cannot rehydrate"],
["a plain value", "cannot rehydrate", "cannot rehydrate"],
])("keeps the cause, an %s", (_name, cause, message) => {
const error = new TaskNotRestartedError(cause)

expect(error).toBeInstanceOf(Error)
expect(error.name).toBe("TaskNotRestartedError")
expect(error.message).toBe(message)
expect(error.cause).toBe(cause)
})
})
Loading
Loading