fix(web): reset new-conversation draft when project chat defaults are saved (#223)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c98749fbff
commit
75a8aa12c8
6 files changed
+249
-4
No files matched your search
@@ -20,7 +20,11 @@ import {
|
|||||||
} from "../../lib/semantic-id";
|
} from "../../lib/semantic-id";
|
||||||
import { agentDisplayName, projectDisplayName, useProject } from "../../state/project";
|
import { agentDisplayName, projectDisplayName, useProject } from "../../state/project";
|
||||||
import { useAuth } from "../../state/auth";
|
import { useAuth } from "../../state/auth";
|
||||||
import { clearDraftModelRef } from "../../features/chat/draft-cache";
|
import { clearDraftChatDefaults, clearDraftModelRef } from "../../features/chat/draft-cache";
|
||||||
|
import {
|
||||||
|
dispatchChatDefaultsChanged,
|
||||||
|
type ChatDefaultsChangedDetail,
|
||||||
|
} from "../../features/chat/chat-defaults-event";
|
||||||
import { ModelSelect } from "../../features/chat/model-select";
|
import { ModelSelect } from "../../features/chat/model-select";
|
||||||
import { SELECTABLE_THINKING_LEVELS } from "../../features/chat/thinking-level";
|
import { SELECTABLE_THINKING_LEVELS } from "../../features/chat/thinking-level";
|
||||||
import { WorkspaceSelect } from "../../features/chat/workspace-select";
|
import { WorkspaceSelect } from "../../features/chat/workspace-select";
|
||||||
@@ -479,10 +483,21 @@ function ChatDefaultsSection({ projectId, isOwner }: { projectId: string; isOwne
|
|||||||
* One Save persists both writes: the `[default_chat]` block (whole-block PUT — a field
|
* One Save persists both writes: the `[default_chat]` block (whole-block PUT — a field
|
||||||
* left "not set" is simply omitted, which clears it) and, when changed, the default
|
* left "not set" is simply omitted, which clears it) and, when changed, the default
|
||||||
* model via the narrow route. Failures toast and keep the edits for retry.
|
* model via the narrow route. Failures toast and keep the edits for retry.
|
||||||
|
*
|
||||||
|
* Each landed write also resets the saving user's new-conversation draft so new chats
|
||||||
|
* pick the change up instead of being shadowed by the values a previous /chat/new visit
|
||||||
|
* pinned into the cache: the corresponding cache fields are stripped (typed text and
|
||||||
|
* staged skills always survive), and one same-tab event carries the fresh values to any
|
||||||
|
* MOUNTED draft view — its component state still holds the old selections and its
|
||||||
|
* debounced persist would silently write them right back over the stripped cache.
|
||||||
*/
|
*/
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
if (busy || (!blockDirty && !modelDirty)) return;
|
if (busy || (!blockDirty && !modelDirty)) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
// Collected per landed write, dispatched in `finally`: when the block PUT lands but the
|
||||||
|
// model PUT throws, the block change still happened server-side and live views must
|
||||||
|
// still reseed from it.
|
||||||
|
let changed: ChatDefaultsChangedDetail | null = null;
|
||||||
try {
|
try {
|
||||||
if (blockDirty) {
|
if (blockDirty) {
|
||||||
const body: ChatDefaultsDto = {
|
const body: ChatDefaultsDto = {
|
||||||
@@ -491,7 +506,14 @@ function ChatDefaultsSection({ projectId, isOwner }: { projectId: string; isOwne
|
|||||||
...(approval ? { approvalMode: approval as ApprovalMode } : {}),
|
...(approval ? { approvalMode: approval as ApprovalMode } : {}),
|
||||||
...(thinking ? { thinkingLevel: thinking as ChatDefaultsDto["thinkingLevel"] } : {}),
|
...(thinking ? { thinkingLevel: thinking as ChatDefaultsDto["thinkingLevel"] } : {}),
|
||||||
};
|
};
|
||||||
setSaved(await api.putChatDefaults(projectId, body));
|
const stored = await api.putChatDefaults(projectId, body);
|
||||||
|
setSaved(stored);
|
||||||
|
// Release the draft-cached Agent / Workspace / approval pins so the next
|
||||||
|
// /chat/new seeds from the just-saved block. The model pin is deliberately NOT
|
||||||
|
// touched here: it is the switch-becomes-default carry-over, released only below
|
||||||
|
// when the default model itself changed.
|
||||||
|
if (user) clearDraftChatDefaults(user.userId, projectId);
|
||||||
|
changed = { projectId, defaults: stored };
|
||||||
}
|
}
|
||||||
if (modelDirty && modelRef) {
|
if (modelDirty && modelRef) {
|
||||||
const res = await api.putDefaultModel(projectId, {
|
const res = await api.putDefaultModel(projectId, {
|
||||||
@@ -502,10 +524,12 @@ function ChatDefaultsSection({ projectId, isOwner }: { projectId: string; isOwne
|
|||||||
// Same follow-through as the models page: drop the draft-cached model pin so open
|
// Same follow-through as the models page: drop the draft-cached model pin so open
|
||||||
// drafts pick up the new default.
|
// drafts pick up the new default.
|
||||||
if (user) clearDraftModelRef(user.userId, projectId);
|
if (user) clearDraftModelRef(user.userId, projectId);
|
||||||
|
changed = { ...(changed ?? { projectId }), defaultModel: res.defaultModel };
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toastError(apiErrorText(e));
|
toastError(apiErrorText(e));
|
||||||
} finally {
|
} finally {
|
||||||
|
if (changed) dispatchChatDefaultsChanged(changed);
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Same-tab notification that a Project's new-chat defaults were saved (project-settings
|
||||||
|
* dialog → any live view seeded from them). The dialog and the draft page are mounted by
|
||||||
|
* different trees (sidebar vs route), so a window CustomEvent is the narrowest channel: the
|
||||||
|
* dialog dispatches after its PUTs land, carrying the fresh server-confirmed values, and a
|
||||||
|
* mounted DraftView / ChatPage reseeds from the payload without refetching what the dialog
|
||||||
|
* already holds. Same-tab only by design — other tabs (and the stripped localStorage cache)
|
||||||
|
* pick the new defaults up on their next /chat/new mount.
|
||||||
|
*/
|
||||||
|
import type { ChatDefaultsDto, ModelRefDto } from "@prismshadow/penguin-server/api";
|
||||||
|
|
||||||
|
export const CHAT_DEFAULTS_CHANGED_EVENT = "penguin:chat-defaults-changed";
|
||||||
|
|
||||||
|
export interface ChatDefaultsChangedDetail {
|
||||||
|
projectId: string;
|
||||||
|
/** Present iff the `[default_chat]` block changed: the stored block the PUT returned. */
|
||||||
|
defaults?: ChatDefaultsDto;
|
||||||
|
/** Present iff the Project default model changed: the new default (paired reference). */
|
||||||
|
defaultModel?: ModelRefDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dispatchChatDefaultsChanged(detail: ChatDefaultsChangedDetail): void {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent<ChatDefaultsChangedDetail>(CHAT_DEFAULTS_CHANGED_EVENT, { detail }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Listener-side accessor: returns the detail when the event targets `projectId`, else null. */
|
||||||
|
export function chatDefaultsChangedDetail(
|
||||||
|
e: Event,
|
||||||
|
projectId: string,
|
||||||
|
): ChatDefaultsChangedDetail | null {
|
||||||
|
const detail = (e as CustomEvent<ChatDefaultsChangedDetail>).detail;
|
||||||
|
return detail && detail.projectId === projectId ? detail : null;
|
||||||
|
}
|
||||||
@@ -59,6 +59,7 @@ import { latestTaskHasSubagent, taskStartCount } from "./agent-topology";
|
|||||||
import { ChatInput } from "./chat-input";
|
import { ChatInput } from "./chat-input";
|
||||||
import { ConversationOutline, OutlineMenuButton, useOutlineRailFit } from "./conversation-outline";
|
import { ConversationOutline, OutlineMenuButton, useOutlineRailFit } from "./conversation-outline";
|
||||||
import { DraftView } from "./draft-view";
|
import { DraftView } from "./draft-view";
|
||||||
|
import { CHAT_DEFAULTS_CHANGED_EVENT, chatDefaultsChangedDetail } from "./chat-defaults-event";
|
||||||
import { advanceCostStat, applyUsageFetch, createCostStatHold } from "./header-stats";
|
import { advanceCostStat, applyUsageFetch, createCostStatHold } from "./header-stats";
|
||||||
import type { CostStatDisplay } from "./header-stats";
|
import type { CostStatDisplay } from "./header-stats";
|
||||||
import { buildInputHistory } from "./input-history";
|
import { buildInputHistory } from "./input-history";
|
||||||
@@ -605,6 +606,34 @@ export function ChatPage() {
|
|||||||
};
|
};
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
|
// Project new-chat defaults saved in this tab (project-settings dialog) with a CHANGED
|
||||||
|
// default model: refetch the model config so the "project default" marker and a later
|
||||||
|
// draft mount see the fresh default. models goes null first — DraftView's model
|
||||||
|
// fallback holds off while null, so it cannot re-pin the stale default from the old
|
||||||
|
// response in the meantime (the mounted draft's own selection comes straight from the
|
||||||
|
// event payload, see DraftView.onDefaultsChanged); a failed refetch leaves models null,
|
||||||
|
// the same degraded state as a failed mount fetch.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectId) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const onEvent = (e: Event) => {
|
||||||
|
const detail = chatDefaultsChangedDetail(e, projectId);
|
||||||
|
if (!detail || detail.defaultModel === undefined) return;
|
||||||
|
setModels(null);
|
||||||
|
api
|
||||||
|
.getModels(projectId)
|
||||||
|
.then((res) => {
|
||||||
|
if (!cancelled) setModels(res);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
};
|
||||||
|
window.addEventListener(CHAT_DEFAULTS_CHANGED_EVENT, onEvent);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.removeEventListener(CHAT_DEFAULTS_CHANGED_EVENT, onEvent);
|
||||||
|
};
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
// Self-heal: the server returned a new session_id, update the route and list (shared by tasks and compact).
|
// Self-heal: the server returned a new session_id, update the route and list (shared by tasks and compact).
|
||||||
const syncHealedSessionId = useCallback(
|
const syncHealedSessionId = useCallback(
|
||||||
async (currentId: string, respondedId: string) => {
|
async (currentId: string, respondedId: string) => {
|
||||||
|
|||||||
@@ -149,3 +149,36 @@ export function clearDraftModelRef(
|
|||||||
const draft = loadDraft(key, storage);
|
const draft = loadDraft(key, storage);
|
||||||
if (draft.modelRef) saveDraft(key, { ...draft, modelRef: undefined }, storage);
|
if (draft.modelRef) saveDraft(key, { ...draft, modelRef: undefined }, storage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops the draft-cached Agent / Workspace / approval-mode selections for this user ×
|
||||||
|
* Project — the fields the `[default_chat]` block seeds — so the next new-conversation
|
||||||
|
* draft re-seeds from the just-saved Project defaults instead of the values a previous
|
||||||
|
* visit pinned into the cache (the draft page persists all selections on mount, so a
|
||||||
|
* stale cache otherwise shadows a defaults change forever). Called by the
|
||||||
|
* project-settings save when the block actually changed. Deliberately narrower than the
|
||||||
|
* full seeded set: typed text and staged skills are user content; modelRef is the
|
||||||
|
* "switch-becomes-default" carry-over released only by clearDraftModelRef when the
|
||||||
|
* default MODEL itself changes (the model is not part of the `[default_chat]` block);
|
||||||
|
* the handoff/switch chips are explicit user staging, never default-derived. A draft
|
||||||
|
* with none of the three fields is a no-op, never an errant write.
|
||||||
|
*/
|
||||||
|
export function clearDraftChatDefaults(
|
||||||
|
userId: string,
|
||||||
|
projectId: string,
|
||||||
|
storage: DraftStorage = localStorage,
|
||||||
|
): void {
|
||||||
|
const key = draftKey(userId, projectId);
|
||||||
|
const draft = loadDraft(key, storage);
|
||||||
|
if (
|
||||||
|
draft.agentId !== undefined ||
|
||||||
|
draft.workspace !== undefined ||
|
||||||
|
draft.approvalMode !== undefined
|
||||||
|
) {
|
||||||
|
saveDraft(
|
||||||
|
key,
|
||||||
|
{ ...draft, agentId: undefined, workspace: undefined, approvalMode: undefined },
|
||||||
|
storage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,13 @@
|
|||||||
* field, the Project's new-chat defaults ([default_chat]) prefill Agent / Workspace /
|
* field, the Project's new-chat defaults ([default_chat]) prefill Agent / Workspace /
|
||||||
* approval mode (precedence: route state > draft cache > project default > built-in
|
* approval mode (precedence: route state > draft cache > project default > built-in
|
||||||
* fallback); the model default already flows through models.defaultModel.
|
* fallback); the model default already flows through models.defaultModel.
|
||||||
|
*
|
||||||
|
* Saving the Project's new-chat defaults resets the seeded selections so new chats pick
|
||||||
|
* the change up: the project-settings dialog strips the cached pins (next visits reseed
|
||||||
|
* from the fresh defaults) and dispatches a same-tab chat-defaults-changed event that a
|
||||||
|
* MOUNTED draft answers by resetting Agent / Workspace / approval mode / Model in
|
||||||
|
* component state (see onDefaultsChanged) — typed-but-unsent text and staged skills
|
||||||
|
* always survive.
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router";
|
import { useLocation, useNavigate } from "react-router";
|
||||||
@@ -60,6 +67,11 @@ import { EXAMPLE_FOLDERS } from "./example-tasks";
|
|||||||
import type { ExampleFolderId, ExampleTask, ExampleTaskId } from "./example-tasks";
|
import type { ExampleFolderId, ExampleTask, ExampleTaskId } from "./example-tasks";
|
||||||
import { clearDraft, draftKey, loadDraft, saveDraft } from "./draft-cache";
|
import { clearDraft, draftKey, loadDraft, saveDraft } from "./draft-cache";
|
||||||
import type { DraftCache } from "./draft-cache";
|
import type { DraftCache } from "./draft-cache";
|
||||||
|
import {
|
||||||
|
CHAT_DEFAULTS_CHANGED_EVENT,
|
||||||
|
chatDefaultsChangedDetail,
|
||||||
|
type ChatDefaultsChangedDetail,
|
||||||
|
} from "./chat-defaults-event";
|
||||||
import { effectiveThinkingLevel } from "./thinking-level";
|
import { effectiveThinkingLevel } from "./thinking-level";
|
||||||
import { WorkspaceSelect, pillClass } from "./workspace-select";
|
import { WorkspaceSelect, pillClass } from "./workspace-select";
|
||||||
import { sameModelRef } from "../models/model-grouping";
|
import { sameModelRef } from "../models/model-grouping";
|
||||||
@@ -161,15 +173,21 @@ export function DraftView({
|
|||||||
// route location.state > mount-time draft cache > project default > built-in fallback;
|
// route location.state > mount-time draft cache > project default > built-in fallback;
|
||||||
// null = still loading (the thinking picker below stays disabled until resolved).
|
// null = still loading (the thinking picker below stays disabled until resolved).
|
||||||
const [chatDefaults, setChatDefaults] = useState<ChatDefaultsDto | null>(null);
|
const [chatDefaults, setChatDefaults] = useState<ChatDefaultsDto | null>(null);
|
||||||
|
/**
|
||||||
|
* Set once the chat-defaults-changed event delivered a fresh block (see the reseed
|
||||||
|
* handler below): from then on the mount-time fetch must not apply — it was started
|
||||||
|
* earlier and would overwrite the fresher event payload when it resolves.
|
||||||
|
*/
|
||||||
|
const defaultsFromEventRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
api
|
api
|
||||||
.getChatDefaults(projectId)
|
.getChatDefaults(projectId)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (!cancelled) setChatDefaults(res);
|
if (!cancelled && !defaultsFromEventRef.current) setChatDefaults(res);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setChatDefaults({});
|
if (!cancelled && !defaultsFromEventRef.current) setChatDefaults({});
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
@@ -292,6 +310,64 @@ export function DraftView({
|
|||||||
);
|
);
|
||||||
}, [models, modelRef]);
|
}, [models, modelRef]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live reseed: the project-settings dialog saved new defaults in THIS tab while the
|
||||||
|
* draft is mounted. The dialog already stripped the cached pins, but this component's
|
||||||
|
* state still holds the old selections and persistNow would silently write them right
|
||||||
|
* back over the stripped cache — so the seeded fields are reset here to exactly what a
|
||||||
|
* fresh /chat/new mount would now produce (with the cache stripped, the seeding
|
||||||
|
* precedence collapses to: fresh project default > built-in fallback); the persist
|
||||||
|
* effect then pins the NEW values. Typed text and staged skills are user content and
|
||||||
|
* stay untouched; route-state overrides and in-mount picks are superseded — the save is
|
||||||
|
* the later explicit intent, and the next fresh mount would drop them anyway. Values
|
||||||
|
* come from the event payload (server-confirmed by the dialog's PUTs), not a refetch.
|
||||||
|
* The mount-time seeding effects re-run when chatDefaults changes but cannot fight
|
||||||
|
* this: their apply-once refs are already consumed, and where they are not, they
|
||||||
|
* re-apply the same fresh values.
|
||||||
|
*/
|
||||||
|
const onDefaultsChanged = useCallback(
|
||||||
|
(detail: ChatDefaultsChangedDetail) => {
|
||||||
|
if (detail.defaults) {
|
||||||
|
const d = detail.defaults;
|
||||||
|
defaultsFromEventRef.current = true;
|
||||||
|
setChatDefaults(d);
|
||||||
|
touchedRef.current = { agent: false, workspace: false, approval: false };
|
||||||
|
setWorkspace(d.workspace ?? "");
|
||||||
|
setApprovalMode(d.approvalMode ?? "allow-all");
|
||||||
|
const valid = (id: string | undefined): id is string =>
|
||||||
|
id !== undefined && agents.some((a) => a.agentId === id);
|
||||||
|
if (valid(d.agentId)) {
|
||||||
|
setAgentId(d.agentId);
|
||||||
|
} else if (agents.length > 0) {
|
||||||
|
// No (valid) default Agent in the new block: the same fallback chain a fresh
|
||||||
|
// mount runs — the global current Agent, then default_agent, then the first.
|
||||||
|
// Skipped while the list is empty (nothing to validate against; keep the pick).
|
||||||
|
setAgentId(
|
||||||
|
currentAgent?.agentId ??
|
||||||
|
(agents.find((a) => a.agentId === "default_agent") ?? agents[0])?.agentId ??
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// New default model: adopt it directly (the event carries the authoritative pair).
|
||||||
|
// Setting null and leaning on the fallback effect would race ChatPage's models
|
||||||
|
// refetch and re-pin the STALE default from the old models prop.
|
||||||
|
if (detail.defaultModel !== undefined) setModelRef(detail.defaultModel);
|
||||||
|
},
|
||||||
|
[agents, currentAgent],
|
||||||
|
);
|
||||||
|
/** Latest-closure mirror for the window listener (same convention as persistRef). */
|
||||||
|
const onDefaultsChangedRef = useRef(onDefaultsChanged);
|
||||||
|
onDefaultsChangedRef.current = onDefaultsChanged;
|
||||||
|
useEffect(() => {
|
||||||
|
const onEvent = (e: Event) => {
|
||||||
|
const detail = chatDefaultsChangedDetail(e, projectId);
|
||||||
|
if (detail) onDefaultsChangedRef.current(detail);
|
||||||
|
};
|
||||||
|
window.addEventListener(CHAT_DEFAULTS_CHANGED_EVENT, onEvent);
|
||||||
|
return () => window.removeEventListener(CHAT_DEFAULTS_CHANGED_EVENT, onEvent);
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
// —— Conversation-time thinking level (backed by the Agent settings) ——
|
// —— Conversation-time thinking level (backed by the Agent settings) ——
|
||||||
// The picker DISPLAYS the effective level, resolved by the same chain core applies when
|
// The picker DISPLAYS the effective level, resolved by the same chain core applies when
|
||||||
// the Session is created (core agent.ts `configuredThinkingLevel`): the Agent's explicit
|
// the Session is created (core agent.ts `configuredThinkingLevel`): the Agent's explicit
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
clearDraft,
|
clearDraft,
|
||||||
|
clearDraftChatDefaults,
|
||||||
clearDraftModelRef,
|
clearDraftModelRef,
|
||||||
draftKey,
|
draftKey,
|
||||||
loadDraft,
|
loadDraft,
|
||||||
@@ -221,6 +222,53 @@ describe("load / save / clear (key isolation, errors silenced)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("clearDraftChatDefaults strips only the [default_chat]-seeded selections, preserving user content and the model pin", () => {
|
||||||
|
// Called by the project-settings save when the block changed: the next /chat/new must
|
||||||
|
// reseed Agent / Workspace / approval mode from the fresh defaults, while typed text,
|
||||||
|
// staged skills, the handoff/switch chips and the switch-becomes-default model
|
||||||
|
// carry-over (released only by clearDraftModelRef) all survive.
|
||||||
|
const s = memStorage();
|
||||||
|
saveDraft(
|
||||||
|
draftKey("user-a1", "project-a"),
|
||||||
|
{
|
||||||
|
text: "typed but unsent",
|
||||||
|
agentId: "old_default_agent",
|
||||||
|
workspace: "/srv/old-default",
|
||||||
|
approvalMode: "read-only",
|
||||||
|
modelRef: { provider: "deepseek", modelId: "deepseek-v4-pro" },
|
||||||
|
handoffAgentId: "agent_helper",
|
||||||
|
switchModelRef: { provider: "openai", modelId: "gpt-5" },
|
||||||
|
skills: ["agent-creation"],
|
||||||
|
},
|
||||||
|
s,
|
||||||
|
);
|
||||||
|
clearDraftChatDefaults("user-a1", "project-a", s);
|
||||||
|
expect(loadDraft(draftKey("user-a1", "project-a"), s)).toEqual({
|
||||||
|
text: "typed but unsent",
|
||||||
|
modelRef: { provider: "deepseek", modelId: "deepseek-v4-pro" },
|
||||||
|
handoffAgentId: "agent_helper",
|
||||||
|
switchModelRef: { provider: "openai", modelId: "gpt-5" },
|
||||||
|
skills: ["agent-creation"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearDraftChatDefaults strips any subset of the three fields (a cached "" workspace counts) and is otherwise a no-op', () => {
|
||||||
|
const s = memStorage();
|
||||||
|
// A single seeded field is enough to rewrite; "" workspace is an explicit "auto temp"
|
||||||
|
// pin and must be stripped like any other value (undefined-check, not truthiness).
|
||||||
|
saveDraft(draftKey("user-a1", "project-a"), { text: "t", workspace: "" }, s);
|
||||||
|
clearDraftChatDefaults("user-a1", "project-a", s);
|
||||||
|
expect(loadDraft(draftKey("user-a1", "project-a"), s)).toEqual({ text: "t" });
|
||||||
|
// Nothing seeded left (or no draft at all): a no-op, never an errant write.
|
||||||
|
clearDraftChatDefaults("user-a1", "project-a", s);
|
||||||
|
clearDraftChatDefaults("user-b2", "project-a", s);
|
||||||
|
expect(s.map.has(draftKey("user-b2", "project-a"))).toBe(false);
|
||||||
|
// Scoped by user × Project: another user's pins survive.
|
||||||
|
saveDraft(draftKey("user-b2", "project-a"), { agentId: "default_agent" }, s);
|
||||||
|
clearDraftChatDefaults("user-a1", "project-a", s);
|
||||||
|
expect(loadDraft(draftKey("user-b2", "project-a"), s)).toEqual({ agentId: "default_agent" });
|
||||||
|
});
|
||||||
|
|
||||||
it("storage throwing (quota/private mode): save does not throw, load yields an empty draft", () => {
|
it("storage throwing (quota/private mode): save does not throw, load yields an empty draft", () => {
|
||||||
const broken: DraftStorage = {
|
const broken: DraftStorage = {
|
||||||
getItem: () => {
|
getItem: () => {
|
||||||
|
|||||||
Reference in new issue
Block a user