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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions apps/desktop/electron/main/ipc/agent-ipc.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 },
Expand Down
34 changes: 23 additions & 11 deletions apps/desktop/src/features/chat/composer/hooks/useComposerSubmit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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" ||
Expand Down
64 changes: 64 additions & 0 deletions apps/desktop/src/features/chat/composer/slash-dispatch.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
33 changes: 26 additions & 7 deletions apps/desktop/src/hooks/use-composer-autocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ComposerCommand | null> {
): Promise<ComposerCommandResolution> {
const key = useAppStore.getState().workspace?.path ?? "";
if (
!commandsCache ||
Expand All @@ -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({
Expand Down
18 changes: 18 additions & 0 deletions apps/desktop/src/lib/session-transcript-read.ts
Original file line number Diff line number Diff line change
@@ -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<SessionDetail, "messages" | "messageCount"> | null | undefined,
): boolean {
if (!session) return false;
return (session.messages ?? []).length === 0 && (session.messageCount ?? 0) > 0;
}
16 changes: 11 additions & 5 deletions apps/desktop/src/stores/runtime/session-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
});
Expand Down
27 changes: 27 additions & 0 deletions apps/desktop/src/stores/slices/session-slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
durableCoversLiveSessionMessages,
mergeLiveSessionMessages,
} from "../../lib/session-transcript";
import { sessionReadLooksEmpty } from "../../lib/session-transcript-read";
import type {
AppState,
DraftSessionConfiguration,
Expand Down Expand Up @@ -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,
Expand Down
24 changes: 24 additions & 0 deletions apps/desktop/test/plugin-timeout-budgets.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading