From 16d114b6bd51a39d3a8fc25338bf113856fcbcb0 Mon Sep 17 00:00:00 2001 From: vastsa Date: Tue, 22 Sep 2026 11:37:58 +0800 Subject: [PATCH 1/4] fix(composer): refuse a slash submission when the command source fails (#795) `resolveComposerCommand` answered `null` both for "no such command" and for a failed `composer/commands` read, so the submit path could not tell them apart and sent `/compact` to the model as literal prompt text whenever the source was down. The model read that as an instruction and acted on it. Resolution now answers with three outcomes: resolved (builtin/plugin/extension dispatch), unknown (templates, aliases, and id-less entries keep the prompt path), and unavailable. An unavailable source refuses the submission, keeps the draft, and reports `chat.slashCommandSourceUnavailable`; the failed read is not cached, so the next submit retries it, while a warm cache still resolves through a source blip. The decision lives in the React-free `resolveSlashDispatch` so it is testable on its own. Locked by `apps/desktop/test/slash-command-source.test.mjs` (fails on the previous code) and by a new refusal case in the composer-submission E2E fixture, which reaches the real Composer and asserts nothing was sent. --- .../chat/composer/hooks/useComposerSubmit.ts | 34 ++- .../features/chat/composer/slash-dispatch.ts | 64 ++++++ .../src/hooks/use-composer-autocomplete.ts | 33 ++- .../test/slash-command-source.test.mjs | 200 ++++++++++++++++++ scripts/e2e/composer-submission.tsx | 39 ++++ 5 files changed, 352 insertions(+), 18 deletions(-) create mode 100644 apps/desktop/src/features/chat/composer/slash-dispatch.ts create mode 100644 apps/desktop/test/slash-command-source.test.mjs diff --git a/apps/desktop/src/features/chat/composer/hooks/useComposerSubmit.ts b/apps/desktop/src/features/chat/composer/hooks/useComposerSubmit.ts index 777c0a686f..ab33c78a4e 100644 --- a/apps/desktop/src/features/chat/composer/hooks/useComposerSubmit.ts +++ b/apps/desktop/src/features/chat/composer/hooks/useComposerSubmit.ts @@ -14,6 +14,10 @@ import { api } from "../../../../lib/api"; import { draftKeyForSession } from "../../../../lib/composer-draft-cache"; import { runExtensionCommand, runPaletteCommand } from "../../../../lib/commands"; import { resolveComposerCommand } from "../../../../hooks/use-composer-autocomplete"; +import { + parseSlashSubmission, + resolveSlashDispatch, +} from "../slash-dispatch"; import { readEditorValue, setEditorCaret, type ComposerFileReference } from "../editor"; import type { ComposerDraftController } from "./useComposerDraft"; @@ -204,17 +208,25 @@ export function useComposerSubmit({ invalidatePromptEnhancement(); const submittedDraftKey = draftKey; // Slash dispatch stays local for builtin and extension commands, while - // templates, skills, and unknown aliases continue as normal prompt text. - if (!steering && serializedContent.startsWith("/")) { - const commandEnd = serializedContent.search(/\s/); - const name = serializedContent.slice( - 1, - commandEnd === -1 ? undefined : commandEnd, - ); - const command = name ? await resolveComposerCommand(name) : null; - if (command && command.kind !== "template" && command.id) { - const commandBody = - commandEnd === -1 ? "" : serializedContent.slice(commandEnd).trim(); + // templates, skills, and unknown aliases continue as normal prompt text. A + // command source that cannot be read is a third case: the composer cannot + // prove `/compact` is not a builtin, so it refuses the submission and keeps + // the text out of the model's input (issue #795). + if (!steering) { + const slashSubmission = parseSlashSubmission(serializedContent); + const resolution = slashSubmission + ? await resolveComposerCommand(slashSubmission.name) + : null; + const dispatch = resolveSlashDispatch(slashSubmission, resolution); + if (dispatch.action === "blocked") { + showToast(t("chat.slashCommandSourceUnavailable"), { + variant: "error", + }); + return; + } + if (dispatch.action === "dispatch") { + const command = dispatch.command; + const commandBody = dispatch.body; const isModeCommand = command.id === "builtin.mode.agent" || command.id === "builtin.mode.plan" || diff --git a/apps/desktop/src/features/chat/composer/slash-dispatch.ts b/apps/desktop/src/features/chat/composer/slash-dispatch.ts new file mode 100644 index 0000000000..264a0d2b18 --- /dev/null +++ b/apps/desktop/src/features/chat/composer/slash-dispatch.ts @@ -0,0 +1,64 @@ +import type { ComposerCommand } from "@pi-desktop/shared"; + +import type { ComposerCommandResolution } from "../../../hooks/use-composer-autocomplete"; + +/** + * Slash submission dispatch for the composer (issue #795). + * + * Kept free of React and of the store so the decision can be tested on its own: + * the only input is the typed text plus the command resolution the autocomplete + * hook produced for it. + */ + +/** A submission that starts with `/name`, split into its name and body. */ +export type SlashSubmission = { + name: string; + body: string; +}; + +/** + * Split a composer submission into a slash name and body, or `null` when the + * text is not a slash submission at all — it does not start with `/`, or holds + * nothing after the slash. + */ +export function parseSlashSubmission(content: string): SlashSubmission | null { + if (!content.startsWith("/")) return null; + const end = content.search(/\s/); + const name = content.slice(1, end === -1 ? undefined : end); + if (!name) return null; + return { name, body: end === -1 ? "" : content.slice(end).trim() }; +} + +/** + * What the composer does with one submission. + * + * `blocked` is the fail-closed branch: the command source could not be read, so + * the composer cannot prove `/compact` is not a builtin and shows an error + * instead of handing the text to the model. + */ +export type SlashDispatch = + | { action: "dispatch"; command: ComposerCommand & { id: string }; body: string } + | { action: "prompt" } + | { action: "blocked"; error: Error }; + +/** + * Decide the fate of one submission. Templates, unknown aliases, and + * command entries without a dispatchable id keep the prompt path — that is + * older behavior than the source-failure branch — while an unreadable source + * blocks the submission so a control command is never degraded into prompt + * text. + */ +export function resolveSlashDispatch( + submission: SlashSubmission | null, + resolution: ComposerCommandResolution | null, +): SlashDispatch { + if (!submission || !resolution) return { action: "prompt" }; + if (resolution.status === "unavailable") { + return { action: "blocked", error: resolution.error }; + } + if (resolution.status === "unknown") return { action: "prompt" }; + const { command } = resolution; + const { id } = command; + if (command.kind === "template" || !id) return { action: "prompt" }; + return { action: "dispatch", command: { ...command, id }, body: submission.body }; +} diff --git a/apps/desktop/src/hooks/use-composer-autocomplete.ts b/apps/desktop/src/hooks/use-composer-autocomplete.ts index 0e790ff5e5..8e7cb4132b 100644 --- a/apps/desktop/src/hooks/use-composer-autocomplete.ts +++ b/apps/desktop/src/hooks/use-composer-autocomplete.ts @@ -104,15 +104,28 @@ function filterFiles(entries: FsIndexEntry[], query: string): AutocompleteItem[] })).map(({ entry, match }) => ({ kind: "path", entry, match })); } +/** + * Result of resolving one typed "/name" at send time. + * + * `unavailable` is the branch that keeps a failed source read from looking like + * "no such command": the composer can only guess whether `/compact` is a + * builtin it must not send to the model, so it refuses the submission instead + * of degrading a control command into prompt text (issue #795). + */ +export type ComposerCommandResolution = + | { status: "resolved"; command: ComposerCommand } + | { status: "unknown" } + | { status: "unavailable"; error: Error }; + /** * Resolve a typed "/name" against the merged command and skill list at send - * time (builtin/plugin dispatch and skill validation); templates and unknown - * names return as-is/null and stay on the prompt path. Reuses the menu's TTL - * cache when warm. + * time (builtin/plugin dispatch and skill validation); templates, non-command + * names, and unknown names stay on the prompt path. Reuses the menu's TTL cache + * when warm, so a warm cache keeps resolving through a source blip. */ export async function resolveComposerCommand( name: string, -): Promise { +): Promise { const key = useAppStore.getState().workspace?.path ?? ""; if ( !commandsCache || @@ -122,11 +135,17 @@ export async function resolveComposerCommand( try { const res = await api.composerCommands(); commandsCache = { key, at: Date.now(), commands: res.commands }; - } catch { - return null; + } catch (error) { + // Deliberately leaves the cache cold: the next attempt re-reads the + // source, which is what makes the refusal retriable. + return { + status: "unavailable", + error: error instanceof Error ? error : new Error(String(error)), + }; } } - return commandsCache.commands.find((c) => c.name === name) ?? null; + const command = commandsCache.commands.find((c) => c.name === name); + return command ? { status: "resolved", command } : { status: "unknown" }; } export function useComposerAutocomplete({ diff --git a/apps/desktop/test/slash-command-source.test.mjs b/apps/desktop/test/slash-command-source.test.mjs new file mode 100644 index 0000000000..26ca2ce6c7 --- /dev/null +++ b/apps/desktop/test/slash-command-source.test.mjs @@ -0,0 +1,200 @@ +/** + * Issue #795 ①: a slash command must never be degraded into prompt text. + * + * The composer resolves a typed `/name` against the merged command source + * (builtin + plugin + extension + skill + template) at send time. When that IPC + * read fails the old code returned `null`, which the submit path read as "not a + * command" — so `/compact` was sent to the model as ordinary text and the model + * acted on it as an instruction. These tests lock the three outcomes apart. + */ +import assert from "node:assert/strict"; +import { register } from "node:module"; +import test from "node:test"; +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +register(pathToFileURL(join(here, "helpers/ts-import-hooks.mjs"))); + +const load = (relative) => + import(pathToFileURL(join(here, relative)).href); + +const { resolveComposerCommand } = await load("../src/hooks/use-composer-autocomplete.ts"); +const { parseSlashSubmission, resolveSlashDispatch } = await load( + "../src/features/chat/composer/slash-dispatch.ts", +); +const { api } = await load("../src/lib/api.ts"); +const { useAppStore } = await load("../src/stores/app-store.ts"); + +const read = (path) => readFile(new URL(path, import.meta.url), "utf8"); + +/** Run `body` with `api.composerCommands` replaced, restoring it afterwards. */ +async function withCommandSource(replacement, body) { + const original = api.composerCommands; + api.composerCommands = replacement; + try { + return await body(); + } finally { + api.composerCommands = original; + } +} + +/** Point the store at a fresh workspace so the module-level TTL cache misses. */ +function useWorkspace(path) { + useAppStore.setState({ workspace: { path } }); +} + +test("an unreadable command source refuses the submission instead of sending it", async () => { + useWorkspace("/pi-795-source-unavailable"); + await withCommandSource( + async () => { + throw new Error("composer/commands unavailable"); + }, + async () => { + const resolution = await resolveComposerCommand("compact"); + assert.equal( + resolution.status, + "unavailable", + "a failed source read is not an unknown command", + ); + assert.equal(resolution.error.message, "composer/commands unavailable"); + + const decision = resolveSlashDispatch(parseSlashSubmission("/compact"), resolution); + assert.equal(decision.action, "blocked"); + assert.equal(decision.error, resolution.error); + }, + ); +}); + +test("a failed source read keeps every slash submission retriable", async () => { + useWorkspace("/pi-795-source-retry"); + let attempts = 0; + await withCommandSource( + async () => { + attempts += 1; + if (attempts === 1) throw new Error("sidecar RPC timeout: composer.commands"); + return { + commands: [ + { name: "compact", kind: "builtin", title: "Compact", id: "builtin.agent.compact" }, + ], + }; + }, + async () => { + // The failed attempt must leave the cache cold, so the retry re-reads. + assert.equal((await resolveComposerCommand("compact")).status, "unavailable"); + const retried = await resolveComposerCommand("compact"); + assert.equal(retried.status, "resolved"); + assert.equal(retried.command.id, "builtin.agent.compact"); + assert.equal(attempts, 2); + }, + ); +}); + +test("an unknown alias and a template still travel as prompt text", async () => { + useWorkspace("/pi-795-unknown"); + await withCommandSource( + async () => ({ + commands: [ + { name: "review", kind: "skill", title: "Review", id: "skill.review" }, + { name: "plan", kind: "template", title: "Plan" }, + ], + }), + async () => { + const unknown = await resolveComposerCommand("not-a-command"); + assert.equal(unknown.status, "unknown"); + assert.equal( + resolveSlashDispatch(parseSlashSubmission("/not-a-command hello"), unknown).action, + "prompt", + ); + + const template = await resolveComposerCommand("plan"); + assert.equal(template.status, "resolved"); + assert.equal( + resolveSlashDispatch(parseSlashSubmission("/plan a release"), template).action, + "prompt", + "a prompt template is text, not a control command", + ); + }, + ); +}); + +test("a warm source keeps dispatching a builtin through a source blip", async () => { + useWorkspace("/pi-795-warm"); + let calls = 0; + await withCommandSource( + async () => { + calls += 1; + return { + commands: [ + { name: "compact", kind: "builtin", title: "Compact", id: "builtin.agent.compact" }, + ], + }; + }, + async () => { + const resolution = await resolveComposerCommand("compact"); + assert.equal(resolution.status, "resolved"); + const decision = resolveSlashDispatch(parseSlashSubmission("/compact now"), resolution); + assert.equal(decision.action, "dispatch"); + assert.equal(decision.command.id, "builtin.agent.compact"); + assert.equal(decision.body, "now"); + assert.equal(calls, 1, "the first resolution fetches the source"); + + await withCommandSource( + async () => { + throw new Error("composer/commands unavailable"); + }, + async () => { + assert.equal((await resolveComposerCommand("compact")).status, "resolved"); + assert.equal(calls, 1, "a warm cache must not re-fetch per submission"); + }, + ); + }, + ); +}); + +test("plain text and a bare slash stay on the prompt path", () => { + assert.equal(parseSlashSubmission("hello"), null); + assert.equal(parseSlashSubmission(""), null); + assert.equal(parseSlashSubmission("/"), null); + assert.deepEqual(parseSlashSubmission("/compact"), { name: "compact", body: "" }); + assert.deepEqual(parseSlashSubmission("/plan a release "), { + name: "plan", + body: "a release", + }); + assert.equal(resolveSlashDispatch(null, null).action, "prompt"); +}); + +test("the submit path proves the blocked branch precedes the prompt path", async () => { + const submit = await read( + "../src/features/chat/composer/hooks/useComposerSubmit.ts", + ); + assert.match( + submit, + /import \{[\s\S]*?parseSlashSubmission,[\s\S]*?resolveSlashDispatch,[\s\S]*?\} from "\.\.\/slash-dispatch";/, + ); + assert.match( + submit, + /dispatch\.action === "blocked"[\s\S]*?showToast\(t\("chat\.slashCommandSourceUnavailable"\)[\s\S]*?return;[\s\S]*?if \(dispatch\.action === "dispatch"\)/, + ); + const blocked = submit.indexOf('dispatch.action === "blocked"'); + const promptPath = submit.indexOf("if (!steering && !modelReady)"); + assert.ok( + blocked > -1 && promptPath > blocked, + "the refusal must return before the send path is reached", + ); + assert.doesNotMatch( + submit, + /serializedContent\.startsWith\("\/"\)/, + "slash detection belongs to parseSlashSubmission", + ); +}); + +test("the shipped locales carry the refusal copy", async () => { + const [en, zhCN] = await Promise.all([ + read("../../../packages/i18n/src/locales/en/index.ts"), + read("../../../packages/i18n/src/locales/zh-CN/index.ts"), + ]); + assert.match(en, /slashCommandSourceUnavailable:\s*"[^"]+"/); + assert.match(zhCN, /slashCommandSourceUnavailable:\s*"[^"]+"/); +}); diff --git a/scripts/e2e/composer-submission.tsx b/scripts/e2e/composer-submission.tsx index 550f059803..8aabc8275b 100644 --- a/scripts/e2e/composer-submission.tsx +++ b/scripts/e2e/composer-submission.tsx @@ -4,6 +4,7 @@ import type { i18n } from "i18next"; import { I18nextProvider } from "react-i18next"; import { Composer } from "../../apps/desktop/src/components/Composer"; import { useAppStore } from "../../apps/desktop/src/stores/app-store"; +import { api } from "../../apps/desktop/src/lib/api"; import { writeComposerDraft, deleteComposerDraft } from "../../apps/desktop/src/lib/composer-draft-cache"; import type { ComposerDraftSnapshot } from "../../apps/desktop/src/lib/composer-smart-stop"; @@ -72,6 +73,44 @@ export async function verifyComposerSubmission(imagePath: string, i18n: i18n) { "last image must be reachable by scrolling"); flushSync(() => last.querySelector(".composer-image-attachment-remove")!.click()); assert(tray.children.length === 19 && !sendButton().disabled, "scrolled attachment removal must preserve other images"); + + // Issue #795: a command source that cannot be read must refuse the + // submission, instead of handing `/compact` to the model as prompt text. + const toasts: string[] = []; + const originalComposerCommands = api.composerCommands; + const originalShowToast = useAppStore.getState().showToast; + api.composerCommands = async () => { + throw new Error("composer/commands unavailable"); + }; + useAppStore.setState({ + showToast: (message) => { + toasts.push(String(message)); + }, + }); + try { + prefill("/compact", []); + await painted(); + const attempted = sent.length; + sendButton().click(); + await painted(); + await painted(); + await painted(); + assert( + sent.length === attempted, + `a refused slash submission must not reach the send path: ${JSON.stringify(sent.map((entry) => entry.content))}`, + ); + assert( + editor().textContent === "/compact", + `a refused submission must keep the draft: ${JSON.stringify(editor().textContent)}`, + ); + assert( + toasts.includes(i18n.t("chat.slashCommandSourceUnavailable")), + `the refusal must be visible: ${JSON.stringify(toasts)}`, + ); + } finally { + api.composerCommands = originalComposerCommands; + useAppStore.setState({ showToast: originalShowToast }); + } } finally { flushSync(() => root.unmount()); host.remove(); From 3f982b3b85efa4d4fef006b05656ae48aeb76284 Mon Sep 17 00:00:00 2001 From: vastsa Date: Tue, 22 Sep 2026 11:38:04 +0800 Subject: [PATCH 2/4] fix(agent): give manual compaction its own deadline and a durable verdict (#795) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `agent.compact` is a blocking RPC that spends a whole model summary request inside the sidecar, but it ran under the flat 130s transport default. An 888KB context needed ~158s, so Electron reported `sidecar RPC timeout` while the sidecar kept summarizing and persisted the checkpoint: the user was told the compaction failed, and the next turn proved it had succeeded. The deadline now follows the ceilings the sidecar does enforce — one 180s stream watchdog per attempt, `1 + 3` attempts, the 2s/4s/8s retry backoff, and transport slack (`AGENT_COMPACT_RPC_TIMEOUT_MS`, per-method rather than a wider global default, mirrored by `plugin-timeout-budgets.test.mjs`). Both host paths (Electron `agentCompact` IPC and `RuntimeService.compact`) also stop treating a lost reply as the sidecar's verdict: on a transport timeout they re-read the durable `session.compaction` record through `compactionRecordId`, report success when a new checkpoint landed, log the mismatch, and rethrow the timeout otherwise. A verdict the sidecar reported itself is never reconciled. Pinned by `packages/host-runtime/src/runtime-service.test.ts`, `packages/shared/src/protocol.test.ts`, and `apps/desktop/test/plugin-timeout-budgets.test.mjs`. --- apps/desktop/electron/main/ipc/agent-ipc.ts | 23 +++++++- .../test/plugin-timeout-budgets.test.mjs | 24 ++++++++ .../host-runtime/src/runtime-service.test.ts | 59 +++++++++++++++++++ packages/host-runtime/src/runtime-service.ts | 24 +++++++- .../shared/src/context-compaction.test.ts | 23 ++++++++ packages/shared/src/context-compaction.ts | 15 +++++ packages/shared/src/protocol.test.ts | 27 +++++++++ packages/shared/src/rpc-timeouts.ts | 54 ++++++++++++++++- 8 files changed, 243 insertions(+), 6 deletions(-) diff --git a/apps/desktop/electron/main/ipc/agent-ipc.ts b/apps/desktop/electron/main/ipc/agent-ipc.ts index 809a243541..2da6385ddd 100644 --- a/apps/desktop/electron/main/ipc/agent-ipc.ts +++ b/apps/desktop/electron/main/ipc/agent-ipc.ts @@ -1,4 +1,4 @@ -import { IPC, ErrorCodes, isGlobalPermissionMode, type AgentEventEnvelope, type AgentPromptRequest, type AgentSteerRequest, type UiMessage, type AgentQueuePushRequest, type AgentStopRequest, type AskToolResolution, type GlobalPermissionMode, type MessageUsage, type PlanExecutionFinishStatus, type PlanResolutionResult, type PlanResolveRequest, type PromptEnhancementRequest, type SessionSummarizeTitleRequest, canonicalThinkingLevel, type ThinkingLevel } from "@pi-desktop/shared"; +import { IPC, ErrorCodes, compactionRecordId, isGlobalPermissionMode, isRpcTimeoutError, type AgentEventEnvelope, type AgentPromptRequest, type AgentSteerRequest, type UiMessage, type AgentQueuePushRequest, type AgentStopRequest, type AskToolResolution, type GlobalPermissionMode, type MessageUsage, type PlanExecutionFinishStatus, type PlanResolutionResult, type PlanResolveRequest, type PromptEnhancementRequest, type SessionSummarizeTitleRequest, canonicalThinkingLevel, type ThinkingLevel } from "@pi-desktop/shared"; import type { FinishTurn } from "../runtime/plans"; import { expandSlashInvocation, enhancePromptDraft, summarizeSessionTitle, visionFromModelConfig, type ComposerTemplate, type RuntimeProviderConfig } from "@pi-desktop/agent-runtime"; import { OAUTH_AUTH_KIND, type VendorOAuth } from "../oauth"; @@ -638,7 +638,26 @@ export function registerAgentIpc({ settings, ); sidecar.setProjectInstructionRoot(req.sessionId, launch.projectPath); - const result = await sidecar.call("agent.compact", launch.sidecarParams); + // A lost reply is not the sidecar's verdict: the sidecar keeps summarizing + // and persists the checkpoint through host-core, so the durable record + // decides whether this manual compaction succeeded (issue #795). + const startedWith = compactionRecordId(detail.session); + let result: unknown; + try { + result = await sidecar.call("agent.compact", launch.sidecarParams); + } catch (error) { + if (!isRpcTimeoutError(error)) throw error; + const settled = await host.call<{ session?: unknown }>("session.get", { + id: req.sessionId, + }); + const landed = compactionRecordId(settled.session); + if (landed === startedWith) throw error; + logger.app("session", "warn", "manual compaction outlived its transport deadline; the checkpoint landed", { + sessionId: req.sessionId, + data: { compactionId: landed }, + }); + return { accepted: true }; + } logger.app("session", "info", "context compacted manually", { sessionId: req.sessionId, data: { providerId: launch.providerId, modelId: launch.modelId }, diff --git a/apps/desktop/test/plugin-timeout-budgets.test.mjs b/apps/desktop/test/plugin-timeout-budgets.test.mjs index 7a3ddf0ce0..670a29eda1 100644 --- a/apps/desktop/test/plugin-timeout-budgets.test.mjs +++ b/apps/desktop/test/plugin-timeout-budgets.test.mjs @@ -55,6 +55,30 @@ test("host-core dispatches through the shared dispatch deadline", () => { ); }); +test("the manual-compaction deadline outlasts the summary request it wraps", () => { + // The mirrored constants are the drift risk: if the sidecar raises its stream + // watchdog or its retry count, the shared deadline has to follow, otherwise + // Electron times out a compaction the sidecar is still working on (#795). + const providerRetry = source("packages/agent-runtime/src/provider-retry.ts"); + const summaryInput = source("packages/agent-runtime/src/compaction-summary-input.ts"); + const watchdog = constMs(providerRetry, "STREAM_IDLE_TIMEOUT_DEFAULT_MS"); + const retries = constMs(summaryInput, "COMPACTION_SUMMARY_MAX_RETRIES"); + const baseDelay = constMs(summaryInput, "COMPACTION_SUMMARY_RETRY_BASE_MS"); + assert.equal(constMs(sharedTimeouts, "STREAM_IDLE_TIMEOUT_MS"), watchdog); + assert.equal(constMs(sharedTimeouts, "COMPACTION_SUMMARY_MAX_RETRIES"), retries); + // Doubling waits after the first attempt: base + 2*base + 4*base + ... + assert.equal( + constMs(sharedTimeouts, "COMPACTION_SUMMARY_RETRY_BUDGET_MS"), + baseDelay * (2 ** retries - 1), + ); + // The constants can be right while the call site ignores them. + assert.match( + sharedTimeouts, + /method === "agent\.compact"[\s\S]{0,60}?AGENT_COMPACT_RPC_TIMEOUT_MS/, + "agent.compact must not fall back to the flat default deadline", + ); +}); + test("host-core budgets match their TypeScript mirrors", () => { const hostDispatch = constMs(hostTools, "DESKTOP_TOOL_DISPATCH_TIMEOUT_MS"); assert.equal(constMs(sharedTimeouts, "DESKTOP_TOOL_DISPATCH_TIMEOUT_MS"), hostDispatch); diff --git a/packages/host-runtime/src/runtime-service.test.ts b/packages/host-runtime/src/runtime-service.test.ts index 40bd8985e5..2c61ff7c89 100644 --- a/packages/host-runtime/src/runtime-service.test.ts +++ b/packages/host-runtime/src/runtime-service.test.ts @@ -63,6 +63,10 @@ class FakeSidecar implements RuntimeSidecarLink { roots = new Map(); rejectPrompt = false; running = false; + /** Error `agent.compact` answers with; `null` accepts the compaction. */ + compactError: Error | null = null; + /** Runs inside a failing `agent.compact`, e.g. to persist a checkpoint. */ + beforeCompact: (() => void) | null = null; async call(method: string, params: Record = {}): Promise { this.calls.push({ method, params }); @@ -75,6 +79,10 @@ class FakeSidecar implements RuntimeSidecarLink { return { supportsVision: false } as T; case "agent.steer": return { accepted: true, turnId: params.expectedTurnId } as T; + case "agent.compact": + this.beforeCompact?.(); + if (this.compactError) throw this.compactError; + return { accepted: true } as T; case "agent.abort": return { ok: true } as T; case "agent.stop": @@ -141,6 +149,57 @@ function build() { return { host, sidecar, service, events, ended, logs }; } +describe("RuntimeService manual compaction against a lost reply (#795)", () => { + const timeout = () => new Error("sidecar RPC timeout: agent.compact"); + const record = (id: string) => ({ id, summary: "s", throughMessageId: "m1", tokensBefore: 1, createdAt: "2026-09-18T00:00:00.000Z" }); + + it("reports the durable outcome when the checkpoint landed after a transport timeout", async () => { + const { host, sidecar, service, logs } = build(); + host.session = { ...host.session, compaction: record("c-old") }; + sidecar.compactError = timeout(); + // The sidecar keeps summarizing and persists through host-core, so the + // host's own deadline is not evidence that the compaction failed. + sidecar.beforeCompact = () => { + host.session = { ...host.session, compaction: record("c-new") }; + }; + await expect(service.compact("s1")).resolves.toEqual({ accepted: true }); + expect(host.calls.filter((call) => call.method === "session.get")).toHaveLength(2); + expect(logs).toContainEqual({ + level: "warn", + message: "manual compaction outlived its transport deadline; the checkpoint landed", + }); + }); + + it("keeps reporting a timeout when no checkpoint landed", async () => { + const { host, sidecar, service } = build(); + host.session = { ...host.session, compaction: record("c-old") }; + sidecar.compactError = timeout(); + await expect(service.compact("s1")).rejects.toThrow("sidecar RPC timeout: agent.compact"); + expect(host.calls.filter((call) => call.method === "session.get")).toHaveLength(2); + }); + + it("does not reconcile a sidecar verdict with a checkpoint from another attempt", async () => { + const { host, sidecar, service } = build(); + host.session = { ...host.session, compaction: record("c-old") }; + // A real compaction failure is the sidecar's own verdict: an older or + // unrelated durable checkpoint must not turn it into a success. + sidecar.beforeCompact = () => { + host.session = { ...host.session, compaction: record("c-new") }; + }; + sidecar.compactError = Object.assign(new Error("context compaction failed"), { + errorCode: "CONTEXT_COMPACTION_FAILED", + }); + await expect(service.compact("s1")).rejects.toThrow("context compaction failed"); + expect(host.calls.filter((call) => call.method === "session.get")).toHaveLength(1); + }); + + it("accepts an acknowledged compaction and never re-reads the session", async () => { + const { host, service } = build(); + await expect(service.compact("s1")).resolves.toEqual({ accepted: true }); + expect(host.calls.filter((call) => call.method === "session.get")).toHaveLength(1); + }); +}); + const owner = { subject: "desktop", roles: ["owner" as const], pairedDevice: true }; async function settle(): Promise { diff --git a/packages/host-runtime/src/runtime-service.ts b/packages/host-runtime/src/runtime-service.ts index 1a0835baa5..25d98c89d7 100644 --- a/packages/host-runtime/src/runtime-service.ts +++ b/packages/host-runtime/src/runtime-service.ts @@ -3,6 +3,8 @@ import { randomUUID } from "node:crypto"; import type { RuntimePort, TurnStartRequest, TurnSteerRequest } from "@pi-desktop/agent-host"; import { ErrorCodes, + compactionRecordId, + isRpcTimeoutError, type AgentEventEnvelope, type AgentStatus, type AskToolResolution, @@ -455,8 +457,26 @@ export class RuntimeService implements RuntimePort { if (!detail.session) throw typedError("Session not found", ErrorCodes.NOT_FOUND); const launch = await this.options.launch.resolve(sessionId, detail.session, settings ?? {}); sidecar.setProjectInstructionRoot(sessionId, launch.projectPath); - const result = await sidecar.call<{ accepted?: boolean }>("agent.compact", launch.sidecarParams); - return { accepted: result?.accepted !== false }; + // A lost reply says nothing about the sidecar's own verdict: it keeps + // summarizing and persists the checkpoint through host-core, so the durable + // record decides whether this manual compaction succeeded (issue #795). + const startedWith = compactionRecordId(detail.session); + try { + const result = await sidecar.call<{ accepted?: boolean }>("agent.compact", launch.sidecarParams); + return { accepted: result?.accepted !== false }; + } catch (error) { + if (!isRpcTimeoutError(error)) throw error; + const settled = await host.call<{ session?: Record | null }>("session.get", { + id: sessionId, + }); + const landed = compactionRecordId(settled.session); + if (landed === startedWith) throw error; + this.options.log("warn", "manual compaction outlived its transport deadline; the checkpoint landed", { + sessionId, + compactionId: landed, + }); + return { accepted: true }; + } } async getStatus(sessionId: string): Promise { diff --git a/packages/shared/src/context-compaction.test.ts b/packages/shared/src/context-compaction.test.ts index 7935083fa0..63beb9c194 100644 --- a/packages/shared/src/context-compaction.test.ts +++ b/packages/shared/src/context-compaction.test.ts @@ -3,6 +3,7 @@ import type { ContextCompactionRecord } from "./types.js"; import { checkpointFallback, checkpointGeneration, + compactionRecordId, checkpointSummarized, contextCompactionMark, estimateSummaryTokens, @@ -102,3 +103,25 @@ describe("estimateSummaryTokens", () => { expect(estimateSummaryTokens("abcde")).toBe(2); }); }); + +describe("compactionRecordId", () => { + it("identifies the checkpoint governing the next model request", () => { + expect(compactionRecordId({ compaction: record({ id: "checkpoint-9" }) })).toBe( + "checkpoint-9", + ); + }); + + it("reports no identity for a session that never compacted", () => { + expect(compactionRecordId({ id: "s1" })).toBeNull(); + expect(compactionRecordId({ compaction: null })).toBeNull(); + expect(compactionRecordId(null)).toBeNull(); + expect(compactionRecordId(undefined)).toBeNull(); + expect(compactionRecordId("s1")).toBeNull(); + }); + + it("ignores a malformed durable record instead of matching it", () => { + expect(compactionRecordId({ compaction: {} })).toBeNull(); + expect(compactionRecordId({ compaction: { id: 7 } })).toBeNull(); + expect(compactionRecordId({ compaction: [] })).toBeNull(); + }); +}); diff --git a/packages/shared/src/context-compaction.ts b/packages/shared/src/context-compaction.ts index 55c5535666..5e81ecf8d7 100644 --- a/packages/shared/src/context-compaction.ts +++ b/packages/shared/src/context-compaction.ts @@ -4,6 +4,21 @@ import type { ContextCompactionRecord, } from "./types.js"; +/** + * Identity of the checkpoint governing a session's next model request, or + * `null` when the session has never compacted. A host that lost the reply to a + * manual compaction compares it before and after the call: the sidecar persists + * its checkpoint through host-core regardless, so a missing reply is not + * evidence that the compaction failed (issue #795). + */ +export function compactionRecordId(session: unknown): string | null { + if (typeof session !== "object" || session === null) return null; + const compaction = (session as { compaction?: unknown }).compaction; + if (typeof compaction !== "object" || compaction === null) return null; + const id = (compaction as { id?: unknown }).id; + return typeof id === "string" ? id : null; +} + /** * The generation counter rides inside the checkpoint's opaque `details` value: * the host persists that field verbatim, so it survives the transcript round diff --git a/packages/shared/src/protocol.test.ts b/packages/shared/src/protocol.test.ts index 47f7e64922..7f9175def1 100644 --- a/packages/shared/src/protocol.test.ts +++ b/packages/shared/src/protocol.test.ts @@ -14,6 +14,11 @@ import { isCommandShellOption, isGlobalPermissionMode, isToolsOutputParams, + AGENT_COMPACT_RPC_TIMEOUT_MS, + COMMAND_RPC_BUFFER_MS, + COMPACTION_SUMMARY_MAX_RETRIES, + COMPACTION_SUMMARY_RETRY_BUDGET_MS, + STREAM_IDLE_TIMEOUT_MS, rpcTimeoutMs, type PlanExecution, type PlanArtifact, @@ -244,6 +249,28 @@ describe("Plan protocol contracts", () => { ); }); + it("outlasts the whole model request a manual compaction spends (#795)", () => { + // One summary prompt plus the sidecar's retry budget: every attempt that + // stops producing events is cut by the stream watchdog, and each retry pays + // its backoff wait. A flat 130s fired on a ~158s compaction, so Electron + // reported a failure while the sidecar persisted the checkpoint anyway. + expect(STREAM_IDLE_TIMEOUT_MS).toBe(180_000); + expect(COMPACTION_SUMMARY_MAX_RETRIES).toBe(3); + expect(COMPACTION_SUMMARY_RETRY_BUDGET_MS).toBe(14_000); + expect(AGENT_COMPACT_RPC_TIMEOUT_MS).toBe( + (1 + COMPACTION_SUMMARY_MAX_RETRIES) * STREAM_IDLE_TIMEOUT_MS + + COMPACTION_SUMMARY_RETRY_BUDGET_MS + + COMMAND_RPC_BUFFER_MS, + ); + expect(AGENT_COMPACT_RPC_TIMEOUT_MS).toBe(744_000); + expect(rpcTimeoutMs("agent.compact", { sessionId: "s" })).toBe( + AGENT_COMPACT_RPC_TIMEOUT_MS, + ); + // The deadline is per-method on purpose: widening the global default would + // hide a genuinely lost reply on every other call. + expect(rpcTimeoutMs("agent.getStatus", { sessionId: "s" })).toBe(130_000); + }); + it("covers the permission wait, the admission queue, and host-core dispatch", () => { // Permission (120s) + admission queue (30s) + host-core dispatch (150s) + // slack (10s). A flat 130s would cut off a prompted plugin tool that is diff --git a/packages/shared/src/rpc-timeouts.ts b/packages/shared/src/rpc-timeouts.ts index e84b754f68..20475b4534 100644 --- a/packages/shared/src/rpc-timeouts.ts +++ b/packages/shared/src/rpc-timeouts.ts @@ -27,6 +27,43 @@ export const DEFAULT_DESKTOP_TOOL_RPC_TIMEOUT_MS = TOOL_QUEUE_WAIT_MS + DESKTOP_TOOL_DISPATCH_TIMEOUT_MS + COMMAND_RPC_BUFFER_MS; +/** + * The sidecar's zero-event stream watchdog. Mirrors + * `STREAM_IDLE_TIMEOUT_DEFAULT_MS` in + * `packages/agent-runtime/src/provider-retry.ts`: a provider stream that emits + * nothing for this long is ended as a retriable failure instead of leaving the + * caller hung on a connection the provider never closes. + */ +export const STREAM_IDLE_TIMEOUT_MS = 180_000; +/** + * Retries the summary request may claim after its first failure. Mirrors + * `COMPACTION_SUMMARY_MAX_RETRIES` in + * `packages/agent-runtime/src/compaction-summary-input.ts`, whose waits are + * 2s + 4s + 8s. + */ +export const COMPACTION_SUMMARY_MAX_RETRIES = 3; +export const COMPACTION_SUMMARY_RETRY_BUDGET_MS = 14_000; +/** + * `agent.compact` is a blocking RPC that spends a whole model request inside + * the sidecar: pi serializes the conversation into one summary prompt, streams + * the summary, and may retry a transient failure. No part of that budget is a + * wall clock the transport can read, so this deadline is derived from the + * ceilings the sidecar does enforce — an attempt that stops producing events is + * cut by its stream watchdog, and the retries add at most + * `COMPACTION_SUMMARY_RETRY_BUDGET_MS` of backoff: + * + * (1 + retries) * stream watchdog + retry backoff + transport slack + * + * With the flat 130s default Electron gave up while the sidecar was still + * summarizing a large context (~158s), reported a failed compaction, and the + * sidecar persisted that checkpoint anyway (issue #795). Deliberately + * per-method: a wider global default would also hide a genuinely lost reply on + * every other call. + */ +export const AGENT_COMPACT_RPC_TIMEOUT_MS = + (1 + COMPACTION_SUMMARY_MAX_RETRIES) * STREAM_IDLE_TIMEOUT_MS + + COMPACTION_SUMMARY_RETRY_BUDGET_MS + + COMMAND_RPC_BUFFER_MS; const MAX_TIMER_DELAY_MS = 2_147_483_647; function isRecord(value: unknown): value is Record { @@ -40,18 +77,31 @@ function isDesktopDispatchedTool(toolName: unknown): boolean { ); } +/** + * Whether an error came from a transport deadline rather than from the peer. + * `host-process`, `agent-sidecar`, and `parent host proxy` are the only + * producers of these messages. + */ +export function isRpcTimeoutError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /^(?:host|sidecar|parent host proxy) RPC timeout: /.test(message); +} + /** * Return the transport deadline for an RPC call. Bash and desktop-dispatched * (`plugin_*` / `mcp_*`) tools include the waits host-core can spend before it * reports an outcome — the permission gate, the admission queue, and the * effective execution timeout — plus transport slack, so the transport never - * gives up before host-core reports its own timeout. Every call has a finite - * deadline so a lost response cannot leave a pending promise forever. + * gives up before host-core reports its own timeout. A manual context + * checkpoint carries the same property against the sidecar's summary request + * (`AGENT_COMPACT_RPC_TIMEOUT_MS`). Every call has a finite deadline so a lost + * response cannot leave a pending promise forever. */ export function rpcTimeoutMs( method: string, params: unknown, ): number { + if (method === "agent.compact") return AGENT_COMPACT_RPC_TIMEOUT_MS; if (method !== "tools.execute") return DEFAULT_RPC_TIMEOUT_MS; const input = isRecord(params) ? params : undefined; From 1a322fc8c9959c004a647786a4b060fc356d95ba Mon Sep 17 00:00:00 2001 From: vastsa Date: Tue, 22 Sep 2026 11:38:09 +0800 Subject: [PATCH 3/4] fix(transcript): re-read and never cache an empty durable window (#795) Every durable `session.get` window was taken as the truth and cached. The host answers such a read from a transcript file it may be rewriting, so a window that came back empty for a session with 2700 messages was stored as an empty snapshot; the sidebar's hover prefetch then re-served that emptiness on every later open and the pane stayed blank until the app restarted. Nothing retried a read and nothing told a failed or stale read apart from an empty conversation. A read is now judged against the session's own count (`sessionReadLooksEmpty`): zero messages for a session the sidebar counts as having history triggers one more read, then keeps the snapshot the user already has, and otherwise reports `chat.sessionTranscriptEmpty` instead of committing an empty transcript. The suspicious page is never written to the transcript cache, so a hover prefetch cannot poison every later open. This is a defense, not the host-side root cause: the transcript rewrite and the read side that answers "session exists, no messages" are unchanged. Pinned by `apps/desktop/test/session-transcript-empty-read.test.mjs`. --- .../src/lib/session-transcript-read.ts | 18 ++++ .../src/stores/runtime/session-runtime.ts | 16 +++- .../src/stores/slices/session-slice.ts | 27 ++++++ .../session-transcript-empty-read.test.mjs | 91 +++++++++++++++++++ 4 files changed, 147 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/lib/session-transcript-read.ts create mode 100644 apps/desktop/test/session-transcript-empty-read.test.mjs diff --git a/apps/desktop/src/lib/session-transcript-read.ts b/apps/desktop/src/lib/session-transcript-read.ts new file mode 100644 index 0000000000..415eb41936 --- /dev/null +++ b/apps/desktop/src/lib/session-transcript-read.ts @@ -0,0 +1,18 @@ +import type { SessionDetail } from "@pi-desktop/shared"; + +/** + * Whether a durable window read is suspiciously empty (issue #795). + * + * The host answers `session.get` from a transcript file it may be rewriting at + * that moment, so it can return "the session exists and has no messages" for a + * session that has thousands. Taking that answer at face value cached an empty + * transcript that the sidebar's hover prefetch re-served on every later open, + * and the session stayed blank until the app restarted. A read is only + * trustworthy as "empty" when the session's own count agrees with it. + */ +export function sessionReadLooksEmpty( + session: Pick | null | undefined, +): boolean { + if (!session) return false; + return (session.messages ?? []).length === 0 && (session.messageCount ?? 0) > 0; +} diff --git a/apps/desktop/src/stores/runtime/session-runtime.ts b/apps/desktop/src/stores/runtime/session-runtime.ts index a9ec8684e7..fdcf3609ff 100644 --- a/apps/desktop/src/stores/runtime/session-runtime.ts +++ b/apps/desktop/src/stores/runtime/session-runtime.ts @@ -14,6 +14,7 @@ import { removeLiveSessionMessage, upsertLiveSessionMessage, } from "../../lib/session-transcript"; +import { sessionReadLooksEmpty } from "../../lib/session-transcript-read"; import { sessionIsArchived, type SessionMeta } from "../../lib/sidebar-preferences"; import { normalizeProjectPath, @@ -170,11 +171,16 @@ export function createSessionRuntime({ get, set }: StoreAccess): SessionRuntime liveMessages ? mergeLiveSessionMessages(detail.session.messages ?? [], liveMessages) : detail.session.messages ?? []; - cacheSessionTranscript(id, messages, { - messageStart: detail.session.messageStart ?? 0, - hasMoreBefore: detail.session.hasMoreBefore === true, - contentLimited: options?.contentLimit !== undefined, - }); + // Never cache a suspiciously empty window (issue #795): hover prefetch + // would then re-serve that emptiness on every later open, and the + // session could not recover without a restart. + if (messages.length > 0 || !sessionReadLooksEmpty(detail.session)) { + cacheSessionTranscript(id, messages, { + messageStart: detail.session.messageStart ?? 0, + hasMoreBefore: detail.session.hasMoreBefore === true, + contentLimited: options?.contentLimit !== undefined, + }); + } } return detail; }); diff --git a/apps/desktop/src/stores/slices/session-slice.ts b/apps/desktop/src/stores/slices/session-slice.ts index bb7c986b0a..079acd51b7 100644 --- a/apps/desktop/src/stores/slices/session-slice.ts +++ b/apps/desktop/src/stores/slices/session-slice.ts @@ -44,6 +44,7 @@ import { durableCoversLiveSessionMessages, mergeLiveSessionMessages, } from "../../lib/session-transcript"; +import { sessionReadLooksEmpty } from "../../lib/session-transcript-read"; import type { AppState, DraftSessionConfiguration, @@ -361,6 +362,32 @@ export function createSessionSlice({ detail ??= await detailPromise; if (!runtime.navigationIntentIsCurrent(intent)) return; + if (detail.session && sessionReadLooksEmpty(detail.session)) { + // A window read that comes back empty for a session the sidebar + // counts as having history is not an empty conversation (#795). Ask + // once more, and if the transcript still reads empty keep whatever + // the user already has and say so, instead of committing nothing and + // leaving a blank pane behind. + const reread = await runtime.loadSessionDetail(id, { + messageLimit: 100, + contentLimit: 64 * 1024, + }); + if (!runtime.navigationIntentIsCurrent(intent)) return; + if (reread.session && sessionReadLooksEmpty(reread.session)) { + const retained = + runtime.sessionTranscriptCache.get(id) ?? + get().retainedTranscripts[id]; + if (retained && retained.length > 0) { + commitSelection(retained, true); + } else { + get().showToast(i18n.t("chat.sessionTranscriptEmpty"), { + variant: "error", + }); + } + return; + } + detail = reread; + } const historyWindow = detail.session ? { messageStart: detail.session.messageStart ?? 0, diff --git a/apps/desktop/test/session-transcript-empty-read.test.mjs b/apps/desktop/test/session-transcript-empty-read.test.mjs new file mode 100644 index 0000000000..aee50ebe7e --- /dev/null +++ b/apps/desktop/test/session-transcript-empty-read.test.mjs @@ -0,0 +1,91 @@ +/** + * Issue #795 ③: a durable window read that reports "no messages" for a session + * the sidebar counts as having history must not become the committed truth. + * + * The host answers `session.get` from a transcript file it may be rewriting, so + * "the session exists, with no messages" is a legal looking answer for a + * 2 700-message session. Caching it let the sidebar's hover prefetch re-serve + * that emptiness on every later open, and the pane stayed blank until restart. + */ +import assert from "node:assert/strict"; +import { register } from "node:module"; +import test from "node:test"; +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +register(pathToFileURL(join(here, "helpers/ts-import-hooks.mjs"))); + +const { sessionReadLooksEmpty } = await import( + pathToFileURL(join(here, "../src/lib/session-transcript-read.ts")).href +); + +const read = (path) => readFile(new URL(path, import.meta.url), "utf8"); +const [sessionSlice, sessionRuntime, composerStyle] = await Promise.all([ + read("../src/stores/slices/session-slice.ts"), + read("../src/stores/runtime/session-runtime.ts"), + read("../src/lib/session-transcript-read.ts"), +]); + +test("only a session whose own count agrees may be read as empty", () => { + assert.equal(sessionReadLooksEmpty({ messages: [], messageCount: 2700 }), true); + assert.equal( + sessionReadLooksEmpty({ messages: [], messageCount: 1 }), + true, + "one counted message is still history", + ); + assert.equal( + sessionReadLooksEmpty({ messages: [], messageCount: 0 }), + false, + "a session that was never used may legitimately read empty", + ); + assert.equal( + sessionReadLooksEmpty({ messages: [{ id: "m1" }], messageCount: 0 }), + false, + ); + assert.equal(sessionReadLooksEmpty({ messages: undefined, messageCount: 4 }), true); + assert.equal(sessionReadLooksEmpty(null), false); + assert.equal(sessionReadLooksEmpty(undefined), false); +}); + +test("the empty read is retried once and then recovered instead of committed", () => { + assert.match(composerStyle, /export function sessionReadLooksEmpty\(/); + assert.match( + sessionSlice, + /sessionReadLooksEmpty\(detail\.session\)[\s\S]{0,600}?await runtime\.loadSessionDetail\(id, \{/, + "an empty durable window must be read once more", + ); + assert.match( + sessionSlice, + /sessionReadLooksEmpty\(reread\.session\)[\s\S]{0,500}?retained && retained\.length > 0[\s\S]{0,200}?commitSelection\(retained, true\)/, + "a still-empty read keeps what the user already has", + ); + assert.match( + sessionSlice, + /showToast\(i18n\.t\("chat\.sessionTranscriptEmpty"\)/, + "a blank pane must stay diagnosable", + ); + assert.doesNotMatch( + sessionSlice, + /commitSelection\(\[\], false/, + "no path may commit an empty durable window as the active transcript", + ); +}); + +test("the durable-read cache never stores a suspiciously empty window", () => { + assert.match( + sessionRuntime, + /if \(messages\.length > 0 \|\| !sessionReadLooksEmpty\(detail\.session\)\) \{\s*cacheSessionTranscript\(/, + "hover prefetch shares this cache boundary and must not poison it", + ); +}); + +test("both shipped locales carry the diagnosable copy", async () => { + const [en, zhCN] = await Promise.all([ + read("../../../packages/i18n/src/locales/en/index.ts"), + read("../../../packages/i18n/src/locales/zh-CN/index.ts"), + ]); + assert.match(en, /sessionTranscriptEmpty:\s*"[^"]+"/); + assert.match(zhCN, /sessionTranscriptEmpty:\s*"[^"]+"/); +}); From 9adff074915c1434350238af1b2b3f36868d45fe Mon Sep 17 00:00:00 2001 From: vastsa Date: Tue, 22 Sep 2026 11:38:14 +0800 Subject: [PATCH 4/4] docs(i18n,spec): copy and contracts for the #795 fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `chat.slashCommandSourceUnavailable` and `chat.sessionTranscriptEmpty` in all eight locales (locale typing requires every catalog to carry the key). - `03-runtime/01-ipc-protocol.md` §5.4 records the compaction deadline and the durable-verdict rule; §13c records the three resolutions of a typed `/name`. - `03-runtime/07-process-model.md` states the per-method deadline rule. - `04-ux/09-interaction-patterns.md` records that an empty read for a session with history is read as unreadable, not empty. - Decisions log D613/D614/D615, and the E2E plan gains the four scenarios with the exact commands that automate them. zh-CN mirrors all of it. --- docs/spec/03-runtime/01-ipc-protocol.md | 23 +++++++ docs/spec/03-runtime/07-process-model.md | 6 ++ docs/spec/04-ux/09-interaction-patterns.md | 6 ++ docs/spec/06-delivery/04-e2e-test-plan.md | 18 ++++++ docs/spec/08-meta/decisions-log.md | 60 +++++++++++++++++++ docs/zh-CN/spec/03-runtime/01-ipc-protocol.md | 16 +++++ .../zh-CN/spec/03-runtime/07-process-model.md | 4 ++ .../spec/04-ux/09-interaction-patterns.md | 4 ++ .../spec/06-delivery/04-e2e-test-plan.md | 17 ++++++ docs/zh-CN/spec/08-meta/decisions-log.md | 44 ++++++++++++++ packages/i18n/src/locales/de/index.ts | 2 + packages/i18n/src/locales/en/index.ts | 2 + packages/i18n/src/locales/es/index.ts | 2 + packages/i18n/src/locales/fr/index.ts | 2 + packages/i18n/src/locales/ko/index.ts | 2 + packages/i18n/src/locales/tr/index.ts | 2 + packages/i18n/src/locales/zh-CN/index.ts | 2 + packages/i18n/src/locales/zh-TW/index.ts | 2 + 18 files changed, 214 insertions(+) diff --git a/docs/spec/03-runtime/01-ipc-protocol.md b/docs/spec/03-runtime/01-ipc-protocol.md index 07350b5246..7230857daa 100644 --- a/docs/spec/03-runtime/01-ipc-protocol.md +++ b/docs/spec/03-runtime/01-ipc-protocol.md @@ -323,6 +323,18 @@ session. It is available even when automatic context protection is disabled. Missing provider/session configuration fails through the normal `AppError` envelope; an active turn or compaction returns `AGENT_BUSY`. +`agent.compact` is a blocking summary request, not a status poll: the sidecar +serializes the conversation into one prompt, streams one model summary, and may +retry a transient failure. Its transport deadline is therefore derived from that +budget — `(1 + 3) × 180s` stream watchdog `+ 14s` of retry backoff `+ 10s` +slack — instead of the flat 130s default, which expired while the sidecar was +still summarizing a large context (**D614**, issue #795). The host also treats a +transport deadline as "unknown" rather than "failed": when the call times out it +re-reads the session's durable record, and reports success when a new checkpoint +landed, because the sidecar persists through host-core whether or not Electron +received the reply. A verdict the sidecar itself reported (for example +`CONTEXT_COMPACTION_FAILED`) is never reconciled this way. + ### 5.5 Plan and Goal checkpoint approval Contract approval is separate from a tool permission. Plan and Goal share this @@ -1942,6 +1954,17 @@ Templates load from `/.pi/prompts/*.md` and Without a workspace only user-global templates, builtins, and plugin commands return. +A source that fails is not an empty command list (**D613**, issue #795). +Submit-time resolution distinguishes three outcomes: a resolved +builtin/plugin/extension command dispatches locally, a template, an unknown +alias, and a command entry without a dispatchable id stay on the prompt path, +and an unreadable source refuses the submission. The refusal is deliberate — +with the source down the composer cannot prove `/compact` is not a builtin, and +a control command sent to the model as literal text is acted on. The refusal +keeps the draft, shows `chat.slashCommandSourceUnavailable`, and leaves the TTL +cache cold so the next submit retries the read; a warm cache keeps resolving +through a source blip. + ### fs/index ```ts diff --git a/docs/spec/03-runtime/07-process-model.md b/docs/spec/03-runtime/07-process-model.md index 97759e26ea..b1ef457731 100644 --- a/docs/spec/03-runtime/07-process-model.md +++ b/docs/spec/03-runtime/07-process-model.md @@ -145,6 +145,12 @@ Supervision parameters (the transports, restart policy, and turn lifecycle are renderer-facing status): - Child exit rejects all in-flight RPCs for that child immediately (no 130s timeout wait). +- Every RPC carries a finite transport deadline. Bash and desktop-dispatched + (`plugin_*` / `mcp_*`) tools add the waits host-core can spend before it + reports an outcome, and `agent.compact` adds the sidecar's own summary budget + — its stream watchdog per attempt plus its retry backoff (**D614**, issue + #795); everything else uses the 130s default. Never widen the default to cover + a slow method: that also hides a genuinely lost reply on every other call. - An NDJSON request line over 64 MiB is drained and answered with `LIMIT_EXCEEDED`; it does not end the stdin reader (ADR 0216). Electron rejects the same size before writing stdin (ADR 0217). - The Windows Alt+Space hook retains only a weak stdout sender. After stdin EOF, serve drops the last strong sender and host-core exits. A leaked sender cannot block shutdown for more than 5 s (ADR 0217). diff --git a/docs/spec/04-ux/09-interaction-patterns.md b/docs/spec/04-ux/09-interaction-patterns.md index 25a0f59f1d..66feccd36e 100644 --- a/docs/spec/04-ux/09-interaction-patterns.md +++ b/docs/spec/04-ux/09-interaction-patterns.md @@ -293,6 +293,12 @@ may be retained while exactly one workspace supplies the visible shell context. pointer hover or keyboard focus may prefetch its transcript; duplicate reads share one in-flight request and the renderer retains at most five recent transcript snapshots. +- A transcript window that reports no messages for a session the sidebar counts + as having history is read as unreadable, not as empty (**D615**, issue #795): + the selection asks once more, then keeps the snapshot the user already has, + and otherwise reports `chat.sessionTranscriptEmpty` instead of committing an + empty transcript. Such a page is never cached, so a hover prefetch cannot + re-serve emptiness on every later open. - Transcript loading starts without waiting for an older superseded selection. When session summary metadata is available, project activation/clearing and transcript IO run in parallel. A monotonic navigation generation permits only diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 90b298a147..28e612ed1b 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -14548,3 +14548,21 @@ these gates against the locked dependency version and record the tested bundle. artifact identity and independent review in `docs/project/hosted-search-contract-verification.md`. Never record private conversation content or credentials. A skipped test remains NOT RUN, not PASS. + +## Composer command source, manual compaction, and empty transcript reads (#795) + +**Scope:** the composer's slash dispatch, the manual compaction RPC, and the +renderer's durable transcript reads. No real model or provider is contacted. + +| Scenario | Expected | +| --- | --- | +| `/compact` typed while `composer/commands` fails | The submission is refused with `chat.slashCommandSourceUnavailable`, the draft survives, and no prompt reaches the session. The failed read is not cached, so the next submit retries it. | +| A warm command source | A builtin still dispatches locally, while a template and an unknown alias still travel as prompt text. | +| A manual compaction that outlives its transport deadline | The host re-reads the durable compaction record and reports success when a new checkpoint landed, logs the mismatch, and rethrows the timeout when none did. A verdict the sidecar reported itself is never reconciled. | +| A transcript window that reads empty for a session with history | The selection re-reads once, keeps the snapshot the user already has, and otherwise reports `chat.sessionTranscriptEmpty`; the empty page is never cached, so hover prefetch cannot re-serve it. | + +**Automation:** `node --test apps/desktop/test/slash-command-source.test.mjs`, +`node --test apps/desktop/test/session-transcript-empty-read.test.mjs`, +`node --test apps/desktop/test/plugin-timeout-budgets.test.mjs`, +`pnpm --filter @pi-desktop/shared test`, and +`pnpm --filter @pi-desktop/host-runtime test`. diff --git a/docs/spec/08-meta/decisions-log.md b/docs/spec/08-meta/decisions-log.md index 4cc7e2031d..5dca067777 100644 --- a/docs/spec/08-meta/decisions-log.md +++ b/docs/spec/08-meta/decisions-log.md @@ -6595,3 +6595,63 @@ that was sitting at the bottom — including after the turn had finished. by `apps/desktop/test/default-model-display.test.mjs` and `apps/desktop/test/image-generation-default.test.mjs`, with `provider-model-config.test.mjs` asserting the add branch consults them. + +## 2026-09-22 — An unreadable command source refuses a slash submission (D613, issue #795) + +- Composer send-time resolution read the merged command list and swallowed a + failure as `null`, which the submit path could not tell apart from "no such + command". `/compact` typed while that IPC read failed was therefore sent to the + model as literal prompt text, and the model acted on it as an instruction. +- Resolution now answers with three outcomes instead of one nullable value: + resolved (builtin/plugin/extension dispatch), unknown (templates, aliases, and + id-less entries continue as prompt text), and unavailable. Unavailable refuses + the submission, keeps the draft, and shows + `chat.slashCommandSourceUnavailable`. The failed read leaves the TTL cache + cold, so the next submit retries it; a warm cache still resolves through a + source blip. +- The refusal is fail-closed on purpose: only the command source can say whether + `/name` is a control command, so while it cannot be read a `name` that looks + like plain text is refused rather than guessed. Templates and genuinely unknown + aliases keep the old prompt path. Pinned by + `apps/desktop/test/slash-command-source.test.mjs` and + `03-runtime/01-ipc-protocol.md` §13c. + +## 2026-09-22 — A manual compaction has its own transport deadline and a durable verdict (D614, issue #795) + +- `agent.compact` is a blocking RPC that spends a whole model summary request + inside the sidecar: pi serializes the conversation, streams the summary, and + retries a transient failure. It ran under the flat 130s transport default, so a + ~158s compaction on an 888KB context ended as `sidecar RPC timeout` while the + sidecar kept working and persisted the checkpoint — the user was told the + compaction failed, and the next turn proved it had succeeded. +- The deadline is now derived from the ceilings the sidecar actually enforces: + one stream watchdog (180s) per attempt, `1 + 3` attempts, the 2s/4s/8s retry + backoff, and transport slack — `AGENT_COMPACT_RPC_TIMEOUT_MS`, deliberately + per-method rather than a wider global default. +- Either host path (Electron `agentCompact` IPC and `RuntimeService.compact`) + also stops treating a transport timeout as the sidecar's verdict: it re-reads + the durable `session.compaction` record and reports success when a new + checkpoint landed, logs the mismatch, and rethrows the timeout otherwise. A + verdict the sidecar reported itself is never reconciled. Pinned by + `packages/host-runtime/src/runtime-service.test.ts`, + `packages/shared/src/protocol.test.ts`, + `apps/desktop/test/plugin-timeout-budgets.test.mjs`, and + `03-runtime/01-ipc-protocol.md` §5.4. + +## 2026-09-22 — An empty transcript read is retried and never cached (D615, issue #795) + +- The renderer treated every durable `session.get` window as the truth, and + cached it. A window that came back empty for a session with thousands of + messages — the host answers such a read from a transcript file it may be + rewriting — was stored as an empty snapshot, hover prefetch re-served it, and + the pane stayed blank until the app restarted. Nothing distinguished a failed + or stale read from an empty conversation, and no path retried one. +- A read is now judged against the session's own count: zero messages for a + session the sidebar counts as having history triggers one more read, then keeps + the snapshot the user already has, and otherwise reports + `chat.sessionTranscriptEmpty`. The empty page is never written to the + transcript cache, so a hover prefetch cannot poison every later open. +- This is a defense, not the host-side root cause: the transcript rewrite is the + reporter's 0.15.1 storage layout, and the read side still answers "session + exists, no messages" instead of reporting an unreadable transcript. See + `04-ux/09-interaction-patterns.md` §Session isolation across tabs. diff --git a/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md b/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md index c55a65deb1..fbce330176 100644 --- a/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md +++ b/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md @@ -271,6 +271,15 @@ type AgentCompactResponse = { accepted: boolean }; 缺少 provider/session 配置无法通过正常的 `AppError` 信封;主动转向或压实返回 `AGENT_BUSY`。 +`agent.compact` 是阻塞式摘要请求,而不是状态轮询:sidecar 会把会话序列化成一个 +提示词、流式生成一次模型摘要,并且可能重试瞬时失败。因此它的传输超时由这份预算推导 +—— `(1 + 3) × 180 秒` 流空转看门狗 `+ 14 秒` 重试退避 `+ 10 秒` 余量 —— 而不是沿用 +扁平的 130 秒默认值;后者会在 sidecar 仍在总结大上下文时到期(**D614**,issue #795)。 +宿主也把传输超时视为“结果未知”而不是“失败”:调用超时后,它会重新读取该会话的持久化 +记录,若发现新检查点已落盘就报告成功,因为无论 Electron 是否收到回复,sidecar 都会 +通过 host-core 持久化。sidecar 自己给出的判定(例如 `CONTEXT_COMPACTION_FAILED`) +绝不会用这种方式被改写。 + ### 5.5 Plan 和 Goal 检查点批准 合同批准与工具许可是分开的。 Plan 和 Goal 分享此内容 @@ -1563,6 +1572,13 @@ type ComposerCommand = { 没有工作区,只有用户全局模板、内置函数和插件 命令返回。 +读取失败的指令源不等于“指令列表为空”(**D613**,issue #795)。发送时的解析区分三种 +结果:已解析的内置 / 插件 / 扩展指令在本地分发;提示词模板、未知别名,以及没有可分发 +id 的指令条目仍走普通提示词路径;**无法读取指令源时则拒绝这次提交**。拒绝是刻意的 +——指令源不可用时,Composer 无法证明 `/compact` 不是内置指令,而把控制指令当作字面 +文本交给模型会被执行。拒绝会保留草稿、显示 `chat.slashCommandSourceUnavailable`,并且 +不写 TTL 缓存,因此下一次发送会重试该读取;缓存仍热时,一次数据源抖动不会影响解析。 + ### fs/index ```ts diff --git a/docs/zh-CN/spec/03-runtime/07-process-model.md b/docs/zh-CN/spec/03-runtime/07-process-model.md index fecbb9a222..1be00215f0 100644 --- a/docs/zh-CN/spec/03-runtime/07-process-model.md +++ b/docs/zh-CN/spec/03-runtime/07-process-model.md @@ -113,6 +113,10 @@ Windows 安装包目标为 x64。Windows host-core 使用 监管参数(传输、重启策略与回合生命周期位于 `packages/host-runtime`,ADR 0284;Electron main 适配它们并负责面向渲染层的状态): - 子进程退出立即拒绝该子进程的所有正在进行的 RPC(无 130 秒超时等待)。 +- 每个 RPC 都带有有限的传输超时。Bash 与桌面分发的(`plugin_*` / `mcp_*`)工具会 + 叠加 host-core 在报告结果前可能消耗的等待,`agent.compact` 则叠加 sidecar 自身的摘要 + 预算——每次尝试的流空转看门狗加上重试退避(**D614**,issue #795);其余调用使用 130 + 秒默认值。绝不要为了迁就某个慢方法而放宽默认值:那会同时掩盖其他调用上真正丢失的回复。 - 超过 64 MiB 的 NDJSON 请求行以 `LIMIT_EXCEEDED` 应答,不结束 stdin 读取器(ADR 0216)。Electron 在写入 stdin 前拒绝同样大小的载荷(ADR 0217)。 - Windows Alt+Space 钩子只保留 stdout 发送端的弱引用。stdin EOF 后 serve 丢弃最后一个强引用,host-core 退出;泄漏的发送端不能把关闭卡住超过 5 秒(ADR 0217)。 - 使用指数退避 `0.5s → 1s → 2s` 自动重启(上限 4 秒)。 diff --git a/docs/zh-CN/spec/04-ux/09-interaction-patterns.md b/docs/zh-CN/spec/04-ux/09-interaction-patterns.md index d90d876e6d..0b3eca4427 100644 --- a/docs/zh-CN/spec/04-ux/09-interaction-patterns.md +++ b/docs/zh-CN/spec/04-ux/09-interaction-patterns.md @@ -239,6 +239,10 @@ 指针悬停或键盘焦点可以预取其记录;重复读取 共享一个正在进行的请求,渲染器最多保留五个最近的请求 转录快照。 +- 若一次记录窗口读取对侧边栏计为有历史的会话返回零条消息,该结果按“无法读取” + 而不是“空会话”处理(**D615**,issue #795):选择流程会再读一次,随后保留用户已有的 + 快照,否则显示 `chat.sessionTranscriptEmpty`,而不是提交一份空记录。这类空页绝不会 + 写入缓存,因此悬停预取不会在之后每次打开时反复提供空内容。 - 脚本加载开始,无需等待旧的被取代的选择。 当会话摘要元数据可用时,项目 activation/clearing 和 转录IO并行运行。单调导航生成仅允许 diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index 67407c32f2..d083fcce06 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -8674,3 +8674,20 @@ the latest destination. These assertions measure work counts, not device FPS. **证据:** 记录构建和测试退出码、基线 SHA、依赖版本、产物标识及独立评审,报告位于 `docs/project/hosted-search-contract-verification.md`。不得记录真实会话或凭据。未执行明确标为 NOT RUN,不得标为 PASS。 + +## Composer 指令源、手动压缩与空记录读取(#795) + +**范围:** composer 的斜杠分发、手动压缩 RPC,以及渲染层的持久化记录读取。不调用真实模型或 +提供商。 + +| 场景 | 必须观察到的结果 | +| --- | --- | +| `composer/commands` 失败时输入 `/compact` | 提交被拒绝并显示 `chat.slashCommandSourceUnavailable`,草稿保留,没有任何提示词进入会话。失败的读取不入缓存,因此下一次发送会重试。 | +| 指令源缓存仍热 | 内置指令仍在本地分发;模板与未知别名仍走提示词路径。 | +| 手动压缩超过其传输超时 | 宿主重新读取持久化压缩记录:发现新检查点已落盘就报告成功并记录该不一致,未落盘则原样抛出超时。sidecar 自己报告的判定绝不被改写。 | +| 有历史的会话读到空记录窗口 | 选择流程再读一次,随后保留用户已有的快照,否则显示 `chat.sessionTranscriptEmpty`;这类空页绝不入缓存,因此悬停预取不会反复提供它。 | + +**自动化:** `node --test apps/desktop/test/slash-command-source.test.mjs`、 +`node --test apps/desktop/test/session-transcript-empty-read.test.mjs`、 +`node --test apps/desktop/test/plugin-timeout-budgets.test.mjs`、 +`pnpm --filter @pi-desktop/shared test`、`pnpm --filter @pi-desktop/host-runtime test`。 diff --git a/docs/zh-CN/spec/08-meta/decisions-log.md b/docs/zh-CN/spec/08-meta/decisions-log.md index cb3a1f9695..6db8ed12df 100644 --- a/docs/zh-CN/spec/08-meta/decisions-log.md +++ b/docs/zh-CN/spec/08-meta/decisions-log.md @@ -4677,3 +4677,47 @@ that amendment are retired by ADR 0268; the upstream work-panel lifecycle stays. `apps/desktop/test/default-model-display.test.mjs` 与 `apps/desktop/test/image-generation-default.test.mjs` 固定, `provider-model-config.test.mjs` 断言新增分支会走这两个判断。 + +## 2026-09-22 —— 指令源不可读时拒绝斜杠提交(D613,issue #795) + +- Composer 在发送时读取合并后的指令列表,并把失败吞成 `null`,而提交路径无法把它与 + “没有这个指令”区分开。于是在该 IPC 读取失败时输入的 `/compact` 会作为字面提示词发给 + 模型,模型据此把它当成指令执行。 +- 解析现在返回三种结果,而不再是一个可空值:已解析(内置 / 插件 / 扩展分发)、未知 + (模板、别名与无 id 条目继续走提示词路径)、不可用。不可用时拒绝提交、保留草稿并显示 + `chat.slashCommandSourceUnavailable`。失败的读取不写 TTL 缓存,因此下一次发送会重试; + 缓存仍热时,一次数据源抖动不会影响解析。 +- 拒绝是刻意 fail-closed:只有指令源能判断 `/name` 是否为控制指令,因此读不到时,看起来像 + 普通文本的名称也会被拒绝,而不是靠猜测。模板与确实未知的别名保持原有提示词路径。由 + `apps/desktop/test/slash-command-source.test.mjs` 与 + `03-runtime/01-ipc-protocol.md` §13c 固定。 + +## 2026-09-22 —— 手动压缩有自己的传输超时与持久化判定(D614,issue #795) + +- `agent.compact` 是阻塞式 RPC,会在 sidecar 内消耗一次完整的模型摘要请求:pi 序列化会话、 + 流式生成摘要,并重试瞬时失败。它此前沿用扁平的 130 秒传输默认值,因此 888KB 上下文约 + 158 秒的压缩以 `sidecar RPC timeout` 结束,而 sidecar 仍在继续工作并落盘了检查点——用户 + 被告知压缩失败,而下一个回合证明它其实成功了。 +- 超时现在由 sidecar 真正执行的预算推导:每次尝试的流空转看门狗(180 秒)、`1 + 3` 次 + 尝试、2/4/8 秒重试退避,以及传输余量 —— 即 `AGENT_COMPACT_RPC_TIMEOUT_MS`,刻意按方法 + 区分,而不是放宽全局默认值。 +- 两条宿主路径(Electron 的 `agentCompact` IPC 与 `RuntimeService.compact`)也不再把手动 + 压缩的传输超时当作 sidecar 的判定:它们会重新读取持久化的 `session.compaction` 记录, + 发现新检查点已落盘就报告成功、记录该不一致,否则原样抛出超时。sidecar 自己报告的判定 + 绝不会被这样改写。由 `packages/host-runtime/src/runtime-service.test.ts`、 + `packages/shared/src/protocol.test.ts`、 + `apps/desktop/test/plugin-timeout-budgets.test.mjs` 与 + `03-runtime/01-ipc-protocol.md` §5.4 固定。 + +## 2026-09-22 —— 空记录读取会重试,且绝不入缓存(D615,issue #795) + +- 渲染层把每一次持久化 `session.get` 窗口都当真,并写入缓存。当宿主正在重写记录文件时, + 对拥有数千条消息的会话返回的空窗口被当成“空快照”存下,侧边栏悬停预取又反复送出它, + 于是面板一直空白,只有重启应用才能恢复。此前没有任何逻辑区分“读取失败或读到陈旧数据” + 与“真的空会话”,也没有任何路径重试。 +- 现在的判断依据是会话自己的计数:若侧边栏计为有历史的会话返回零条消息,就再读一次, + 随后保留用户已有的快照,否则显示 `chat.sessionTranscriptEmpty`。这类空页绝不会写入记录 + 缓存,因此悬停预取不会污染之后每次打开。 +- 这是防御措施,而不是宿主侧根因:记录重写是报告者 0.15.1 的存储布局,而读取侧仍然返回 + “会话存在但没有消息”,而不是报告记录不可读。见 `04-ux/09-interaction-patterns.md` + §跨选项卡的会话隔离。 diff --git a/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts index f14c49f77e..2580db9da3 100644 --- a/packages/i18n/src/locales/de/index.ts +++ b/packages/i18n/src/locales/de/index.ts @@ -233,6 +233,8 @@ export const de = { "slashGroupExtensions": "Erweiterungsbefehle", "slashGroupSkills": "Fähigkeiten", "slashEmpty": "Keine übereinstimmenden Befehle", +"slashCommandSourceUnavailable": "Befehlsliste nicht verfügbar, es wurde nichts gesendet. Bitte erneut versuchen.", + "sessionTranscriptEmpty": "Der Verlauf dieser Sitzung konnte nicht gelesen werden. Bitte die Sitzung erneut öffnen.", "fileMenu": "Dateiverweise", "removeFileReference": "Dateiverweis {{name}} entfernen", "messageAttachments": "Anhänge in dieser Nachricht", diff --git a/packages/i18n/src/locales/en/index.ts b/packages/i18n/src/locales/en/index.ts index fd21d488a9..60b38d2ef0 100644 --- a/packages/i18n/src/locales/en/index.ts +++ b/packages/i18n/src/locales/en/index.ts @@ -240,6 +240,8 @@ export const en = { slashGroupExtensions: "Extension commands", slashGroupSkills: "Skills", slashEmpty: "No matching commands", + slashCommandSourceUnavailable: "Command list unavailable, so nothing was sent. Try again.", + sessionTranscriptEmpty: "This session's history could not be read. Reopen the session to try again.", fileMenu: "File references", removeFileReference: "Remove file reference {{name}}", messageAttachments: "Attachments in this message", diff --git a/packages/i18n/src/locales/es/index.ts b/packages/i18n/src/locales/es/index.ts index 9b2e313db7..17d5145043 100644 --- a/packages/i18n/src/locales/es/index.ts +++ b/packages/i18n/src/locales/es/index.ts @@ -233,6 +233,8 @@ export const es = { "slashGroupExtensions": "Comandos de extensión", "slashGroupSkills": "Habilidades", "slashEmpty": "No hay comandos coincidentes", +"slashCommandSourceUnavailable": "La lista de comandos no está disponible, así que no se envió nada. Inténtalo de nuevo.", + "sessionTranscriptEmpty": "No se pudo leer el historial de esta sesión. Vuelve a abrirla para intentarlo de nuevo.", "fileMenu": "Referencias de archivos", "removeFileReference": "Eliminar referencia de archivo {{name}}", "messageAttachments": "Archivos adjuntos en este mensaje", diff --git a/packages/i18n/src/locales/fr/index.ts b/packages/i18n/src/locales/fr/index.ts index c4ea52aea3..6484ef83ed 100644 --- a/packages/i18n/src/locales/fr/index.ts +++ b/packages/i18n/src/locales/fr/index.ts @@ -233,6 +233,8 @@ export const fr = { "slashGroupExtensions": "Commandes d'extension", "slashGroupSkills": "Compétences", "slashEmpty": "Aucune commande correspondante", +"slashCommandSourceUnavailable": "Liste des commandes indisponible, rien n'a été envoyé. Réessayez.", + "sessionTranscriptEmpty": "L'historique de cette session n'a pas pu être lu. Rouvrez la session pour réessayer.", "fileMenu": "Références de fichiers", "removeFileReference": "Supprimer la référence de fichier {{name}}", "messageAttachments": "Pièces jointes à ce message", diff --git a/packages/i18n/src/locales/ko/index.ts b/packages/i18n/src/locales/ko/index.ts index b5c012ba8f..686b454c81 100644 --- a/packages/i18n/src/locales/ko/index.ts +++ b/packages/i18n/src/locales/ko/index.ts @@ -242,6 +242,8 @@ export const ko = { slashGroupExtensions: "확장 명령", slashGroupSkills: "스킬", slashEmpty: "일치하는 명령 없음", + slashCommandSourceUnavailable: "명령 목록을 불러오지 못해 전송하지 않았습니다. 다시 시도하세요.", + sessionTranscriptEmpty: "이 세션의 기록을 읽지 못했습니다. 세션을 다시 열어 시도하세요.", fileMenu: "파일 참조", removeFileReference: "파일 참조 {{name}} 제거", messageAttachments: "이 메시지의 첨부 파일", diff --git a/packages/i18n/src/locales/tr/index.ts b/packages/i18n/src/locales/tr/index.ts index 29c94b55cc..872ecc6311 100644 --- a/packages/i18n/src/locales/tr/index.ts +++ b/packages/i18n/src/locales/tr/index.ts @@ -242,6 +242,8 @@ export const tr = { slashGroupExtensions: "Uzantı komutları", slashGroupSkills: "Beceriler", slashEmpty: "Eşleşen komut yok", + slashCommandSourceUnavailable: "Komut listesi yüklenemedi, hiçbir şey gönderilmedi. Tekrar deneyin.", + sessionTranscriptEmpty: "Bu oturumun geçmişi okunamadı. Yeniden denemek için oturumu tekrar açın.", fileMenu: "Dosya başvuruları", removeFileReference: "{{name}} dosya başvurusunu kaldır", messageAttachments: "Bu iletideki ekler", diff --git a/packages/i18n/src/locales/zh-CN/index.ts b/packages/i18n/src/locales/zh-CN/index.ts index cdbfe54b44..9b1bd77a89 100644 --- a/packages/i18n/src/locales/zh-CN/index.ts +++ b/packages/i18n/src/locales/zh-CN/index.ts @@ -235,6 +235,8 @@ export const zhCN = { slashGroupExtensions: "扩展命令", slashGroupSkills: "技能", slashEmpty: "没有匹配的指令", + slashCommandSourceUnavailable: "指令列表不可用,消息未发送。请重试。", + sessionTranscriptEmpty: "无法读取该会话的记录,请重新打开会话后重试。", fileMenu: "引用文件", removeFileReference: "移除文件引用 {{name}}", messageAttachments: "此消息中的附件", diff --git a/packages/i18n/src/locales/zh-TW/index.ts b/packages/i18n/src/locales/zh-TW/index.ts index 39f614962c..4719a6f8a9 100644 --- a/packages/i18n/src/locales/zh-TW/index.ts +++ b/packages/i18n/src/locales/zh-TW/index.ts @@ -235,6 +235,8 @@ export const zhTW = { slashGroupExtensions: "擴充命令", slashGroupSkills: "技能", slashEmpty: "沒有匹配的指令", + slashCommandSourceUnavailable: "指令清單無法載入,訊息未傳送。請重試。", + sessionTranscriptEmpty: "無法讀取此工作階段的記錄,請重新開啟後再試一次。", fileMenu: "引用檔案", removeFileReference: "移除檔案引用 {{name}}", messageAttachments: "此訊息中的附件",