mirror of
https://github.com/alphaXiv/OpenResearch.git
synced 2026-10-02 01:34:34 +08:00
fix: keep starter prompts when switching harness or model (#284)
* fix: keep starter prompts when switching harness or model The four starter prompts are cached per project brief and locale only, and the UI keys them by project, so changing the composer's harness or model no longer regenerates them or flashes the loading skeleton. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore: rebuild ui/dist Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix: retry starter prompts only for a harness that failed A failed generation is remembered per agent, not per project, so switching from a broken harness to a working one still produces prompts. The route answers with an empty list when there is nothing to offer, so projects with experiments never show the loading skeleton on a switch. Rebuilds ui/dist. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
56ed86d466
commit
b66af71a5b
+3
-2
@@ -2057,7 +2057,8 @@ async fn project_starter_prompts(
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|h| local::harness::is_chat_harness(h));
|
||||
// A project with experiments is past "getting started"; skip the model call.
|
||||
// Past "getting started" or no chat harness named: nothing to offer
|
||||
// (empty), as opposed to a harness that could not answer (null).
|
||||
let prompts = match harness {
|
||||
Some(harness) if experiment_count == 0 => {
|
||||
let locale = q.locale.as_deref().unwrap_or("en");
|
||||
@@ -2072,7 +2073,7 @@ async fn project_starter_prompts(
|
||||
};
|
||||
local::starter::prompts(&project, &agent, locale).await
|
||||
}
|
||||
_ => None,
|
||||
_ => Some(Vec::new()),
|
||||
};
|
||||
Ok(Json(json!({ "prompts": prompts })))
|
||||
}
|
||||
|
||||
+29
-39
@@ -76,7 +76,7 @@ pub struct Agent {
|
||||
|
||||
impl Agent {
|
||||
/// The model as far as the harness's one-shot cares: dropped for a
|
||||
/// harness that ignores it, so a picker change doesn't regenerate.
|
||||
/// harness that ignores it.
|
||||
fn effective_model(&self) -> Option<&str> {
|
||||
let honours =
|
||||
super::harness::chat_harness(&self.harness).is_some_and(|h| h.one_shot_honours_model());
|
||||
@@ -136,7 +136,7 @@ pub fn prewarm(name: String, paper_id: Option<String>, path: Option<String>, loc
|
||||
});
|
||||
}
|
||||
|
||||
/// Cache by content (brief, harness, locale) so a pre-warmed entry serves the
|
||||
/// Cache by content (brief, locale) so a pre-warmed entry serves the
|
||||
/// project created after it. The paper text (a network fetch) is fixed for a
|
||||
/// paper id, so it is fetched only on a miss and never decides staleness.
|
||||
async fn generate(
|
||||
@@ -145,14 +145,21 @@ async fn generate(
|
||||
agent: &Agent,
|
||||
locale: &str,
|
||||
) -> Option<Vec<StarterPrompt>> {
|
||||
let key = fingerprint(&brief, agent, locale);
|
||||
let key = fingerprint(&brief, locale);
|
||||
// Serialize per brief: an in-flight pre-warm, the dev double-effect, and a
|
||||
// reopened empty state all wait on the first call's cache entry.
|
||||
let lock = brief_lock(&key);
|
||||
let _guard = lock.lock().await;
|
||||
match lock_map(cache()).get(&key) {
|
||||
Some(Cached::Prompts(prompts)) => return Some(prompts.clone()),
|
||||
Some(Cached::Failed(at)) if at.elapsed() < FAILURE_TTL => return None,
|
||||
// Only the agent that failed is held back; another may still answer.
|
||||
Some(Cached::Failed { at, harness, model })
|
||||
if at.elapsed() < FAILURE_TTL
|
||||
&& *harness == agent.harness
|
||||
&& model.as_deref() == agent.effective_model() =>
|
||||
{
|
||||
return None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let prompts = generate_uncached(brief, paper_id, agent, locale).await;
|
||||
@@ -162,7 +169,11 @@ async fn generate(
|
||||
}
|
||||
let entry = match &prompts {
|
||||
Some(prompts) => Cached::Prompts(prompts.clone()),
|
||||
None => Cached::Failed(Instant::now()),
|
||||
None => Cached::Failed {
|
||||
at: Instant::now(),
|
||||
harness: agent.harness.clone(),
|
||||
model: agent.effective_model().map(String::from),
|
||||
},
|
||||
};
|
||||
cache.insert(key, entry);
|
||||
prompts
|
||||
@@ -246,7 +257,11 @@ const MAX_CACHED: usize = 64;
|
||||
|
||||
enum Cached {
|
||||
Prompts(Vec<StarterPrompt>),
|
||||
Failed(Instant),
|
||||
Failed {
|
||||
at: Instant,
|
||||
harness: String,
|
||||
model: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn cache() -> &'static Cache {
|
||||
@@ -269,12 +284,10 @@ fn brief_lock(key: &str) -> Arc<tokio::sync::Mutex<()>> {
|
||||
locks.entry(key.to_string()).or_default().clone()
|
||||
}
|
||||
|
||||
fn fingerprint(brief: &str, agent: &Agent, locale: &str) -> String {
|
||||
/// The agent is left out on purpose: switching harness or model in the
|
||||
/// composer keeps the prompts already on screen instead of regenerating them.
|
||||
fn fingerprint(brief: &str, locale: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(agent.harness.as_bytes());
|
||||
hasher.update(b"\0");
|
||||
hasher.update(agent.effective_model().unwrap_or("").as_bytes());
|
||||
hasher.update(b"\0");
|
||||
hasher.update(locale.as_bytes());
|
||||
hasher.update(b"\0");
|
||||
hasher.update(brief.as_bytes());
|
||||
@@ -693,33 +706,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_changes_with_brief_agent_and_locale() {
|
||||
let agent = |harness: &str, model: Option<&str>| Agent {
|
||||
harness: harness.into(),
|
||||
model: model.map(String::from),
|
||||
};
|
||||
let base = fingerprint("brief", &agent("claude-code", None), "en");
|
||||
assert_ne!(
|
||||
base,
|
||||
fingerprint("brief2", &agent("claude-code", None), "en")
|
||||
);
|
||||
assert_ne!(base, fingerprint("brief", &agent("codex", None), "en"));
|
||||
// Claude's one-shot ignores the picked model, so neither does the key.
|
||||
assert_eq!(
|
||||
base,
|
||||
fingerprint("brief", &agent("claude-code", Some("opus")), "en")
|
||||
);
|
||||
assert_ne!(
|
||||
fingerprint("brief", &agent("codex", None), "en"),
|
||||
fingerprint("brief", &agent("codex", Some("gpt-5.5")), "en")
|
||||
);
|
||||
assert_ne!(
|
||||
base,
|
||||
fingerprint("brief", &agent("claude-code", None), "fa")
|
||||
);
|
||||
assert_eq!(
|
||||
base,
|
||||
fingerprint("brief", &agent("claude-code", None), "en")
|
||||
);
|
||||
fn fingerprint_changes_with_brief_and_locale() {
|
||||
let base = fingerprint("brief", "en");
|
||||
assert_ne!(base, fingerprint("brief2", "en"));
|
||||
assert_ne!(base, fingerprint("brief", "fa"));
|
||||
assert_eq!(base, fingerprint("brief", "en"));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -49,7 +49,7 @@
|
||||
html { background: #ffffff; }
|
||||
html[data-theme="dark"] { background: #0e0c0c; }
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/index-BpcDDtWX.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-2YXZFvsW.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-QObKDbL8.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+2
-1
@@ -250,7 +250,8 @@ export interface StarterPrompt {
|
||||
}
|
||||
|
||||
export interface ProjectStarterPrompts {
|
||||
/** Null when the project already has experiments or no harness could answer. */
|
||||
/** Empty when the project already has experiments; null when the harness
|
||||
* could not answer. */
|
||||
prompts: StarterPrompt[] | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
getChatMessages,
|
||||
getProjectStarterPrompts,
|
||||
getSkills,
|
||||
type HarnessId,
|
||||
type StarterPrompt,
|
||||
interruptChat,
|
||||
listChatSessions,
|
||||
@@ -5100,31 +5101,35 @@ export function ChatPanel({
|
||||
// Opening a session or returning from settings starts pinned at the latest messages.
|
||||
const threadMounted = mainView === "chat" && (messages.length > 0 || busy);
|
||||
|
||||
// Keyed by project+harness so a switch never shows another project's prompts.
|
||||
// Keyed by project so a switch never shows another project's prompts; a
|
||||
// harness or model switch keeps them, and only a failed harness is retried.
|
||||
const starterHarness = composerSelection?.harness ?? null;
|
||||
const starterModel = composerSelection?.model ?? null;
|
||||
const [starter, setStarter] = useState<{
|
||||
key: string;
|
||||
projectId: string;
|
||||
harness: HarnessId;
|
||||
prompts: StarterPrompt[] | null;
|
||||
} | null>(null);
|
||||
const starterKey = `${projectId}\0${starterHarness ?? ""}\0${starterModel ?? ""}`;
|
||||
const starterSettled =
|
||||
starter?.projectId === projectId &&
|
||||
(starter.prompts !== null || starter.harness === starterHarness);
|
||||
const starterVisible = mainView === "chat" && !threadMounted && !historyLoading;
|
||||
useEffect(() => {
|
||||
if (!starterVisible || !starterHarness) return;
|
||||
if (!starterVisible || !starterHarness || starterSettled) return;
|
||||
let current = true;
|
||||
getProjectStarterPrompts(projectId, starterHarness, starterModel, getLocale())
|
||||
.then((result) => {
|
||||
if (current) setStarter({ key: starterKey, prompts: result.prompts });
|
||||
if (current) setStarter({ projectId, harness: starterHarness, prompts: result.prompts });
|
||||
})
|
||||
.catch(() => {
|
||||
if (current) setStarter({ key: starterKey, prompts: null });
|
||||
if (current) setStarter({ projectId, harness: starterHarness, prompts: null });
|
||||
});
|
||||
return () => {
|
||||
current = false;
|
||||
};
|
||||
}, [projectId, starterHarness, starterModel, starterKey, starterVisible]);
|
||||
const starterPrompts = starter?.key === starterKey ? starter.prompts : null;
|
||||
const starterLoading = starterHarness !== null && starter?.key !== starterKey;
|
||||
}, [projectId, starterHarness, starterModel, starterSettled, starterVisible]);
|
||||
const starterPrompts = starterSettled && starter ? starter.prompts : null;
|
||||
const starterLoading = starterHarness !== null && !starterSettled;
|
||||
const applyStarterPrompt = (prompt: string) => {
|
||||
setDraft(prompt);
|
||||
setSkillMenuDismissed(false);
|
||||
@@ -6015,7 +6020,7 @@ export function ChatPanel({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{starterPrompts && (
|
||||
{starterPrompts && starterPrompts.length > 0 && (
|
||||
<div className={STARTER_GRID_CLASS} role="group" aria-label={m.chat_panel_starter_prompts()}>
|
||||
{starterPrompts.map((item, index) => {
|
||||
const Icon = STARTER_ICONS[index];
|
||||
|
||||
Reference in New Issue
Block a user