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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions apps/desktop/electron/main/runtime/session-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -658,10 +658,12 @@ export function createSessionLaunchRuntime({
// Trusted extensions enabled for this project (spec 16 §3.2). The set
// and the grants each plugin holds are part of the runtime match, so a
// toggle or a revoked permission retires the runtime.
trustedExtensions: plugins
trustedExtensions: await Promise.all(plugins
.getAgentExtensions()
.filter((extension) => pluginActiveInProject(extension.pluginId, projectPath))
.map((extension) => ({
.map(async (extension) => ({
settings: Object.fromEntries((await plugins.getPluginSettings(extension.pluginId))
.map((setting) => [setting.key, setting.value])),
id: extension.id,
entry: extension.entry,
label: extension.pluginName,
Expand All @@ -671,7 +673,7 @@ export function createSessionLaunchRuntime({
// sidecar's slot gate consults (ADR 0295 rule 2): `agent.extension`
// says where the module runs, never what it may do to a turn.
permissions: [...(plugins.getLoaded(extension.pluginId)?.permissions ?? [])],
})),
}))),
subagents: subagentCatalog.definitions,
subagentProviders: subagentBindings.providers,
subagentModelKeys,
Expand Down
15 changes: 14 additions & 1 deletion apps/desktop/src/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
useComposerAutocomplete,
} from "../hooks/use-composer-autocomplete";
import { ComposerAutocomplete } from "./ComposerAutocomplete";
import { contextOccupancyTokens, resolveContextWindow } from "../lib/context-usage";
import { AskToolCard } from "./AskToolCard";
import { PlanApprovalBar } from "./PlanApprovalBar";
import {
Expand Down Expand Up @@ -408,6 +409,10 @@ export function Composer({
providerId: provider?.id,
modelId,
thinkingLevel,
referenceContext: {
contextWindow: resolveContextWindow(provider?.id, modelId, providerModels, providers),
usedTokens: composerContextUsage ? contextOccupancyTokens(composerContextUsage.usage) : 0,
},
modelReady,
sendBlocked,
pasting,
Expand Down Expand Up @@ -548,6 +553,14 @@ export function Composer({
anchorRef={composerShellRef}
ac={composerAc}
onAccept={acceptCompletion}
onAcceptText={(text) => {
const result = composerAc.acceptText(text);
if (!result) return false;
invalidatePromptEnhancement();
applyEditorDraft(result.value, fileReferencesRef.current, result.cursor);
composerAc.close();
return true;
}}
/>
) : null}
<ComposerInput
Expand Down Expand Up @@ -599,7 +612,7 @@ export function Composer({
enhancementDraft={enhancementDraft}
value={value}
modelReady={modelReady}
sendBlocked={sendBlocked}
sendBlocked={sendBlocked || submitController.preparingReferences}
enhancingPrompt={enhancingPrompt}
enhancementUndoText={enhancementUndoText}
enhancePrompt={enhancePrompt}
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/components/ComposerAutocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,12 @@ export function ComposerAutocomplete({
anchorRef,
ac,
onAccept,
onAcceptText,
}: {
anchorRef: React.RefObject<HTMLElement | null>;
ac: ReturnType<typeof useComposerAutocomplete>;
onAccept: (index: number) => void;
onAcceptText?: (text: string) => boolean;
}) {
const { t } = useTranslation();
const listRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -184,7 +186,7 @@ export function ComposerAutocomplete({
* keep their order and their keyboard acceptance; a plugin adds
* candidates for the same query after them. `ac.mode` is null only
* while the popover is closed, which this render has already left. */}
<CompletionSourceSlot mode={ac.mode === "file" ? "file" : "slash"} query={ac.query} />
<CompletionSourceSlot mode={ac.mode === "file" ? "file" : "slash"} query={ac.query} sessionId={ac.sessionId ?? undefined} acceptText={onAcceptText} />
</div>
<div className="composer-ac-footer">
<span>{t("chat.acHint")}</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,18 @@ import { useRendererCandidates } from "../../../plugins/renderer-slots/use-rende
export function CompletionSourceSlot({
mode,
query,
sessionId,
acceptText,
}: {
/** The trigger the popover is open for. */
mode: "slash" | "file";
/** What the user has typed after that trigger. */
query: string;
sessionId?: string;
acceptText?: (text: string) => boolean;
}) {
const candidates = useRendererCandidates();
const slotProps = useMemo(() => ({ mode, query }), [mode, query]);
const slotProps = useMemo(() => ({ mode, query, sessionId, acceptText }), [mode, query, sessionId, acceptText]);
return (
<PluginSlot
slot="completionSource"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { validateReferenceSend } from "../../../../plugins/renderer-slots/reference-preflight";
import type { TFunction } from "i18next";
import {
restoreInlineComposerFileReferenceTokens,
Expand All @@ -23,6 +24,7 @@ type UseComposerSubmitOptions = {
providerId?: string;
modelId?: string;
thinkingLevel: Parameters<AppState["configureActiveSession"]>[0]["thinkingLevel"];
referenceContext?: { contextWindow: number; usedTokens: number; maxOutputTokens?: number };
modelReady: boolean;
sendBlocked: boolean;
pasting: boolean;
Expand All @@ -43,6 +45,7 @@ type UseComposerSubmitOptions = {
};

export type ComposerSubmitController = {
preparingReferences: boolean;
enhancingPrompt: boolean;
enhancementUndoText: string | null;
enhancementError: { message: string; code: string } | null;
Expand All @@ -66,6 +69,7 @@ export function useComposerSubmit({
modelId,
thinkingLevel,
modelReady,
referenceContext,
sendBlocked,
pasting,
activeFileReferences,
Expand All @@ -75,6 +79,9 @@ export function useComposerSubmit({
showToast,
draft,
}: UseComposerSubmitOptions): ComposerSubmitController {
const [preparingReferences, setPreparingReferences] = useState(false);
const preflight = useRef<AbortController | null>(null);
useEffect(() => () => preflight.current?.abort(), [draftKey]);
const [enhancingPrompt, setEnhancingPrompt] = useState(false);
const [enhancementUndoText, setEnhancementUndoText] = useState<string | null>(null);
const [enhancementError, setEnhancementError] = useState<{
Expand Down Expand Up @@ -189,6 +196,7 @@ export function useComposerSubmit({
};

const submit = async (steering = false) => {
if (preflight.current) return;
const text = draft.ref.current ? readEditorValue(draft.ref.current) : value;
const inlineContent = serializeInlineComposerFileReferences(
text,
Expand Down Expand Up @@ -272,6 +280,29 @@ export function useComposerSubmit({
return;
}
const submittedDraft = draft.draftSnapshot(text);
const controller = new AbortController();
preflight.current = controller;
setPreparingReferences(true);
try {
await validateReferenceSend({ text: inlineContent, sessionId: activeSessionId ?? undefined,
contextWindow: referenceContext?.contextWindow ?? 0,
usedTokens: referenceContext?.usedTokens ?? 0,
maxOutputTokens: referenceContext?.maxOutputTokens,
hasAttachments: activeFileReferences.length > 0, steering }, controller.signal);
controller.signal.throwIfAborted();
const liveText = draft.ref.current ? readEditorValue(draft.ref.current) : value;
if (liveText !== text || JSON.stringify(draft.draftSnapshot(liveText).fileReferences) !==
JSON.stringify(submittedDraft.fileReferences) ||
draftKeyForSession(useAppStore.getState().activeSessionId) !== submittedDraftKey) {
return;
}
} catch (error) {
if (!controller.signal.aborted) showToast(error instanceof Error ? error.message : String(error), { variant: "error" });
return;
} finally {
if (preflight.current === controller) preflight.current = null;
setPreparingReferences(false);
}
draft.clearDraftForKey(submittedDraftKey);
const accepted = steering
? await steerPrompt(inlineContent, submittedDraft)
Expand All @@ -280,6 +311,7 @@ export function useComposerSubmit({
};

return {
preparingReferences,
enhancingPrompt,
enhancementUndoText,
enhancementError,
Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/src/hooks/use-composer-autocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,9 @@ export function useComposerAutocomplete({
composing: boolean;
enabled: boolean;
}) {
const sessionId = useAppStore((s) => s.activeSessionId);
const live = useRef({ value, cursor, composing, enabled, sessionId });
live.current = { value, cursor, composing, enabled, sessionId };
const workspaceKey = useAppStore((s) => s.workspace?.path ?? "");
const hasWorkspace = workspaceKey !== "";
const [commands, setCommands] = useState<ComposerCommand[] | null>(null);
Expand Down Expand Up @@ -283,7 +286,18 @@ export function useComposerAutocomplete({
[trigger, items, value],
);

const acceptText = (text: string) => {
const current = live.current;
if (!open || !trigger || current.composing || !current.enabled ||
current.value !== value || current.cursor !== cursor ||
current.sessionId !== sessionId || typeof text !== "string" ||
!text || text.length > 4096) return null;
return applyCompletion(value, trigger, text);
};

return {
sessionId,
acceptText,
open,
mode: open && trigger ? trigger.mode : null,
query: open && trigger ? trigger.query : "",
Expand Down
47 changes: 47 additions & 0 deletions apps/desktop/src/plugins/renderer-slots/reference-preflight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { PiRendererReferenceSendInput } from "@pi-desktop/plugin-sdk";
import { pluginSlots } from "./registry";
import { slotDispatchFor } from "../renderer-host/relay";

/** Validate the current reference registrations as one bounded, fail-closed operation. */
export async function validateReferenceSend(
input: Omit<PiRendererReferenceSendInput, "signal" | "dispatch">,
signal: AbortSignal,
timeoutMs = 22_000,
): Promise<void> {
const registrations = pluginSlots.list("composerReference").filter((entry) => entry.validateSend);
if (!registrations.length) return;
const controller = new AbortController();
const cancel = () => controller.abort(signal.reason);
if (signal.aborted) cancel();
else signal.addEventListener("abort", cancel, { once: true });
const timer = setTimeout(() => controller.abort(new Error("PLUGIN_REFERENCE_TIMEOUT")), timeoutMs);
const unchanged = () => registrations.every((entry) => pluginSlots.list("composerReference").includes(entry));
const unsubscribe = pluginSlots.subscribe(() => {
if (!unchanged()) controller.abort(new Error("PLUGIN_REFERENCE_UNLOADED"));
});
let rejectAbort: (reason?: unknown) => void = () => {};
const cancelled = new Promise<never>((_, reject) => { rejectAbort = reject; });
const onAbort = () => rejectAbort(controller.signal.reason ?? new Error("PLUGIN_REFERENCE_CANCELLED"));
controller.signal.addEventListener("abort", onAbort, { once: true });
try {
controller.signal.throwIfAborted();
const work = (async () => {
for (const entry of registrations) {
controller.signal.throwIfAborted();
const answer = await entry.validateSend!({ ...input, signal: controller.signal,
dispatch: slotDispatchFor(entry.pluginId) });
controller.signal.throwIfAborted();
if (!answer || answer.ok !== true) {
throw new Error(answer && !answer.ok ? answer.reason : "PLUGIN_REFERENCE_INVALID_RESULT");
}
}
})();
await Promise.race([work, cancelled]);
if (!unchanged()) throw new Error("PLUGIN_REFERENCE_UNLOADED");
} finally {
clearTimeout(timer);
unsubscribe();
signal.removeEventListener("abort", cancel);
controller.signal.removeEventListener("abort", onAbort);
}
}
8 changes: 8 additions & 0 deletions apps/desktop/src/plugins/renderer-slots/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export type PluginSlotRegistration = {
* refuses a bad list, so a mount can compare against it directly.
*/
positions?: readonly PiRendererComposerControlPosition[];
validateSend?: PiRendererSlotOptions["validateSend"];
};

/**
Expand Down Expand Up @@ -175,6 +176,12 @@ class PluginSlotRegistry {
});
return null;
}
if (options?.validateSend !== undefined &&
(slot !== "composerReference" || typeof options.validateSend !== "function")) {
this.report({ pluginId, slot, code: "PLUGIN_SLOT_INVALID_COMPONENT",
detail: "validateSend must be a function on composerReference" });
return null;
}
let language: string | undefined;
let positions: readonly PiRendererComposerControlPosition[] | undefined;
if (slot === "codeBlock") {
Expand Down Expand Up @@ -239,6 +246,7 @@ class PluginSlotRegistry {
component: component as PluginSlotComponent,
...(language === undefined ? {} : { language }),
...(positions === undefined ? {} : { positions }),
...(options?.validateSend === undefined ? {} : { validateSend: options.validateSend }),
};
list.push(entry);
this.registrations.set(key, list);
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/test/composer-send-state.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ test("mode slash prefixes send the trailing prompt and retain failed drafts", ()
);
assert.match(
submit,
/const submittedDraft = draft\.draftSnapshot\(text\);\s*draft\.clearDraftForKey\(submittedDraftKey\);\s*const accepted = steering[\s\S]*?await steerPrompt\(inlineContent, submittedDraft\)[\s\S]*?await sendPrompt\(inlineContent, submittedDraft\);\s*if \(!accepted\) draft\.restoreDraftForKey\(submittedDraftKey, submittedDraft\);/,
/const submittedDraft = draft\.draftSnapshot\(text\);[\s\S]*?await validateReferenceSend\([\s\S]*?controller\.signal\.throwIfAborted\(\);[\s\S]*?draft\.clearDraftForKey\(submittedDraftKey\);\s*const accepted = steering[\s\S]*?await steerPrompt\(inlineContent, submittedDraft\)[\s\S]*?await sendPrompt\(inlineContent, submittedDraft\);\s*if \(!accepted\) draft\.restoreDraftForKey\(submittedDraftKey, submittedDraft\);/,
);
assert.match(store, /draft\?: ComposerDraftSnapshot/);
const sendPrompt = queueSlice.slice(
Expand Down
62 changes: 62 additions & 0 deletions apps/desktop/test/plugin-reference-preflight.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import { register } from 'node:module';
import { test } from 'node:test';
register(new URL('./helpers/ts-import-hooks.mjs', import.meta.url));
globalThis.piDesktop = { invoke: async () => ({ ok: true, data: null }), on: () => () => {} };
const { pluginSlots, resetPluginSlots } = await import('../src/plugins/renderer-slots/registry.ts');
const { validateReferenceSend } = await import('../src/plugins/renderer-slots/reference-preflight.ts');
const input = { text: 'draft', contextWindow: 128000, usedTokens: 0, hasAttachments: false, steering: false };
function hook(validateSend) {
resetPluginSlots();
return pluginSlots.register('test.reference', 'composerReference', () => null, { validateSend });
}
test('existing reference slot validates before a caller can commit its draft', async () => {
let checked = false;
hook(async (value) => { checked = true; assert.equal(value.text, 'draft'); return { ok: false, reason: 'too large' }; });
let committed = false;
await assert.rejects(async () => { await validateReferenceSend(input, new AbortController().signal); committed = true; }, /too large/);
assert.equal(checked, true); assert.equal(committed, false);
});
test('a hung reference provider fails closed under the global deadline', async () => {
hook(() => new Promise(() => {}));
await assert.rejects(validateReferenceSend(input, new AbortController().signal, 10), /TIMEOUT/);
});
test('unloading a provider cancels an in-flight reference validation', async () => {
const registration = hook(() => new Promise(() => {}));
const pending = validateReferenceSend(input, new AbortController().signal, 1000);
registration.remove();
await assert.rejects(pending, /UNLOADED/);
});
test('an invalid or throwing response never authorizes a send', async () => {
hook(() => undefined);
await assert.rejects(validateReferenceSend(input, new AbortController().signal), /INVALID_RESULT/);
hook(() => { throw new Error('failed'); });
await assert.rejects(validateReferenceSend(input, new AbortController().signal), /failed/);
});
test('validation cannot be attached to an unrelated component slot', () => {
resetPluginSlots();
assert.equal(pluginSlots.register('test.invalid', 'entryExtra', () => null, { validateSend: () => ({ ok: true }) }), null);
});
test('ordinary sends without a reference validator stay unchanged', async () => {
resetPluginSlots();
await validateReferenceSend(input, new AbortController().signal);
});
test('successful reference validations run in order and preserve the original input', async () => {
const seen = [];
hook(async (value) => { seen.push(value.text); return { ok: true }; });
pluginSlots.register('test.second', 'composerReference', () => null, {
validateSend: async (value) => { seen.push(value.text); return { ok: true }; },
});
await validateReferenceSend(input, new AbortController().signal);
assert.deepEqual(seen, ['draft', 'draft']);
assert.equal(input.text, 'draft');
});
test('caller cancellation prevents a late successful response from authorizing a send', async () => {
let finish;
hook(() => new Promise((resolve) => { finish = resolve; }));
const controller = new AbortController();
const pending = validateReferenceSend(input, controller.signal);
controller.abort(new Error('draft changed'));
finish({ ok: true });
await assert.rejects(pending, /draft changed/);
});
Loading