mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 08:01:26 +00:00
feat(ai-sessions): add plan mode (#10057)
* feat(sessions): let an opener name the artifact version to show A tab already remembers the version a reader pinned, and re-pointing it keeps that pin. Plan mode needs the two intents that leaves out: a plan card scrolled up the transcript wants the version it proposed, and a plan going up for approval wants the current text with no pin at all. `ArtifactVersionTarget` is those two alongside the existing one: a number, `'latest'`, or omitted. Omitted still cannot double as `'latest'` — every artifact tool re-opens the document it just wrote, so taking that as a request to move would yank a reader out of the version they chose on every edit the agent makes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(copilot): add the plan-mode gate and tag plan-mode-safe tools Plan mode is a read-only posture, so something has to decide which tools it may still run. `Tool.planModeSafe` is that tag, and processToolCall fails closed on it: untagged means mutating means blocked. Deriving it from `requiresConfirmation` was not an option — unconfirmed mutating tools exist, and a posture that leaks one is not a posture. The gate runs twice per call. Before `validateBeforeConfirmation`, so a validator cannot reach out while planning; and again after the confirmation wait, because plan mode can be entered while a mutating tool's card is already pending, and that approval must not carry it through. Arguments are read one field at a time rather than through a parse of the whole call. `change_note` is optional and cosmetic, and a model that sends it as `null` would otherwise fail the object parse and take the plan down with it — the user being told there was no plan to approve, which is false. Also here, because refusing a call well needs them: a validator may now return the row the user reads and the result the model gets separately, a tool may word its own cancellation, and a tool may start work when its card appears rather than when it is approved. The gate is consulted before any of them. `shouldAutoAcceptToolConfirmations` is asked about the tool by name, because skipping the confirmation wait is itself an answer on the user's behalf and one tool must not be answered for. Deciding that without the name would put the exception out of reach of the only path that needs it. The gate stays inert until a chat supplies `isPlanModeActive`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(copilot): give a session one versioned plan document The plan the user agrees to has to survive `/clear`, so it belongs to the session rather than the conversation, and a session holds exactly one. Its id is the session's, so the primary key is the constraint — there is no second row to mint, no index to maintain and no schema change at all. Every write reads the row it is about to replace inside the transaction that replaces it. Read outside, two tabs both see version N, both stamp N+1, and the later write silently drops the earlier one's text and its snapshot; IndexedDB serialises readwrite transactions over a store, so read and write together cannot interleave. Approval takes the same route but patches only the pointer: an approval computed while another tab was revising must not carry this tab's older content back over the newer text. Approval is `approvedVersion`, a pointer at a version, never a flag. Below the current version means the newest text is a proposal the user has not agreed to; absent means nothing here was ever approved. Only exit_plan_mode can leave the pointer behind, since every write outside plan mode carries it forward — an amendment the user's posture already trusts is still the agreed plan. Declining writes nothing at all: the refused proposal stands as the newest version, with the agreed one still in history. Nor can create_artifact confer approval. It asks for no confirmation, so the model writing a plan document is not the user agreeing to one; a plan written there holds the session's slot as a draft until a decision lands on it. That is also why the approved version is exempt from pruning. A plan approved at v1 and then planned against for twenty more rounds would otherwise lose the very version that stands as agreed, and with it the card that opens it, the banner offering it back, and read_artifact at that version. It is excluded from the pruning candidates rather than added on top, so the budget is unchanged and what survives simply stops being contiguous. The write reports whether the database took it. Most callers still degrade like the reads do, but a plan cannot: returning one the database refused would let the user approve and execute against a document that disappears on reload — a refused plan write raises instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(copilot): add plan mode — the posture and its two tools enter_plan_mode asks to hold work; exit_plan_mode hands over a plan and, on approval, gives the posture back to whatever preceded it. Both carry `planModeSafe`, since a posture with no exit is a trap. Only the transition the current posture allows is offered, so there is no tool for leaving a posture the chat is not in. A planning round runs from entering plan mode to the proposal the user decides on. It remembers only the write it made, because nothing it does is undone — and that write is shared between the card's confirmation hook and the tool's `fn`, so the plan is on screen while the user is deciding whether to approve it rather than after. The round is identified by an epoch bumped on *entering*, not by the conversation. A chat rotation mid-approval must still let that approval hand the posture back; a round the user has since left and re-entered must not, or approving the old plan would drop them out of a read-only posture they just chose. Saving a proposal revises the session's plan document and creates one only when there is none — both halves in a single transaction, so a second tab proposing at the same moment revises the row this one wrote rather than racing it. Persistence failures hold the posture. Approval is reported only once both the proposal and the approval pointer are durable, so a plan the database refused cannot unblock mutating tools. The failure is reported from `fn` and no earlier: the write settles while the card is still waiting to be confirmed, and clearing that card from underneath the wait would take away the only control that resolves it. An auto-accepting posture answers for the user through one predicate, asked by every path that answers: the pending-card sweep, the confirmation itself, and the decision to skip the wait at all. enter_plan_mode never qualifies: YOLO means "stop asking and run it", and a call from a tool set snapshotted before the switch must not answer that with a read-only posture — whether its card is already pending or has yet to be registered. Plan mode lives in its own controller with a narrow view of the chat it runs in: it reads that autonomy state and asks for the two changes it can cause, rather than owning any of it. Plan mode is offered only in a session chat, and a session chat is GLOBAL for its whole life. The gate reads that mode, so `changeMode` refuses to move one out of GLOBAL rather than resting the invariant on a picker being hidden. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(copilot): surface plan mode in the chat and the artifact list Plan mode is the only posture that refuses work, so the composer says so before the user types the request it is about to turn down: the mode pill is tinted whole rather than by its icon, and the empty placeholder carries the constraint in words. Teal, not the house green — green is the transcript's success colour a few rows up, and a mode signal in it would read as "this worked" rather than "this is held". A blocked tool renders as its own lean row naming the tool, not as an error: the call did what plan mode says it should, and "why can't it edit" is answered where it is asked. A plan card names the decision — proposed, approved, or not approved — and never the button, since a Stop and a posture switch resolve it too. Its button opens the version that card proposed, so a card far up the transcript still shows the plan it put forward rather than whatever the document has become since. The artifact list and the preview header both label the plan through one badge helper, so the two cannot disagree about what counts as one: a plan the user never approved keeps the plan icon and takes the neutral badge, leaving the teal to mean exactly one thing. In the viewer, an unapproved revision says so in a bar that cannot be scrolled past, with the version the user did agree to one click away. The autonomy picker became a table with one row per posture, so adding one touches a single place instead of four parallel switch statements. A version of a plan is read against the one the user approved, not against the newest: latest is only where the model happened to stop. So the approved version is never stale — its bar is teal and points forward to the draft rather than warning about it — the version in front of it is the draft, and anything behind it is history that is neither and takes no pill at all. The list opens a plan at the approved version for the same reason, which is what lets its pill say `plan` while an unapproved draft sits at the head. One helper answers all of it, so the list and the preview header cannot drift apart on what counts as the plan. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(ai-evals): exercise plan mode end to end A case a unit test cannot stand in for: it starts in plan mode against the real gate and the real exit_plan_mode, and grades whether the model researches and hands over a usable plan instead of guessing at one. The checklist does not grade what the harness does for the model — exit_plan_mode writes the plan document itself, so "saves the plan as an artifact" would pass on any run where the tool is called at all. The eval store seeds artifacts with history and mirrors the store's own approval rules, so a rename cannot promote a proposal the user turned down. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ai-evals): import the plan-mode messages from the module that owns them `PLAN_MODE_MESSAGES` moved to `planModeMessages.ts`; `planMode.ts` imports it without re-exporting. Under vitest, which runs the frontend adapters, the stale import resolved to `undefined` rather than failing to link, so `global-planmode1-hands-over-a-plan` threw on the approval message after the posture had already been dropped and the tool withdrawn. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(copilot): state plan mode's constraint in neutral text The composer's two-tone placeholder becomes a plain "Read-only" beside the autonomy picker, next to where YOLO puts its own warning, and a blocked call's row drops the mode colour. Teal is left marking what the posture is — the badge, the version bars, the pill — rather than every call it refuses. ContextTextarea goes back to main with the accent: `placeholderAccent` had no other consumer, and the aria-label existed only because the accent blanked the native placeholder. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(copilot): hold the plan header's verdict until the snapshot lands Opening a plan at the version its reader approved pins a version behind the head, and until that read resolves `shownVersion` is still the head — so the header wore the draft's badge and its orange "not approved" bar over the very case the pin exists to serve, then flipped. The header now says nothing while `restoringPin`, as the body already does. Judging `pinned` instead would print the approved signal over text that is still the draft, trading a true transient signal for a false one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(copilot): refuse a hand-over once plan mode has ended A response can carry two exit_plan_mode calls, and the tool list they run against is snapshotted before the first one restores the posture. The second then found the tool with plan mode already over: under YOLO every confirmation is answered for the user, so it wrote its own summary and stamped the user's approval on a plan no card had shown them. Refused in `validateBeforeConfirmation` rather than in `fn`, since `onConfirmationRequested` writes the document too. The maintenance path is untouched — a plan still gets revised outside the posture with update_artifact, which is what the tool's own description already tells the model to use. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,54 @@
|
||||
// SessionArtifactsStore can't run here (bun has no IndexedDB, nor the compiled $state runes),
|
||||
// so mirror only the shape the artifact tools call, not its scoping or race handling.
|
||||
export const EVAL_SESSION_ID = "eval-session";
|
||||
export function createEvalArtifactHelpers() {
|
||||
|
||||
/** An artifact the session already holds when the case starts: history has to predate the
|
||||
* run, since one prompt cannot both build a past and reason about it. */
|
||||
export interface SeededArtifact {
|
||||
name: string;
|
||||
role?: "plan";
|
||||
/** Which version the user agreed to. Below the last one means the current text is a
|
||||
* proposal they turned down, which is the state worth seeding. */
|
||||
approvedVersion?: number;
|
||||
/** Oldest first; the last one is the artifact's current content. */
|
||||
versions: Array<{ content: string; note?: string }>;
|
||||
}
|
||||
|
||||
export function createEvalArtifactHelpers(seed: SeededArtifact[] = []) {
|
||||
const items = new Map<string, Record<string, any>>();
|
||||
// Snapshots per artifact id, oldest first — the version tools read history from here.
|
||||
const history = new Map<string, Array<Record<string, any>>>();
|
||||
let seq = 0;
|
||||
for (const entry of seed) {
|
||||
const id = `eval-artifact-${seq++}`;
|
||||
const current = entry.versions.at(-1);
|
||||
if (!current) continue;
|
||||
items.set(id, {
|
||||
id,
|
||||
sessionId: EVAL_SESSION_ID,
|
||||
chatId: "eval-chat",
|
||||
kind: "md",
|
||||
name: entry.name,
|
||||
content: current.content,
|
||||
role: entry.role,
|
||||
approvedVersion: entry.approvedVersion,
|
||||
createdAt: 0,
|
||||
updatedAt: seq,
|
||||
version: entry.versions.length,
|
||||
});
|
||||
history.set(
|
||||
id,
|
||||
entry.versions.map((v, i) => ({
|
||||
key: `${id}:${i + 1}`,
|
||||
artifactId: id,
|
||||
version: i + 1,
|
||||
name: entry.name,
|
||||
content: v.content,
|
||||
savedAt: i,
|
||||
note: v.note,
|
||||
})),
|
||||
);
|
||||
}
|
||||
const snapshotOf = (
|
||||
artifact: Record<string, any>,
|
||||
version: number,
|
||||
@@ -21,6 +64,16 @@ export function createEvalArtifactHelpers() {
|
||||
});
|
||||
const store = {
|
||||
create: async (sessionId: string, input: Record<string, any>) => {
|
||||
// One plan per session, as SessionArtifactsStore enforces it — the tool refuses
|
||||
// first, so reaching this means a case drove create_artifact past that message.
|
||||
if (
|
||||
input.role === "plan" &&
|
||||
[...items.values()].some(
|
||||
(a) => a.sessionId === sessionId && a.role === "plan",
|
||||
)
|
||||
) {
|
||||
throw new Error(`Session ${sessionId} already has a plan document`);
|
||||
}
|
||||
const now = seq++;
|
||||
const artifact = {
|
||||
id: `eval-artifact-${now}`,
|
||||
@@ -29,6 +82,10 @@ export function createEvalArtifactHelpers() {
|
||||
kind: input.kind ?? "md",
|
||||
name: input.name,
|
||||
content: input.content,
|
||||
// The plan document is only distinguishable by these, both in the snapshot the
|
||||
// judge reads and in what list_artifacts reports back to the model.
|
||||
role: input.role,
|
||||
approvedVersion: input.approvedVersion,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
version: 1,
|
||||
@@ -58,6 +115,15 @@ export function createEvalArtifactHelpers() {
|
||||
...existing,
|
||||
name: input.name ?? existing.name,
|
||||
content: input.content ?? existing.content,
|
||||
// Carried only onto a version this write produced, as SessionArtifactsStore does:
|
||||
// a rename cannot promote a proposal the user turned down.
|
||||
approvedVersion:
|
||||
input.approvedVersion ??
|
||||
(input.keepApproved &&
|
||||
existing.approvedVersion !== undefined &&
|
||||
contentChanged
|
||||
? version
|
||||
: existing.approvedVersion),
|
||||
updatedAt: seq++,
|
||||
version,
|
||||
};
|
||||
|
||||
@@ -12,9 +12,14 @@ import {
|
||||
getGlobalDraft,
|
||||
listGlobalDrafts,
|
||||
} from "../../../../../frontend/src/lib/components/copilot/chat/global/userDraftAdapter";
|
||||
import { appendPlanModeInstructions } from "../../../../../frontend/src/lib/components/copilot/chat/planMode";
|
||||
import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared";
|
||||
import { createEvalPlanTools } from "./planModeTools";
|
||||
import { UserDraft } from "../../../../../frontend/src/lib/userDraft.svelte";
|
||||
import { createEvalArtifactHelpers } from "./evalArtifactStore";
|
||||
import {
|
||||
createEvalArtifactHelpers,
|
||||
type SeededArtifact,
|
||||
} from "./evalArtifactStore";
|
||||
import type { ModeRunContext } from "../../../../core/types";
|
||||
import type { GlobalDraftState } from "../../../../core/validators";
|
||||
import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings";
|
||||
@@ -83,11 +88,16 @@ export interface GlobalEvalOptions {
|
||||
user?: GlobalUserFixture;
|
||||
// Emulate a session chat (preview tools + session prompt); default false = standalone baseline.
|
||||
sessionChat?: boolean;
|
||||
// Start in plan mode: the gate refuses every tool without `planModeSafe`, and the two plan
|
||||
// tools are offered. Needs sessionChat, which is what plan mode is gated on in production.
|
||||
planMode?: boolean;
|
||||
model?: string;
|
||||
maxIterations?: number;
|
||||
provider?: AIProvider;
|
||||
backend: WindmillBackendSettings;
|
||||
workspaceRoot?: string;
|
||||
// Artifacts the session already holds when the run starts.
|
||||
artifacts?: SeededArtifact[];
|
||||
runContext?: ModeRunContext;
|
||||
}
|
||||
|
||||
@@ -113,19 +123,41 @@ export async function runGlobalEval(
|
||||
process.env[DISABLE_ACTIVE_EDITOR_CONTEXT_ENV] !== "1";
|
||||
// Pass the seeded identity straight to the prompt builder rather than mutating
|
||||
// the process-global `userStore`, so concurrent cases never race on it.
|
||||
const evalArtifacts = createEvalArtifactHelpers();
|
||||
const evalArtifacts = createEvalArtifactHelpers(options.artifacts);
|
||||
const planMode = options.planMode
|
||||
? createEvalPlanTools({
|
||||
create: evalArtifacts.helpers.artifacts.create,
|
||||
sessionId: evalArtifacts.helpers.sessionId,
|
||||
chatId: evalArtifacts.helpers.getChatId(),
|
||||
})
|
||||
: undefined;
|
||||
const baseSystemMessage = prepareGlobalSystemMessage(undefined, {
|
||||
user: options.user,
|
||||
previewTools: options.sessionChat ?? false,
|
||||
});
|
||||
const rawResult = await runEval({
|
||||
userPrompt,
|
||||
systemMessage: prepareGlobalSystemMessage(undefined, {
|
||||
user: options.user,
|
||||
previewTools: options.sessionChat ?? false,
|
||||
}),
|
||||
systemMessage: baseSystemMessage,
|
||||
// Re-derived per request, as production's getter is: the instructions have to leave
|
||||
// the prompt when the plan is approved, or the model is still told it may not build
|
||||
// while the gate has already opened.
|
||||
getSystemMessage: planMode
|
||||
? () =>
|
||||
planMode.isPlanModeActive()
|
||||
? appendPlanModeInstructions(baseSystemMessage, 0)
|
||||
: baseSystemMessage
|
||||
: undefined,
|
||||
isPlanModeActive: planMode?.isPlanModeActive,
|
||||
isToolAvailable: planMode?.isToolAvailable,
|
||||
userMessage: prepareGlobalUserMessage(
|
||||
userPrompt,
|
||||
[],
|
||||
injectActiveEditorContext ? { workspace: workspaceRoot } : {},
|
||||
),
|
||||
tools: getGlobalEvalTools(options.sessionChat ?? false),
|
||||
tools: [
|
||||
...getGlobalEvalTools(options.sessionChat ?? false),
|
||||
...(planMode?.tools ?? []),
|
||||
],
|
||||
helpers: evalArtifacts.helpers,
|
||||
apiKey,
|
||||
getOutput: async () => ({
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
EXIT_PLAN_MODE_TOOL,
|
||||
EXIT_PLAN_MODE_TOOL_DESCRIPTION,
|
||||
derivePlanTitle,
|
||||
exitPlanModeArgs,
|
||||
planSummaryOf,
|
||||
} from "../../../../../frontend/src/lib/components/copilot/chat/planMode";
|
||||
import { PLAN_MODE_MESSAGES } from "../../../../../frontend/src/lib/components/copilot/chat/planModeMessages";
|
||||
import { createToolDef } from "../../../../../frontend/src/lib/components/copilot/chat/shared";
|
||||
import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared";
|
||||
|
||||
/**
|
||||
* `exit_plan_mode` built from the production schema, description and messages, so a case
|
||||
* exercises the real gate and wording with the posture living here rather than on the
|
||||
* manager. It resolves immediately — the runners define no `requestConfirmation`, so the
|
||||
* plan is always approved and a refused one cannot be expressed.
|
||||
*/
|
||||
export function createEvalPlanTools(artifacts: {
|
||||
create: (
|
||||
sessionId: string,
|
||||
input: Record<string, unknown>,
|
||||
) => Promise<{ id: string; name: string }>;
|
||||
sessionId: string;
|
||||
chatId: string;
|
||||
}): {
|
||||
tools: ProductionTool<{}>[];
|
||||
isPlanModeActive: () => boolean;
|
||||
isToolAvailable: (name: string) => boolean;
|
||||
} {
|
||||
let planActive = true;
|
||||
return {
|
||||
isPlanModeActive: () => planActive,
|
||||
// Withdrawn on approval, as production's tool getter does it: leaving it advertised
|
||||
// invites a second hand-over of a plan already agreed, which would write a duplicate.
|
||||
// Production would offer enter_plan_mode in its place; these cases stop at the first
|
||||
// hand-over, so a fresh planning round belongs to a case of its own.
|
||||
isToolAvailable: (name) => name !== EXIT_PLAN_MODE_TOOL || planActive,
|
||||
// Production offers one plan tool at a time and these cases start in plan mode, so
|
||||
// enter_plan_mode would only invite a turn spent entering a posture already held.
|
||||
tools: [
|
||||
{
|
||||
def: createToolDef(
|
||||
exitPlanModeArgs,
|
||||
EXIT_PLAN_MODE_TOOL,
|
||||
EXIT_PLAN_MODE_TOOL_DESCRIPTION,
|
||||
),
|
||||
// Carries the safety tag for the same reason production does: it is the only way out
|
||||
// of the posture, so the gate must not refuse it.
|
||||
planModeSafe: true,
|
||||
fn: async ({ args }) => {
|
||||
const summary = planSummaryOf(args);
|
||||
if (!summary?.trim()) {
|
||||
return PLAN_MODE_MESSAGES.missingSummary;
|
||||
}
|
||||
planActive = false;
|
||||
await artifacts.create(artifacts.sessionId, {
|
||||
name: derivePlanTitle(summary),
|
||||
content: summary,
|
||||
kind: "md",
|
||||
role: "plan",
|
||||
approvedVersion: 1,
|
||||
chatId: artifacts.chatId,
|
||||
});
|
||||
return PLAN_MODE_MESSAGES.approvedWithDoc;
|
||||
},
|
||||
},
|
||||
] as ProductionTool<{}>[],
|
||||
};
|
||||
}
|
||||
@@ -43,6 +43,15 @@ export interface RunEvalParams<THelpers, TOutput> {
|
||||
getOutput: () => TOutput | Promise<TOutput>;
|
||||
/** Model and Windmill backend configuration */
|
||||
options: EvalRunnerOptions;
|
||||
/** Drives the production plan-mode gate in processToolCall. Absent leaves it inert,
|
||||
* which is what every mode but an opted-in global case wants. */
|
||||
isPlanModeActive?: () => boolean;
|
||||
/** Which of `tools` the model is offered on this request. Absent offers all of them. */
|
||||
isToolAvailable?: (name: string) => boolean;
|
||||
/** Re-read before every request, as production's systemMessage getter is. Needed when a
|
||||
* tool changes what the prompt should say — plan mode's instructions have to come back
|
||||
* out once the plan is approved. Falls back to the fixed `systemMessage`. */
|
||||
getSystemMessage?: () => ChatCompletionSystemMessageParam;
|
||||
onAssistantMessageStart?: () => void;
|
||||
onAssistantToken?: (token: string) => void;
|
||||
onAssistantMessageEnd?: () => void;
|
||||
@@ -68,6 +77,9 @@ export async function runEval<THelpers, TOutput>(
|
||||
onAssistantToken,
|
||||
onAssistantMessageEnd,
|
||||
onToolCall,
|
||||
isPlanModeActive,
|
||||
isToolAvailable,
|
||||
getSystemMessage,
|
||||
} = params;
|
||||
let shouldEmitMessageStart = true;
|
||||
|
||||
@@ -119,6 +131,7 @@ export async function runEval<THelpers, TOutput>(
|
||||
} = {
|
||||
setToolStatus: () => {},
|
||||
removeToolStatus: () => {},
|
||||
isPlanModeActive,
|
||||
onNewToken: (token: string) => {
|
||||
if (shouldEmitMessageStart) {
|
||||
onAssistantMessageStart?.();
|
||||
@@ -140,8 +153,17 @@ export async function runEval<THelpers, TOutput>(
|
||||
try {
|
||||
const result = await runChatLoop({
|
||||
messages,
|
||||
systemMessage,
|
||||
tools: wrappedTools,
|
||||
get systemMessage() {
|
||||
return getSystemMessage?.() ?? systemMessage;
|
||||
},
|
||||
// Re-derived per request, as `systemMessage` is: a tool the posture has withdrawn
|
||||
// must leave the schema too, or the model keeps being offered a call the run has
|
||||
// moved past — and the token counts a case reports include a tool it cannot use.
|
||||
get tools() {
|
||||
return isToolAvailable
|
||||
? wrappedTools.filter((t) => isToolAvailable(t.def.function.name))
|
||||
: wrappedTools;
|
||||
},
|
||||
helpers,
|
||||
abortController,
|
||||
callbacks,
|
||||
|
||||
@@ -1756,8 +1756,32 @@
|
||||
judgeChecklist:
|
||||
- saves the plan as a markdown artifact via create_artifact rather than only replying inline
|
||||
- the artifact content has a title, a one-line summary, and three or four bullet steps for onboarding
|
||||
- the artifact is registered as the session's plan (role "plan"), not as an ordinary note - the user asked for the plan they will come back to and revise
|
||||
- does not create a flow or script draft yet
|
||||
|
||||
- id: global-planmode1-hands-over-a-plan
|
||||
prompt: |-
|
||||
Our support inbox is a mess. I want incoming emails triaged by urgency and routed to the
|
||||
right team, with anything urgent also posted to Slack.
|
||||
Work out how you'd build this in Windmill.
|
||||
initial: ai_evals/fixtures/frontend/global/initial/user_admin_evals_folder.json
|
||||
runtime:
|
||||
maxTurns: 10
|
||||
sessionChat: true
|
||||
planMode: true
|
||||
# No draft assertion: approving the plan opens the gate mid-run, and building from there is
|
||||
# what production asks for, so a draft is not a failure. The gate itself is covered by
|
||||
# shared.test.ts; what only a real model can show is whether it researches and hands over a
|
||||
# usable plan instead of guessing at one.
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- exit_plan_mode
|
||||
# Not "saves the plan as an artifact": exit_plan_mode writes it, so the harness would
|
||||
# satisfy that on every run the tool is called at all — it grades itself, not the model.
|
||||
judgeChecklist:
|
||||
- the plan covers classifying an incoming email by urgency, routing it to a team, and posting urgent ones to Slack
|
||||
- the plan is specific about what would be built in Windmill (a flow and its steps, or the scripts involved)
|
||||
|
||||
- id: global-npm1-script-search-package
|
||||
prompt: |-
|
||||
Find a good npm package for parsing RSS/Atom feeds and use it to create a draft Bun script
|
||||
|
||||
@@ -33,6 +33,9 @@ export interface EvalCaseRuntimeSpec {
|
||||
appContext?: EvalCaseRuntimeAppContextSpec;
|
||||
// Global mode: run as a session chat (preview tools + session prompt) vs the standalone chat.
|
||||
sessionChat?: boolean;
|
||||
// Global session chats: start the case in plan mode, so mutating tools are refused until
|
||||
// the model hands over a plan with exit_plan_mode.
|
||||
planMode?: boolean;
|
||||
}
|
||||
|
||||
export interface FlowValidationSpec {
|
||||
|
||||
@@ -6,9 +6,14 @@ import {
|
||||
type GlobalLiveEditorDraftFixture,
|
||||
type GlobalUserFixture,
|
||||
} from "../adapters/frontend/core/global/globalEvalRunner";
|
||||
import type { SeededArtifact } from "../adapters/frontend/core/global/evalArtifactStore";
|
||||
import type { BenchmarkWorkspaceRunnables } from "../adapters/frontend/mockBackend";
|
||||
import type { FrontendEvalModelConfig } from "../core/models";
|
||||
import type { BenchmarkArtifactFile, GlobalValidationSpec, ModeRunner } from "../core/types";
|
||||
import type {
|
||||
BenchmarkArtifactFile,
|
||||
GlobalValidationSpec,
|
||||
ModeRunner,
|
||||
} from "../core/types";
|
||||
import { validateGlobalState, type GlobalDraftState } from "../core/validators";
|
||||
import type { WindmillBackendSettings } from "../core/windmillBackendSettings";
|
||||
import { getFrontendApiKey } from "./frontendCommon";
|
||||
@@ -17,6 +22,7 @@ export interface GlobalInitialFixture {
|
||||
workspace?: BenchmarkWorkspaceRunnables;
|
||||
liveEditorDrafts?: GlobalLiveEditorDraftFixture[];
|
||||
user?: GlobalUserFixture;
|
||||
artifacts?: SeededArtifact[];
|
||||
}
|
||||
|
||||
export function createGlobalModeRunner(
|
||||
@@ -41,7 +47,9 @@ export function createGlobalModeRunner(
|
||||
workspaceFixtures: initial?.workspace,
|
||||
liveEditorDrafts: initial?.liveEditorDrafts,
|
||||
user: initial?.user,
|
||||
artifacts: initial?.artifacts,
|
||||
sessionChat: context.evalCase?.runtime?.sessionChat,
|
||||
planMode: context.evalCase?.runtime?.planMode,
|
||||
maxIterations: context.evalCase?.runtime?.maxTurns,
|
||||
provider: modelConfig.provider,
|
||||
model: modelConfig.model,
|
||||
@@ -81,7 +89,9 @@ export function createGlobalModeRunner(
|
||||
};
|
||||
}
|
||||
|
||||
async function loadGlobalInitialFixture(path: string): Promise<GlobalInitialFixture> {
|
||||
async function loadGlobalInitialFixture(
|
||||
path: string,
|
||||
): Promise<GlobalInitialFixture> {
|
||||
if ((await stat(path)).isDirectory()) {
|
||||
const { initialFrontend, initialBackend, initialDatatables } =
|
||||
await loadAppFixtureForEval(path);
|
||||
@@ -104,14 +114,19 @@ async function loadGlobalInitialFixture(path: string): Promise<GlobalInitialFixt
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(await readFile(path, "utf8")) as GlobalInitialFixture;
|
||||
const parsed = JSON.parse(
|
||||
await readFile(path, "utf8"),
|
||||
) as GlobalInitialFixture;
|
||||
return {
|
||||
workspace: parsed.workspace ?? {},
|
||||
liveEditorDrafts: parsed.liveEditorDrafts ?? [],
|
||||
user: parsed.user,
|
||||
artifacts: parsed.artifacts,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadGlobalExpectedFixture(path: string): Promise<GlobalDraftState> {
|
||||
async function loadGlobalExpectedFixture(
|
||||
path: string,
|
||||
): Promise<GlobalDraftState> {
|
||||
return JSON.parse(await readFile(path, "utf8")) as GlobalDraftState;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
ChevronDown,
|
||||
ChevronsRight,
|
||||
CheckIcon,
|
||||
ClipboardList,
|
||||
FileText,
|
||||
Folder,
|
||||
Hand,
|
||||
@@ -25,6 +26,8 @@
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import { pendingUserAction, type DisplayMessage } from './shared'
|
||||
import { PLAN_MODE_TEXT_COLOR, PLAN_MODE_TRIGGER_CLASS } from './planMode'
|
||||
import { PLAN_MODE_MESSAGES } from './planModeMessages'
|
||||
import type { ContextElement } from './context'
|
||||
import ChatQuickActions from './ChatQuickActions.svelte'
|
||||
import ContextUsageIndicator from './ContextUsageIndicator.svelte'
|
||||
@@ -57,46 +60,79 @@
|
||||
|
||||
const MAX_YOLO_TOOLTIP_TOOLS = 8
|
||||
const aiChatManager = getAiChatManager()
|
||||
// `label` is shown in the dropdown; `shortLabel` (when set) is shown in the
|
||||
// compact trigger pill to save horizontal space.
|
||||
type AutonomyModeOption = { label: string; shortLabel?: string; mode: AIAutonomyMode }
|
||||
// One row per autonomy posture, in picker order, so adding one touches only this
|
||||
// table. `isAvailable` hides the postures that would do nothing in the current AI
|
||||
// mode, which is why the picker can be shorter than this list.
|
||||
type AutonomyAvailability = {
|
||||
autoAcceptEditsAvailable: boolean
|
||||
autoAcceptToolConfirmationsAvailable: boolean
|
||||
planModeAvailable: boolean
|
||||
}
|
||||
type AutonomyModeOption = {
|
||||
mode: AIAutonomyMode
|
||||
label: string
|
||||
shortLabel?: string
|
||||
icon: typeof Hand
|
||||
iconColor: string
|
||||
/** Tints the whole trigger, not just its icon. Only plan mode needs it. */
|
||||
triggerClass?: string
|
||||
tooltip: (a: AutonomyAvailability) => string
|
||||
isAvailable: (a: AutonomyAvailability) => boolean
|
||||
}
|
||||
// The one posture available everywhere, so also the fallback for a mode the
|
||||
// current AI mode does not offer.
|
||||
const askPermissionOption: AutonomyModeOption = {
|
||||
mode: AIAutonomyMode.DEFAULT,
|
||||
label: 'Ask permission',
|
||||
icon: Hand,
|
||||
iconColor: 'text-secondary',
|
||||
tooltip: (a) =>
|
||||
a.autoAcceptEditsAvailable
|
||||
? 'Requires confirmation for edits and tool calls.'
|
||||
: 'Requires confirmation for tool calls.',
|
||||
isAvailable: () => true
|
||||
}
|
||||
const autonomyModeOptions: AutonomyModeOption[] = [
|
||||
{ label: 'Ask permission', mode: AIAutonomyMode.DEFAULT },
|
||||
{ label: 'Auto-accept edits', mode: AIAutonomyMode.ACCEPT_EDIT },
|
||||
{ label: 'Yolo (bypass permissions)', shortLabel: 'Yolo', mode: AIAutonomyMode.YOLO }
|
||||
{
|
||||
mode: AIAutonomyMode.PLAN,
|
||||
label: 'Plan (read-only)',
|
||||
shortLabel: 'Plan',
|
||||
icon: ClipboardList,
|
||||
iconColor: PLAN_MODE_TEXT_COLOR,
|
||||
triggerClass: PLAN_MODE_TRIGGER_CLASS,
|
||||
tooltip: () =>
|
||||
'Read-only: the assistant researches and drafts a plan for your approval before it can change anything.',
|
||||
isAvailable: (a) => a.planModeAvailable
|
||||
},
|
||||
askPermissionOption,
|
||||
{
|
||||
mode: AIAutonomyMode.ACCEPT_EDIT,
|
||||
label: 'Auto-accept edits',
|
||||
icon: ChevronsRight,
|
||||
iconColor: 'text-accent',
|
||||
tooltip: () =>
|
||||
'Automatically accepts script and flow edits. Tool calls still ask for confirmation.',
|
||||
isAvailable: (a) => a.autoAcceptEditsAvailable
|
||||
},
|
||||
{
|
||||
mode: AIAutonomyMode.YOLO,
|
||||
label: 'Yolo (bypass permissions)',
|
||||
shortLabel: 'Yolo',
|
||||
icon: ChevronsRight,
|
||||
iconColor: 'text-red-500',
|
||||
tooltip: (a) =>
|
||||
a.autoAcceptEditsAvailable
|
||||
? 'Automatically accepts script and flow edits plus tool confirmations.'
|
||||
: 'Automatically accepts tool confirmations.',
|
||||
isAvailable: (a) => a.autoAcceptToolConfirmationsAvailable
|
||||
}
|
||||
]
|
||||
const autonomyModeOption = (mode: AIAutonomyMode) =>
|
||||
autonomyModeOptions.find((o) => o.mode === mode) ?? askPermissionOption
|
||||
const autonomyModeLabel = (mode: AIAutonomyMode) => {
|
||||
const option = autonomyModeOptions.find((o) => o.mode === mode) ?? autonomyModeOptions[0]
|
||||
const option = autonomyModeOption(mode)
|
||||
return option.shortLabel ?? option.label
|
||||
}
|
||||
// "Auto-accept edits" only applies where script/flow edits can be accepted,
|
||||
// "Bypass permissions" only where tool confirmations exist; filter the picker
|
||||
// to the levels that actually do something in the current mode.
|
||||
const isAutonomyModeAvailable = (
|
||||
mode: AIAutonomyMode,
|
||||
autoAcceptEditsAvailable: boolean,
|
||||
autoAcceptToolConfirmationsAvailable: boolean
|
||||
) => {
|
||||
switch (mode) {
|
||||
case AIAutonomyMode.DEFAULT:
|
||||
return true
|
||||
case AIAutonomyMode.ACCEPT_EDIT:
|
||||
return autoAcceptEditsAvailable
|
||||
case AIAutonomyMode.YOLO:
|
||||
return autoAcceptToolConfirmationsAvailable
|
||||
}
|
||||
return false
|
||||
}
|
||||
// Ask-permission holds (raised hand); auto-accept/bypass fast-forward. Color
|
||||
// ramps from muted (ask) to accent (auto-accept) to red (bypass).
|
||||
const autonomyModeIcon = (mode: AIAutonomyMode) =>
|
||||
mode === AIAutonomyMode.DEFAULT ? Hand : ChevronsRight
|
||||
const autonomyModeIconColor = (mode: AIAutonomyMode) =>
|
||||
mode === AIAutonomyMode.YOLO
|
||||
? 'text-red-500'
|
||||
: mode === AIAutonomyMode.DEFAULT
|
||||
? 'text-secondary'
|
||||
: 'text-accent'
|
||||
|
||||
let {
|
||||
messages,
|
||||
@@ -451,14 +487,13 @@
|
||||
if (input.files && input.files.length > 0) void handleAddFiles(input.files)
|
||||
input.value = ''
|
||||
}
|
||||
const availableAutonomyModeOptions = $derived.by(() =>
|
||||
autonomyModeOptions.filter((option) =>
|
||||
isAutonomyModeAvailable(
|
||||
option.mode,
|
||||
aiChatManager.autoAcceptEditsAvailable,
|
||||
aiChatManager.autoAcceptToolConfirmationsAvailable
|
||||
)
|
||||
)
|
||||
const autonomyAvailability = $derived({
|
||||
autoAcceptEditsAvailable: aiChatManager.autoAcceptEditsAvailable,
|
||||
autoAcceptToolConfirmationsAvailable: aiChatManager.autoAcceptToolConfirmationsAvailable,
|
||||
planModeAvailable: aiChatManager.planModeAvailable
|
||||
})
|
||||
const availableAutonomyModeOptions = $derived(
|
||||
autonomyModeOptions.filter((option) => option.isAvailable(autonomyAvailability))
|
||||
)
|
||||
// Fall back to ask-permission when the persisted mode isn't applicable in the
|
||||
// current AI mode (e.g. auto-accept edits while in a mode without edits).
|
||||
@@ -468,22 +503,7 @@
|
||||
: AIAutonomyMode.DEFAULT
|
||||
)
|
||||
const showAutonomyModeSelector = $derived(!disabled && availableAutonomyModeOptions.length > 1)
|
||||
const autonomyModeTooltip = $derived.by(() => {
|
||||
switch (effectiveAutonomyMode) {
|
||||
case AIAutonomyMode.ACCEPT_EDIT:
|
||||
return 'Automatically accepts script and flow edits. Tool calls still ask for confirmation.'
|
||||
case AIAutonomyMode.YOLO:
|
||||
if (!aiChatManager.autoAcceptEditsAvailable) {
|
||||
return 'Automatically accepts tool confirmations.'
|
||||
}
|
||||
return 'Automatically accepts script and flow edits plus tool confirmations.'
|
||||
default:
|
||||
if (!aiChatManager.autoAcceptEditsAvailable) {
|
||||
return 'Requires confirmation for tool calls.'
|
||||
}
|
||||
return 'Requires confirmation for edits and tool calls.'
|
||||
}
|
||||
})
|
||||
const effectiveAutonomyModeOption = $derived(autonomyModeOption(effectiveAutonomyMode))
|
||||
|
||||
// The typing-dots indicator implies the AI is busy, which is misleading while
|
||||
// the loop is parked on the user; surface a text pill instead so users know to
|
||||
@@ -916,10 +936,11 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
nonCaptureEvent
|
||||
unifiedSize="2xs"
|
||||
variant="default"
|
||||
title={autonomyModeTooltip}
|
||||
title={effectiveAutonomyModeOption.tooltip(autonomyAvailability)}
|
||||
btnClasses={effectiveAutonomyModeOption.triggerClass ?? ''}
|
||||
startIcon={{
|
||||
icon: autonomyModeIcon(effectiveAutonomyMode),
|
||||
classes: autonomyModeIconColor(effectiveAutonomyMode)
|
||||
icon: effectiveAutonomyModeOption.icon,
|
||||
classes: effectiveAutonomyModeOption.iconColor
|
||||
}}
|
||||
endIcon={{ icon: ChevronDown }}
|
||||
>
|
||||
@@ -928,6 +949,9 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
{/if}
|
||||
{#if effectiveAutonomyMode === AIAutonomyMode.PLAN}
|
||||
<span class="text-2xs text-secondary">{PLAN_MODE_MESSAGES.modeNote}</span>
|
||||
{/if}
|
||||
{#if effectiveAutonomyMode === AIAutonomyMode.YOLO && aiChatManager.autoAcceptToolConfirmationsAvailable}
|
||||
<Tooltip small placement="top">
|
||||
<AlertTriangle class="w-3 h-3 text-red-500" />
|
||||
|
||||
@@ -134,7 +134,10 @@ import { scopedKey, onUserChange, migrateLegacyLocalStorage } from '$lib/userSco
|
||||
import { getLocalSetting, storeLocalSetting } from '$lib/utils'
|
||||
import { AttachedFilesStore } from './files/attachedFiles.svelte'
|
||||
import { SessionArtifactsStore } from './artifacts/artifactsState.svelte'
|
||||
import type { ArtifactVersionTarget } from '$lib/components/sessions/previewRouter'
|
||||
import { appendAttachedFilesRoster } from './files/fileTools'
|
||||
import { ENTER_PLAN_MODE_TOOL, EXIT_PLAN_MODE_TOOL } from './planMode'
|
||||
import { PlanModeController, type PlanModeHost } from './planModeController.svelte'
|
||||
|
||||
// SSR and users who prefer reduced motion get no typewriter pacing.
|
||||
function prefersInstantReveal(): boolean {
|
||||
@@ -207,6 +210,7 @@ export enum AIMode {
|
||||
}
|
||||
|
||||
export enum AIAutonomyMode {
|
||||
PLAN = 'plan',
|
||||
DEFAULT = 'default',
|
||||
ACCEPT_EDIT = 'acceptedit',
|
||||
YOLO = 'yolo'
|
||||
@@ -221,6 +225,7 @@ const AUTO_ACCEPT_TOOL_CONFIRMATION_MODES = new Set<AIMode>([
|
||||
AIMode.APP,
|
||||
AIMode.GLOBAL
|
||||
])
|
||||
const PLAN_MODES = new Set<AIMode>([AIMode.GLOBAL])
|
||||
|
||||
export function isAIMode(mode: unknown): mode is AIMode {
|
||||
return ALL_AI_MODES.includes(mode as AIMode)
|
||||
@@ -238,6 +243,10 @@ export function supportsAutoAcceptToolConfirmations(mode: AIMode): boolean {
|
||||
return AUTO_ACCEPT_TOOL_CONFIRMATION_MODES.has(mode)
|
||||
}
|
||||
|
||||
export function supportsPlanMode(mode: AIMode): boolean {
|
||||
return PLAN_MODES.has(mode)
|
||||
}
|
||||
|
||||
export function isAIModeVisible(mode: AIMode): boolean {
|
||||
return mode !== AIMode.GLOBAL || isGlobalAiEnabled()
|
||||
}
|
||||
@@ -262,7 +271,7 @@ function getPersistedAutonomyMode(): AIAutonomyMode {
|
||||
return AIAutonomyMode.ACCEPT_EDIT
|
||||
}
|
||||
const persistedMode = getLocalSetting(key)
|
||||
if (isAIAutonomyMode(persistedMode)) {
|
||||
if (isAIAutonomyMode(persistedMode) && persistedMode !== AIAutonomyMode.PLAN) {
|
||||
return persistedMode
|
||||
}
|
||||
// No stored preference: default to auto-accepting edits (tool calls still
|
||||
@@ -275,6 +284,11 @@ function getPersistedAutonomyMode(): AIAutonomyMode {
|
||||
}
|
||||
|
||||
function persistAutonomyMode(mode: AIAutonomyMode) {
|
||||
// Plan is session-only: persisting it would re-block a later session where the
|
||||
// picker never offered Plan. The stored pre-plan baseline is what a reload restores.
|
||||
if (mode === AIAutonomyMode.PLAN) {
|
||||
return
|
||||
}
|
||||
const key = scopedKey(AI_AUTONOMY_MODE_STORAGE_KEY)
|
||||
if (!BROWSER || !key) {
|
||||
return
|
||||
@@ -342,6 +356,37 @@ type QueuedEntry = {
|
||||
context: ContextElement[] | undefined
|
||||
}
|
||||
|
||||
/** Plan mode's view of the chat it runs in. A function rather than an object literal in the
|
||||
* field initializer so the getters close over the manager instead of over themselves. */
|
||||
function planModeHostFor(m: AIChatManager): PlanModeHost {
|
||||
return {
|
||||
get active() {
|
||||
return m.planModeActive
|
||||
},
|
||||
get available() {
|
||||
return m.planModeAvailable
|
||||
},
|
||||
get autoAccepting() {
|
||||
return m.autoAcceptToolConfirmationsActive
|
||||
},
|
||||
get isSessionChat() {
|
||||
return m.isSessionChat
|
||||
},
|
||||
get sessionId() {
|
||||
return m.sessionId
|
||||
},
|
||||
get chatId() {
|
||||
return m.historyManager.getCurrentChatId()
|
||||
},
|
||||
get artifacts() {
|
||||
return m.artifacts
|
||||
},
|
||||
openArtifact: (id, name, version) => m.openArtifact?.(id, name, version),
|
||||
enter: () => m.setAutonomyMode(AIAutonomyMode.PLAN),
|
||||
restore: () => m.setAutonomyMode(m.prePlanAutonomyMode ?? AIAutonomyMode.DEFAULT)
|
||||
}
|
||||
}
|
||||
|
||||
export class AIChatManager {
|
||||
contextManager = new ContextManager()
|
||||
historyManager = new HistoryManager()
|
||||
@@ -416,7 +461,7 @@ export class AIChatManager {
|
||||
* undefined in the global side-panel chat, where the tray falls back to opening
|
||||
* the run in a new browser tab. */
|
||||
openRunInPreview?: (a: { jobId: string; workspace: string; label: string }) => void
|
||||
openArtifact?: (artifactId: string, name: string) => void
|
||||
openArtifact?: (artifactId: string, name: string, version?: ArtifactVersionTarget) => void
|
||||
closeArtifact?: (artifactId: string) => void
|
||||
loading = $state<boolean>(false)
|
||||
currentReply = $state<string>('')
|
||||
@@ -528,6 +573,10 @@ export class AIChatManager {
|
||||
// labels while set; the hook clears it back to undefined when done.
|
||||
loadingLabel = $state<string | undefined>(undefined)
|
||||
autonomyMode = $state<AIAutonomyMode>(getPersistedAutonomyMode())
|
||||
// Set by AI sessions. Enables the session-only preview tools and gates plan mode, which
|
||||
// needs the preview pane; the global side-panel chat leaves it false. Reactive because
|
||||
// `planModeAvailable` derives from it.
|
||||
isSessionChat = $state(false)
|
||||
autoAcceptEditsAvailable = $derived(supportsAutoAcceptEdits(this.mode))
|
||||
autoAcceptEditsActive = $derived(
|
||||
this.autoAcceptEditsAvailable &&
|
||||
@@ -538,6 +587,12 @@ export class AIChatManager {
|
||||
autoAcceptToolConfirmationsActive = $derived(
|
||||
this.autonomyMode === AIAutonomyMode.YOLO && this.autoAcceptToolConfirmationsAvailable
|
||||
)
|
||||
planModeAvailable = $derived(this.isSessionChat && supportsPlanMode(this.mode))
|
||||
planModeActive = $derived(this.autonomyMode === AIAutonomyMode.PLAN && this.planModeAvailable)
|
||||
prePlanAutonomyMode = $state<AIAutonomyMode | undefined>(undefined)
|
||||
// The posture's own state — its two tools, the plan document and the planning round.
|
||||
// Everything it needs from this manager goes through the host above.
|
||||
planMode = new PlanModeController(planModeHostFor(this))
|
||||
#automaticScroll = $state<boolean>(true)
|
||||
systemMessage = $state<ChatCompletionSystemMessageParam>({
|
||||
role: 'system',
|
||||
@@ -567,15 +622,14 @@ export class AIChatManager {
|
||||
/** Cached datatables for app context (fetched asynchronously) */
|
||||
cachedDatatables = $state<AppDatatableElement[]>([])
|
||||
|
||||
private confirmationCallbacks = new Map<string, (value: boolean) => void>()
|
||||
private confirmationCallbacks = new Map<
|
||||
string,
|
||||
{ resolve: (value: boolean) => void; toolName?: string }
|
||||
>()
|
||||
private userQuestionCallbacks = new Map<string, (choices: string[] | undefined) => void>()
|
||||
private appDatatablesRefreshTimeout: ReturnType<typeof setTimeout> | undefined = undefined
|
||||
|
||||
disabledModes: Partial<Record<AIMode, boolean>> = $state({})
|
||||
// Set by AI sessions. Enables the session-only preview tools (open_preview /
|
||||
// get_preview_status) and their system-prompt guidance in GLOBAL mode; the
|
||||
// global side-panel chat leaves it false so those tools aren't offered.
|
||||
isSessionChat = false
|
||||
// The session this manager belongs to (session chats only). Carried into the
|
||||
// tool `helpers` in GLOBAL mode so the preview/deploy tools dispatch to THIS
|
||||
// session rather than the UI-active one — keeps backgrounded sessions isolated.
|
||||
@@ -1449,14 +1503,23 @@ export class AIChatManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** enter_plan_mode never qualifies: YOLO means "stop asking and run it", and a read-only
|
||||
* posture inverts that. Every accept path asks here rather than carrying its own copy. */
|
||||
private autoAcceptsTool = (toolName: string | undefined) => toolName !== ENTER_PLAN_MODE_TOOL
|
||||
|
||||
/** Asked before the confirmation wait is skipped, so a tool the posture will not answer
|
||||
* for still gets a card rather than running unasked. */
|
||||
shouldAutoAcceptTool = (toolName?: string) =>
|
||||
this.autoAcceptToolConfirmationsActive && this.autoAcceptsTool(toolName)
|
||||
|
||||
// Request confirmation from user for a tool call
|
||||
requestConfirmation = (toolId: string): Promise<boolean> => {
|
||||
requestConfirmation = (toolId: string, toolName?: string): Promise<boolean> => {
|
||||
if (this.autoAcceptToolConfirmationsActive) {
|
||||
return Promise.resolve(true)
|
||||
return Promise.resolve(this.autoAcceptsTool(toolName))
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.confirmationCallbacks.set(toolId, resolve)
|
||||
this.confirmationCallbacks.set(toolId, { resolve, toolName })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1464,14 +1527,14 @@ export class AIChatManager {
|
||||
handleToolConfirmation = (toolId: string, confirmed: boolean) => {
|
||||
const confirmationCallback = this.confirmationCallbacks.get(toolId)
|
||||
if (confirmationCallback) {
|
||||
confirmationCallback(confirmed)
|
||||
confirmationCallback.resolve(confirmed)
|
||||
this.confirmationCallbacks.delete(toolId)
|
||||
}
|
||||
}
|
||||
|
||||
private acceptPendingToolConfirmations = () => {
|
||||
for (const confirmationCallback of this.confirmationCallbacks.values()) {
|
||||
confirmationCallback(true)
|
||||
for (const { resolve, toolName } of this.confirmationCallbacks.values()) {
|
||||
resolve(this.autoAcceptsTool(toolName))
|
||||
}
|
||||
this.confirmationCallbacks.clear()
|
||||
}
|
||||
@@ -1482,10 +1545,34 @@ export class AIChatManager {
|
||||
}
|
||||
}
|
||||
|
||||
private resolvePendingPlanCard = (toolName: string, confirmed: boolean) => {
|
||||
for (const [toolId, cb] of this.confirmationCallbacks) {
|
||||
if (cb.toolName === toolName) {
|
||||
cb.resolve(confirmed)
|
||||
this.confirmationCallbacks.delete(toolId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setAutonomyMode = (mode: AIAutonomyMode) => {
|
||||
const enteringPlan = mode === AIAutonomyMode.PLAN && this.autonomyMode !== AIAutonomyMode.PLAN
|
||||
const leavingPlan = mode !== AIAutonomyMode.PLAN && this.autonomyMode === AIAutonomyMode.PLAN
|
||||
if (enteringPlan) {
|
||||
this.prePlanAutonomyMode = this.autonomyMode
|
||||
this.planMode.startRound()
|
||||
} else if (mode !== AIAutonomyMode.PLAN) {
|
||||
this.prePlanAutonomyMode = undefined
|
||||
this.planMode.resetBlocks()
|
||||
}
|
||||
this.autonomyMode = mode
|
||||
persistAutonomyMode(mode)
|
||||
|
||||
if (enteringPlan) {
|
||||
this.resolvePendingPlanCard(ENTER_PLAN_MODE_TOOL, true)
|
||||
} else if (leavingPlan) {
|
||||
// Opting into YOLO means "run it"; leaving plan mode any other way is not a sign-off.
|
||||
this.resolvePendingPlanCard(EXIT_PLAN_MODE_TOOL, mode === AIAutonomyMode.YOLO)
|
||||
}
|
||||
if (this.autoAcceptToolConfirmationsActive) {
|
||||
this.acceptPendingToolConfirmations()
|
||||
}
|
||||
@@ -1507,6 +1594,8 @@ export class AIChatManager {
|
||||
hydrateUserScopedAutonomy = () => {
|
||||
migrateLegacyAutonomyKeys()
|
||||
this.autonomyMode = getPersistedAutonomyMode()
|
||||
this.prePlanAutonomyMode = undefined
|
||||
this.planMode.resetRound()
|
||||
}
|
||||
|
||||
applyScriptEditorCode = async (code: string, opts?: ReviewChangesOpts) => {
|
||||
@@ -1761,6 +1850,12 @@ export class AIChatManager {
|
||||
}
|
||||
) {
|
||||
if (!isAIModeVisible(mode)) return
|
||||
// A session chat is GLOBAL for its whole life, and the plan gate reads that mode: moving
|
||||
// it lifts the gate on a session the user still has set to Plan.
|
||||
if (this.isSessionChat && mode !== AIMode.GLOBAL) {
|
||||
console.error(`Refusing to move a session chat to ${mode} mode: sessions are GLOBAL-only.`)
|
||||
return
|
||||
}
|
||||
if (mode === AIMode.SCRIPT && !tryGetCurrentModel()) return
|
||||
this.mode = mode
|
||||
this.pendingPrompt = pendingPrompt ?? ''
|
||||
@@ -2270,20 +2365,21 @@ export class AIChatManager {
|
||||
messages,
|
||||
addedMessages,
|
||||
get systemMessage() {
|
||||
const base = systemMessageOverride ?? self.systemMessage
|
||||
let base = systemMessageOverride ?? self.systemMessage
|
||||
// Inject the attached-files roster at request time (re-read each iteration)
|
||||
// so it always reflects the live file list without reactive bookkeeping.
|
||||
if (self.mode === AIMode.GLOBAL && self.attachedFiles.count > 0) {
|
||||
return appendAttachedFilesRoster(
|
||||
base = appendAttachedFilesRoster(
|
||||
base,
|
||||
self.attachedFiles,
|
||||
self.orphanedMessageFileIds()
|
||||
)
|
||||
}
|
||||
base = self.planMode.decorateSystemMessage(base)
|
||||
return base
|
||||
},
|
||||
get tools() {
|
||||
return self.tools
|
||||
return [...self.tools, ...self.planMode.tools]
|
||||
},
|
||||
get helpers() {
|
||||
return self.helpers
|
||||
@@ -2567,6 +2663,7 @@ export class AIChatManager {
|
||||
return false
|
||||
}
|
||||
}
|
||||
this.planMode.resetBlocks()
|
||||
// Built-in session commands run locally instead of becoming a chat turn.
|
||||
// Intercepted here — before the beforeSend workspace commit, file regrants,
|
||||
// and skill expansion. Scoped to session chat GLOBAL mode, where the
|
||||
@@ -3118,7 +3215,9 @@ export class AIChatManager {
|
||||
}
|
||||
},
|
||||
requestConfirmation: this.requestConfirmation,
|
||||
shouldAutoAcceptToolConfirmations: () => this.autoAcceptToolConfirmationsActive,
|
||||
shouldAutoAcceptToolConfirmations: this.shouldAutoAcceptTool,
|
||||
isPlanModeActive: () => this.planModeActive,
|
||||
onToolBlockedByPlanMode: this.planMode.noteBlockedTool,
|
||||
requestUserQuestion: this.requestUserQuestion,
|
||||
onItemModified: (kind, path) => this.recordModifiedItem(kind, path),
|
||||
onItemDeployed: (kind, from, to) => void this.renameModifiedItem(kind, from, to),
|
||||
@@ -3359,8 +3458,8 @@ export class AIChatManager {
|
||||
}
|
||||
|
||||
cancel = (reason?: string) => {
|
||||
for (const confirmationCallback of this.confirmationCallbacks.values()) {
|
||||
confirmationCallback(false)
|
||||
for (const { resolve } of this.confirmationCallbacks.values()) {
|
||||
resolve(false)
|
||||
}
|
||||
this.confirmationCallbacks.clear()
|
||||
for (const resolveQuestion of this.userQuestionCallbacks.values()) {
|
||||
@@ -3536,6 +3635,7 @@ export class AIChatManager {
|
||||
// Message-attached rows belong to the conversation just left in every case.
|
||||
this.#syncMessageFiles()
|
||||
this.syncArtifactsSession()
|
||||
this.planMode.resetRound()
|
||||
this.onChatRotated?.(this.historyManager.getCurrentChatId())
|
||||
}
|
||||
|
||||
@@ -3578,6 +3678,7 @@ export class AIChatManager {
|
||||
this.#syncMessageFiles()
|
||||
this.#automaticScroll = true
|
||||
this.syncArtifactsSession()
|
||||
this.planMode.resetRound()
|
||||
this.onChatRotated?.(id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ReviewChangesOpts } from './monaco-adapter'
|
||||
import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions.mjs'
|
||||
import type { AttachedImage } from './imageUtils'
|
||||
import { AIChatManager, AIMode, AIAutonomyMode } from './AIChatManager.svelte'
|
||||
import { PLAN_MODE_MESSAGES } from './planModeMessages'
|
||||
import { runChatLoop } from './chatLoop'
|
||||
|
||||
// This suite forces esm-env BROWSER=true (below). That makes @sveltejs/kit's
|
||||
@@ -408,6 +409,121 @@ describe('AIChatManager autonomy mode', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// The posture's own behaviour lives in planModeController.test.ts. What is left here is the
|
||||
// wiring only the manager owns: which pending confirmation cards a change of autonomy mode
|
||||
// answers, and with what.
|
||||
describe('AIChatManager plan mode posture', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
// Plan mode is never the persisted posture, so a case starts from the one it is
|
||||
// entered from and hands back to.
|
||||
localStorage.setItem(`ai-chat-autonomy-mode::${TEST_EMAIL}`, AIAutonomyMode.DEFAULT)
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const sessionManager = (mode = AIAutonomyMode.DEFAULT) => {
|
||||
const manager = new AIChatManager()
|
||||
manager.mode = AIMode.GLOBAL
|
||||
manager.isSessionChat = true
|
||||
manager.setAutonomyMode(mode)
|
||||
return manager
|
||||
}
|
||||
|
||||
it('enters plan mode through the tool and remembers the posture to hand back to', async () => {
|
||||
const manager = sessionManager(AIAutonomyMode.ACCEPT_EDIT)
|
||||
|
||||
await manager.planMode.enterTool.fn({
|
||||
args: { reason: 'research the change first' },
|
||||
workspace: 'test-workspace',
|
||||
helpers: {},
|
||||
toolCallbacks: { setToolStatus: vi.fn(), removeToolStatus: vi.fn() },
|
||||
toolId: 'call_enter'
|
||||
})
|
||||
|
||||
expect(manager.planModeActive).toBe(true)
|
||||
expect(manager.prePlanAutonomyMode).toBe(AIAutonomyMode.ACCEPT_EDIT)
|
||||
})
|
||||
|
||||
it('refuses to move a session chat out of GLOBAL, so the gate cannot lift under it', () => {
|
||||
const manager = sessionManager(AIAutonomyMode.ACCEPT_EDIT)
|
||||
manager.setAutonomyMode(AIAutonomyMode.PLAN)
|
||||
expect(manager.planModeActive).toBe(true)
|
||||
// Without a configured model changeMode returns early on SCRIPT, and the case would pass
|
||||
// against the very guard it is meant to pin.
|
||||
mocks.getCurrentModel.mockReturnValue({ provider: 'openai', model: 'gpt-4o' })
|
||||
mocks.tryGetCurrentModel.mockReturnValue({ provider: 'openai', model: 'gpt-4o' })
|
||||
const logged = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
manager.changeMode(AIMode.SCRIPT)
|
||||
|
||||
expect(manager.mode).toBe(AIMode.GLOBAL)
|
||||
expect(manager.planModeActive).toBe(true)
|
||||
// A switch that silently does nothing gives its caller no way to learn why.
|
||||
expect(logged).toHaveBeenCalled()
|
||||
logged.mockRestore()
|
||||
})
|
||||
|
||||
it('never auto-accepts an enter_plan_mode card, whichever side of the switch it lands on', async () => {
|
||||
// Switching to YOLO answers every pending confirmation — except this one. "Run it
|
||||
// without asking" must not be answered by forcing the user into a read-only posture.
|
||||
const before = sessionManager()
|
||||
const enterPending = before.requestConfirmation('call_enter', 'enter_plan_mode')
|
||||
const writePending = before.requestConfirmation('call_write', 'write_script')
|
||||
before.setAutonomyMode(AIAutonomyMode.YOLO)
|
||||
expect(await enterPending).toBe(false)
|
||||
expect(await writePending).toBe(true)
|
||||
})
|
||||
|
||||
it('declines an enter_plan_mode that arrives after the switch to YOLO', async () => {
|
||||
// The tool set is snapshotted per iteration, so a call can still arrive once the user
|
||||
// has moved to YOLO. Driven through processToolCall rather than requestConfirmation
|
||||
// directly: an auto-accepting posture skips the confirmation wait entirely, so asserting
|
||||
// against the wait would pass on a build that never reaches it.
|
||||
const { processToolCall } = await import('./shared')
|
||||
const manager = sessionManager(AIAutonomyMode.YOLO)
|
||||
|
||||
const result = await processToolCall({
|
||||
tools: [manager.planMode.enterTool] as any,
|
||||
toolCall: {
|
||||
id: 'call_enter',
|
||||
type: 'function',
|
||||
function: { name: 'enter_plan_mode', arguments: JSON.stringify({ reason: 'research' }) }
|
||||
} as any,
|
||||
helpers: {},
|
||||
workspace: 'test-workspace',
|
||||
toolCallbacks: {
|
||||
setToolStatus: vi.fn(),
|
||||
removeToolStatus: vi.fn(),
|
||||
requestConfirmation: manager.requestConfirmation,
|
||||
shouldAutoAcceptToolConfirmations: manager.shouldAutoAcceptTool
|
||||
} as any
|
||||
})
|
||||
|
||||
expect(result.content).toBe(PLAN_MODE_MESSAGES.enterDeclined)
|
||||
expect(manager.autonomyMode).toBe(AIAutonomyMode.YOLO)
|
||||
expect(manager.planModeActive).toBe(false)
|
||||
})
|
||||
|
||||
it('answers a pending plan card the way the picker was moved', async () => {
|
||||
const entering = sessionManager()
|
||||
const enterPending = entering.requestConfirmation('call_enter', 'enter_plan_mode')
|
||||
entering.setAutonomyMode(AIAutonomyMode.PLAN)
|
||||
expect(await enterPending).toBe(true)
|
||||
|
||||
// Leaving plan mode any other way is not a sign-off on the plan on the card.
|
||||
const leaving = sessionManager(AIAutonomyMode.PLAN)
|
||||
const exitPending = leaving.requestConfirmation('call_exit', 'exit_plan_mode')
|
||||
leaving.setAutonomyMode(AIAutonomyMode.DEFAULT)
|
||||
expect(await exitPending).toBe(false)
|
||||
|
||||
// Opting into YOLO does mean "run it".
|
||||
const yolo = sessionManager(AIAutonomyMode.PLAN)
|
||||
const yoloPending = yolo.requestConfirmation('call_exit', 'exit_plan_mode')
|
||||
yolo.setAutonomyMode(AIAutonomyMode.YOLO)
|
||||
expect(await yoloPending).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AIChatManager persisted autonomy default', () => {
|
||||
// Mirrors the private storage keys in AIChatManager.svelte.ts, namespaced by
|
||||
// the logged-in user's email (see userScopedStorage).
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/common'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
|
||||
interface Props {
|
||||
toolCallId: string | undefined
|
||||
/** Omit the label for an icon-only reject button. */
|
||||
rejectLabel?: string
|
||||
rejectIcon?: any
|
||||
rejectDestructive?: boolean
|
||||
confirmLabel: string
|
||||
/** Omit for a label-only confirm button. */
|
||||
confirmIcon?: any
|
||||
class?: string
|
||||
}
|
||||
|
||||
let {
|
||||
toolCallId,
|
||||
rejectLabel,
|
||||
rejectIcon,
|
||||
rejectDestructive = false,
|
||||
confirmLabel,
|
||||
confirmIcon,
|
||||
class: className
|
||||
}: Props = $props()
|
||||
|
||||
const aiChatManager = getAiChatManager()
|
||||
|
||||
function respond(confirmed: boolean) {
|
||||
if (toolCallId) {
|
||||
aiChatManager.handleToolConfirmation(toolCallId, confirmed)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={twMerge('flex flex-row items-center justify-end gap-2', className)}>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
destructive={rejectDestructive}
|
||||
startIcon={rejectIcon ? { icon: rejectIcon } : undefined}
|
||||
on:click={() => respond(false)}
|
||||
>
|
||||
{rejectLabel ?? ''}
|
||||
</Button>
|
||||
<Button
|
||||
variant="accent"
|
||||
size="xs"
|
||||
startIcon={confirmIcon ? { icon: confirmIcon } : undefined}
|
||||
on:click={() => respond(true)}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1,12 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { XCircle, Play } from 'lucide-svelte'
|
||||
import {
|
||||
Loader2,
|
||||
ChevronRight,
|
||||
XCircle,
|
||||
Play,
|
||||
ClipboardList,
|
||||
Check,
|
||||
CircleMinus,
|
||||
FileText,
|
||||
PanelRight,
|
||||
Lock
|
||||
} from 'lucide-svelte'
|
||||
import {
|
||||
EXIT_PLAN_MODE_TOOL,
|
||||
isPlanCardTool,
|
||||
planCardState,
|
||||
planVersionTarget,
|
||||
PLAN_CARD_COPY,
|
||||
PLAN_MODE_TEXT_COLOR
|
||||
} from './planMode'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { markdownProse } from '$lib/components/markdownProse'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
|
||||
const aiChatManager = getAiChatManager()
|
||||
import { isActiveUserQuestion, type ToolDisplayMessage } from './shared'
|
||||
import ChatCollapsibleCard from './ChatCollapsibleCard.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { slide } from 'svelte/transition'
|
||||
import Markdown from 'svelte-exmarkdown'
|
||||
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
|
||||
import CodeDisplay from './script/CodeDisplay.svelte'
|
||||
import LinkRenderer from './LinkRenderer.svelte'
|
||||
import ToolContentDisplay from './ToolContentDisplay.svelte'
|
||||
import ToolConfirmationFooter from './ToolConfirmationFooter.svelte'
|
||||
import ToolMessageActions from './ToolMessageActions.svelte'
|
||||
import ToolPreviewCard from './ToolPreviewCard.svelte'
|
||||
import AskUserQuestionDisplay from './AskUserQuestionDisplay.svelte'
|
||||
@@ -19,6 +46,38 @@
|
||||
|
||||
let { message }: Props = $props()
|
||||
|
||||
const isPlanReview = $derived(message.toolName === EXIT_PLAN_MODE_TOOL)
|
||||
const isPlanCard = $derived(isPlanCardTool(message.toolName))
|
||||
const planCopy = $derived(
|
||||
isPlanCardTool(message.toolName) ? PLAN_CARD_COPY[message.toolName] : undefined
|
||||
)
|
||||
// exit_plan_mode carries the plan, enter_plan_mode the one-line justification.
|
||||
const planBody = $derived(isPlanReview ? message.parameters?.summary : message.parameters?.reason)
|
||||
const planBodyText = $derived(typeof planBody === 'string' ? planBody : '')
|
||||
// Resolved once so the label and the icon cannot disagree about which state this is.
|
||||
// Undefined means this call does not read as a plan card at all; it renders as the
|
||||
// ordinary tool call below, where its error is the message.
|
||||
const planState = $derived(isPlanCard ? planCardState(message) : undefined)
|
||||
const planLabel = $derived((planState && planCopy?.[planState]) ?? '')
|
||||
const planDoc = $derived(
|
||||
message.planArtifactId
|
||||
? aiChatManager.artifacts.artifacts.find((a) => a.id === message.planArtifactId)
|
||||
: undefined
|
||||
)
|
||||
// The version this card wrote, not the document's current one, since later proposals move it on.
|
||||
const planCardVersion = $derived(planVersionTarget(planDoc, message.planVersion))
|
||||
// Keyed by call id: a bare flag would leak the expansion onto the next message reusing
|
||||
// this instance. The plan opens in the preview, so only enter's reason needs expanding.
|
||||
let planToggled = $state<{ id: string | undefined; open: boolean } | undefined>(undefined)
|
||||
const planExpanded = $derived(
|
||||
planToggled?.id === message.tool_call_id
|
||||
? planToggled.open
|
||||
: isPlanCard && !isPlanReview && Boolean(message.needsConfirmation)
|
||||
)
|
||||
|
||||
// The preview pane's renderers, so a model-written link opens in a new tab either way.
|
||||
const planPlugins = [gfmPlugin(), { renderer: { pre: CodeDisplay, a: LinkRenderer } }]
|
||||
|
||||
const hasParameters = $derived(
|
||||
message.parameters !== undefined && Object.keys(message.parameters).length > 0
|
||||
)
|
||||
@@ -69,6 +128,103 @@
|
||||
|
||||
{#if activeUserQuestion}
|
||||
<AskUserQuestionDisplay toolCallId={message.tool_call_id} userQuestion={activeUserQuestion} />
|
||||
{:else if message.blockedByPlanMode}
|
||||
<!-- Not an error card: the call did what plan mode says it should. One flat row
|
||||
naming the refused tool, so "why can't it edit" is answered where it is asked. -->
|
||||
<div class="font-mono text-xs flex items-center gap-2 py-0.5 my-0.5 min-w-0">
|
||||
<Lock class="w-3.5 h-3.5 text-tertiary shrink-0" />
|
||||
<span class="font-medium text-2xs text-tertiary shrink-0">
|
||||
{message.content}
|
||||
</span>
|
||||
{#if message.toolName}
|
||||
<span class="text-2xs text-tertiary truncate">{message.toolName}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if planState}
|
||||
<!-- Same lean shape as a tool call below: a header row that collapses into the
|
||||
transcript, with everything else in one box under it. -->
|
||||
<div
|
||||
class={twMerge(
|
||||
'font-mono text-xs',
|
||||
message.isQueued && !message.error ? 'opacity-60 hover:opacity-100 transition-opacity' : ''
|
||||
)}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<button
|
||||
class="min-w-0 py-0.5 my-0.5 rounded-md hover:bg-surface-hover transition-colors inline-flex items-center text-left"
|
||||
onclick={() => (planToggled = { id: message.tool_call_id, open: !planExpanded })}
|
||||
disabled={!planBodyText}
|
||||
aria-expanded={planExpanded}
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
{#if message.isLoading && !message.needsConfirmation}
|
||||
<Loader2 class="w-3.5 h-3.5 animate-spin text-blue-500 shrink-0" />
|
||||
{:else if planState === 'settled'}
|
||||
<Check class={twMerge('w-3.5 h-3.5 shrink-0', PLAN_MODE_TEXT_COLOR)} />
|
||||
{:else if planState === 'declined'}
|
||||
<!-- Muted and not red: declining a plan is an outcome, not a failure, and
|
||||
this state also covers a card resolved by leaving plan mode. -->
|
||||
<CircleMinus class="w-3.5 h-3.5 text-tertiary shrink-0" />
|
||||
{:else}
|
||||
<ClipboardList class="w-3.5 h-3.5 text-secondary shrink-0" />
|
||||
{/if}
|
||||
<span class="text-primary font-medium text-2xs">{planLabel}</span>
|
||||
{#if planBodyText}
|
||||
<ChevronRight
|
||||
class={twMerge(
|
||||
'w-3 h-3 text-secondary transition-transform duration-150 shrink-0',
|
||||
planExpanded ? 'rotate-90' : ''
|
||||
)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{#if planDoc}
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="2xs"
|
||||
wrapperClasses="shrink-0"
|
||||
title="Open this plan in the side panel: {planDoc.name}"
|
||||
startIcon={{ icon: FileText, classes: PLAN_MODE_TEXT_COLOR }}
|
||||
endIcon={{ icon: PanelRight }}
|
||||
on:click={() => aiChatManager.openArtifact?.(planDoc.id, planDoc.name, planCardVersion)}
|
||||
>
|
||||
<span class="font-main">Plan</span>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet confirmFooter(extraClass: string)}
|
||||
<ToolConfirmationFooter
|
||||
toolCallId={message.tool_call_id}
|
||||
rejectLabel={planCopy?.reject}
|
||||
confirmLabel={planCopy?.confirm ?? ''}
|
||||
confirmIcon={isPlanReview ? undefined : ClipboardList}
|
||||
class={extraClass}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#if planExpanded && planBodyText}
|
||||
<div
|
||||
transition:slide={{ duration: 150 }}
|
||||
class="border border-border-light rounded-md bg-surface p-3 space-y-3 font-main"
|
||||
>
|
||||
{#if isPlanReview}
|
||||
<div class={markdownProse.sm}>
|
||||
<Markdown md={planBodyText} plugins={planPlugins} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-xs text-secondary leading-snug">{planBodyText}</div>
|
||||
{/if}
|
||||
{#if message.needsConfirmation}
|
||||
{@render confirmFooter('')}
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Collapsed: the buttons stand on their own rather than boxing empty space. -->
|
||||
{:else if message.needsConfirmation}
|
||||
{@render confirmFooter('py-1 font-main')}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Discrete preview chip for an item a tool created/updated, pinned to the
|
||||
right of the header row. Rendered inline (not gated on expand) so it stays
|
||||
@@ -124,31 +280,13 @@
|
||||
|
||||
<!-- Confirmation Footer -->
|
||||
{#if message.needsConfirmation}
|
||||
<div class="flex flex-row items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
on:click={() => {
|
||||
if (message.tool_call_id) {
|
||||
aiChatManager.handleToolConfirmation(message.tool_call_id, false)
|
||||
}
|
||||
}}
|
||||
startIcon={{ icon: XCircle }}
|
||||
destructive
|
||||
></Button>
|
||||
<Button
|
||||
variant="accent"
|
||||
size="xs"
|
||||
on:click={() => {
|
||||
if (message.tool_call_id) {
|
||||
aiChatManager.handleToolConfirmation(message.tool_call_id, true)
|
||||
}
|
||||
}}
|
||||
startIcon={{ icon: Play }}
|
||||
>
|
||||
Run
|
||||
</Button>
|
||||
</div>
|
||||
<ToolConfirmationFooter
|
||||
toolCallId={message.tool_call_id}
|
||||
rejectIcon={XCircle}
|
||||
rejectDestructive
|
||||
confirmLabel="Run"
|
||||
confirmIcon={Play}
|
||||
/>
|
||||
|
||||
<!-- Logs and Result - hide while streaming -->
|
||||
{:else if !message.isStreamingArguments}
|
||||
|
||||
@@ -107,6 +107,7 @@ export function createApiTools(
|
||||
|
||||
return {
|
||||
def: chatTool,
|
||||
planModeSafe: !!endpoint && ['GET', 'HEAD', 'OPTIONS'].includes(method),
|
||||
requiresConfirmation: needsConfirmation,
|
||||
confirmationMessage: `Run ${toolName}`,
|
||||
showDetails: true,
|
||||
|
||||
@@ -513,6 +513,7 @@ export const getAppTools = memo((): Tool<AppAIChatHelpers>[] => [
|
||||
// Lightweight file/runnable metadata tool (no source contents)
|
||||
{
|
||||
def: getListFilesToolDef(),
|
||||
planModeSafe: true,
|
||||
fn: async ({ helpers, toolId, toolCallbacks }) => {
|
||||
toolCallbacks.setToolStatus(toolId, { content: 'Listing files...' })
|
||||
const files = helpers.getFiles()
|
||||
@@ -548,6 +549,7 @@ export const getAppTools = memo((): Tool<AppAIChatHelpers>[] => [
|
||||
// Frontend tools
|
||||
{
|
||||
def: getGetFrontendFileToolDef(),
|
||||
planModeSafe: true,
|
||||
fn: async ({ args, helpers, toolId, toolCallbacks }) => {
|
||||
const parsedArgs = getGetFrontendFileSchema().parse(args)
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
@@ -680,6 +682,7 @@ export const getAppTools = memo((): Tool<AppAIChatHelpers>[] => [
|
||||
// Backend tools
|
||||
{
|
||||
def: getGetBackendRunnableToolDef(),
|
||||
planModeSafe: true,
|
||||
fn: async ({ args, helpers, toolId, toolCallbacks }) => {
|
||||
const parsedArgs = getGetBackendRunnableSchema().parse(args)
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
@@ -758,6 +761,7 @@ export const getAppTools = memo((): Tool<AppAIChatHelpers>[] => [
|
||||
// Lint tool
|
||||
{
|
||||
def: getLintToolDef(),
|
||||
planModeSafe: true,
|
||||
fn: async ({ helpers, toolId, toolCallbacks }) => {
|
||||
toolCallbacks.setToolStatus(toolId, { content: 'Linting app...' })
|
||||
const lintResult = helpers.lint()
|
||||
@@ -779,6 +783,7 @@ export const getAppTools = memo((): Tool<AppAIChatHelpers>[] => [
|
||||
// Data table tools
|
||||
{
|
||||
def: getListDatatablesToolDef(),
|
||||
planModeSafe: true,
|
||||
fn: async ({ helpers, toolId, toolCallbacks }) => {
|
||||
toolCallbacks.setToolStatus(toolId, { content: 'Listing datatables...' })
|
||||
try {
|
||||
@@ -801,6 +806,7 @@ export const getAppTools = memo((): Tool<AppAIChatHelpers>[] => [
|
||||
},
|
||||
{
|
||||
def: getGetDatatableTableSchemaToolDef(),
|
||||
planModeSafe: true,
|
||||
fn: async ({ args, helpers, toolId, toolCallbacks }) => {
|
||||
const parsedArgs = getGetDatatableTableSchemaSchema().parse(args)
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { untrack } from 'svelte'
|
||||
import Markdown from 'svelte-exmarkdown'
|
||||
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
|
||||
import { Code, Eye, FileText, Copy, Check, Download } from 'lucide-svelte'
|
||||
import { Code, Eye, FileText, Copy, Check, Download, ClipboardList } from 'lucide-svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
@@ -22,6 +22,8 @@
|
||||
import type { SessionArtifactsStore } from './artifactsState.svelte'
|
||||
import { History } from 'lucide-svelte'
|
||||
import TimeAgo from '$lib/components/TimeAgo.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { planBadge, planVersionView, PLAN_MODE_TEXT_COLOR } from '../planMode'
|
||||
|
||||
interface Props {
|
||||
artifact: PersistedArtifact
|
||||
@@ -55,9 +57,13 @@
|
||||
$effect(() => {
|
||||
const version = pinned
|
||||
void readAttempt // dependency: see selectVersion
|
||||
if (version === undefined) {
|
||||
// A pin not behind the document is cleared rather than shown: the stale bar below keys on
|
||||
// the snapshot alone, so it would label current text as history.
|
||||
// `latest` untracked — as a dependency it would re-read the snapshot on every version added.
|
||||
if (version === undefined || version >= untrack(() => latest)) {
|
||||
pinnedContent = undefined
|
||||
restoringPin = false
|
||||
if (version !== undefined) onPin(undefined)
|
||||
return
|
||||
}
|
||||
const id = artifact.id
|
||||
@@ -89,6 +95,23 @@
|
||||
|
||||
// Markdown is the only rendered kind in v1; anything else shows source only.
|
||||
const canPreview = $derived(artifact.kind === 'md')
|
||||
const isPlan = $derived(artifact.role === 'plan')
|
||||
// Which pill and which bar this version earns — one place, so the list and this header
|
||||
// cannot disagree about what counts as the plan.
|
||||
// Silent until the snapshot lands, like the body below: `shownVersion` is still the head
|
||||
// then, so a plan opened at the version its reader approved would wear the draft's badge
|
||||
// and warning for the length of the read. Judging `pinned` instead would print the
|
||||
// approved signal over text that is still the draft, which is worse.
|
||||
const view = $derived(
|
||||
restoringPin
|
||||
? { badge: undefined, bar: undefined, backToPlan: undefined }
|
||||
: planVersionView(artifact, shownVersion)
|
||||
)
|
||||
const badge = $derived(planBadge(view.badge))
|
||||
// Browsing history offers the plan rather than the newest text, since that is what the
|
||||
// user settled on — and the bar on the plan leads on to the draft, so neither needs a
|
||||
// second button. The approved version is never pruned, so it is always still reachable.
|
||||
const backTo = $derived(view.backToPlan)
|
||||
let showSource = $state(false)
|
||||
const source = $derived(!canPreview || showSource)
|
||||
|
||||
@@ -112,10 +135,28 @@
|
||||
<div class="flex flex-col h-full bg-surface-tertiary">
|
||||
<div class="flex items-center justify-between gap-2 px-8 py-2">
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<FileText size={14} class="shrink-0 text-secondary" />
|
||||
{#if isPlan}
|
||||
<ClipboardList size={14} class={twMerge('shrink-0', PLAN_MODE_TEXT_COLOR)} />
|
||||
{:else}
|
||||
<FileText size={14} class="shrink-0 text-secondary" />
|
||||
{/if}
|
||||
<span class="truncate text-xs font-normal text-emphasis" title={shown.name}>
|
||||
{shown.name}
|
||||
</span>
|
||||
{#if badge}
|
||||
<!-- Sized against the title beside it, not the Copy button opposite: at the
|
||||
list's text-2xs it reads as heavy as the 12px name it annotates.
|
||||
`pt-px pb-0` is optical, not a typo: an all-caps word leaves the line
|
||||
box's descender space empty, so symmetric padding sits it 1px high. -->
|
||||
<span
|
||||
class={twMerge(
|
||||
'shrink-0 rounded px-1 pt-px pb-0 text-3xs uppercase tracking-wide',
|
||||
badge.class
|
||||
)}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
{/if}
|
||||
{#if hasHistory}
|
||||
<!-- The body is blank while restoring, so the chip names the version being fetched:
|
||||
the latest is the one version certainly not on screen. -->
|
||||
@@ -171,7 +212,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if pinnedContent}
|
||||
{#if view.bar === 'approved-with-newer'}
|
||||
<!-- Before the stale bar below, because this version is reached by pinning too — and
|
||||
it is the one pinned version that is not stale. -->
|
||||
<div
|
||||
class="flex items-center gap-2 px-8 py-1 text-2xs font-normal
|
||||
bg-teal-600/10 text-teal-700 dark:bg-teal-500/10 dark:text-teal-500"
|
||||
>
|
||||
<ClipboardList size={12} class="shrink-0" />
|
||||
<span class="truncate">
|
||||
This is the plan you approved · a newer draft (v{latest}) is not approved
|
||||
</span>
|
||||
<div class="ml-auto shrink-0">
|
||||
<Button unifiedSize="xs" variant="default" onClick={() => selectVersion(undefined)}>
|
||||
View last draft
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if pinnedContent}
|
||||
<!-- Everything below is stale text; say so where it cannot be scrolled past unnoticed. -->
|
||||
<div
|
||||
class="flex items-center gap-2 px-8 py-1 text-2xs font-normal
|
||||
@@ -184,8 +242,25 @@
|
||||
<TimeAgo date={new Date(pinnedContent.savedAt).toISOString()} compact /> ago
|
||||
</span>
|
||||
<div class="ml-auto shrink-0">
|
||||
<Button unifiedSize="xs" variant="default" onClick={() => selectVersion(undefined)}>
|
||||
Back to latest
|
||||
<Button unifiedSize="xs" variant="default" onClick={() => selectVersion(backTo)}>
|
||||
{backTo === undefined ? 'Back to latest' : `Back to the plan (v${backTo})`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if view.bar === 'unapproved-head'}
|
||||
<!-- Same bar as above rather than a second signal: both say "this is not the text you
|
||||
settled on", and they are mutually exclusive, so only one is ever on screen. -->
|
||||
<div
|
||||
class="flex items-center gap-2 px-8 py-1 text-2xs font-normal
|
||||
bg-orange-100 text-orange-800 dark:bg-orange-950 dark:text-orange-300"
|
||||
>
|
||||
<ClipboardList size={12} class="shrink-0" />
|
||||
<span class="truncate">
|
||||
This revision is not approved · the plan you approved is v{view.backToPlan}
|
||||
</span>
|
||||
<div class="ml-auto shrink-0">
|
||||
<Button unifiedSize="xs" variant="default" onClick={() => selectVersion(view.backToPlan)}>
|
||||
View the plan
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,19 +2,23 @@
|
||||
import { Button } from '$lib/components/common'
|
||||
import TimeAgo from '$lib/components/TimeAgo.svelte'
|
||||
import SessionStatusPopover from '$lib/components/sessions/SessionStatusPopover.svelte'
|
||||
import { Download, Trash2 } from 'lucide-svelte'
|
||||
import { download, displayDate } from '$lib/utils'
|
||||
import { ClipboardList, Code2, FileText, Trash2 } from 'lucide-svelte'
|
||||
import { displayDate } from '$lib/utils'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { getAiChatManager } from '../aiChatManagerContext'
|
||||
import {
|
||||
artifactFilename,
|
||||
artifactMimeType,
|
||||
currentVersion,
|
||||
type PersistedArtifact
|
||||
} from './artifactsDB'
|
||||
import { planBadge, listBadge, listOpenTarget, PLAN_MODE_TEXT_COLOR } from '../planMode'
|
||||
import { currentVersion, type PersistedArtifact } from './artifactsDB'
|
||||
import { planFirst } from './artifactsState.svelte'
|
||||
|
||||
const aiChatManager = getAiChatManager()
|
||||
const artifacts = $derived(aiChatManager.artifacts.artifacts)
|
||||
// The plan is what the user comes back to, and in update order it sinks under
|
||||
// everything the approved run then produces.
|
||||
const planCount = $derived(artifacts.filter((a) => a.role === 'plan').length)
|
||||
const orderedArtifacts = $derived(planFirst(artifacts))
|
||||
const label = $derived(`${artifacts.length} artifact${artifacts.length === 1 ? '' : 's'}`)
|
||||
const rowIcon = (a: PersistedArtifact) =>
|
||||
a.role === 'plan' ? ClipboardList : a.kind === 'html' ? Code2 : FileText
|
||||
|
||||
// Empty-at-0 gating is owned by the parent (SessionChangesBar) so the status
|
||||
// line's separators stay correct; this renders unconditionally.
|
||||
@@ -25,17 +29,31 @@
|
||||
bind:open
|
||||
{label}
|
||||
title="Artifacts this session"
|
||||
items={artifacts}
|
||||
items={orderedArtifacts}
|
||||
itemKey={(a) => a.id}
|
||||
rowTitle={(a) => a.name}
|
||||
onPick={(a: PersistedArtifact) => aiChatManager.openArtifact?.(a.id, a.name)}
|
||||
separatorAfter={(_, index) => index === planCount - 1 && planCount < orderedArtifacts.length}
|
||||
onPick={(a: PersistedArtifact) => aiChatManager.openArtifact?.(a.id, a.name, listOpenTarget(a))}
|
||||
>
|
||||
{#snippet row(a)}
|
||||
<span class="min-w-0 flex-1 truncate font-normal text-primary">{a.name}</span>
|
||||
{@const Icon = rowIcon(a)}
|
||||
<Icon
|
||||
class={twMerge('h-3 w-3 shrink-0', a.role === 'plan' ? PLAN_MODE_TEXT_COLOR : 'text-hint')}
|
||||
/>
|
||||
<span
|
||||
class="shrink-0 rounded bg-surface-secondary px-1 py-0.5 text-2xs font-normal uppercase text-tertiary"
|
||||
class={twMerge(
|
||||
'min-w-0 flex-1 truncate text-primary',
|
||||
a.role === 'plan' ? 'font-medium' : 'font-normal'
|
||||
)}>{a.name}</span
|
||||
>
|
||||
{a.kind}
|
||||
{@const badge = planBadge(listBadge(a))}
|
||||
<span
|
||||
class={twMerge(
|
||||
'shrink-0 rounded px-1 py-0.5 text-2xs uppercase',
|
||||
badge?.class ?? 'bg-surface-secondary font-normal text-tertiary'
|
||||
)}
|
||||
>
|
||||
{badge?.label ?? a.kind}
|
||||
</span>
|
||||
<span
|
||||
class="min-w-[4.5rem] shrink-0 text-right text-2xs font-normal text-hint"
|
||||
@@ -46,14 +64,6 @@
|
||||
</span>
|
||||
{/snippet}
|
||||
{#snippet actions(a)}
|
||||
<Button
|
||||
unifiedSize="xs"
|
||||
variant="subtle"
|
||||
iconOnly
|
||||
title="Download"
|
||||
startIcon={{ icon: Download }}
|
||||
onClick={() => download(artifactFilename(a), a.content, artifactMimeType(a.kind))}
|
||||
/>
|
||||
<Button
|
||||
unifiedSize="xs"
|
||||
destructive
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// What every writer of an artifact has to respect, in one place because they do not share a
|
||||
// call path. Import-free on purpose: artifactTools pulls ../shared and ../shared pulls
|
||||
// planMode, so anything shared between them has to sit outside that cycle.
|
||||
|
||||
/** Bounds what a snapshot stores and replays to the model. */
|
||||
export const MAX_ARTIFACT_BYTES = 256 * 1024
|
||||
|
||||
/** Returned rather than formatted: the advice for an oversized document is not the advice
|
||||
* for an oversized plan, so each caller words its own refusal. */
|
||||
export function artifactOverflowBytes(content: string): number | undefined {
|
||||
const bytes = new TextEncoder().encode(content).length
|
||||
return bytes > MAX_ARTIFACT_BYTES ? bytes : undefined
|
||||
}
|
||||
|
||||
/** Truncated rather than rejected: refusing over a cosmetic label would throw away a real
|
||||
* content update the model would have to resend. */
|
||||
const MAX_NOTE_CHARS = 120
|
||||
|
||||
export function normalizeChangeNote(note: string | undefined): string | undefined {
|
||||
// Blank collapses to undefined, not '': an empty string is not nullish, so it would slip
|
||||
// past the picker's fallback and render a row with no label.
|
||||
return note?.trim().slice(0, MAX_NOTE_CHARS) || undefined
|
||||
}
|
||||
@@ -85,10 +85,68 @@ describe('artifact tools', () => {
|
||||
const b = JSON.parse(await ctx.call('create_artifact', { name: 'B', content: 'b' }))
|
||||
const list = JSON.parse(await ctx.call('list_artifacts', {}))
|
||||
expect(list.map((x: any) => x.id).sort()).toEqual([a.id, b.id].sort())
|
||||
expect(list.find((x: any) => x.id === b.id)).toEqual({ id: b.id, name: 'B', kind: 'md' })
|
||||
expect(list.find((x: any) => x.id === b.id)).toEqual({
|
||||
id: b.id,
|
||||
name: 'B',
|
||||
kind: 'md',
|
||||
version: 1
|
||||
})
|
||||
expect(list[0]).not.toHaveProperty('content')
|
||||
})
|
||||
|
||||
it('creates the plan as a draft, and refuses a second one for the session', async () => {
|
||||
// This tool asks for no confirmation, so the model writing a plan document is not the
|
||||
// user agreeing to one. It holds the session's plan slot, but stays a draft until an
|
||||
// approval lands on it.
|
||||
const plan = JSON.parse(
|
||||
await ctx.call('create_artifact', { name: 'Ship it', content: '# Ship it', role: 'plan' })
|
||||
)
|
||||
expect(plan.id).toBe(ctx.dbMod.planArtifactId('s1'))
|
||||
expect(await ctx.dbMod.getArtifact(plan.id)).toMatchObject({
|
||||
role: 'plan',
|
||||
approvedVersion: undefined
|
||||
})
|
||||
|
||||
// The slot is taken, and the refusal has to name what holds it — the model's next
|
||||
// move is to rewrite that document, which it cannot do without the id.
|
||||
const second = JSON.parse(
|
||||
await ctx.call('create_artifact', { name: 'Other', content: '# Other', role: 'plan' })
|
||||
)
|
||||
expect(second.success).toBe(false)
|
||||
expect(second.error).toContain(plan.id)
|
||||
const list = JSON.parse(await ctx.call('list_artifacts', {}))
|
||||
expect(list.filter((x: any) => x.role === 'plan').map((x: any) => x.id)).toEqual([plan.id])
|
||||
})
|
||||
|
||||
it('reports which version of a plan the user approved, if any', async () => {
|
||||
// A plan is written when it is proposed, so one they refused stays on disk, and an
|
||||
// approved one keeps collecting versions afterwards. Without the pointer the model
|
||||
// reads whatever the document currently says as the plan they signed off.
|
||||
const plan = await ctx.store.create('s1', {
|
||||
name: 'Drafted',
|
||||
content: '# Drafted',
|
||||
role: 'plan',
|
||||
chatId: 'c1'
|
||||
})
|
||||
const drafted = JSON.parse(await ctx.call('list_artifacts', {})).find(
|
||||
(x: any) => x.id === plan.id
|
||||
)
|
||||
expect(drafted).toMatchObject({ role: 'plan', version: 1 })
|
||||
// Absent, not null: nothing here was ever approved.
|
||||
expect(drafted).not.toHaveProperty('approvedVersion')
|
||||
|
||||
await ctx.store.update(plan.id, { approvedVersion: 1 })
|
||||
await ctx.store.update(plan.id, { content: '# Drafted, proposed anew' })
|
||||
const list = JSON.parse(await ctx.call('list_artifacts', {}))
|
||||
|
||||
// Approved at v1, current text is v2: a proposal the user has not decided on.
|
||||
expect(list.find((x: any) => x.id === plan.id)).toMatchObject({
|
||||
role: 'plan',
|
||||
version: 2,
|
||||
approvedVersion: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('read_artifact returns the full content', async () => {
|
||||
const a = JSON.parse(await ctx.call('create_artifact', { name: 'A', content: 'body' }))
|
||||
const read = JSON.parse(await ctx.call('read_artifact', { id: a.id }))
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { z } from 'zod'
|
||||
import { createToolDef, type Tool } from '../shared'
|
||||
import { currentVersion, type ArtifactVersion } from './artifactsDB'
|
||||
import type { SessionArtifactsStore } from './artifactsState.svelte'
|
||||
import { artifactOverflowBytes, MAX_ARTIFACT_BYTES, normalizeChangeNote } from './artifactLimits'
|
||||
import { currentVersion, type ArtifactVersion, type PersistedArtifact } from './artifactsDB'
|
||||
import type { ArtifactVersionTarget } from '$lib/components/sessions/previewRouter'
|
||||
import { PlanSlotTakenError, type SessionArtifactsStore } from './artifactsState.svelte'
|
||||
|
||||
// The subset of GlobalToolHelpers these tools read. Kept local (not imported from
|
||||
// global/core) so the tools don't pull the whole global tool module — which would be a
|
||||
@@ -10,19 +12,18 @@ type ArtifactToolHelpers = {
|
||||
artifacts?: SessionArtifactsStore
|
||||
sessionId?: string
|
||||
getChatId?: () => string | undefined
|
||||
openArtifact?: (artifactId: string, name: string) => void
|
||||
openArtifact?: (artifactId: string, name: string, version?: ArtifactVersionTarget) => void
|
||||
}
|
||||
|
||||
const MAX_ARTIFACT_BYTES = 256 * 1024
|
||||
|
||||
// Bounds what a snapshot stores and replays to the model. Enforced by truncation rather
|
||||
// than by the schema: rejecting the call would throw away a real content update over a
|
||||
// cosmetic label, and the model would have to resend the whole document to recover.
|
||||
const MAX_NOTE_CHARS = 120
|
||||
|
||||
const createArtifactSchema = z.object({
|
||||
name: z.string().describe('Short display title for the artifact.'),
|
||||
content: z.string().describe('Full markdown content of the artifact.')
|
||||
content: z.string().describe('Full markdown content of the artifact.'),
|
||||
role: z
|
||||
.enum(['plan'])
|
||||
.optional()
|
||||
.describe(
|
||||
"Set to `plan` to make this the session's plan document — what the user reads as the agreed plan, and what a later planning round revises. One per session: if list_artifacts already shows an entry whose `role` is `plan`, rewrite that one with update_artifact instead. Omit for an ordinary document."
|
||||
)
|
||||
})
|
||||
|
||||
const updateArtifactSchema = z.object({
|
||||
@@ -51,8 +52,8 @@ const listArtifactVersionsSchema = z.object({
|
||||
})
|
||||
|
||||
function tooLarge(content: string): string | undefined {
|
||||
const bytes = new TextEncoder().encode(content).length
|
||||
if (bytes <= MAX_ARTIFACT_BYTES) return undefined
|
||||
const bytes = artifactOverflowBytes(content)
|
||||
if (bytes === undefined) return undefined
|
||||
return `Content is too large (${bytes} bytes, limit ${MAX_ARTIFACT_BYTES}). Shorten or split it.`
|
||||
}
|
||||
|
||||
@@ -79,13 +80,26 @@ export const artifactTools: Tool<{}>[] = [
|
||||
toolCallbacks.setToolStatus(toolId, { content: sizeError, error: sizeError })
|
||||
return JSON.stringify({ success: false, error: sizeError })
|
||||
}
|
||||
const artifact = await h.artifacts.create(sessionId, {
|
||||
name: parsed.name,
|
||||
content: parsed.content,
|
||||
kind: 'md',
|
||||
chatId: h.getChatId?.()
|
||||
})
|
||||
h.openArtifact?.(artifact.id, artifact.name)
|
||||
let artifact: PersistedArtifact
|
||||
try {
|
||||
artifact = await h.artifacts.create(sessionId, {
|
||||
name: parsed.name,
|
||||
content: parsed.content,
|
||||
kind: 'md',
|
||||
chatId: h.getChatId?.(),
|
||||
role: parsed.role
|
||||
// No approvedVersion: this tool asks for no confirmation, so a plan written here
|
||||
// stands as a draft until a card decides it. exit_plan_mode alone confers it.
|
||||
})
|
||||
} catch (e) {
|
||||
// The store checks the slot inside the write transaction, so another tab cannot
|
||||
// take it in between.
|
||||
if (!(e instanceof PlanSlotTakenError)) throw e
|
||||
const error = `This session's plan is already "${e.plan.name}" (id ${e.plan.id}). Rewrite that document with update_artifact — a session holds one plan.`
|
||||
toolCallbacks.setToolStatus(toolId, { content: error, error })
|
||||
return JSON.stringify({ success: false, error })
|
||||
}
|
||||
h.openArtifact?.(artifact.id, artifact.name, 'latest')
|
||||
toolCallbacks.setToolStatus(toolId, { content: `Created artifact "${artifact.name}"` })
|
||||
return JSON.stringify({ success: true, id: artifact.id, name: artifact.name })
|
||||
}
|
||||
@@ -115,9 +129,10 @@ export const artifactTools: Tool<{}>[] = [
|
||||
{
|
||||
content: parsed.content,
|
||||
name: parsed.name,
|
||||
// Blank collapses to undefined, not "": an empty string is not nullish, so it
|
||||
// would slip past the picker's fallback and render a row with no label.
|
||||
note: parsed.change_note.trim().slice(0, MAX_NOTE_CHARS) || undefined
|
||||
note: normalizeChangeNote(parsed.change_note),
|
||||
// An agreed plan revised here is still agreed: this tool is blocked in plan mode,
|
||||
// so every call is one the posture already trusts. A draft stays a draft.
|
||||
keepApproved: true
|
||||
},
|
||||
{ sessionId }
|
||||
)
|
||||
@@ -135,8 +150,9 @@ export const artifactTools: Tool<{}>[] = [
|
||||
def: createToolDef(
|
||||
listArtifactsSchema,
|
||||
'list_artifacts',
|
||||
"List the current session's artifacts (id, name, kind)."
|
||||
"List the current session's artifacts (id, name, kind, version, role, approvedVersion). `role` is `plan` on the session's one plan document and on nothing else. On that one, `approvedVersion` is the version the user signed off: below `version` means the current text is a proposal they have not agreed to, and absent means nothing here was ever approved."
|
||||
),
|
||||
planModeSafe: true,
|
||||
fn: async ({ toolId, toolCallbacks, helpers }) => {
|
||||
const h = helpers as ArtifactToolHelpers
|
||||
const sessionId = h.sessionId
|
||||
@@ -151,7 +167,16 @@ export const artifactTools: Tool<{}>[] = [
|
||||
return JSON.stringify(
|
||||
items
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
.map((a) => ({ id: a.id, name: a.name, kind: a.kind }))
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
kind: a.kind,
|
||||
version: currentVersion(a),
|
||||
role: a.role,
|
||||
// A plan exists from the moment it is proposed, so without this the model reads
|
||||
// a refused proposal as the one they signed off.
|
||||
approvedVersion: a.role === 'plan' ? a.approvedVersion : undefined
|
||||
}))
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -161,6 +186,7 @@ export const artifactTools: Tool<{}>[] = [
|
||||
'read_artifact',
|
||||
"Read an artifact's full markdown content by id, at its current or an earlier version."
|
||||
),
|
||||
planModeSafe: true,
|
||||
fn: async ({ args, toolId, toolCallbacks, helpers }) => {
|
||||
const parsed = readArtifactSchema.parse(args)
|
||||
const h = helpers as ArtifactToolHelpers
|
||||
@@ -221,6 +247,9 @@ export const artifactTools: Tool<{}>[] = [
|
||||
'list_artifact_versions',
|
||||
"List an artifact's saved versions, newest first. Read one with read_artifact's version argument."
|
||||
),
|
||||
// How the model recovers the approved plan once a refused draft stands in front of it;
|
||||
// the fail-closed gate would otherwise block that in the posture that needs it.
|
||||
planModeSafe: true,
|
||||
fn: async ({ args, toolId, toolCallbacks, helpers }) => {
|
||||
const parsed = listArtifactVersionsSchema.parse(args)
|
||||
const h = helpers as ArtifactToolHelpers
|
||||
|
||||
@@ -58,6 +58,10 @@ function version(v: number, artifactId = 'a1'): ArtifactVersion {
|
||||
}
|
||||
}
|
||||
|
||||
// mutateArtifact takes a mutator, not a row: these tests only need "write exactly this".
|
||||
const put = (m: any, a: any, snapshots: any[]) =>
|
||||
m.mutateArtifact(a.id, () => ({ artifact: a, snapshots }))
|
||||
|
||||
describe('artifactsDB', () => {
|
||||
it('derives filename and mime type from the artifact kind', () => {
|
||||
expect(db.artifactFilename({ name: 'Plan', kind: 'md' })).toBe('Plan.md')
|
||||
@@ -132,14 +136,13 @@ describe('artifactsDB', () => {
|
||||
expect((await upgraded.getArtifact('old'))?.content).toBe('written at v1')
|
||||
expect((await upgraded.listArtifactsForSession('s1')).map((a) => a.id)).toEqual(['old'])
|
||||
// The store the upgrade added works on the upgraded database, not just a fresh one.
|
||||
await upgraded.putArtifactWithVersions(artifact({ id: 'old' }), [version(1, 'old')])
|
||||
await put(upgraded, artifact({ id: 'old' }), [version(1, 'old')])
|
||||
expect((await upgraded.listArtifactVersions('old')).map((v) => v.version)).toEqual([1])
|
||||
})
|
||||
|
||||
it('keeps only the most recent versions of an artifact', async () => {
|
||||
const total = db.MAX_VERSIONS_PER_ARTIFACT + 5
|
||||
for (let v = 1; v <= total; v++)
|
||||
await db.putArtifactWithVersions(artifact({ id: 'a1' }), [version(v)])
|
||||
for (let v = 1; v <= total; v++) await put(db, artifact({ id: 'a1' }), [version(v)])
|
||||
|
||||
const kept = await db.listArtifactVersions('a1')
|
||||
expect(kept).toHaveLength(db.MAX_VERSIONS_PER_ARTIFACT)
|
||||
@@ -149,12 +152,28 @@ describe('artifactsDB', () => {
|
||||
expect(kept.at(-1)?.version).toBe(total - db.MAX_VERSIONS_PER_ARTIFACT + 1)
|
||||
})
|
||||
|
||||
it('never prunes away the version that stands as the approved plan', async () => {
|
||||
// Approve at v1, then plan against it for another twenty rounds: without this the
|
||||
// text the user agreed to is the first thing the ring buffer drops.
|
||||
const total = db.MAX_VERSIONS_PER_ARTIFACT + 5
|
||||
for (let v = 1; v <= total; v++) {
|
||||
await put(db, artifact({ id: 'a1', role: 'plan', approvedVersion: 1 }), [version(v)])
|
||||
}
|
||||
|
||||
const kept = await db.listArtifactVersions('a1')
|
||||
// Protected, not extra: the budget is unchanged, so the survivors are v1 plus the
|
||||
// newest MAX-1 rather than a contiguous run.
|
||||
expect(kept).toHaveLength(db.MAX_VERSIONS_PER_ARTIFACT)
|
||||
expect(kept.at(-1)?.version).toBe(1)
|
||||
expect(kept.at(-2)?.version).toBe(total - db.MAX_VERSIONS_PER_ARTIFACT + 2)
|
||||
})
|
||||
|
||||
it('keeps fewer versions of a large artifact, but never fewer than the minimum', async () => {
|
||||
// Big enough that the char budget, not the count, decides — a plain count cap would
|
||||
// let one document's history run to several MB.
|
||||
const big = 'x'.repeat(db.MAX_VERSION_CHARS_PER_ARTIFACT / 4)
|
||||
for (let v = 1; v <= 8; v++)
|
||||
await db.putArtifactWithVersions(artifact({ id: 'a1' }), [{ ...version(v), content: big }])
|
||||
await put(db, artifact({ id: 'a1' }), [{ ...version(v), content: big }])
|
||||
|
||||
const kept = await db.listArtifactVersions('a1')
|
||||
expect(kept).toHaveLength(4)
|
||||
@@ -162,15 +181,15 @@ describe('artifactsDB', () => {
|
||||
|
||||
// A single snapshot larger than the whole budget still leaves a usable history.
|
||||
const huge = 'x'.repeat(db.MAX_VERSION_CHARS_PER_ARTIFACT * 2)
|
||||
await db.putArtifactWithVersions(artifact({ id: 'a1' }), [{ ...version(9), content: huge }])
|
||||
await put(db, artifact({ id: 'a1' }), [{ ...version(9), content: huge }])
|
||||
expect(await db.listArtifactVersions('a1')).toHaveLength(db.MIN_VERSIONS_PER_ARTIFACT)
|
||||
})
|
||||
|
||||
it('deleting an artifact, or a whole session, drops the versions with it', async () => {
|
||||
await db.putArtifact(artifact({ id: 'a1', sessionId: 's1' }))
|
||||
await db.putArtifact(artifact({ id: 'a2', sessionId: 's1' }))
|
||||
await db.putArtifactWithVersions(artifact({ id: 'a1', sessionId: 's1' }), [version(1)])
|
||||
await db.putArtifactWithVersions(artifact({ id: 'a2', sessionId: 's1' }), [version(1, 'a2')])
|
||||
await put(db, artifact({ id: 'a1', sessionId: 's1' }), [version(1)])
|
||||
await put(db, artifact({ id: 'a2', sessionId: 's1' }), [version(1, 'a2')])
|
||||
|
||||
await db.deleteArtifact('a1')
|
||||
expect(await db.listArtifactVersions('a1')).toEqual([])
|
||||
|
||||
@@ -10,6 +10,12 @@ export interface PersistedArtifact {
|
||||
sessionId: string
|
||||
chatId?: string
|
||||
kind: ArtifactKind
|
||||
/** What the artifact is for, where that outlives the session — as opposed to `kind`,
|
||||
* which is its format. Optional, so records written before it read as undefined. */
|
||||
role?: 'plan'
|
||||
/** The version that stands as the agreed plan; below the current one means the newest
|
||||
* text is undecided. Only exit_plan_mode can leave it behind. */
|
||||
approvedVersion?: number
|
||||
name: string
|
||||
content: string
|
||||
createdAt: number
|
||||
@@ -54,6 +60,12 @@ export function currentVersion(a: Pick<PersistedArtifact, 'version'>): number {
|
||||
return a.version ?? 1
|
||||
}
|
||||
|
||||
/** A session holds one plan, so its id is the session's — the primary key is the constraint,
|
||||
* and no two writers can mint a second row for the same session. */
|
||||
export function planArtifactId(sessionId: string): string {
|
||||
return `plan:${sessionId}`
|
||||
}
|
||||
|
||||
export function versionKey(artifactId: string, version: number): string {
|
||||
return `${artifactId}:${version}`
|
||||
}
|
||||
@@ -140,31 +152,6 @@ export interface ArtifactEdit {
|
||||
snapshots: ArtifactVersion[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an artifact and the snapshots that edit produced in one transaction.
|
||||
*
|
||||
* Never as two writes: a row stamped version N whose snapshot is missing still *reads*
|
||||
* as complete, because listVersions synthesizes N from the row itself — until the next
|
||||
* edit overwrites that row, at which point N's content is gone and the history has a
|
||||
* hole nothing can back-fill.
|
||||
*/
|
||||
export async function putArtifactWithVersions(
|
||||
artifact: PersistedArtifact,
|
||||
snapshots: ArtifactVersion[]
|
||||
): Promise<void> {
|
||||
const db = await getDB()
|
||||
if (!db) return
|
||||
try {
|
||||
const tx = db.transaction(['items', 'versions'], 'readwrite')
|
||||
await writeEdit(tx.objectStore('items'), tx.objectStore('versions'), { artifact, snapshots })
|
||||
await tx.done
|
||||
} catch (err) {
|
||||
// A rejected write (most likely QuotaExceededError) leaves the artifact usable for the
|
||||
// session but unpersisted — degrade like the reads rather than throwing at the caller.
|
||||
console.error('Could not persist artifact', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function writeEdit(
|
||||
items: ItemsStore,
|
||||
versions: VersionsStore,
|
||||
@@ -173,19 +160,40 @@ async function writeEdit(
|
||||
await items.put(edit.artifact)
|
||||
for (const entry of edit.snapshots) await versions.put(entry)
|
||||
const newest = edit.snapshots.at(-1)
|
||||
if (newest) await pruneVersionsIn(versions, edit.artifact.id, newest.content.length)
|
||||
if (newest) {
|
||||
await pruneVersionsIn(
|
||||
versions,
|
||||
edit.artifact.id,
|
||||
newest.content.length,
|
||||
edit.artifact.approvedVersion
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** `unavailable` is no database at all (private browsing), where nothing was refused —
|
||||
* distinct from `rejected`, the database turning this write down. */
|
||||
export type WriteOutcome = 'saved' | 'rejected' | 'unavailable'
|
||||
|
||||
export interface ArtifactWrite {
|
||||
outcome: WriteOutcome
|
||||
/** The edited row, persisted or not; absent only when the mutator wrote nothing. */
|
||||
artifact?: PersistedArtifact
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an artifact and write it back in one transaction. `mutate` returns the edit to
|
||||
* write, or undefined to leave the artifact alone and resolve to undefined. A store that
|
||||
* write, or undefined to leave the artifact alone and resolve to no artifact. A store that
|
||||
* fails is reported rather than thrown, so the edited row resolves either way — persisted
|
||||
* where it could be, and usable for the session where it could not.
|
||||
*
|
||||
* Most callers read only `artifact` and degrade as the reads do. A caller whose write
|
||||
* carries a *constraint* reads `outcome` instead: the plan cannot be approved on the
|
||||
* strength of a row that would be gone on reload.
|
||||
*/
|
||||
export async function mutateArtifact(
|
||||
id: string,
|
||||
mutate: (existing: PersistedArtifact | undefined) => ArtifactEdit | undefined
|
||||
): Promise<PersistedArtifact | undefined> {
|
||||
): Promise<ArtifactWrite> {
|
||||
const db = await getDB()
|
||||
// `transaction()` throws on a connection closed since `getDB()` answered — another tab
|
||||
// upgrading the schema, or a user switch releasing the handle.
|
||||
@@ -197,15 +205,19 @@ export async function mutateArtifact(
|
||||
}
|
||||
// Not `return undefined`: `create` can hand out an artifact the store never took, and it
|
||||
// stays revisable only if the edit is computed anyway.
|
||||
if (!opened) return mutate(undefined)?.artifact
|
||||
if (!opened) return { outcome: 'unavailable', artifact: mutate(undefined)?.artifact }
|
||||
const tx = opened
|
||||
// Attached before the first await: idb builds `done` eagerly and rejects it on abort, so
|
||||
// attaching later would leave an unhandled rejection. Cleared before each deliberate
|
||||
// abort below, whose own site reports the failure when there was one.
|
||||
let reportFailure = true
|
||||
const settled = tx.done.catch((err) => {
|
||||
if (reportFailure) console.error('Could not persist artifact', err)
|
||||
})
|
||||
const settled: Promise<WriteOutcome> = tx.done.then(
|
||||
() => 'saved',
|
||||
(err) => {
|
||||
if (reportFailure) console.error('Could not persist artifact', err)
|
||||
return 'rejected'
|
||||
}
|
||||
)
|
||||
const abort = () => {
|
||||
try {
|
||||
tx.abort()
|
||||
@@ -222,8 +234,7 @@ export async function mutateArtifact(
|
||||
console.error('Could not read the artifact being written', err)
|
||||
reportFailure = false
|
||||
abort()
|
||||
await settled
|
||||
return mutate(undefined)?.artifact
|
||||
return { outcome: await settled, artifact: mutate(undefined)?.artifact }
|
||||
}
|
||||
// Kept out of the store's own error handling: a mutator that fails is not the store
|
||||
// failing, so its error is neither reported as one nor swallowed.
|
||||
@@ -236,11 +247,12 @@ export async function mutateArtifact(
|
||||
await settled
|
||||
throw err
|
||||
}
|
||||
// Nothing to write, so nothing was refused either.
|
||||
if (!edit) {
|
||||
reportFailure = false
|
||||
abort()
|
||||
await settled
|
||||
return undefined
|
||||
return { outcome: 'saved' }
|
||||
}
|
||||
try {
|
||||
await writeEdit(items, versions, edit)
|
||||
@@ -252,21 +264,32 @@ export async function mutateArtifact(
|
||||
reportFailure = false
|
||||
abort()
|
||||
}
|
||||
await settled
|
||||
return edit.artifact
|
||||
return { outcome: await settled, artifact: edit.artifact }
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the oldest snapshots past the budget, except the one `protect` names.
|
||||
*
|
||||
* A plan approved at v1 and planned against for twenty more rounds would otherwise lose the
|
||||
* version that stands as agreed. Excluded from the candidates rather than added on top, so
|
||||
* the budget is unchanged and what survives simply stops being contiguous.
|
||||
*/
|
||||
async function pruneVersionsIn(
|
||||
store: VersionsStore,
|
||||
artifactId: string,
|
||||
newestChars: number
|
||||
newestChars: number,
|
||||
protect?: number
|
||||
): Promise<void> {
|
||||
const keys = await store.index('by-artifact').getAllKeys(artifactId)
|
||||
const keep = versionsToKeep(newestChars)
|
||||
if (keys.length <= keep) return
|
||||
const protectedKey = protect === undefined ? undefined : versionKey(artifactId, protect)
|
||||
// Keys sort lexicographically, which puts ":10" before ":2" — order by the parsed
|
||||
// number so pruning drops the genuinely oldest snapshots.
|
||||
const oldest = keys.sort((a, b) => versionOf(a) - versionOf(b)).slice(0, keys.length - keep)
|
||||
const oldest = keys
|
||||
.sort((a, b) => versionOf(a) - versionOf(b))
|
||||
.filter((key) => key !== protectedKey)
|
||||
.slice(0, keys.length - keep)
|
||||
for (const key of oldest) await store.delete(key)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,9 @@ import {
|
||||
listArtifactVersions,
|
||||
listArtifactsForSession,
|
||||
mutateArtifact,
|
||||
putArtifactWithVersions,
|
||||
planArtifactId,
|
||||
versionKey,
|
||||
type ArtifactEdit,
|
||||
type ArtifactKind,
|
||||
type ArtifactVersion,
|
||||
type PersistedArtifact
|
||||
@@ -18,6 +19,8 @@ export interface CreateArtifactInput {
|
||||
name: string
|
||||
content: string
|
||||
kind?: ArtifactKind
|
||||
role?: PersistedArtifact['role']
|
||||
approvedVersion?: number
|
||||
chatId?: string
|
||||
}
|
||||
|
||||
@@ -26,6 +29,27 @@ export interface UpdateArtifactInput {
|
||||
content?: string
|
||||
/** Recorded on the snapshot this update produces; ignored if content is unchanged. */
|
||||
note?: string
|
||||
/** The version that stands as the agreed plan. Set explicitly only on approval. */
|
||||
approvedVersion?: number
|
||||
/** Carry an existing approval onto the version this write produces. Opt-in, so forgetting
|
||||
* it leaves a draft rather than marking one agreed; ignored when nothing was approved. */
|
||||
keepApproved?: boolean
|
||||
}
|
||||
|
||||
/** A session holds one plan, and this one is taken. Carries the document that holds it,
|
||||
* because the only useful thing a caller can do next is revise that one. */
|
||||
export class PlanSlotTakenError extends Error {
|
||||
constructor(readonly plan: PersistedArtifact) {
|
||||
super(`Session ${plan.sessionId} already has a plan document`)
|
||||
this.name = 'PlanSlotTakenError'
|
||||
}
|
||||
}
|
||||
|
||||
export class ArtifactPersistenceError extends Error {
|
||||
constructor() {
|
||||
super('The plan document could not be saved')
|
||||
this.name = 'ArtifactPersistenceError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,6 +98,14 @@ export class SessionArtifactsStore {
|
||||
this.loading = false
|
||||
}
|
||||
|
||||
// Insert-or-replace: `update` resolves from the database too, so a write can be the first
|
||||
// this session hears of a plan another tab created.
|
||||
#reflect(artifact: PersistedArtifact): void {
|
||||
if (artifact.sessionId !== this.#sessionId) return
|
||||
const rest = this.artifacts.filter((a) => a.id !== artifact.id)
|
||||
this.#applyWrite(sortByUpdatedDesc([artifact, ...rest]))
|
||||
}
|
||||
|
||||
async get(id: string): Promise<PersistedArtifact | undefined> {
|
||||
// In-memory first: a write whose persist silently failed (quota) is still readable here.
|
||||
return this.artifacts.find((a) => a.id === id) ?? (await getArtifact(id))
|
||||
@@ -87,22 +119,33 @@ export class SessionArtifactsStore {
|
||||
/** Persist a new artifact for `sessionId` and reflect it in the list if that session is loaded. */
|
||||
async create(sessionId: string, input: CreateArtifactInput): Promise<PersistedArtifact> {
|
||||
const now = Date.now()
|
||||
const artifact: PersistedArtifact = {
|
||||
id: randomUUID(),
|
||||
const draft = (id: string): PersistedArtifact => ({
|
||||
id,
|
||||
sessionId,
|
||||
chatId: input.chatId,
|
||||
kind: input.kind ?? 'md',
|
||||
role: input.role,
|
||||
approvedVersion: input.approvedVersion,
|
||||
name: input.name,
|
||||
content: input.content,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
version: 1
|
||||
}
|
||||
await putArtifactWithVersions(artifact, [snapshotOf(artifact, 1)])
|
||||
if (sessionId === this.#sessionId) {
|
||||
this.#applyWrite(sortByUpdatedDesc([artifact, ...this.artifacts]))
|
||||
}
|
||||
return artifact
|
||||
})
|
||||
// A plan's id is the session's, so a second one cannot be minted; the slot check happens
|
||||
// on the row this write is about to replace, inside the transaction that replaces it.
|
||||
const id = input.role === 'plan' ? planArtifactId(sessionId) : randomUUID()
|
||||
const { outcome, artifact } = await mutateArtifact(id, (existing) => {
|
||||
if (existing) throw new PlanSlotTakenError(existing)
|
||||
const created = draft(id)
|
||||
return { artifact: created, snapshots: [snapshotOf(created, 1)] }
|
||||
})
|
||||
// An ordinary artifact degrades unpersisted; a plan cannot. Returning one the database
|
||||
// refused would let the user approve a plan that disappears on reload.
|
||||
if (input.role === 'plan' && outcome !== 'saved') throw new ArtifactPersistenceError()
|
||||
const written = artifact ?? draft(id)
|
||||
this.#reflect(written)
|
||||
return written
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -114,42 +157,78 @@ export class SessionArtifactsStore {
|
||||
input: UpdateArtifactInput,
|
||||
opts?: { sessionId?: string }
|
||||
): Promise<PersistedArtifact | undefined> {
|
||||
const updated = await mutateArtifact(id, (stored) => {
|
||||
let refused = false
|
||||
const { outcome, artifact } = await mutateArtifact(id, (stored) => {
|
||||
// Read inside the mutator: hoisted out, it would weigh a stale copy against a fresh one.
|
||||
const existing = furtherAlong(
|
||||
stored,
|
||||
this.artifacts.find((a) => a.id === id)
|
||||
)
|
||||
if (!existing) return undefined
|
||||
if (opts?.sessionId !== undefined && existing.sessionId !== opts.sessionId) return undefined
|
||||
// Only a content change earns a version: a rename or an identical rewrite would
|
||||
// otherwise fill the picker with entries the user cannot tell apart.
|
||||
const contentChanged = input.content !== undefined && input.content !== existing.content
|
||||
const version = currentVersion(existing) + (contentChanged ? 1 : 0)
|
||||
const artifact: PersistedArtifact = {
|
||||
...existing,
|
||||
name: input.name ?? existing.name,
|
||||
content: input.content ?? existing.content,
|
||||
updatedAt: Date.now(),
|
||||
version
|
||||
if (!existing || (opts?.sessionId !== undefined && existing.sessionId !== opts.sessionId)) {
|
||||
refused = true
|
||||
return undefined
|
||||
}
|
||||
const snapshots: ArtifactVersion[] = []
|
||||
// An artifact written before history existed has no snapshot of its current content,
|
||||
// so capture one on *any* update, not just a content change: this write stamps
|
||||
// `version`, and nothing afterwards would recognise it as pre-history.
|
||||
if (existing.version === undefined) {
|
||||
snapshots.push(snapshotOf(existing, currentVersion(existing)))
|
||||
}
|
||||
if (contentChanged) {
|
||||
snapshots.push(snapshotOf(artifact, version, input.note))
|
||||
}
|
||||
return { artifact, snapshots }
|
||||
return reviseInto(existing, input)
|
||||
})
|
||||
if (!updated) return undefined
|
||||
if (updated.sessionId === this.#sessionId) {
|
||||
this.#applyWrite(sortByUpdatedDesc(this.artifacts.map((a) => (a.id === id ? updated : a))))
|
||||
}
|
||||
return updated
|
||||
if (refused) return undefined
|
||||
if (artifact?.role === 'plan' && outcome !== 'saved') throw new ArtifactPersistenceError()
|
||||
if (artifact) this.#reflect(artifact)
|
||||
return artifact
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a proposal into the session's one plan document, creating it the first time.
|
||||
*
|
||||
* Both halves inside one transaction, so a second tab proposing at the same moment revises
|
||||
* the row this one wrote rather than racing it: the id is the session's, and whichever
|
||||
* transaction runs second reads the first one's result.
|
||||
*/
|
||||
async savePlan(
|
||||
sessionId: string,
|
||||
revision: { name: string; content: string; note: string },
|
||||
chatId: string | undefined
|
||||
): Promise<PersistedArtifact> {
|
||||
const id = planArtifactId(sessionId)
|
||||
const { outcome, artifact } = await mutateArtifact(id, (existing) => {
|
||||
if (existing) return reviseInto(existing, revision)
|
||||
const now = Date.now()
|
||||
const created: PersistedArtifact = {
|
||||
id,
|
||||
sessionId,
|
||||
chatId,
|
||||
kind: 'md',
|
||||
role: 'plan',
|
||||
name: revision.name,
|
||||
content: revision.content,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
version: 1
|
||||
}
|
||||
return { artifact: created, snapshots: [snapshotOf(created, 1)] }
|
||||
})
|
||||
// A plan the database refused would let the user approve one that disappears on reload.
|
||||
if (!artifact || outcome !== 'saved') throw new ArtifactPersistenceError()
|
||||
this.#reflect(artifact)
|
||||
return artifact
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the version the user agreed to, and nothing else.
|
||||
*
|
||||
* Not `update`: that rebuilds the row, so an approval computed while another tab was
|
||||
* revising would carry this tab's older content back over the newer text. Read and
|
||||
* patched in one transaction, it can only ever move the pointer.
|
||||
*/
|
||||
async approve(id: string, version: number): Promise<boolean> {
|
||||
const { outcome, artifact } = await mutateArtifact(id, (existing) =>
|
||||
existing ? { artifact: { ...existing, approvedVersion: version }, snapshots: [] } : undefined
|
||||
)
|
||||
// Reflected only once it is stored, unlike an ordinary edit, which degrades unpersisted:
|
||||
// content the store lost is still content, but an approval the store lost never happened,
|
||||
// and showing the `plan` pill over it would put the user's name on it anyway.
|
||||
if (!artifact || outcome !== 'saved') return false
|
||||
this.#reflect(artifact)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -219,6 +298,44 @@ function furtherAlong(
|
||||
return heldVersion > storedVersion ? held : stored
|
||||
}
|
||||
|
||||
/**
|
||||
* The next version of an artifact, and the snapshots that edit produces. Shared by every
|
||||
* writer so the version and approval rules cannot drift between them; always given the row
|
||||
* `furtherAlong` settled on, never a remembered one.
|
||||
*/
|
||||
function reviseInto(existing: PersistedArtifact, input: UpdateArtifactInput): ArtifactEdit {
|
||||
// Only a content change earns a version: a rename or an identical rewrite would otherwise
|
||||
// fill the picker with entries the user cannot tell apart.
|
||||
const contentChanged = input.content !== undefined && input.content !== existing.content
|
||||
const version = currentVersion(existing) + (contentChanged ? 1 : 0)
|
||||
// Carried onto a version this write produced, so one that produces none moves nothing: a
|
||||
// rename would otherwise promote a proposal the user turned down.
|
||||
const approvedVersion =
|
||||
input.approvedVersion ??
|
||||
(input.keepApproved && existing.approvedVersion !== undefined && contentChanged
|
||||
? version
|
||||
: existing.approvedVersion)
|
||||
const artifact: PersistedArtifact = {
|
||||
...existing,
|
||||
name: input.name ?? existing.name,
|
||||
content: input.content ?? existing.content,
|
||||
approvedVersion,
|
||||
updatedAt: Date.now(),
|
||||
version
|
||||
}
|
||||
const snapshots: ArtifactVersion[] = []
|
||||
// An artifact written before history existed has no snapshot of its current content, so
|
||||
// capture one on *any* update, not just a content change: this write stamps `version`, and
|
||||
// nothing afterwards would recognise it as pre-history.
|
||||
if (existing.version === undefined) {
|
||||
snapshots.push(snapshotOf(existing, currentVersion(existing)))
|
||||
}
|
||||
if (contentChanged) {
|
||||
snapshots.push(snapshotOf(artifact, version, input.note))
|
||||
}
|
||||
return { artifact, snapshots }
|
||||
}
|
||||
|
||||
function snapshotOf(a: PersistedArtifact, version: number, note?: string): ArtifactVersion {
|
||||
return {
|
||||
key: versionKey(a.id, version),
|
||||
@@ -234,3 +351,13 @@ function snapshotOf(a: PersistedArtifact, version: number, note?: string): Artif
|
||||
function sortByUpdatedDesc(items: PersistedArtifact[]): PersistedArtifact[] {
|
||||
return [...items].sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
}
|
||||
|
||||
/**
|
||||
* The plan first, the rest still newest-first. Display order only — `list_artifacts` reads
|
||||
* the store's own order. A partition rather than a lift of one row, so it survives a session
|
||||
* that briefly holds two.
|
||||
*/
|
||||
export function planFirst(items: PersistedArtifact[]): PersistedArtifact[] {
|
||||
if (!items.some((a) => a.role === 'plan')) return items
|
||||
return [...items.filter((a) => a.role === 'plan'), ...items.filter((a) => a.role !== 'plan')]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { IDBFactory } from 'fake-indexeddb'
|
||||
import { SessionArtifactsStore } from './artifactsState.svelte'
|
||||
import { planFirst, SessionArtifactsStore } from './artifactsState.svelte'
|
||||
import * as db from './artifactsDB'
|
||||
|
||||
// The user-scoping subscription is BROWSER-gated; the node test env reports false.
|
||||
@@ -314,6 +314,143 @@ describe('SessionArtifactsStore', () => {
|
||||
expect((await store.listVersions('legacy')).map((v) => v.version)).toEqual([2, 1])
|
||||
})
|
||||
|
||||
it('a plan another tab created joins the loaded list when this one revises it', async () => {
|
||||
await store.setSession('s1')
|
||||
// Written straight to the DB under the id the session derives: this store loaded s1
|
||||
// before the plan existed, which is the cross-tab case.
|
||||
await dbMod.putArtifact({
|
||||
id: dbMod.planArtifactId('s1'),
|
||||
sessionId: 's1',
|
||||
kind: 'md',
|
||||
role: 'plan',
|
||||
name: 'Theirs',
|
||||
content: 'v1',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
version: 1
|
||||
})
|
||||
|
||||
await store.savePlan('s1', { name: 'Theirs', content: 'v2', note: 'revised' }, undefined)
|
||||
|
||||
// Persisted but absent from here would leave it out of the preview, the transcript's
|
||||
// plan card and list_artifacts until a reload.
|
||||
expect(store.artifacts.map((a) => a.id)).toEqual([dbMod.planArtifactId('s1')])
|
||||
expect(store.artifacts[0].content).toBe('v2')
|
||||
})
|
||||
|
||||
it('refuses a plan, and an approval, the store would not keep', async () => {
|
||||
await store.setSession('s1')
|
||||
const quiet = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const refuseOnce = () =>
|
||||
vi.spyOn(IDBObjectStore.prototype, 'put').mockImplementationOnce(() => {
|
||||
throw new DOMException('quota', 'QuotaExceededError')
|
||||
})
|
||||
|
||||
// An ordinary artifact degrades unpersisted; a plan cannot, because approving one that
|
||||
// is gone on reload leaves the user agreeing to a document nothing can show them.
|
||||
let put = refuseOnce()
|
||||
await expect(
|
||||
store.savePlan('s1', { name: 'Plan', content: '# p', note: 'first' }, undefined)
|
||||
).rejects.toThrow(/could not be saved/)
|
||||
put.mockRestore()
|
||||
|
||||
const plan = await store.savePlan(
|
||||
's1',
|
||||
{ name: 'Plan', content: '# p', note: 'first' },
|
||||
undefined
|
||||
)
|
||||
put = refuseOnce()
|
||||
expect(await store.approve(plan.id, 1)).toBe(false)
|
||||
put.mockRestore()
|
||||
// And the refusal is not merely reported: an approval reflected in memory anyway would
|
||||
// show the `plan` pill, and tell the model the user signed off, until the next reload.
|
||||
expect(store.artifacts.find((a) => a.id === plan.id)?.approvedVersion).toBeUndefined()
|
||||
expect((await store.get(plan.id))?.approvedVersion).toBeUndefined()
|
||||
|
||||
expect(await store.approve(plan.id, 1)).toBe(true)
|
||||
expect(store.artifacts.find((a) => a.id === plan.id)?.approvedVersion).toBe(1)
|
||||
quiet.mockRestore()
|
||||
})
|
||||
|
||||
it('holds one plan per session, and frees the slot when it is deleted', async () => {
|
||||
await store.setSession('s1')
|
||||
const plan = await store.create('s1', { name: 'Plan', content: 'x', role: 'plan' })
|
||||
await expect(store.create('s1', { name: 'Other', content: 'y', role: 'plan' })).rejects.toThrow(
|
||||
/already has a plan/
|
||||
)
|
||||
// Another session's slot is its own.
|
||||
await expect(
|
||||
store.create('s2', { name: 'Elsewhere', content: 'z', role: 'plan' })
|
||||
).resolves.toBeDefined()
|
||||
|
||||
await store.remove(plan.id)
|
||||
await expect(store.create('s1', { name: 'Next', content: 'w', role: 'plan' })).resolves.toEqual(
|
||||
expect.objectContaining({ role: 'plan' })
|
||||
)
|
||||
})
|
||||
|
||||
it('gives two tabs proposing at once distinct versions, keeping both snapshots', async () => {
|
||||
// The hazard the transaction exists for: read outside it and both tabs stamp the same
|
||||
// next version, so one proposal and its snapshot vanish under the other.
|
||||
const other = new (await import('./artifactsState.svelte')).SessionArtifactsStore()
|
||||
await store.setSession('s1')
|
||||
await store.savePlan('s1', { name: 'Plan', content: 'v1', note: 'first' }, undefined)
|
||||
|
||||
await Promise.all([
|
||||
store.savePlan('s1', { name: 'Plan', content: 'from A', note: 'A' }, undefined),
|
||||
other.savePlan('s1', { name: 'Plan', content: 'from B', note: 'B' }, undefined)
|
||||
])
|
||||
|
||||
const versions = (await store.listVersions(dbMod.planArtifactId('s1'))).map((v) => v.version)
|
||||
expect(versions).toEqual([3, 2, 1])
|
||||
expect((await dbMod.getArtifact(dbMod.planArtifactId('s1')))?.version).toBe(3)
|
||||
})
|
||||
|
||||
it('an approval racing a newer proposal moves the pointer without reverting content', async () => {
|
||||
// approve() must patch, never rewrite: an approval computed while another tab was
|
||||
// revising would otherwise carry this tab's older text back over the newer one.
|
||||
const other = new (await import('./artifactsState.svelte')).SessionArtifactsStore()
|
||||
await store.setSession('s1')
|
||||
const plan = await store.savePlan(
|
||||
's1',
|
||||
{ name: 'Plan', content: 'v1', note: 'first' },
|
||||
undefined
|
||||
)
|
||||
|
||||
await other.savePlan(
|
||||
's1',
|
||||
{ name: 'Plan', content: 'v2 from the other tab', note: 'B' },
|
||||
undefined
|
||||
)
|
||||
await store.approve(plan.id, 1)
|
||||
|
||||
const row = await dbMod.getArtifact(plan.id)
|
||||
expect(row).toMatchObject({ content: 'v2 from the other tab', version: 2, approvedVersion: 1 })
|
||||
})
|
||||
|
||||
it('does not move the approval when a write produces no new version', async () => {
|
||||
// A plan approved at v1 and revised into a proposal the user turned down. Renaming it,
|
||||
// or rewriting it with the text already there, adds no version — so there is nothing
|
||||
// for the approval to move onto, and the refused text must stay refused.
|
||||
await store.setSession('s1')
|
||||
const plan = await store.create('s1', { name: 'Plan', content: 'v1', role: 'plan' })
|
||||
await store.update(plan.id, { approvedVersion: 1 })
|
||||
const refused = await store.update(plan.id, { content: 'v2 the user rejected' })
|
||||
expect(refused).toMatchObject({ version: 2, approvedVersion: 1 })
|
||||
|
||||
const renamed = await store.update(plan.id, {
|
||||
name: 'Plan, renamed',
|
||||
content: 'v2 the user rejected',
|
||||
keepApproved: true
|
||||
})
|
||||
expect(renamed).toMatchObject({ version: 2, approvedVersion: 1 })
|
||||
|
||||
// A write that does add a version still carries it: an edit outside plan mode is one
|
||||
// the user's posture already trusts.
|
||||
const revised = await store.update(plan.id, { content: 'v3', keepApproved: true })
|
||||
expect(revised).toMatchObject({ version: 3, approvedVersion: 3 })
|
||||
})
|
||||
|
||||
it('remove deletes from the DB and the loaded list', async () => {
|
||||
await store.setSession('s1')
|
||||
const created = await store.create('s1', { name: 'Plan', content: 'x' })
|
||||
@@ -337,3 +474,20 @@ function mk(over: Partial<db.PersistedArtifact> = {}): db.PersistedArtifact {
|
||||
...over
|
||||
}
|
||||
}
|
||||
|
||||
describe('planFirst', () => {
|
||||
it('pins plans above artifacts updated more recently', () => {
|
||||
// Store order is newest-first; a run that writes a CSV after the plan is
|
||||
// approved would otherwise bury the plan the user keeps coming back to.
|
||||
const csv = mk({ id: 'csv', name: 'runs.csv', updatedAt: 20 })
|
||||
const plan = mk({ id: 'plan', name: 'Add retries', role: 'plan', updatedAt: 10 })
|
||||
expect(planFirst([csv, plan]).map((a) => a.id)).toEqual(['plan', 'csv'])
|
||||
})
|
||||
|
||||
it('keeps each group newest-first, and several plans together', () => {
|
||||
const newPlan = mk({ id: 'p2', role: 'plan', updatedAt: 30 })
|
||||
const doc = mk({ id: 'doc', updatedAt: 20 })
|
||||
const oldPlan = mk({ id: 'p1', role: 'plan', updatedAt: 10 })
|
||||
expect(planFirst([newPlan, doc, oldPlan]).map((a) => a.id)).toEqual(['p2', 'p1', 'doc'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -216,6 +216,7 @@ export function getDatatableTools(): Tool<{}>[] {
|
||||
return [
|
||||
{
|
||||
def: getListDatatablesToolDef(),
|
||||
planModeSafe: true,
|
||||
fn: async ({ workspace, toolId, toolCallbacks }) => {
|
||||
toolCallbacks.setToolStatus(toolId, { content: 'Listing datatables...' })
|
||||
try {
|
||||
@@ -245,6 +246,7 @@ export function getDatatableTools(): Tool<{}>[] {
|
||||
},
|
||||
{
|
||||
def: getGetDatatableTableSchemaToolDef(),
|
||||
planModeSafe: true,
|
||||
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
|
||||
const parsedArgs = getGetDatatableTableSchemaSchema().parse(args)
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
|
||||
@@ -35,6 +35,7 @@ const READ_DOCS_PAGE_TOOL: ChatCompletionTool = {
|
||||
|
||||
export const readDocsPageTool: Tool<{}> = {
|
||||
def: READ_DOCS_PAGE_TOOL,
|
||||
planModeSafe: true,
|
||||
fn: async ({ args, toolId, toolCallbacks }) => {
|
||||
const url = typeof args?.url === 'string' ? args.url : ''
|
||||
const section =
|
||||
@@ -56,7 +57,9 @@ export const readDocsPageTool: Tool<{}> = {
|
||||
})
|
||||
console.error('Error reading documentation page:', error)
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'An error occurred while reading the documentation page'
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'An error occurred while reading the documentation page'
|
||||
return `Failed to read documentation page: ${errorMessage}, pursuing with the user request...`
|
||||
}
|
||||
}
|
||||
@@ -84,6 +87,7 @@ const SEARCH_DOCS_TOOL: ChatCompletionTool = {
|
||||
|
||||
export const searchDocsTool: Tool<{}> = {
|
||||
def: SEARCH_DOCS_TOOL,
|
||||
planModeSafe: true,
|
||||
fn: async ({ args, toolId, toolCallbacks }) => {
|
||||
const query = typeof args?.query === 'string' ? args.query.trim() : ''
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
@@ -106,7 +110,9 @@ export const searchDocsTool: Tool<{}> = {
|
||||
})
|
||||
console.error('Error searching documentation:', error)
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'An error occurred while searching the documentation'
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'An error occurred while searching the documentation'
|
||||
return `Failed to search documentation: ${errorMessage}, pursuing with the user request...`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ export function getDucklakeTools(): Tool<{}>[] {
|
||||
return [
|
||||
{
|
||||
def: listDucklakesToolDef,
|
||||
planModeSafe: true,
|
||||
fn: async ({ workspace, toolId, toolCallbacks }) => {
|
||||
toolCallbacks.setToolStatus(toolId, { content: 'Listing DuckLake catalogs...' })
|
||||
try {
|
||||
|
||||
@@ -97,6 +97,7 @@ const searchFilesToolDef = createToolDef(
|
||||
|
||||
export const searchFilesTool: Tool<{}> = {
|
||||
def: searchFilesToolDef,
|
||||
planModeSafe: true,
|
||||
fn: async ({ args, helpers, toolId, toolCallbacks }) => {
|
||||
const store = storeFrom(helpers)
|
||||
if (!store || store.count === 0) {
|
||||
@@ -165,6 +166,7 @@ const readFileToolDef = createToolDef(
|
||||
|
||||
export const readFileTool: Tool<{}> = {
|
||||
def: readFileToolDef,
|
||||
planModeSafe: true,
|
||||
fn: async ({ args, helpers, toolId, toolCallbacks }) => {
|
||||
const store = storeFrom(helpers)
|
||||
if (!store || store.count === 0) {
|
||||
|
||||
@@ -366,6 +366,7 @@ export const flowTools: Tool<FlowAIChatHelpers>[] = [
|
||||
...createWorkspaceMutationTools<FlowAIChatHelpers>(),
|
||||
{
|
||||
def: resourceTypeToolDef,
|
||||
planModeSafe: true,
|
||||
fn: async ({ args, toolId, workspace, toolCallbacks }) => {
|
||||
const parsedArgs = resourceTypeToolSchema.parse(args)
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
@@ -384,6 +385,7 @@ export const flowTools: Tool<FlowAIChatHelpers>[] = [
|
||||
},
|
||||
{
|
||||
def: getInstructionsForCodeGenerationToolDef,
|
||||
planModeSafe: true,
|
||||
fn: async ({ args, toolId, toolCallbacks }) => {
|
||||
const parsedArgs = getInstructionsForCodeGenerationToolSchema.parse(args)
|
||||
const langContext = getLangContext(parsedArgs.language, {
|
||||
@@ -473,6 +475,7 @@ export const flowTools: Tool<FlowAIChatHelpers>[] = [
|
||||
},
|
||||
{
|
||||
def: inspectInlineScriptToolDef,
|
||||
planModeSafe: true,
|
||||
fn: async ({ args, helpers, toolCallbacks, toolId }) => {
|
||||
const parsedArgs = inspectInlineScriptSchema.parse(args)
|
||||
const moduleId = parsedArgs.moduleId
|
||||
@@ -796,6 +799,7 @@ export const flowTools: Tool<FlowAIChatHelpers>[] = [
|
||||
},
|
||||
{
|
||||
def: getLintErrorsToolDef,
|
||||
planModeSafe: true,
|
||||
fn: async ({ args, helpers, toolCallbacks, toolId }) => {
|
||||
const parsedArgs = getLintErrorsSchema.parse(args)
|
||||
|
||||
|
||||
@@ -24,10 +24,14 @@ const COVERED_ENDPOINTS: Record<string, string> = {
|
||||
// The item read/list endpoints return deployed state only, blind to the user's
|
||||
// drafts; read_workspace_item / list_workspace_items merge drafts, and for
|
||||
// flows return the compact JSON that patch_flow_json matches against.
|
||||
getScriptByPath: 'read_workspace_item (reads your draft when one exists; pass version: "deployed" for the deployed state)',
|
||||
getFlowByPath: 'read_workspace_item (reads your draft when one exists; pass version: "deployed" for the deployed state)',
|
||||
getResource: 'read_workspace_item (reads your draft when one exists; pass version: "deployed" for the deployed state)',
|
||||
getSchedule: 'read_workspace_item (reads your draft when one exists; pass version: "deployed" for the deployed state)',
|
||||
getScriptByPath:
|
||||
'read_workspace_item (reads your draft when one exists; pass version: "deployed" for the deployed state)',
|
||||
getFlowByPath:
|
||||
'read_workspace_item (reads your draft when one exists; pass version: "deployed" for the deployed state)',
|
||||
getResource:
|
||||
'read_workspace_item (reads your draft when one exists; pass version: "deployed" for the deployed state)',
|
||||
getSchedule:
|
||||
'read_workspace_item (reads your draft when one exists; pass version: "deployed" for the deployed state)',
|
||||
listScripts: 'list_workspace_items (it includes your drafts)',
|
||||
listFlows: 'list_workspace_items (it includes your drafts)',
|
||||
listResource: 'list_workspace_items (it includes your drafts)',
|
||||
@@ -259,6 +263,7 @@ export const apiCatalogTools: Tool<{}>[] = [
|
||||
'search_api_endpoints',
|
||||
'Search the Windmill REST API endpoint catalog for operations no dedicated tool covers (workers, queue state, job details, running deployed items, deletions, ...). Returns endpoint names to pass to call_api_get or call_api_endpoint.'
|
||||
),
|
||||
planModeSafe: true,
|
||||
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
|
||||
const parsed = searchApiEndpointsSchema.parse(args)
|
||||
toolCallbacks.setToolStatus(toolId, { content: 'Searching API endpoints...' })
|
||||
@@ -314,6 +319,9 @@ export const apiCatalogTools: Tool<{}>[] = [
|
||||
'call_api_get',
|
||||
'Call a read-only GET endpoint from the API catalog by name. Use search_api_endpoints first to find the endpoint name; a failed call returns the parameter schema.'
|
||||
),
|
||||
// Readonly rests on the method check below plus the catalog only exposing
|
||||
// side-effect-free GETs; never mark a mutating GET as an `x-mcp-tool`.
|
||||
planModeSafe: true,
|
||||
showDetails: true,
|
||||
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
|
||||
const parsed = callApiGetSchema.parse(args)
|
||||
|
||||
@@ -5005,6 +5005,26 @@ describe('prepareGlobalSystemMessage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('plan-mode safety classification', () => {
|
||||
it('allows inspection but not preview execution', () => {
|
||||
const tool = (name: string) => globalTools.find((t) => t.def.function.name === name)
|
||||
expect(tool('diff')?.planModeSafe).toBe(true)
|
||||
expect(tool('get_db_schema')?.planModeSafe).toBe(true)
|
||||
expect(tool('open_preview')?.planModeSafe).not.toBe(true)
|
||||
})
|
||||
|
||||
it('never tags a tool that stops to ask the user before it acts', () => {
|
||||
// The tag's dangerous direction: omitting it only over-blocks, but adding it to a tool
|
||||
// that stops to ask lets that tool run unasked for the whole posture, silently. This
|
||||
// covers the deploy and delete tools rather than everything mutating — the plan tools
|
||||
// are the deliberate exception, and the controller registers those, not this list.
|
||||
const leaked = globalTools
|
||||
.filter((t) => t.requiresConfirmation === true && t.planModeSafe === true)
|
||||
.map((t) => t.def.function.name)
|
||||
expect(leaked).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('session-only preview tools gating', () => {
|
||||
const toolNames = (sessionPreview: boolean) =>
|
||||
globalToolsFor({ sessionPreview }).map((t) => t.def.function.name)
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
WorkspaceService
|
||||
} from '$lib/gen'
|
||||
import { createTwoFilesPatch } from 'diff'
|
||||
import type { ArtifactVersionTarget } from '$lib/components/sessions/previewRouter'
|
||||
import { $ScriptLang } from '$lib/gen/schemas.gen'
|
||||
import type {
|
||||
AppWithLastVersion,
|
||||
@@ -1276,7 +1277,8 @@ ${
|
||||
: `- When the user raises how a raw app looks (something is off, or they want the design or layout improved) and their description alone isn't specific enough to pinpoint the problem, ask them to paste or drop a screenshot of it into the chat before changing anything.`
|
||||
}
|
||||
- open_page opens its page as a tab in the side-panel preview next to the chat — the only way to show one of these pages there (open_preview only handles editable items). Changing filters on a page already open updates that same tab; only pass new_tab when the user explicitly asks for a separate tab.
|
||||
- create_artifact saves a persistent markdown document (a planning doc, design write-up, spec, or other longer structured output) shown in the session preview panel. Prefer it over a long inline reply for content the user will revisit; keep brief answers inline. To revise one, call list_artifacts then read_artifact for the current content, then update_artifact to overwrite it — never create a second artifact for the same document. Each content change is saved as a version, keeping the most recent ones: use list_artifact_versions and read_artifact's version argument to recover earlier wording the user asks to go back to, rather than rewriting it from memory. list_artifact_versions is the source of truth for what is still available — do not assume a version that is not listed.`
|
||||
- create_artifact saves a persistent markdown document (a planning doc, design write-up, spec, or other longer structured output) shown in the session preview panel. Prefer it over a long inline reply for content the user will revisit; keep brief answers inline. To revise one, call list_artifacts then read_artifact for the current content, then update_artifact to overwrite it — never create a second artifact for the same document. Each content change is saved as a version, keeping the most recent ones: use list_artifact_versions and read_artifact's version argument to recover earlier wording the user asks to go back to, rather than rewriting it from memory. list_artifact_versions is the source of truth for what is still available — do not assume a version that is not listed.
|
||||
- The artifact whose \`role\` is \`plan\` is this session's plan document — one per session, surviving \`/clear\` — and \`approvedVersion\` is the version the user signed off. Below \`version\` means the current text is a proposal they have not agreed to, usually one they turned down; absent means nothing here was ever approved. In either case never describe the current text as agreed or build from it: ask what they want changed, or read the version they did agree to with read_artifact. An agreed plan is an ordinary artifact: revise it the same way, and do not call exit_plan_mode to amend it — that tool only exists while plan mode is active. Update it when the work parts ways with it (a step turns out unnecessary, an approach has to change, scope grows), not after every step you complete. Never quietly rewrite it to describe what you already built: say in your reply how the work now differs from the approved plan, then update the document. Updating it asks the user to approve nothing, so that sentence in your reply is their only chance to object before you keep building.`
|
||||
: ''
|
||||
}
|
||||
|
||||
@@ -2247,6 +2249,7 @@ export const readSkillTool: Tool<{}> = {
|
||||
'read_skill',
|
||||
'Load the full instructions for a workspace AI skill by name. Skills are listed in the system prompt under "Skills"; call this before acting on a task a skill covers, then follow its instructions.'
|
||||
),
|
||||
planModeSafe: true,
|
||||
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
|
||||
const parsed = readSkillSchema.parse(args)
|
||||
toolCallbacks.setToolStatus(toolId, { content: `Reading skill "${parsed.name}"...` })
|
||||
@@ -2818,6 +2821,7 @@ export const openPageTool: Tool<{}> = {
|
||||
'open_page',
|
||||
OPEN_PAGE_DESCRIPTION
|
||||
),
|
||||
planModeSafe: true,
|
||||
// Keep the row expanded so the link chip (attached below as an action) is visible
|
||||
// without the user having to expand the tool call.
|
||||
showDetails: true,
|
||||
@@ -2911,6 +2915,7 @@ export const globalTools: Tool<{}>[] = [
|
||||
'get_instructions',
|
||||
'Get authoring guidance for scripts, flows, data pipelines, resources, apps, or the datatable SQL SDK (wmill.datatable()) used inside runnables.'
|
||||
),
|
||||
planModeSafe: true,
|
||||
fn: async (ctx) => {
|
||||
const { args, toolId, toolCallbacks } = ctx
|
||||
const parsed = getInstructionsSchema.parse(args)
|
||||
@@ -2933,6 +2938,7 @@ export const globalTools: Tool<{}>[] = [
|
||||
'Ask the user a question with proposed answers and wait for their selected or custom answer before continuing.'
|
||||
),
|
||||
streamingLabel: 'Asking the user a question...',
|
||||
planModeSafe: true,
|
||||
fn: async ({ args, toolId, toolCallbacks }) => {
|
||||
const parsed = askUserQuestionSchema.parse(args)
|
||||
const userQuestion = {
|
||||
@@ -3072,6 +3078,7 @@ export const globalTools: Tool<{}>[] = [
|
||||
'list_workspace_items',
|
||||
'List workspace items and drafts. Returns metadata only, up to limit items per item type per page (default 50); pass page to continue past a full page.'
|
||||
),
|
||||
planModeSafe: true,
|
||||
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
|
||||
const parsed = listWorkspaceItemsSchema.parse(args)
|
||||
const types = getRequestedTypes(parsed.types)
|
||||
@@ -3127,6 +3134,7 @@ export const globalTools: Tool<{}>[] = [
|
||||
'read_workspace_item',
|
||||
'Read one workspace item or draft. Prefers your draft when one exists; pass version: "deployed" to read the deployed state instead.'
|
||||
),
|
||||
planModeSafe: true,
|
||||
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
|
||||
const parsed = readWorkspaceItemSchema.parse(args)
|
||||
if (parsed.type === 'trigger' && !parsed.trigger_kind) {
|
||||
@@ -3299,6 +3307,7 @@ export const globalTools: Tool<{}>[] = [
|
||||
'get_trigger_schema',
|
||||
'Get the configuration schema for one trigger kind. Call before write_trigger.'
|
||||
),
|
||||
planModeSafe: true,
|
||||
fn: async (ctx) => {
|
||||
const { kind } = getTriggerSchemaSchema.parse(ctx.args)
|
||||
return triggerConfigJsonSchema(kind)
|
||||
@@ -3310,6 +3319,7 @@ export const globalTools: Tool<{}>[] = [
|
||||
'get_schedule_schema',
|
||||
"Get the shape of write_schedule's `advanced` object: retry, pausing, tags, and error-handler tuning."
|
||||
),
|
||||
planModeSafe: true,
|
||||
fn: async () => JSON.stringify(advancedScheduleShape(), null, 2)
|
||||
},
|
||||
{
|
||||
@@ -3383,6 +3393,7 @@ export const globalTools: Tool<{}>[] = [
|
||||
'list_runs',
|
||||
"List recent runs (jobs), most recent first. Optionally filter by path, creator, label, or status. Returns compact metadata only — use get_job_logs with a returned id to read a run's logs."
|
||||
),
|
||||
planModeSafe: true,
|
||||
showDetails: true,
|
||||
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
|
||||
const parsed = listRunsSchema.parse(args)
|
||||
@@ -3411,6 +3422,7 @@ export const globalTools: Tool<{}>[] = [
|
||||
'get_flow_run_details',
|
||||
"Inspect a flow run's execution tree: per-step statuses and truncated results, including subflow steps, loop iterations, branches, and retries. Works on running flows too. Pass step to fetch one step's result in full (up to 12k chars)."
|
||||
),
|
||||
planModeSafe: true,
|
||||
showDetails: true,
|
||||
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
|
||||
const parsed = getFlowRunDetailsSchema.parse(args)
|
||||
@@ -3435,6 +3447,7 @@ export const globalTools: Tool<{}>[] = [
|
||||
'get_job_logs',
|
||||
'Fetch the logs of a job by its id. Use this to inspect the output of an existing run.'
|
||||
),
|
||||
planModeSafe: true,
|
||||
showDetails: true,
|
||||
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
|
||||
const parsed = getJobLogsSchema.parse(args)
|
||||
@@ -3523,6 +3536,9 @@ export const globalTools: Tool<{}>[] = [
|
||||
'diff',
|
||||
"Diff workspace changes. Read-only. Default: drafts vs deployed versions (index without type/path, one item's unified diff with them; file=<name> for one file inside an app). against='parent_workspace': deployed fork vs its parent workspace. search=<text> greps changed lines across all diffs."
|
||||
),
|
||||
// Safe while planning: diff only flushes user-authored parked autosaves, honors the
|
||||
// autosave toggle, and never changes content, deploys, or runs user code.
|
||||
planModeSafe: true,
|
||||
showDetails: true,
|
||||
fn: async (ctx) => {
|
||||
const parsed = diffSchema.parse(ctx.args)
|
||||
@@ -3604,6 +3620,7 @@ export const globalTools: Tool<{}>[] = [
|
||||
'search_resource_types',
|
||||
'Search workspace resource types and schemas.'
|
||||
),
|
||||
planModeSafe: true,
|
||||
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
|
||||
const parsed = searchResourceTypesSchema.parse(args)
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
@@ -3638,6 +3655,7 @@ export const globalTools: Tool<{}>[] = [
|
||||
'read_flow_module_code',
|
||||
'Read inline script code from one flow module.'
|
||||
),
|
||||
planModeSafe: true,
|
||||
fn: async (ctx) => {
|
||||
const parsed = readFlowModuleCodeSchema.parse(ctx.args)
|
||||
return readFlowModuleCode(parsed, ctx)
|
||||
@@ -3677,6 +3695,7 @@ export const globalTools: Tool<{}>[] = [
|
||||
'read_app_file',
|
||||
'Read one raw app frontend file or inline backend runnable. Large files are truncated to a head slice; pass offset/limit to page through the rest.'
|
||||
),
|
||||
planModeSafe: true,
|
||||
fn: async (ctx) => {
|
||||
const parsed = readAppFileSchema.parse(ctx.args)
|
||||
return readAppFile(parsed, ctx)
|
||||
@@ -3688,6 +3707,7 @@ export const globalTools: Tool<{}>[] = [
|
||||
'search_app',
|
||||
"Grep across all of a raw app's frontend files and inline backend runnables in one call. Returns matching file:line rows (capped), not file bodies — use it to locate a symbol or string before read_app_file instead of reading whole files one by one."
|
||||
),
|
||||
planModeSafe: true,
|
||||
fn: async (ctx) => {
|
||||
const parsed = searchAppSchema.parse(ctx.args)
|
||||
return searchApp(parsed, ctx)
|
||||
@@ -3776,6 +3796,7 @@ export const globalTools: Tool<{}>[] = [
|
||||
'get_preview_status',
|
||||
'Check whether the side-panel preview is open in this AI session and which item (kind + path) it is showing. Call this before offering or calling open_preview so you do not re-open a preview that is already showing the item you just edited. Only meaningful inside a session.'
|
||||
),
|
||||
planModeSafe: true,
|
||||
fn: async (ctx) => getSessionPreviewStatus(sessionIdFromCtx(ctx))
|
||||
},
|
||||
{
|
||||
@@ -3795,6 +3816,7 @@ export const globalTools: Tool<{}>[] = [
|
||||
'get_app_runtime_logs',
|
||||
'Fetch the most recent browser console logs (and uncaught errors) from the raw app preview currently open in this AI session.'
|
||||
),
|
||||
planModeSafe: true,
|
||||
showDetails: true,
|
||||
autoCollapseDetails: false,
|
||||
fn: async (ctx) => {
|
||||
@@ -3814,6 +3836,7 @@ export const globalTools: Tool<{}>[] = [
|
||||
'list_app_runs',
|
||||
'List the backend runnable executions (jobs) the raw app preview currently open in this AI session has triggered, newest first.'
|
||||
),
|
||||
planModeSafe: true,
|
||||
showDetails: true,
|
||||
fn: async (ctx) => {
|
||||
const parsed = listAppRunsSchema.parse(ctx.args)
|
||||
@@ -3832,6 +3855,7 @@ export const globalTools: Tool<{}>[] = [
|
||||
'search_dom',
|
||||
'Search the live rendered HTML of the raw app preview open in this AI session with a regex, returning matching lines with their line numbers. Use it to check what actually rendered (verify an edit landed, diagnose a blank/empty view). Scope to an element with `selector`, or omit it for the whole page. The DOM is read live, so it reflects the current state.'
|
||||
),
|
||||
planModeSafe: true,
|
||||
showDetails: true,
|
||||
fn: async (ctx) => {
|
||||
const parsed = searchDomSchema.parse(ctx.args)
|
||||
@@ -3859,6 +3883,7 @@ export const globalTools: Tool<{}>[] = [
|
||||
'read_dom',
|
||||
'Read a bounded window of the live rendered HTML of the raw app preview open in this AI session, pretty-printed and line-numbered. Scope to an element with `selector`, or omit it for the whole page. Use search_dom first to locate content, then read_dom to see a specific region. The DOM is read live.'
|
||||
),
|
||||
planModeSafe: true,
|
||||
showDetails: true,
|
||||
fn: async (ctx) => {
|
||||
const parsed = readDomSchema.parse(ctx.args)
|
||||
@@ -3888,6 +3913,7 @@ export const globalTools: Tool<{}>[] = [
|
||||
// the result belongs on the result, where only a real capture pays for it.
|
||||
'Capture a screenshot of the raw app preview currently open in this AI session and attach it as an image so you can see the rendered UI. Use it when the user raises how the app looks, whether reporting a problem or asking for the design improved, rather than to check your own edits. The image is attached in the following message. Requires the raw app preview open (open_preview kind="raw_app").'
|
||||
),
|
||||
planModeSafe: true,
|
||||
showDetails: true,
|
||||
fn: async (ctx) => {
|
||||
// A known text-only model would reject the follow-up image message and fail
|
||||
@@ -4020,7 +4046,7 @@ export type GlobalToolHelpers = SessionToolHelpers & {
|
||||
// modifiedItemsMask.ts); undefined when the chat doesn't track them (the global
|
||||
// side-panel chat). Backs open_page's compare-page default preselection.
|
||||
getModifiedItems?: () => string[] | undefined
|
||||
openArtifact?: (artifactId: string, name: string) => void
|
||||
openArtifact?: (artifactId: string, name: string, version?: ArtifactVersionTarget) => void
|
||||
}
|
||||
|
||||
function sessionIdFromCtx(ctx: { helpers?: unknown }): string | undefined {
|
||||
|
||||
@@ -240,6 +240,7 @@ const triggerComponentTool: Tool<{}> = {
|
||||
|
||||
const getTriggerableComponentsTool: Tool<{}> = {
|
||||
def: GET_TRIGGERABLE_COMPONENTS_TOOL,
|
||||
planModeSafe: true,
|
||||
fn: async ({ toolId, toolCallbacks }) => {
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: 'Scanning the page...'
|
||||
@@ -254,6 +255,7 @@ const getTriggerableComponentsTool: Tool<{}> = {
|
||||
|
||||
const getCurrentPageNameTool: Tool<{}> = {
|
||||
def: GET_CURRENT_PAGE_NAME_TOOL,
|
||||
planModeSafe: true,
|
||||
fn: async ({ toolId, toolCallbacks }) => {
|
||||
const pageName = getCurrentPageName()
|
||||
toolCallbacks.setToolStatus(toolId, { content: 'Retrieved current page name' })
|
||||
@@ -263,6 +265,7 @@ const getCurrentPageNameTool: Tool<{}> = {
|
||||
|
||||
const getAvailableResourcesTool: Tool<{}> = {
|
||||
def: GET_AVAILABLE_RESOURCES_TOOL,
|
||||
planModeSafe: true,
|
||||
fn: async ({ args, toolId, toolCallbacks }) => {
|
||||
toolCallbacks.setToolStatus(toolId, { content: 'Getting available resources...' })
|
||||
try {
|
||||
|
||||
@@ -208,6 +208,7 @@ function inferredLineageNote(reads: string[], writes: string[]): string {
|
||||
export const pipelineTools: Tool<PipelineToolHelpers>[] = [
|
||||
{
|
||||
def: getPipelineGraphToolDef,
|
||||
planModeSafe: true,
|
||||
fn: async ({ helpers, toolId, toolCallbacks }) => {
|
||||
const pipeline = requirePipeline(helpers)
|
||||
toolCallbacks.setToolStatus(toolId, { content: 'Reading pipeline graph...' })
|
||||
@@ -221,6 +222,7 @@ export const pipelineTools: Tool<PipelineToolHelpers>[] = [
|
||||
},
|
||||
{
|
||||
def: readPipelineNodeToolDef,
|
||||
planModeSafe: true,
|
||||
fn: async ({ args, helpers, toolId, toolCallbacks }) => {
|
||||
const pipeline = requirePipeline(helpers)
|
||||
const { path } = readPipelineNodeSchema.parse(args)
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
appendPlanModeInstructions,
|
||||
derivePlanTitle,
|
||||
exitPlanModeRejection,
|
||||
isPlanCardTool,
|
||||
listBadge,
|
||||
listOpenTarget,
|
||||
planCardState,
|
||||
planVersionTarget,
|
||||
planVersionView
|
||||
} from './planMode'
|
||||
import { PLAN_MODE_MESSAGES } from './planModeMessages'
|
||||
import { MAX_ARTIFACT_BYTES } from './artifacts/artifactLimits'
|
||||
|
||||
describe('isPlanCardTool', () => {
|
||||
it('rejects inherited property names', () => {
|
||||
expect(isPlanCardTool('exit_plan_mode')).toBe(true)
|
||||
expect(isPlanCardTool('enter_plan_mode')).toBe(true)
|
||||
// Tool names come from the model, so `in` would render these as plan cards.
|
||||
expect(isPlanCardTool('toString')).toBe(false)
|
||||
expect(isPlanCardTool('constructor')).toBe(false)
|
||||
expect(isPlanCardTool('__proto__')).toBe(false)
|
||||
expect(isPlanCardTool(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('planCardState', () => {
|
||||
it('reads as declined only when the user decided, not on any error', () => {
|
||||
expect(
|
||||
planCardState({ error: 'Tool execution was cancelled by user', declinedByUser: true })
|
||||
).toBe('declined')
|
||||
// Everything else that ends in an error renders as an ordinary tool error: claiming a
|
||||
// decision the user never made is the whole failure mode this guards.
|
||||
expect(planCardState({ error: PLAN_MODE_MESSAGES.persistenceFailed })).toBeUndefined()
|
||||
expect(
|
||||
planCardState({ error: 'Tool call arguments were invalid or truncated' })
|
||||
).toBeUndefined()
|
||||
expect(planCardState({ error: 'Unknown tool call: enter_plan_mode.' })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('holds a call that has not resolved yet at pending', () => {
|
||||
expect(planCardState({ needsConfirmation: true })).toBe('pending')
|
||||
expect(planCardState({ isLoading: true })).toBe('pending')
|
||||
// A card waiting its turn behind another tool has no error and no confirmation
|
||||
// pending yet, so without this it would read as already approved.
|
||||
expect(planCardState({ isQueued: true })).toBe('pending')
|
||||
expect(planCardState({ isStreamingArguments: true })).toBe('pending')
|
||||
expect(planCardState({})).toBe('settled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('exitPlanModeRejection', () => {
|
||||
it('passes a real plan and refuses anything with nothing to approve', () => {
|
||||
expect(exitPlanModeRejection({ summary: '# Plan\n\nDo it.' })).toBeUndefined()
|
||||
for (const args of [{}, { summary: '' }, { summary: ' \n ' }, { summary: 42 }, null]) {
|
||||
expect(exitPlanModeRejection(args)).toEqual({
|
||||
label: PLAN_MODE_MESSAGES.missingSummaryLabel,
|
||||
result: PLAN_MODE_MESSAGES.missingSummary
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a plan a malformed change_note would otherwise have sunk', () => {
|
||||
// `change_note` is optional and cosmetic, and `null` is the shape a model reaches for
|
||||
// when omitting an optional field. Reading the summary through a parse of the whole
|
||||
// call would fail on it and tell the user there was no plan to approve.
|
||||
for (const note of [null, 42, {}]) {
|
||||
expect(
|
||||
exitPlanModeRejection({ summary: '# Plan\n\nDo it.', change_note: note })
|
||||
).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses a plan too large for the document that has to hold it', () => {
|
||||
// The plan goes to the store through the save path, which never sees the artifact
|
||||
// tools' cap — so refusing here is the only thing standing between an oversized plan
|
||||
// and a card offering to approve one the document silently never received.
|
||||
const huge = `# Plan\n\n${'x'.repeat(MAX_ARTIFACT_BYTES)}`
|
||||
const rejection = exitPlanModeRejection({ summary: huge })
|
||||
expect(rejection?.label).toBe(PLAN_MODE_MESSAGES.oversizedPlanLabel)
|
||||
// The model cannot measure bytes, so the refusal has to say how far over it is.
|
||||
expect(rejection?.result).toContain(String(MAX_ARTIFACT_BYTES))
|
||||
})
|
||||
})
|
||||
|
||||
describe('planVersionView', () => {
|
||||
const plan = (approvedVersion: number | undefined, version: number) => ({
|
||||
role: 'plan' as const,
|
||||
approvedVersion,
|
||||
version
|
||||
})
|
||||
|
||||
it('reads every version of a plan against the one the user approved', () => {
|
||||
// shown is undefined while unpinned, which means the latest.
|
||||
const cases: [string, ReturnType<typeof plan>, number | undefined, unknown][] = [
|
||||
[
|
||||
'never approved, only version',
|
||||
plan(undefined, 1),
|
||||
undefined,
|
||||
{ badge: 'draft', bar: undefined, backToPlan: undefined }
|
||||
],
|
||||
[
|
||||
'never approved, on latest',
|
||||
plan(undefined, 3),
|
||||
undefined,
|
||||
{ badge: 'draft', bar: undefined, backToPlan: undefined }
|
||||
],
|
||||
[
|
||||
'never approved, in history',
|
||||
plan(undefined, 3),
|
||||
1,
|
||||
{ badge: undefined, bar: undefined, backToPlan: undefined }
|
||||
],
|
||||
[
|
||||
'approved is the only version',
|
||||
plan(1, 1),
|
||||
undefined,
|
||||
{ badge: 'plan', bar: undefined, backToPlan: undefined }
|
||||
],
|
||||
[
|
||||
'approved is the latest',
|
||||
plan(3, 3),
|
||||
undefined,
|
||||
{ badge: 'plan', bar: undefined, backToPlan: undefined }
|
||||
],
|
||||
[
|
||||
'unapproved head',
|
||||
plan(2, 3),
|
||||
undefined,
|
||||
{ badge: 'draft', bar: 'unapproved-head', backToPlan: 2 }
|
||||
],
|
||||
[
|
||||
'on the plan, newer draft exists',
|
||||
plan(2, 3),
|
||||
2,
|
||||
{ badge: 'plan', bar: 'approved-with-newer', backToPlan: undefined }
|
||||
],
|
||||
['behind the plan', plan(2, 3), 1, { badge: undefined, bar: undefined, backToPlan: 2 }],
|
||||
[
|
||||
// Offering v3 here would pin the current version and re-open it under the very
|
||||
// history bar the button exists to leave.
|
||||
'behind a plan approved at the head',
|
||||
plan(3, 3),
|
||||
1,
|
||||
{ badge: undefined, bar: undefined, backToPlan: undefined }
|
||||
],
|
||||
[
|
||||
'between the plan and the head',
|
||||
plan(1, 4),
|
||||
3,
|
||||
{ badge: undefined, bar: undefined, backToPlan: 1 }
|
||||
]
|
||||
]
|
||||
for (const [label, artifact, shown, expected] of cases) {
|
||||
expect(planVersionView(artifact, shown), label).toEqual(expected)
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves ordinary artifacts unlabelled at every version', () => {
|
||||
expect(planVersionView({ version: 3 }, undefined)).toEqual({
|
||||
badge: undefined,
|
||||
bar: undefined,
|
||||
backToPlan: undefined
|
||||
})
|
||||
expect(planVersionView({ version: 3 }, 1)).toEqual({
|
||||
badge: undefined,
|
||||
bar: undefined,
|
||||
backToPlan: undefined
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('planVersionTarget', () => {
|
||||
it('pins a version only while the document has moved past it', () => {
|
||||
// Pinning the current version would open it dressed as history — the stale-version
|
||||
// banner over the very text the opener meant to show.
|
||||
expect(planVersionTarget({ version: 3 }, 3)).toBe('latest')
|
||||
expect(planVersionTarget({ version: 3 }, 2)).toBe(2)
|
||||
// Nothing approved, or no document to compare against: there is no version to pin.
|
||||
expect(planVersionTarget({ version: 3 }, undefined)).toBe('latest')
|
||||
expect(planVersionTarget(undefined, 2)).toBe('latest')
|
||||
// A single-version document has `version` unset.
|
||||
expect(planVersionTarget({}, 1)).toBe('latest')
|
||||
})
|
||||
})
|
||||
|
||||
describe('listOpenTarget', () => {
|
||||
it('names a version for a plan and none for an ordinary artifact', () => {
|
||||
// `'latest'` clears the tab's pin; only omitting it keeps the reader where they were,
|
||||
// and an ordinary artifact's version is theirs to choose.
|
||||
expect(listOpenTarget({ version: 3 })).toBeUndefined()
|
||||
expect(listOpenTarget({ version: 3, approvedVersion: 2 })).toBeUndefined()
|
||||
expect(listOpenTarget({ role: 'plan', version: 3, approvedVersion: 2 })).toBe(2)
|
||||
expect(listOpenTarget({ role: 'plan', version: 3, approvedVersion: 3 })).toBe('latest')
|
||||
expect(listOpenTarget({ role: 'plan', version: 3 })).toBe('latest')
|
||||
})
|
||||
})
|
||||
|
||||
describe('listBadge', () => {
|
||||
it('labels the version the row opens, not the newest', () => {
|
||||
// The head is an unapproved draft, but the row opens v2, so labelling the head would
|
||||
// promise a draft and hand over the plan.
|
||||
expect(listBadge({ role: 'plan', version: 3, approvedVersion: 2 })).toBe('plan')
|
||||
expect(listBadge({ role: 'plan', version: 3 })).toBe('draft')
|
||||
// No pill at all, so the row keeps showing the artifact's kind.
|
||||
expect(listBadge({ version: 3, approvedVersion: 2 })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('derivePlanTitle', () => {
|
||||
it('uses the first markdown heading of any level', () => {
|
||||
expect(derivePlanTitle('## Add a retry policy\n\nSteps...')).toBe('Add a retry policy')
|
||||
expect(derivePlanTitle('Lead-in\n\n# Top level\n\n## Later')).toBe('Top level')
|
||||
})
|
||||
|
||||
it('falls back to a default when the summary has no heading', () => {
|
||||
expect(derivePlanTitle('Just prose, no heading.')).toBe('Implementation plan')
|
||||
expect(derivePlanTitle('#### Too deep')).toBe('Implementation plan')
|
||||
// A bare '#' must not swallow the blank line and title the plan after the next prose.
|
||||
expect(derivePlanTitle('#\n\nJust prose.')).toBe('Implementation plan')
|
||||
})
|
||||
|
||||
it('ignores headings inside fenced code blocks', () => {
|
||||
expect(derivePlanTitle('Lead-in.\n\n```bash\n# Install the deps\n```\n\n## Real title')).toBe(
|
||||
'Real title'
|
||||
)
|
||||
expect(derivePlanTitle('Lead-in.\n\n~~~bash\n# Install the deps\n~~~\n\n## Real title')).toBe(
|
||||
'Real title'
|
||||
)
|
||||
// A longer fence closes only on its own length, so an inner fence must not end it.
|
||||
expect(derivePlanTitle('Lead-in.\n\n````md\n```\n# Inner\n```\n````\n\n## Real title')).toBe(
|
||||
'Real title'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendPlanModeInstructions', () => {
|
||||
const base = { role: 'system' as const, content: 'BASE' }
|
||||
|
||||
it('appends the plan-mode block below the base prompt', () => {
|
||||
const result = appendPlanModeInstructions(base, 0)
|
||||
expect(typeof result.content).toBe('string')
|
||||
expect(result.content).toMatch(/^BASE\n\n/)
|
||||
expect(result.content).toContain('Plan mode active')
|
||||
})
|
||||
|
||||
it('does not append the escalation steer below the threshold', () => {
|
||||
expect(appendPlanModeInstructions(base, 2).content).not.toContain('STOP retrying tools')
|
||||
})
|
||||
|
||||
it('appends the escalation steer at or above the threshold', () => {
|
||||
expect(appendPlanModeInstructions(base, 3).content).toContain('STOP retrying tools')
|
||||
})
|
||||
|
||||
it('passes non-string content through unchanged', () => {
|
||||
const arrayContent = { role: 'system' as const, content: [{ type: 'text', text: 'x' }] as any }
|
||||
expect(appendPlanModeInstructions(arrayContent, 5)).toBe(arrayContent)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,261 @@
|
||||
import { z } from 'zod'
|
||||
import type { ChatCompletionSystemMessageParam } from 'openai/resources/chat/completions.mjs'
|
||||
import { artifactOverflowBytes } from './artifacts/artifactLimits'
|
||||
import { PLAN_MODE_MESSAGES } from './planModeMessages'
|
||||
import type { ArtifactVersionTarget } from '$lib/components/sessions/previewRouter'
|
||||
|
||||
const ESCALATE_AFTER_BLOCKS = 3
|
||||
|
||||
const PLAN_MODE_INSTRUCTIONS = `# Plan mode active
|
||||
|
||||
This is a read-only research posture. Use only inspection tools; writes, execution, and deployment stay blocked until approval.
|
||||
|
||||
- When the plan is complete, call \`exit_plan_mode\` with the full, self-contained markdown plan. It persists and opens the document for approval, so do not create a separate plan artifact or repeat it in chat.
|
||||
- The summary replaces the whole document. For revisions, first find the session's \`role: "plan"\` artifact, read its current text, and merge the new work into the complete replacement.
|
||||
- If \`approvedVersion\` is behind the current version, the current text is an unapproved draft. Revise that draft; read the numbered approved version only when recovering what the user accepted. Missing \`approvedVersion\` means nothing was approved.
|
||||
- Do not call \`exit_plan_mode\` for questions or incomplete plans.`
|
||||
|
||||
const PLAN_MODE_ESCALATION = `\n\nSTOP retrying tools — they will stay blocked. Finalize your plan now and call \`exit_plan_mode\`.`
|
||||
|
||||
/** The tint every plan-mode surface shares. */
|
||||
const PLAN_MODE_TINT = 'bg-teal-600/10 dark:bg-teal-500/10'
|
||||
|
||||
/** Teal, not the house green: green is the transcript's success colour a few rows above,
|
||||
* so a mode signal in it would read as "this worked". */
|
||||
export const PLAN_MODE_TEXT_COLOR = 'text-teal-600 dark:text-teal-500'
|
||||
|
||||
/** The `plan` pill, in the artifact list and on the preview header. */
|
||||
export const PLAN_MODE_BADGE_CLASS = `${PLAN_MODE_TINT} ${PLAN_MODE_TEXT_COLOR}`
|
||||
|
||||
/** Renders what `planVersionView` decided. */
|
||||
export function planBadge(
|
||||
state: 'plan' | 'draft' | undefined
|
||||
): { label: string; class: string } | undefined {
|
||||
if (state === undefined) return undefined
|
||||
return state === 'plan'
|
||||
? { label: 'plan', class: `font-medium ${PLAN_MODE_BADGE_CLASS}` }
|
||||
: { label: 'draft', class: 'font-normal bg-surface-secondary text-tertiary' }
|
||||
}
|
||||
|
||||
/**
|
||||
* How one version reads, for the pill and the bar above it. Judged against the version the
|
||||
* user approved, never the newest: latest is only where the model stopped. So the approved
|
||||
* version is never stale, the one in front of it is a draft, and anything behind is history
|
||||
* that is neither. The list and the header both ask here so they cannot disagree.
|
||||
*/
|
||||
export function planVersionView(
|
||||
a: { role?: 'plan'; approvedVersion?: number; version?: number },
|
||||
/** Undefined while unpinned, which means the latest. */
|
||||
shown: number | undefined
|
||||
): {
|
||||
badge: 'plan' | 'draft' | undefined
|
||||
bar: 'approved-with-newer' | 'unapproved-head' | undefined
|
||||
/** The version the history bar offers, when the plan is not what is on screen. Undefined
|
||||
* when that is simply the latest, which is reached by clearing the pin rather than by
|
||||
* pinning it — the same rule `planVersionTarget` follows. */
|
||||
backToPlan: number | undefined
|
||||
} {
|
||||
const latest = a.version ?? 1
|
||||
const at = shown ?? latest
|
||||
const isPlan = a.role === 'plan'
|
||||
const approvedHere = isPlan && a.approvedVersion === at
|
||||
const approvedElsewhere = isPlan && !approvedHere ? a.approvedVersion : undefined
|
||||
return {
|
||||
badge: !isPlan ? undefined : approvedHere ? 'plan' : at === latest ? 'draft' : undefined,
|
||||
bar: approvedHere
|
||||
? latest > at
|
||||
? 'approved-with-newer'
|
||||
: undefined
|
||||
: approvedElsewhere !== undefined && at === latest
|
||||
? 'unapproved-head'
|
||||
: undefined,
|
||||
backToPlan: approvedElsewhere === latest ? undefined : approvedElsewhere
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How to open a plan at a particular version — the card's own proposal, or the approved one.
|
||||
* Pins it only while it is behind the document: pinning the current version dresses it as
|
||||
* history, banner and all, and omitting a version would strand the reader wherever they were.
|
||||
*/
|
||||
export function planVersionTarget(
|
||||
doc: { version?: number } | undefined,
|
||||
wanted: number | undefined
|
||||
): ArtifactVersionTarget {
|
||||
return doc && wanted !== undefined && wanted < (doc.version ?? 1) ? wanted : 'latest'
|
||||
}
|
||||
|
||||
/**
|
||||
* What a click in the artifact list should open. Only a plan names a version, because only a
|
||||
* plan has one the reader did not pick: for anything else, naming `'latest'` would throw away
|
||||
* the version they pinned on that tab, which omitting it is what preserves.
|
||||
*/
|
||||
export function listOpenTarget(artifact: {
|
||||
role?: 'plan'
|
||||
version?: number
|
||||
approvedVersion?: number
|
||||
}): ArtifactVersionTarget | undefined {
|
||||
if (artifact.role !== 'plan') return undefined
|
||||
return planVersionTarget(artifact, artifact.approvedVersion)
|
||||
}
|
||||
|
||||
/**
|
||||
* The pill on a list row. Read at the version `listOpenTarget` opens rather than at the
|
||||
* newest, so a row cannot label one version and open another.
|
||||
*/
|
||||
export function listBadge(artifact: {
|
||||
role?: 'plan'
|
||||
version?: number
|
||||
approvedVersion?: number
|
||||
}): 'plan' | 'draft' | undefined {
|
||||
const target = listOpenTarget(artifact)
|
||||
return planVersionView(artifact, typeof target === 'number' ? target : undefined).badge
|
||||
}
|
||||
|
||||
/** The only posture that refuses work, so the only one colouring the whole trigger: the
|
||||
* user has to see from the composer why an edit went nowhere. `!` beats the Button. */
|
||||
export const PLAN_MODE_TRIGGER_CLASS = `${PLAN_MODE_TINT} !border-teal-600/40 hover:bg-teal-600/[0.15] !text-teal-600 dark:!border-teal-500/40 dark:hover:bg-teal-500/[0.15] dark:!text-teal-500`
|
||||
|
||||
export const enterPlanModeArgs = z.object({
|
||||
reason: z
|
||||
.string()
|
||||
.describe(
|
||||
'One concise sentence on what you want to research/plan and why, shown to the user when asking to enter plan mode.'
|
||||
)
|
||||
})
|
||||
|
||||
export const exitPlanModeArgs = z.object({
|
||||
summary: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
'The plan to execute, as concise well-structured markdown. Shown verbatim to the user for approval.'
|
||||
),
|
||||
change_note: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Only when revising a plan the user has already seen: what changed since the last proposal, as a short label they will read in the version picker — under 60 characters, no trailing period, starting with a verb ("Dropped the migration step", "Split phase 2 in two"). Omit on a first proposal.'
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* One argument, never a parse of the whole call: `change_note` is optional and cosmetic, so
|
||||
* a model sending it as `null` would fail the object parse and take the plan down with it.
|
||||
*/
|
||||
function stringArg(args: unknown, key: 'summary' | 'change_note' | 'reason'): string | undefined {
|
||||
const value = (args as Record<string, unknown> | null | undefined)?.[key]
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
export const planSummaryOf = (args: unknown) => stringArg(args, 'summary')
|
||||
export const planChangeNoteOf = (args: unknown) => stringArg(args, 'change_note')
|
||||
export const planReasonOf = (args: unknown) => stringArg(args, 'reason')
|
||||
|
||||
export const ENTER_PLAN_MODE_TOOL_DESCRIPTION = `Call this before starting a non-trivial change to research first and get the user's sign-off on your approach. Prefer it when the task adds meaningful new functionality, has several valid approaches, requires an architectural decision, will touch more than a couple of files, or is unclear enough that you need to explore before you understand the scope. Do NOT use it for small, well-specified edits (a typo, one obvious bug, a single function with clear requirements) or pure questions. On approval you enter a read-only posture; investigate, then call \`exit_plan_mode\` with your plan.`
|
||||
|
||||
export const EXIT_PLAN_MODE_TOOL_DESCRIPTION = `Call once your plan is ready and you want to start executing it. Shows the plan to the user for approval; only on approval are mutating tools unblocked. Do not call it to ask a question — use it only to hand over a complete plan. Valid only while plan mode is active: once the plan is approved there is nothing left to approve, and a revision goes into the plan document with \`update_artifact\`.`
|
||||
|
||||
/** Exported because the autonomy picker resolves pending cards by name, far from the
|
||||
* `createToolDef` calls — a rename missing one site would go silently inert. */
|
||||
export const ENTER_PLAN_MODE_TOOL = 'enter_plan_mode'
|
||||
export const EXIT_PLAN_MODE_TOOL = 'exit_plan_mode'
|
||||
|
||||
/** `declined` is not only the reject button: a Stop and leaving plan mode both resolve a
|
||||
* pending card, so it must name the outcome rather than the button. */
|
||||
export const PLAN_CARD_COPY = {
|
||||
[ENTER_PLAN_MODE_TOOL]: {
|
||||
settled: 'Planning started',
|
||||
declined: 'Continuing without planning',
|
||||
pending: 'Start planning?',
|
||||
reject: 'Not now',
|
||||
confirm: 'Start planning'
|
||||
},
|
||||
[EXIT_PLAN_MODE_TOOL]: {
|
||||
settled: 'Plan approved',
|
||||
declined: 'Plan not approved',
|
||||
pending: 'Proposed plan',
|
||||
reject: 'Keep planning',
|
||||
confirm: 'Approve and implement'
|
||||
}
|
||||
} as const
|
||||
|
||||
export type PlanCardTool = keyof typeof PLAN_CARD_COPY
|
||||
|
||||
export function isPlanCardTool(name: string | undefined): name is PlanCardTool {
|
||||
// hasOwn, not `in`: tool names come from the model, and `in` would accept
|
||||
// `toString` or `__proto__` and render an unknown call as a plan card.
|
||||
return name !== undefined && Object.hasOwn(PLAN_CARD_COPY, name)
|
||||
}
|
||||
|
||||
/** The six fields of a tool message this decision reads, named rather than imported so the
|
||||
* contract is the parameter and a case is one object literal. */
|
||||
type PlanCardStatus = {
|
||||
error?: string
|
||||
declinedByUser?: boolean
|
||||
needsConfirmation?: boolean
|
||||
isLoading?: boolean
|
||||
isQueued?: boolean
|
||||
isStreamingArguments?: boolean
|
||||
}
|
||||
|
||||
/** Undefined renders as an ordinary tool error. Keyed off the decision, not off each error
|
||||
* path, so an error added later cannot read as a plan the user turned down. */
|
||||
export function planCardState(
|
||||
status: PlanCardStatus
|
||||
): 'settled' | 'declined' | 'pending' | undefined {
|
||||
if (status.error) return status.declinedByUser ? 'declined' : undefined
|
||||
// isQueued matters: a card waiting its turn has no error and no confirmation pending
|
||||
// yet, so without it a queued call reads as already resolved.
|
||||
return status.needsConfirmation ||
|
||||
status.isLoading ||
|
||||
status.isQueued ||
|
||||
status.isStreamingArguments
|
||||
? 'pending'
|
||||
: 'settled'
|
||||
}
|
||||
|
||||
/** An unusable call must be refused before a card offers to approve it, not swallowed
|
||||
* into a blank approval. */
|
||||
export function exitPlanModeRejection(
|
||||
args: unknown
|
||||
): { label: string; result: string } | undefined {
|
||||
const summary = planSummaryOf(args)
|
||||
if (!summary?.trim()) {
|
||||
return {
|
||||
label: PLAN_MODE_MESSAGES.missingSummaryLabel,
|
||||
result: PLAN_MODE_MESSAGES.missingSummary
|
||||
}
|
||||
}
|
||||
// The plan reaches the store through the save path, never create_artifact, so the cap
|
||||
// applies here or not at all — otherwise the card offers a plan the document never got.
|
||||
const bytes = artifactOverflowBytes(summary)
|
||||
if (bytes !== undefined) {
|
||||
return {
|
||||
label: PLAN_MODE_MESSAGES.oversizedPlanLabel,
|
||||
result: PLAN_MODE_MESSAGES.oversizedPlan(bytes)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function derivePlanTitle(summary: string): string {
|
||||
// Unfenced, a `# comment` inside a snippet would win over the plan's real heading.
|
||||
const heading = summary
|
||||
.replace(/^(`{3,}|~{3,})[\s\S]*?^\1/gm, '')
|
||||
.match(/^#{1,3}[ \t]+(.+)$/m)?.[1]
|
||||
?.trim()
|
||||
return heading || 'Implementation plan'
|
||||
}
|
||||
|
||||
export function appendPlanModeInstructions(
|
||||
base: ChatCompletionSystemMessageParam,
|
||||
blocksThisTurn: number
|
||||
): ChatCompletionSystemMessageParam {
|
||||
if (typeof base.content !== 'string') return base
|
||||
const block =
|
||||
blocksThisTurn >= ESCALATE_AFTER_BLOCKS
|
||||
? PLAN_MODE_INSTRUCTIONS + PLAN_MODE_ESCALATION
|
||||
: PLAN_MODE_INSTRUCTIONS
|
||||
return { ...base, content: `${base.content}\n\n${block}` }
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import type { ChatCompletionSystemMessageParam } from 'openai/resources/chat/completions.mjs'
|
||||
import type { ArtifactVersionTarget } from '$lib/components/sessions/previewRouter'
|
||||
import { createToolDef, type Tool, type ToolCallbacks } from './shared'
|
||||
import {
|
||||
appendPlanModeInstructions,
|
||||
derivePlanTitle,
|
||||
enterPlanModeArgs,
|
||||
exitPlanModeArgs,
|
||||
exitPlanModeRejection,
|
||||
planChangeNoteOf,
|
||||
planReasonOf,
|
||||
planSummaryOf,
|
||||
ENTER_PLAN_MODE_TOOL,
|
||||
ENTER_PLAN_MODE_TOOL_DESCRIPTION,
|
||||
EXIT_PLAN_MODE_TOOL,
|
||||
EXIT_PLAN_MODE_TOOL_DESCRIPTION
|
||||
} from './planMode'
|
||||
import { PLAN_MODE_MESSAGES } from './planModeMessages'
|
||||
import { normalizeChangeNote } from './artifacts/artifactLimits'
|
||||
import { currentVersion } from './artifacts/artifactsDB'
|
||||
import { type SessionArtifactsStore } from './artifacts/artifactsState.svelte'
|
||||
|
||||
/** What plan mode needs from the chat it runs in: it reads the autonomy state and asks for
|
||||
* the two changes it can cause, rather than owning any of it. */
|
||||
export interface PlanModeHost {
|
||||
/** Offered in this chat *and* selected. */
|
||||
readonly active: boolean
|
||||
/** Offered in this chat at all. */
|
||||
readonly available: boolean
|
||||
/** Auto-accepting confirmations, so there is no one to ask before entering. */
|
||||
readonly autoAccepting: boolean
|
||||
readonly isSessionChat: boolean
|
||||
readonly sessionId: string | undefined
|
||||
readonly chatId: string | undefined
|
||||
readonly artifacts: SessionArtifactsStore
|
||||
openArtifact(id: string, name: string, version: ArtifactVersionTarget): void
|
||||
enter(): void
|
||||
/** Hand the posture back to whatever preceded plan mode. */
|
||||
restore(): void
|
||||
}
|
||||
|
||||
/** Read off the write that proposed the plan, so nothing re-reads and races a later one. */
|
||||
type PlanSaveResult = { id: string; name: string; version: number }
|
||||
|
||||
/** The failure carries the model's message, so a lost slot and an unwritable store stay
|
||||
* distinguishable when `fn` reports them after the confirmation. */
|
||||
type PlanSaveOutcome = { plan: PlanSaveResult } | { error: string }
|
||||
|
||||
/**
|
||||
* A round runs from entering plan mode to the proposal the user decides on. Nothing in it is
|
||||
* undone — a refused proposal stands as the newest version — so it only has to remember the
|
||||
* write it made, for an approval landing after the chat has moved on.
|
||||
*/
|
||||
export class PlanModeController {
|
||||
#host: PlanModeHost
|
||||
/** Keyed by tool call so a card's confirmation hook and the tool's `fn` share one write. */
|
||||
#save: { toolId: string; doc: Promise<PlanSaveOutcome> } | undefined
|
||||
/** Bumped only on *entering*, so it names the round, not the conversation: a chat rotation
|
||||
* mid-approval still hands the posture back, a re-entered round must not. */
|
||||
#epoch = 0
|
||||
/** Drives the prompt's escalation once the model keeps retrying blocked tools. */
|
||||
blocksThisTurn = $state(0)
|
||||
|
||||
constructor(host: PlanModeHost) {
|
||||
this.#host = host
|
||||
}
|
||||
|
||||
/** The confirmation hook deliberately does not block on the write, so this is how a caller
|
||||
* waits for it to settle. */
|
||||
get pendingSave(): Promise<PlanSaveOutcome> | undefined {
|
||||
return this.#save?.doc
|
||||
}
|
||||
|
||||
/** A new round, whose approval is its own: one still in flight must not end this one. */
|
||||
startRound = () => {
|
||||
this.#save = undefined
|
||||
this.#epoch++
|
||||
}
|
||||
|
||||
/** The conversation rotated. The save-dedup belongs to it; the plan document does not —
|
||||
* that one is the session's, and rotating a chat leaves it exactly where it was. */
|
||||
resetRound = () => {
|
||||
this.#save = undefined
|
||||
}
|
||||
|
||||
resetBlocks = () => {
|
||||
this.blocksThisTurn = 0
|
||||
}
|
||||
|
||||
noteBlockedTool = () => {
|
||||
this.blocksThisTurn++
|
||||
}
|
||||
|
||||
/** They have to leave the prompt on approval, or the model is still told it may not build
|
||||
* while the gate has already opened. */
|
||||
decorateSystemMessage = (
|
||||
base: ChatCompletionSystemMessageParam
|
||||
): ChatCompletionSystemMessageParam =>
|
||||
this.#host.active ? appendPlanModeInstructions(base, this.blocksThisTurn) : base
|
||||
|
||||
/** Only the transition the current posture allows; auto-accepting exposes neither, since
|
||||
* entering is the user's choice. */
|
||||
get tools(): Tool<any>[] {
|
||||
if (!this.#host.available) return []
|
||||
if (this.#host.autoAccepting) return []
|
||||
return this.#host.active ? [this.exitTool] : [this.enterTool]
|
||||
}
|
||||
|
||||
// This safety tag is what keeps plan mode escapable through its handoff tool.
|
||||
exitTool: Tool<any> = {
|
||||
def: createToolDef(exitPlanModeArgs, EXIT_PLAN_MODE_TOOL, EXIT_PLAN_MODE_TOOL_DESCRIPTION),
|
||||
planModeSafe: true,
|
||||
requiresConfirmation: true,
|
||||
// A batch's tool list is snapshotted before its calls are run, so a second hand-over in
|
||||
// one response still finds this tool after the first restored the posture. Refused here
|
||||
// rather than in `fn`, because `onConfirmationRequested` writes the document too — and
|
||||
// under YOLO nothing asks: the tool would confer the user's approval on a plan no card
|
||||
// ever showed them.
|
||||
validateBeforeConfirmation: ({ args }) =>
|
||||
this.#host.active
|
||||
? exitPlanModeRejection(args)
|
||||
: { label: PLAN_MODE_MESSAGES.endedLabel, result: PLAN_MODE_MESSAGES.ended },
|
||||
confirmationMessage: (args) => planSummaryOf(args) ?? PLAN_MODE_MESSAGES.exitPrompt,
|
||||
cancellationMessage: PLAN_MODE_MESSAGES.exitDeclined,
|
||||
showDetails: true,
|
||||
onConfirmationRequested: (p) => {
|
||||
void this.#ensurePlanDoc(p)
|
||||
},
|
||||
fn: async ({ args, toolCallbacks, toolId }) => {
|
||||
const save = this.#ensurePlanDoc({ args, toolCallbacks, toolId })
|
||||
// Captured before the await: the approval belongs to the round that proposed this
|
||||
// plan, and the user can leave and re-enter plan mode while the write is in flight.
|
||||
const epoch = this.#epoch
|
||||
const saved = await save
|
||||
// Reporting the failure is `fn`'s to do and no earlier: the write settles while the
|
||||
// card is still waiting to be confirmed, and clearing that card from underneath the
|
||||
// wait would take away the only control left that resolves it.
|
||||
if ('error' in saved) {
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: 'Plan was not saved',
|
||||
error: saved.error
|
||||
})
|
||||
return saved.error
|
||||
}
|
||||
if (!(await this.#markApproved(saved.plan.id, saved.plan.version))) {
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: 'Plan approval was not saved',
|
||||
error: PLAN_MODE_MESSAGES.persistenceFailed
|
||||
})
|
||||
return PLAN_MODE_MESSAGES.persistenceFailed
|
||||
}
|
||||
// Only to the round this approval belongs to: ending a re-entered round would drop the
|
||||
// user out of a read-only posture they just chose.
|
||||
if (this.#host.active && epoch === this.#epoch) this.#host.restore()
|
||||
return PLAN_MODE_MESSAGES.approvedWithDoc
|
||||
}
|
||||
}
|
||||
|
||||
enterTool: Tool<any> = {
|
||||
def: createToolDef(enterPlanModeArgs, ENTER_PLAN_MODE_TOOL, ENTER_PLAN_MODE_TOOL_DESCRIPTION),
|
||||
planModeSafe: true,
|
||||
requiresConfirmation: true,
|
||||
confirmationMessage: (args) => planReasonOf(args) ?? PLAN_MODE_MESSAGES.enterPrompt,
|
||||
cancellationMessage: PLAN_MODE_MESSAGES.enterDeclined,
|
||||
showDetails: true,
|
||||
fn: async () => {
|
||||
this.#host.enter()
|
||||
return PLAN_MODE_MESSAGES.entered
|
||||
}
|
||||
}
|
||||
|
||||
// A round spans the whole posture, so re-proposing revises the same document.
|
||||
#ensurePlanDoc = (p: { args: any; toolCallbacks: ToolCallbacks; toolId: string }) => {
|
||||
if (this.#save?.toolId !== p.toolId) {
|
||||
this.#save = { toolId: p.toolId, doc: this.#savePlanDoc(p) }
|
||||
}
|
||||
return this.#save.doc
|
||||
}
|
||||
|
||||
#savePlanDoc = async (p: {
|
||||
args: any
|
||||
toolCallbacks: ToolCallbacks
|
||||
toolId: string
|
||||
}): Promise<PlanSaveOutcome> => {
|
||||
const host = this.#host
|
||||
// Read once, before any await: every write below is about the session that proposed the
|
||||
// plan, not whichever one the getter would answer with by the time they land.
|
||||
const sessionId = host.sessionId
|
||||
const chatId = host.chatId
|
||||
const unsaved = { error: PLAN_MODE_MESSAGES.persistenceFailed }
|
||||
if (!host.isSessionChat || !sessionId) return unsaved
|
||||
const summary = planSummaryOf(p.args)
|
||||
if (!summary) return unsaved
|
||||
try {
|
||||
// No approval field: the write bumps the version and leaves `approvedVersion` where
|
||||
// it was, which is what makes the new text read as an undecided proposal. The note
|
||||
// keeps the picker a sequence of decisions rather than a stack of dates.
|
||||
const plan = await host.artifacts.savePlan(
|
||||
sessionId,
|
||||
{
|
||||
name: derivePlanTitle(summary),
|
||||
content: summary,
|
||||
note: normalizeChangeNote(planChangeNoteOf(p.args)) ?? PLAN_MODE_MESSAGES.revisionNote
|
||||
},
|
||||
chatId
|
||||
)
|
||||
const version = currentVersion(plan)
|
||||
// The write stands either way; only what the user would *see* is held back, so a
|
||||
// session swapped in mid-save gets neither another session's plan nor a dead card.
|
||||
if (sessionId === host.sessionId) {
|
||||
// 'latest', not the version just written: this plan is being put up for approval,
|
||||
// so it has to be readable — and pinning it would strand the reader there when
|
||||
// the next round revises the document.
|
||||
host.openArtifact(plan.id, plan.name, 'latest')
|
||||
p.toolCallbacks.setToolStatus(p.toolId, { planArtifactId: plan.id, planVersion: version })
|
||||
}
|
||||
return { plan: { id: plan.id, name: plan.name, version } }
|
||||
} catch (e) {
|
||||
console.error('Failed to persist plan artifact', e)
|
||||
return unsaved
|
||||
}
|
||||
}
|
||||
|
||||
// Lands on the document rather than being inferred from a transcript read long after the
|
||||
// card scrolled away. Unguarded: the approval holds whichever chat is current.
|
||||
#markApproved = async (id: string, version: number): Promise<boolean> => {
|
||||
try {
|
||||
return await this.#host.artifacts.approve(id, version)
|
||||
} catch (e) {
|
||||
console.error('Failed to mark plan artifact approved', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// The controller reaches `shared.ts` for `createToolDef`, which drags the editor in behind it.
|
||||
vi.mock('monaco-editor', () => ({ editor: {} }))
|
||||
|
||||
import { PlanModeController, type PlanModeHost } from './planModeController.svelte'
|
||||
import { PLAN_MODE_MESSAGES } from './planModeMessages'
|
||||
import { processToolCall } from './shared'
|
||||
|
||||
type Doc = {
|
||||
id: string
|
||||
sessionId: string
|
||||
name: string
|
||||
content: string
|
||||
kind: 'md'
|
||||
role: 'plan'
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
version: number
|
||||
approvedVersion?: number
|
||||
}
|
||||
|
||||
describe('PlanModeController', () => {
|
||||
let active: boolean
|
||||
let autoAccepting: boolean
|
||||
let sessionId: string
|
||||
let docs: Doc[]
|
||||
let openArtifact: ReturnType<typeof vi.fn>
|
||||
let restore: ReturnType<typeof vi.fn>
|
||||
let artifacts: any
|
||||
let controller: PlanModeController
|
||||
|
||||
beforeEach(() => {
|
||||
active = true
|
||||
autoAccepting = false
|
||||
sessionId = 'session-1'
|
||||
docs = []
|
||||
openArtifact = vi.fn()
|
||||
restore = vi.fn(() => {
|
||||
active = false
|
||||
})
|
||||
artifacts = {
|
||||
// Mirrors the store: one row per session keyed by the session, created or revised in
|
||||
// a single step, and an approval that moves nothing but the pointer.
|
||||
savePlan: vi.fn(async (sessionId: string, revision: any, chatId?: string) => {
|
||||
const existing = docs.find((d) => d.sessionId === sessionId)
|
||||
if (existing) {
|
||||
if (revision.content !== existing.content) existing.version++
|
||||
existing.name = revision.name
|
||||
existing.content = revision.content
|
||||
return existing
|
||||
}
|
||||
const doc: Doc = {
|
||||
id: `plan:${sessionId}`,
|
||||
sessionId,
|
||||
name: revision.name,
|
||||
content: revision.content,
|
||||
kind: 'md',
|
||||
role: 'plan',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
version: 1
|
||||
}
|
||||
docs = [...docs, doc]
|
||||
return doc
|
||||
}),
|
||||
approve: vi.fn(async (id: string, version: number) => {
|
||||
const doc = docs.find((d) => d.id === id)
|
||||
if (!doc) return false
|
||||
doc.approvedVersion = version
|
||||
return true
|
||||
})
|
||||
}
|
||||
const host: PlanModeHost = {
|
||||
get active() {
|
||||
return active
|
||||
},
|
||||
available: true,
|
||||
get autoAccepting() {
|
||||
return autoAccepting
|
||||
},
|
||||
isSessionChat: true,
|
||||
get sessionId() {
|
||||
return sessionId
|
||||
},
|
||||
chatId: 'chat-1',
|
||||
artifacts,
|
||||
openArtifact,
|
||||
enter: () => {
|
||||
active = true
|
||||
},
|
||||
restore
|
||||
}
|
||||
controller = new PlanModeController(host)
|
||||
controller.startRound()
|
||||
})
|
||||
|
||||
const callbacks = () => ({ setToolStatus: vi.fn(), removeToolStatus: vi.fn() })
|
||||
const propose = async (summary: string, toolId = 'exit-1') => {
|
||||
const toolCallbacks = callbacks()
|
||||
controller.exitTool.onConfirmationRequested?.({ args: { summary }, toolCallbacks, toolId })
|
||||
await controller.pendingSave
|
||||
return toolCallbacks
|
||||
}
|
||||
|
||||
it('persists and opens the first proposal at its current version', async () => {
|
||||
const toolCallbacks = await propose('# Add retries\n\nRetry failed work.')
|
||||
|
||||
expect(docs[0]).toMatchObject({ name: 'Add retries', version: 1 })
|
||||
expect(openArtifact).toHaveBeenCalledWith('plan:session-1', 'Add retries', 'latest')
|
||||
expect(toolCallbacks.setToolStatus).toHaveBeenCalledWith(
|
||||
'exit-1',
|
||||
expect.objectContaining({ planArtifactId: 'plan:session-1', planVersion: 1 })
|
||||
)
|
||||
})
|
||||
|
||||
it('revises the session plan while preserving its approval pointer', async () => {
|
||||
docs = [
|
||||
{
|
||||
id: 'plan:session-1',
|
||||
sessionId: 'session-1',
|
||||
name: 'Old',
|
||||
content: 'old',
|
||||
kind: 'md',
|
||||
role: 'plan',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
version: 1,
|
||||
approvedVersion: 1
|
||||
}
|
||||
]
|
||||
|
||||
await propose('# Revised\n\nNew approach.')
|
||||
|
||||
expect(artifacts.savePlan).toHaveBeenCalledWith(
|
||||
'session-1',
|
||||
expect.not.objectContaining({ approvedVersion: expect.anything() }),
|
||||
'chat-1'
|
||||
)
|
||||
expect(docs[0]).toMatchObject({ version: 2, approvedVersion: 1 })
|
||||
})
|
||||
|
||||
it('restores posture only after proposal and approval pointer are durable', async () => {
|
||||
await propose('# Durable\n\nBuild it.')
|
||||
const result = await controller.exitTool.fn({
|
||||
args: { summary: '# Durable\n\nBuild it.' },
|
||||
workspace: 'w',
|
||||
helpers: {},
|
||||
toolCallbacks: callbacks(),
|
||||
toolId: 'exit-1'
|
||||
})
|
||||
|
||||
expect(docs[0].approvedVersion).toBe(1)
|
||||
expect(restore).toHaveBeenCalledOnce()
|
||||
expect(result).toBe(PLAN_MODE_MESSAGES.approvedWithDoc)
|
||||
})
|
||||
|
||||
it('keeps plan mode active when persistence fails', async () => {
|
||||
artifacts.savePlan.mockRejectedValueOnce(new Error('quota'))
|
||||
const toolCallbacks = await propose('# Fails\n\nRetry later.')
|
||||
const result = await controller.exitTool.fn({
|
||||
args: { summary: '# Fails\n\nRetry later.' },
|
||||
workspace: 'w',
|
||||
helpers: {},
|
||||
toolCallbacks,
|
||||
toolId: 'exit-1'
|
||||
})
|
||||
|
||||
expect(active).toBe(true)
|
||||
expect(restore).not.toHaveBeenCalled()
|
||||
expect(result).toBe(PLAN_MODE_MESSAGES.persistenceFailed)
|
||||
})
|
||||
|
||||
it('leaves a card that outlived its failed save something to resolve it', async () => {
|
||||
// The write settles while the card is still waiting to be confirmed. Reporting the
|
||||
// failure onto it there would strip the confirmation the tool call is blocked on, and
|
||||
// nothing else ever resolves that — the turn would hang with no control left to click.
|
||||
artifacts.savePlan.mockRejectedValueOnce(new Error('quota'))
|
||||
// The card is the only thing that resolves the confirmation, and it offers the choice
|
||||
// for exactly as long as its status asks for one — so the click can only land while it
|
||||
// is still asking. That coupling is what makes clearing the card mid-wait fatal.
|
||||
let asking = false
|
||||
const turn = processToolCall({
|
||||
tools: [controller.exitTool],
|
||||
toolCall: {
|
||||
id: 'exit-1',
|
||||
type: 'function',
|
||||
function: { name: 'exit_plan_mode', arguments: JSON.stringify({ summary: '# P\n\nGo.' }) }
|
||||
} as any,
|
||||
helpers: {},
|
||||
workspace: 'w',
|
||||
toolCallbacks: {
|
||||
setToolStatus: (_id: string, status: any) => {
|
||||
if ('needsConfirmation' in status) asking = status.needsConfirmation
|
||||
},
|
||||
removeToolStatus: vi.fn(),
|
||||
requestConfirmation: () =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
const click = () => (asking ? resolve(true) : setTimeout(click, 5))
|
||||
setTimeout(click, 5)
|
||||
}),
|
||||
isPlanModeActive: () => active,
|
||||
shouldAutoAcceptToolConfirmations: () => false
|
||||
} as any
|
||||
})
|
||||
|
||||
const settled = await Promise.race([
|
||||
turn,
|
||||
new Promise((resolve) => setTimeout(() => resolve('HUNG'), 200))
|
||||
])
|
||||
expect(settled).toMatchObject({ content: PLAN_MODE_MESSAGES.persistenceFailed })
|
||||
expect(active).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses a second hand-over from the batch that already ended plan mode', async () => {
|
||||
// One response can carry two exit_plan_mode calls, and the tool list they run against is
|
||||
// snapshotted before the first one restores the posture. Under YOLO nothing asks, so the
|
||||
// stale call would write its own summary and stamp the user's approval on a plan no card
|
||||
// ever showed them.
|
||||
const frozenTools = [controller.exitTool]
|
||||
const handOver = (summary: string, id: string) =>
|
||||
processToolCall({
|
||||
tools: frozenTools,
|
||||
toolCall: {
|
||||
id,
|
||||
type: 'function',
|
||||
function: { name: 'exit_plan_mode', arguments: JSON.stringify({ summary }) }
|
||||
} as any,
|
||||
helpers: {},
|
||||
workspace: 'w',
|
||||
toolCallbacks: {
|
||||
setToolStatus: vi.fn(),
|
||||
removeToolStatus: vi.fn(),
|
||||
requestConfirmation: () => Promise.resolve(true),
|
||||
isPlanModeActive: () => active,
|
||||
// YOLO: every confirmation is answered for the user.
|
||||
shouldAutoAcceptToolConfirmations: () => true
|
||||
} as any
|
||||
})
|
||||
|
||||
expect(await handOver('# Agreed\n\nGo.', 'exit-1')).toMatchObject({
|
||||
content: PLAN_MODE_MESSAGES.approvedWithDoc
|
||||
})
|
||||
expect(active).toBe(false)
|
||||
|
||||
expect(await handOver('# Something else\n\nGo.', 'exit-2')).toMatchObject({
|
||||
content: PLAN_MODE_MESSAGES.ended
|
||||
})
|
||||
expect(docs[0]).toMatchObject({ content: '# Agreed\n\nGo.', version: 1, approvedVersion: 1 })
|
||||
})
|
||||
|
||||
it('files a plan under the session that proposed it, not one swapped in mid-save', async () => {
|
||||
let released: (() => void) | undefined
|
||||
artifacts.savePlan.mockImplementationOnce(async (sessionId: string, revision: any) => {
|
||||
await new Promise<void>((resolve) => (released = resolve))
|
||||
docs = [{ id: `plan:${sessionId}`, sessionId, name: revision.name, version: 1 } as Doc]
|
||||
return docs[0]
|
||||
})
|
||||
const toolCallbacks = callbacks()
|
||||
controller.exitTool.onConfirmationRequested?.({
|
||||
args: { summary: '# Mine\n\nGo.' },
|
||||
toolCallbacks,
|
||||
toolId: 'exit-1'
|
||||
})
|
||||
await vi.waitFor(() => expect(released).toBeDefined())
|
||||
sessionId = 'session-2'
|
||||
released?.()
|
||||
await controller.pendingSave
|
||||
|
||||
// The write is the proposing session's either way, but the other session must not have
|
||||
// it appear in its preview or on a card linking a document it cannot show.
|
||||
expect(docs[0].sessionId).toBe('session-1')
|
||||
expect(openArtifact).not.toHaveBeenCalled()
|
||||
expect(toolCallbacks.setToolStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('registers only the transition available in the current posture', () => {
|
||||
expect(controller.tools.map((tool) => tool.def.function.name)).toEqual(['exit_plan_mode'])
|
||||
active = false
|
||||
expect(controller.tools.map((tool) => tool.def.function.name)).toEqual(['enter_plan_mode'])
|
||||
autoAccepting = true
|
||||
expect(controller.tools).toEqual([])
|
||||
})
|
||||
|
||||
it('an old approval cannot end a newly entered round', async () => {
|
||||
await propose('# Old\n\nOld round.')
|
||||
const approval = controller.exitTool.fn({
|
||||
args: { summary: '# Old\n\nOld round.' },
|
||||
workspace: 'w',
|
||||
helpers: {},
|
||||
toolCallbacks: callbacks(),
|
||||
toolId: 'exit-1'
|
||||
})
|
||||
controller.startRound()
|
||||
await approval
|
||||
expect(active).toBe(true)
|
||||
expect(restore).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
// Apart from the artifact size limit, import-free on purpose: shared.ts reads the gate's two
|
||||
// refusals at module scope, so this has to stay outside the graph its shallow-import rule
|
||||
// guards. artifactLimits imports nothing at all, for the same reason.
|
||||
import { MAX_ARTIFACT_BYTES } from './artifacts/artifactLimits'
|
||||
|
||||
/** Plan mode's prose, in one place. The user-facing and model-facing strings for one event
|
||||
* are deliberately distinct: one is an instruction, the other a refusal. */
|
||||
export const PLAN_MODE_MESSAGES = {
|
||||
blockedLabel: 'Blocked in plan mode',
|
||||
blockedResult:
|
||||
'Blocked: plan mode is active. Put this change in your plan; call exit_plan_mode when ready for approval.',
|
||||
/** Sits beside the autonomy picker while plan mode holds. The picker's tooltip carries
|
||||
* the rest, so this states only the constraint. */
|
||||
modeNote: 'Read-only',
|
||||
entered: 'Plan mode active.',
|
||||
approvedWithDoc: 'Plan approved and saved as a document. You may now execute it.',
|
||||
persistenceFailed:
|
||||
'Plan approval could not be saved. Plan mode remains active. Retry after artifact persistence is available.',
|
||||
enterPrompt:
|
||||
'Switch to plan mode? The assistant will research and draft a plan for your approval before changing anything.',
|
||||
exitPrompt: 'Ready to execute this plan?',
|
||||
enterDeclined:
|
||||
'The user declined plan mode. Continue with the task directly, requesting confirmation on changes as usual.',
|
||||
// Each pairs the row the user reads with the steer the model needs, which is far too
|
||||
// long to be that row.
|
||||
missingSummaryLabel: 'No plan to approve',
|
||||
missingSummary:
|
||||
'exit_plan_mode needs a non-empty `summary` holding the full plan — there is nothing to approve without it. Call it again with the plan as `summary`.',
|
||||
endedLabel: 'Plan already handed over',
|
||||
ended:
|
||||
"Plan mode has already ended — this hand-over was made and the posture is back to the user's own. There is nothing left to approve, so this call was refused rather than stamping the user's approval on a plan they never saw. To change the plan document now, rewrite it with update_artifact.",
|
||||
oversizedPlanLabel: 'Plan too large to save',
|
||||
// How far over decides whether the model trims or rewrites, and it cannot count bytes.
|
||||
oversizedPlan: (bytes: number) =>
|
||||
`The plan is too large to save (${bytes} bytes, limit ${MAX_ARTIFACT_BYTES}) and was not shown to the user. Cut it to the decisions and the steps — name the files you will touch instead of quoting them — and call exit_plan_mode again.`,
|
||||
// What the version picker shows for a revision the model did not label.
|
||||
revisionNote: 'Revised the plan',
|
||||
// States the outcome, not a decision: pressing Stop and moving the autonomy picker out of
|
||||
// plan mode both land here too, and this text persists in the transcript — asserting a
|
||||
// rejection would open the next turn interrogating a user who never made one.
|
||||
exitDeclined:
|
||||
'This plan was not approved. Stop here and hand the turn back to them: do not execute it, do not re-propose it, ' +
|
||||
'and do not start another round of research. Ask in one or two sentences what they want changed, and wait for their ' +
|
||||
'answer — they may have turned the plan down, interrupted you, or simply left plan mode, so do not assume which. ' +
|
||||
'Once you understand what they want, revise and propose again.'
|
||||
} as const
|
||||
@@ -446,6 +446,7 @@ export interface ScriptChatHelpers {
|
||||
|
||||
export const resourceTypeTool: Tool<ScriptChatHelpers> = {
|
||||
def: RESOURCE_TYPE_FUNCTION_DEF,
|
||||
planModeSafe: true,
|
||||
fn: async ({ args, workspace, helpers, toolCallbacks, toolId }) => {
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: 'Searching resource types for "' + args.query + '"...'
|
||||
@@ -471,6 +472,10 @@ export function createDbSchemaTool<T>(
|
||||
function: { ...DB_SCHEMA_FUNCTION_DEF.function, description }
|
||||
}
|
||||
: DB_SCHEMA_FUNCTION_DEF,
|
||||
// The one safe tool that starts a job: the job runs a fixed introspection query this
|
||||
// codebase authors, never the user's code, and writes nothing. Planning a change to a
|
||||
// database is not possible without its schema.
|
||||
planModeSafe: true,
|
||||
fn: async ({ args, workspace, toolCallbacks, toolId }) => {
|
||||
if (!args.resourcePath) {
|
||||
throw new Error('Database path not provided')
|
||||
@@ -604,6 +609,7 @@ const SEARCH_NPM_PACKAGES_TOOL: ChatCompletionFunctionTool = {
|
||||
// Helpers-agnostic so both script mode and global mode can offer it.
|
||||
export const searchNpmPackagesTool: Tool<{}> = {
|
||||
def: SEARCH_NPM_PACKAGES_TOOL,
|
||||
planModeSafe: true,
|
||||
fn: async ({ args, toolId, toolCallbacks }) => {
|
||||
toolCallbacks.setToolStatus(toolId, { content: 'Searching for relevant packages...' })
|
||||
const result = await searchExternalIntegrationResources(args)
|
||||
@@ -927,6 +933,7 @@ export const testRunScriptTool: Tool<ScriptChatHelpers> = {
|
||||
|
||||
export const getLintErrorsTool: Tool<ScriptChatHelpers> = {
|
||||
def: GET_LINT_ERRORS_TOOL,
|
||||
planModeSafe: true,
|
||||
fn: async function ({ helpers, toolCallbacks, toolId }) {
|
||||
toolCallbacks.setToolStatus(toolId, { content: 'Getting lint errors...' })
|
||||
|
||||
|
||||
@@ -318,7 +318,7 @@ describe('processToolCall', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(requestConfirmation).toHaveBeenCalledWith('call_2')
|
||||
expect(requestConfirmation).toHaveBeenCalledWith('call_2', 'create_schedule')
|
||||
expect(fn).toHaveBeenCalled()
|
||||
expect(setToolStatus).toHaveBeenCalledWith(
|
||||
'call_2',
|
||||
@@ -904,6 +904,172 @@ describe('processToolCall', () => {
|
||||
})
|
||||
})
|
||||
|
||||
async function runToolCall(
|
||||
tool: Partial<import('./shared').Tool<any>> & {
|
||||
def: import('./shared').Tool<any>['def']
|
||||
fn: import('./shared').Tool<any>['fn']
|
||||
},
|
||||
toolCallbacks: Partial<import('./shared').ToolCallbacks>,
|
||||
args: Record<string, unknown> = {}
|
||||
) {
|
||||
const { processToolCall } = await import('./shared')
|
||||
return processToolCall({
|
||||
tools: [tool as import('./shared').Tool<any>],
|
||||
toolCall: {
|
||||
id: 'call_plan',
|
||||
type: 'function',
|
||||
function: { name: tool.def.function.name, arguments: JSON.stringify(args) }
|
||||
},
|
||||
helpers: {},
|
||||
workspace: 'test-workspace',
|
||||
toolCallbacks: {
|
||||
setToolStatus: vi.fn(),
|
||||
removeToolStatus: vi.fn(),
|
||||
...toolCallbacks
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('processToolCall plan-mode gate', () => {
|
||||
it('blocks an untagged tool while plan mode is active', async () => {
|
||||
const { createToolDef } = await import('./shared')
|
||||
const fn = vi.fn().mockResolvedValue('ran')
|
||||
const onToolBlockedByPlanMode = vi.fn()
|
||||
|
||||
const result = await runToolCall(
|
||||
{ def: createToolDef(z.object({}), 'write_script', 'Write script'), fn },
|
||||
{ isPlanModeActive: () => true, onToolBlockedByPlanMode }
|
||||
)
|
||||
|
||||
expect(fn).not.toHaveBeenCalled()
|
||||
expect(onToolBlockedByPlanMode).toHaveBeenCalledOnce()
|
||||
expect(result.content).toContain('plan mode is active')
|
||||
})
|
||||
|
||||
it('blocks a mutating tool before its own validator gets to run', async () => {
|
||||
const { createToolDef } = await import('./shared')
|
||||
const fn = vi.fn().mockResolvedValue('ran')
|
||||
const validateBeforeConfirmation = vi.fn().mockResolvedValue('target is undeployed')
|
||||
const onToolBlockedByPlanMode = vi.fn()
|
||||
|
||||
const result = await runToolCall(
|
||||
{
|
||||
def: createToolDef(z.object({}), 'write_script', 'Write script'),
|
||||
validateBeforeConfirmation,
|
||||
fn
|
||||
},
|
||||
{ isPlanModeActive: () => true, onToolBlockedByPlanMode }
|
||||
)
|
||||
|
||||
expect(validateBeforeConfirmation).not.toHaveBeenCalled()
|
||||
expect(onToolBlockedByPlanMode).toHaveBeenCalledOnce()
|
||||
expect(result.content).toContain('plan mode is active')
|
||||
})
|
||||
|
||||
it('allows a plan-mode-safe tool while plan mode is active', async () => {
|
||||
const { createToolDef } = await import('./shared')
|
||||
const fn = vi.fn().mockResolvedValue('ok')
|
||||
|
||||
const result = await runToolCall(
|
||||
{ def: createToolDef(z.object({}), 'read_file', 'Read file'), planModeSafe: true, fn },
|
||||
{ isPlanModeActive: () => true }
|
||||
)
|
||||
|
||||
expect(fn).toHaveBeenCalled()
|
||||
expect(result.content).toBe('ok')
|
||||
})
|
||||
|
||||
it('runs an untagged tool normally when plan mode is inactive', async () => {
|
||||
const { createToolDef } = await import('./shared')
|
||||
const fn = vi.fn().mockResolvedValue('ran')
|
||||
|
||||
const result = await runToolCall(
|
||||
{ def: createToolDef(z.object({}), 'write_script', 'Write script'), fn },
|
||||
{ isPlanModeActive: () => false }
|
||||
)
|
||||
|
||||
expect(fn).toHaveBeenCalled()
|
||||
expect(result.content).toBe('ran')
|
||||
})
|
||||
|
||||
it('does not block an unknown tool name — falls through to the unknown-tool error', async () => {
|
||||
const { processToolCall } = await import('./shared')
|
||||
const result = await processToolCall({
|
||||
tools: [],
|
||||
toolCall: {
|
||||
id: 'call_unknown',
|
||||
type: 'function',
|
||||
function: { name: 'made_up_tool', arguments: '{}' }
|
||||
},
|
||||
helpers: {},
|
||||
workspace: 'test-workspace',
|
||||
toolCallbacks: {
|
||||
setToolStatus: vi.fn(),
|
||||
removeToolStatus: vi.fn(),
|
||||
isPlanModeActive: () => true
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.content).not.toContain('plan mode is active')
|
||||
expect(result.content).toContain('Unknown tool call')
|
||||
})
|
||||
|
||||
it('returns the tool cancellationMessage when the user rejects the confirmation', async () => {
|
||||
const { createToolDef } = await import('./shared')
|
||||
const fn = vi.fn()
|
||||
const setToolStatus = vi.fn()
|
||||
|
||||
const result = await runToolCall(
|
||||
{
|
||||
def: createToolDef(z.object({ summary: z.string() }), 'exit_plan_mode', 'Exit plan mode'),
|
||||
planModeSafe: true,
|
||||
requiresConfirmation: true,
|
||||
cancellationMessage: 'keep planning',
|
||||
fn
|
||||
},
|
||||
{
|
||||
isPlanModeActive: () => true,
|
||||
requestConfirmation: vi.fn().mockResolvedValue(false),
|
||||
setToolStatus
|
||||
}
|
||||
)
|
||||
|
||||
expect(fn).not.toHaveBeenCalled()
|
||||
expect(result.content).toBe('keep planning')
|
||||
// The one place a decline is recorded: planCardState reads it to tell a plan the
|
||||
// user turned down apart from a call that merely errored.
|
||||
expect(setToolStatus).toHaveBeenCalledWith(
|
||||
'call_plan',
|
||||
expect.objectContaining({ declinedByUser: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('blocks a mutating tool if plan mode is entered while its confirmation is pending', async () => {
|
||||
const { createToolDef } = await import('./shared')
|
||||
const fn = vi.fn().mockResolvedValue('ran')
|
||||
let planActive = false
|
||||
// The user switches into plan mode while the confirmation card is open, then
|
||||
// approves it: requestConfirmation flips the posture, then resolves true.
|
||||
const requestConfirmation = vi.fn().mockImplementation(async () => {
|
||||
planActive = true
|
||||
return true
|
||||
})
|
||||
|
||||
const result = await runToolCall(
|
||||
{
|
||||
def: createToolDef(z.object({}), 'write_script', 'Write script'),
|
||||
requiresConfirmation: true,
|
||||
fn
|
||||
},
|
||||
{ isPlanModeActive: () => planActive, requestConfirmation }
|
||||
)
|
||||
|
||||
expect(requestConfirmation).toHaveBeenCalled()
|
||||
expect(fn).not.toHaveBeenCalled()
|
||||
expect(result.content).toContain('plan mode is active')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isActiveUserQuestion', () => {
|
||||
function toolMessage(overrides: Partial<ToolDisplayMessage> = {}): ToolDisplayMessage {
|
||||
return {
|
||||
@@ -1402,3 +1568,45 @@ describe('createSearchHubScriptsTool', () => {
|
||||
expect(results[1].content).toBe('ok')
|
||||
})
|
||||
})
|
||||
|
||||
describe('processToolCall confirmation hooks', () => {
|
||||
async function hookedTool() {
|
||||
const { createToolDef } = await import('./shared')
|
||||
return {
|
||||
def: createToolDef(z.object({}), 'apply_change', 'Apply change'),
|
||||
requiresConfirmation: true,
|
||||
fn: vi.fn().mockResolvedValue('ok'),
|
||||
onConfirmationRequested: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
it('requests before the card resolves', async () => {
|
||||
const tool = await hookedTool()
|
||||
let requestedBeforeResolve = false
|
||||
const requestConfirmation = vi.fn(async () => {
|
||||
requestedBeforeResolve = tool.onConfirmationRequested.mock.calls.length === 1
|
||||
return false
|
||||
})
|
||||
|
||||
await runToolCall(tool, { requestConfirmation })
|
||||
|
||||
expect(requestedBeforeResolve).toBe(true)
|
||||
// The hook needs the id and callbacks to attach what it sets up to this card.
|
||||
expect(tool.onConfirmationRequested).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ toolId: 'call_plan', toolCallbacks: expect.any(Object) })
|
||||
)
|
||||
expect(tool.fn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fires no hook when the confirmation is auto-accepted', async () => {
|
||||
const tool = await hookedTool()
|
||||
|
||||
await runToolCall(tool, {
|
||||
requestConfirmation: vi.fn(async () => true),
|
||||
shouldAutoAcceptToolConfirmations: () => true
|
||||
})
|
||||
|
||||
expect(tool.onConfirmationRequested).not.toHaveBeenCalled()
|
||||
expect(tool.fn).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,9 @@ import type {
|
||||
ChatCompletionMessageParam
|
||||
} from 'openai/resources/chat/completions.mjs'
|
||||
import type { UserDraftItemKind } from '$lib/gen'
|
||||
// The gate's two refusals, from a module that holds prose and one size limit: under the
|
||||
// shallow-import rule below, the rest of plan mode is not reachable from here.
|
||||
import { PLAN_MODE_MESSAGES } from './planModeMessages'
|
||||
|
||||
// The tool modules that import this one (workspaceTools, flow/core, global/core, ...)
|
||||
// call createToolDef and read SPECIAL_MODULE_IDS at *module scope*, so if a chunk cycle
|
||||
@@ -600,6 +603,16 @@ export type ToolDisplayMessage = {
|
||||
* always-visible card that opens (or focuses) the item's preview in the
|
||||
* session side panel. Set only for session chats — the side panel is their surface. */
|
||||
previewCard?: { kind: PreviewCardKind; path: string }
|
||||
planArtifactId?: string
|
||||
/** The version this card's proposal wrote, so a card scrolled far up still opens the plan
|
||||
* it proposed rather than what the document became. */
|
||||
planVersion?: number
|
||||
/** Refused by the plan-mode gate. Renders as its own lean row rather than a tool
|
||||
* error, so the transcript says the mode stopped it and not that the call failed. */
|
||||
blockedByPlanMode?: boolean
|
||||
/** The user declined: the reject button, a Stop, or a posture switch. Set only there, so
|
||||
* a decision is distinguishable from every other way a call errors. */
|
||||
declinedByUser?: boolean
|
||||
}
|
||||
|
||||
export type AssistantDisplayMessage = BaseDisplayMessage & {
|
||||
@@ -720,17 +733,24 @@ async function callTool<T>({
|
||||
|
||||
type MaybePromise<T> = T | Promise<T>
|
||||
|
||||
/** A refused tool call: the row the user reads, and the result the model gets. A bare string
|
||||
* is both at once. */
|
||||
export type ToolRejection = string | { label: string; result: string }
|
||||
|
||||
function normalizeToolRejection(
|
||||
rejection: ToolRejection | undefined
|
||||
): { label: string; result: string } | undefined {
|
||||
if (rejection === undefined) return undefined
|
||||
return typeof rejection === 'string' ? { label: rejection, result: rejection } : rejection
|
||||
}
|
||||
|
||||
/**
|
||||
* Key paths present in `supplied` that a strip-mode parse discarded. Sub-fields of a
|
||||
* schedule's `retry` are all optional, so a guessed shape validates clean, loses the
|
||||
* misspelled keys and saves a policy that does nothing. Recursive because dropping one
|
||||
* nested key leaves the parent non-empty.
|
||||
*/
|
||||
export function droppedOptionKeys(
|
||||
supplied: unknown,
|
||||
parsed: unknown,
|
||||
prefix = ''
|
||||
): string[] {
|
||||
export function droppedOptionKeys(supplied: unknown, parsed: unknown, prefix = ''): string[] {
|
||||
if (supplied === null || typeof supplied !== 'object' || Array.isArray(supplied)) {
|
||||
return parsed === undefined && prefix ? [prefix] : []
|
||||
}
|
||||
@@ -738,7 +758,11 @@ export function droppedOptionKeys(
|
||||
return Object.keys(supplied).length && prefix ? [prefix] : []
|
||||
}
|
||||
return Object.entries(supplied).flatMap(([key, value]) =>
|
||||
droppedOptionKeys(value, (parsed as Record<string, unknown>)[key], prefix ? `${prefix}.${key}` : key)
|
||||
droppedOptionKeys(
|
||||
value,
|
||||
(parsed as Record<string, unknown>)[key],
|
||||
prefix ? `${prefix}.${key}` : key
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -791,19 +815,22 @@ export async function processToolCall<T>({
|
||||
const tool = tools.find((t) => t.def.function.name === toolCall.function.name)
|
||||
const workspaceId = workspace ?? get(workspaceStore) ?? ''
|
||||
|
||||
const validationError = await tool?.validateBeforeConfirmation?.({
|
||||
args,
|
||||
workspace: workspaceId,
|
||||
helpers
|
||||
})
|
||||
if (validationError) {
|
||||
// Fails closed: untagged is blocked, only the safety tag exempt. Runs before anything
|
||||
// belonging to the tool, so a validator cannot probe while planning — and again after
|
||||
// the confirmation wait, since plan mode can be entered while a card is pending.
|
||||
const planModeBlock = (): ChatCompletionMessageParam | undefined => {
|
||||
if (!toolCallbacks.isPlanModeActive?.() || !tool || tool.planModeSafe === true) {
|
||||
return undefined
|
||||
}
|
||||
toolCallbacks.onToolBlockedByPlanMode?.()
|
||||
toolCallbacks.setToolStatus(toolCall.id, {
|
||||
content: validationError,
|
||||
content: PLAN_MODE_MESSAGES.blockedLabel,
|
||||
parameters: args,
|
||||
isLoading: false,
|
||||
isQueued: false,
|
||||
isStreamingArguments: false,
|
||||
error: validationError,
|
||||
error: PLAN_MODE_MESSAGES.blockedResult,
|
||||
blockedByPlanMode: true,
|
||||
needsConfirmation: false,
|
||||
showDetails: tool?.showDetails,
|
||||
autoCollapseDetails: tool?.autoCollapseDetails
|
||||
@@ -811,14 +838,43 @@ export async function processToolCall<T>({
|
||||
return {
|
||||
role: 'tool' as const,
|
||||
tool_call_id: toolCall.id,
|
||||
content: validationError
|
||||
content: PLAN_MODE_MESSAGES.blockedResult
|
||||
}
|
||||
}
|
||||
|
||||
const preConfirmationBlock = planModeBlock()
|
||||
if (preConfirmationBlock) {
|
||||
return preConfirmationBlock
|
||||
}
|
||||
|
||||
const rejection = normalizeToolRejection(
|
||||
await tool?.validateBeforeConfirmation?.({ args, workspace: workspaceId, helpers })
|
||||
)
|
||||
if (rejection) {
|
||||
toolCallbacks.setToolStatus(toolCall.id, {
|
||||
content: rejection.label,
|
||||
parameters: args,
|
||||
isLoading: false,
|
||||
isQueued: false,
|
||||
isStreamingArguments: false,
|
||||
error: rejection.label,
|
||||
needsConfirmation: false,
|
||||
showDetails: tool?.showDetails,
|
||||
autoCollapseDetails: tool?.autoCollapseDetails
|
||||
})
|
||||
return {
|
||||
role: 'tool' as const,
|
||||
tool_call_id: toolCall.id,
|
||||
content: rejection.result
|
||||
}
|
||||
}
|
||||
|
||||
// Check if tool requires confirmation
|
||||
const requiresConfirmation = tool?.requiresConfirmation === true
|
||||
// By name: skipping the wait is itself an answer on the user's behalf, and one tool
|
||||
// must not be answered for.
|
||||
const autoAcceptConfirmation =
|
||||
requiresConfirmation && toolCallbacks.shouldAutoAcceptToolConfirmations?.() === true
|
||||
requiresConfirmation &&
|
||||
toolCallbacks.shouldAutoAcceptToolConfirmations?.(toolCall.function.name) === true
|
||||
const needsConfirmation = requiresConfirmation && !autoAcceptConfirmation
|
||||
|
||||
const confirmationContent =
|
||||
@@ -845,7 +901,8 @@ export async function processToolCall<T>({
|
||||
|
||||
// If confirmation is needed and we have the callback, wait for it
|
||||
if (needsConfirmation && toolCallbacks.requestConfirmation) {
|
||||
const confirmed = await toolCallbacks.requestConfirmation(toolCall.id)
|
||||
tool?.onConfirmationRequested?.({ args, toolCallbacks, toolId: toolCall.id })
|
||||
const confirmed = await toolCallbacks.requestConfirmation(toolCall.id, toolCall.function.name)
|
||||
|
||||
if (!confirmed) {
|
||||
toolCallbacks.setToolStatus(toolCall.id, {
|
||||
@@ -853,15 +910,21 @@ export async function processToolCall<T>({
|
||||
isLoading: false,
|
||||
isStreamingArguments: false,
|
||||
error: 'Tool execution was cancelled by user',
|
||||
declinedByUser: true,
|
||||
needsConfirmation: false
|
||||
})
|
||||
return {
|
||||
role: 'tool' as const,
|
||||
tool_call_id: toolCall.id,
|
||||
content: 'Tool execution was cancelled by user'
|
||||
content: tool?.cancellationMessage ?? 'Tool execution was cancelled by user'
|
||||
}
|
||||
}
|
||||
|
||||
const postConfirmationBlock = planModeBlock()
|
||||
if (postConfirmationBlock) {
|
||||
return postConfirmationBlock
|
||||
}
|
||||
|
||||
// Update status to executing after confirmation
|
||||
toolCallbacks.setToolStatus(toolCall.id, {
|
||||
isLoading: true,
|
||||
@@ -958,16 +1021,27 @@ export interface Tool<T> {
|
||||
toolId: string
|
||||
}) => Promise<string>
|
||||
preAction?: (p: { toolCallbacks: ToolCallbacks; toolId: string }) => void
|
||||
/** Refuse the call before any confirmation is offered. A bare string is both the row the
|
||||
* user reads and the result the model gets; return the pair when the model needs a steer
|
||||
* too long to be a transcript row. */
|
||||
validateBeforeConfirmation?: (p: {
|
||||
args: any
|
||||
workspace: string
|
||||
helpers: T
|
||||
}) => MaybePromise<string | undefined>
|
||||
}) => MaybePromise<ToolRejection | undefined>
|
||||
setSchema?: (helpers: any) => Promise<void>
|
||||
/** Safe to run while plan mode is active. Absence fails closed. */
|
||||
planModeSafe?: boolean
|
||||
requiresConfirmation?: boolean
|
||||
/** Header shown on the confirmation card before the tool runs. Pass a function
|
||||
* to derive it from the parsed arguments (e.g. name the script being tested). */
|
||||
confirmationMessage?: string | ((args: any) => string)
|
||||
/** Only when a card gates the call, so `fn` must not rely on it. Not awaited, so it may
|
||||
* not throw, and must be safe for a call the user then declines. */
|
||||
onConfirmationRequested?: (p: { args: any; toolCallbacks: ToolCallbacks; toolId: string }) => void
|
||||
/** Model-facing result returned when the user rejects the confirmation; defaults
|
||||
* to a generic cancellation. */
|
||||
cancellationMessage?: string
|
||||
showDetails?: boolean
|
||||
autoCollapseDetails?: boolean
|
||||
streamArguments?: boolean
|
||||
@@ -1111,8 +1185,10 @@ export interface ToolCallbacks {
|
||||
/** Fired when the model starts reasoning — drives a "Thinking" indicator even when
|
||||
* no summary text is returned (e.g. OpenAI reasoning models). */
|
||||
onReasoningStart?: () => void
|
||||
requestConfirmation?: (toolId: string) => Promise<boolean>
|
||||
shouldAutoAcceptToolConfirmations?: () => boolean
|
||||
requestConfirmation?: (toolId: string, toolName?: string) => Promise<boolean>
|
||||
shouldAutoAcceptToolConfirmations?: (toolName?: string) => boolean
|
||||
isPlanModeActive?: () => boolean
|
||||
onToolBlockedByPlanMode?: () => void
|
||||
requestUserQuestion?: (
|
||||
toolId: string,
|
||||
question: UserQuestionDisplay
|
||||
@@ -1236,6 +1312,7 @@ export function isHubPath(path: string): boolean {
|
||||
|
||||
export const createSearchHubScriptsTool = (withContent: boolean = false) => ({
|
||||
def: searchHubScriptsToolDef,
|
||||
planModeSafe: true,
|
||||
fn: async ({ args, toolId, toolCallbacks }) => {
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: 'Searching for hub scripts related to "' + args.query + '"...'
|
||||
@@ -2085,6 +2162,7 @@ export const workspaceRunnablesSearch = new WorkspaceRunnablesSearch()
|
||||
|
||||
export const createSearchWorkspaceTool = () => ({
|
||||
def: searchWorkspaceToolDef,
|
||||
planModeSafe: true,
|
||||
fn: async ({
|
||||
args,
|
||||
workspace,
|
||||
@@ -2135,6 +2213,7 @@ const getRunnableDetailsToolDef = createToolDef(
|
||||
|
||||
export const createGetRunnableDetailsTool = () => ({
|
||||
def: getRunnableDetailsToolDef,
|
||||
planModeSafe: true,
|
||||
fn: async ({
|
||||
args,
|
||||
workspace,
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
row,
|
||||
actions,
|
||||
footer,
|
||||
separatorAfter,
|
||||
customTrigger,
|
||||
ariaLabel,
|
||||
triggerClass = TOKEN_TRIGGER_CLASS,
|
||||
@@ -43,6 +44,9 @@
|
||||
/** Pinned below the scrolling list; give it `data-status-row` to join the
|
||||
* arrow-key order as the last stop. */
|
||||
footer?: Snippet
|
||||
/** Closes a pinned group after this row, as a pane edge rather than a hairline —
|
||||
* rows are otherwise unruled, so a 1px line would read as a row border. */
|
||||
separatorAfter?: (item: T, index: number) => boolean
|
||||
/** Replaces the default SessionStatusToken trigger. */
|
||||
customTrigger?: Snippet
|
||||
ariaLabel?: string
|
||||
@@ -118,7 +122,7 @@
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions (keydown only routes arrows to the row buttons) -->
|
||||
<div class="flex min-h-0 flex-col" bind:this={listRoot} onkeydown={handleListKeydown}>
|
||||
<div role="list" class="{maxHeightClass} overflow-y-auto py-1">
|
||||
{#each items as item (itemKey(item))}
|
||||
{#each items as item, index (itemKey(item))}
|
||||
<div
|
||||
class="flex items-center gap-2 py-1 pl-3 pr-2 hover:bg-surface-hover focus-within:bg-surface-hover"
|
||||
role="listitem"
|
||||
@@ -138,6 +142,12 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if separatorAfter?.(item, index)}
|
||||
<!-- Full-bleed and outside the row: a divider inside it would land in the
|
||||
row's button and take its hover. role=presentation keeps the list's
|
||||
children listitems for assistive tech. -->
|
||||
<div class="my-1 border-b-2 border-border-light" role="presentation"></div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{#if footer}
|
||||
|
||||
@@ -52,13 +52,21 @@ import { normalizePipelineFolder } from '$lib/utils/pipelineFolder'
|
||||
import type { WorkspaceItem } from '$lib/components/workspacePicker'
|
||||
import type { SessionTargetKind } from './sessionRuntime.svelte'
|
||||
|
||||
/**
|
||||
* Which version of an artifact an opener wants on screen: a number pins that one, `'latest'`
|
||||
* drops any pin, and omitting it leaves the reader where they are. `undefined` cannot double
|
||||
* as `'latest'` — every artifact tool re-opens the document it just wrote, so treating that
|
||||
* as a request to move would yank a reader out of the version they chose on each edit.
|
||||
*/
|
||||
export type ArtifactVersionTarget = number | 'latest'
|
||||
|
||||
/** What the preview breadcrumb picker can route to: a static workspace page
|
||||
* or a workspace item (script/flow/app). The sessions page turns either into
|
||||
* an iframe URL. */
|
||||
export type PreviewTarget =
|
||||
| { type: 'page'; href: string; label: string }
|
||||
| { type: 'item'; item: WorkspaceItem }
|
||||
| { type: 'artifact'; id: string; name: string }
|
||||
| { type: 'artifact'; id: string; name: string; version?: ArtifactVersionTarget }
|
||||
|
||||
export type PreviewPage = { label: string; path: string; icon: DrillIcon }
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
previewLocationLabel,
|
||||
resolvePreviewTab,
|
||||
stripBase,
|
||||
type ArtifactVersionTarget,
|
||||
type PreviewTarget
|
||||
} from './previewRouter'
|
||||
import type { SessionPreviewTab, SessionTarget } from './sessionState.svelte'
|
||||
@@ -56,13 +57,19 @@ function isEditorTabFor(url: string, target: SessionTarget): boolean {
|
||||
return slot.kind === 'editor' && slot.editorKind === target.kind && slot.path === target.path
|
||||
}
|
||||
|
||||
// The version a tab keeps when it is re-pointed: whatever pin is already on it. Re-pointing
|
||||
// must never double as "show the newest" — every artifact tool re-opens the document it just
|
||||
// wrote, so that would yank the reader out of the version they chose on each edit. The pin
|
||||
// belongs to a (tab, artifact) pair: a different document, or a brand-new tab, starts unpinned.
|
||||
function keptVersion(artifactId: string, onto: SessionPreviewTab | undefined): number | undefined {
|
||||
// The version a tab shows when it is re-pointed: the opener's, if it named one, else whatever
|
||||
// pin is already on the tab. Re-pointing must never *silently* double as "show the newest" —
|
||||
// every artifact tool re-opens the document it just wrote, so that would yank the reader out of
|
||||
// the version they chose on each edit; an opener that does want the newest text says 'latest'.
|
||||
// The pin belongs to a (tab, artifact) pair: a different document, or a brand-new tab, starts
|
||||
// unpinned.
|
||||
function keptVersion(
|
||||
target: { id: string; version?: ArtifactVersionTarget },
|
||||
onto: SessionPreviewTab | undefined
|
||||
): number | undefined {
|
||||
if (target.version !== undefined) return target.version === 'latest' ? undefined : target.version
|
||||
const current = onto && parseArtifactRoute(onto.url)
|
||||
return current?.id === artifactId ? current.version : undefined
|
||||
return current?.id === target.id ? current.version : undefined
|
||||
}
|
||||
|
||||
// URL a tab should load for a destination: a page's href, an item's edit route, or an artifact's
|
||||
@@ -71,7 +78,7 @@ function keptVersion(artifactId: string, onto: SessionPreviewTab | undefined): n
|
||||
function targetUrl(target: PreviewTarget, onto?: SessionPreviewTab): string {
|
||||
if (target.type === 'page') return target.href
|
||||
if (target.type === 'artifact') {
|
||||
return artifactUrl(target.id, target.name, keptVersion(target.id, onto))
|
||||
return artifactUrl(target.id, target.name, keptVersion(target, onto))
|
||||
}
|
||||
return `${base}${editPathFor(target.item)}`
|
||||
}
|
||||
|
||||
@@ -473,6 +473,21 @@ describe('SessionPreviewTabs.open', () => {
|
||||
expect(o.tabs[0].url).toBe(artifactUrl('art1', 'Plan, revised'))
|
||||
})
|
||||
|
||||
it('moves the pin for an opener that names a version, and clears it for "latest"', () => {
|
||||
const o = owner()
|
||||
o.open(artifactTarget)
|
||||
o.pinArtifactVersion('art1', 1)
|
||||
|
||||
// A plan card opening the version it proposed, which the reader is not on.
|
||||
o.open({ type: 'artifact', id: 'art1', name: 'Plan', version: 2 })
|
||||
expect(o.tabs[0].url).toBe(artifactUrl('art1', 'Plan', 2))
|
||||
|
||||
// 'latest' is the intent omitting a version cannot express: a plan going up for
|
||||
// approval has to put the current text on screen even over a pin.
|
||||
o.open({ type: 'artifact', id: 'art1', name: 'Plan', version: 'latest' })
|
||||
expect(o.tabs[0].url).toBe(artifactUrl('art1', 'Plan'))
|
||||
})
|
||||
|
||||
it('opens separate tabs for different artifact ids', () => {
|
||||
const o = owner()
|
||||
o.open(artifactTarget)
|
||||
|
||||
@@ -497,8 +497,8 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
})
|
||||
}
|
||||
|
||||
manager.openArtifact = (id, name) => {
|
||||
previewTabs.open({ type: 'artifact', id, name })
|
||||
manager.openArtifact = (id, name, version) => {
|
||||
previewTabs.open({ type: 'artifact', id, name, version })
|
||||
}
|
||||
manager.closeArtifact = (id) => previewTabs.closeArtifact(id)
|
||||
// Key the store before any configureGlobalMode runs, so a new session's first create shows at once.
|
||||
|
||||
Reference in New Issue
Block a user