Skip to content

Commit b57fde5

Browse files
committed
fix: stabilize session title and recovery replay
1 parent 78ca4ed commit b57fde5

5 files changed

Lines changed: 290 additions & 9 deletions

File tree

apps/server/src/services/agent.rs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ use crate::*;
33
const DEFAULT_PTY_COLS: u16 = 120;
44
const DEFAULT_PTY_ROWS: u16 = 30;
55

6+
#[derive(Default)]
7+
struct AgentLifecycleFallbackState {
8+
emitted_tool_started: bool,
9+
emitted_turn_completed: bool,
10+
}
11+
612
fn initial_pty_size(cols: Option<u16>, rows: Option<u16>) -> PtySize {
713
PtySize {
814
rows: rows.filter(|value| *value > 0).unwrap_or(DEFAULT_PTY_ROWS),
@@ -12,6 +18,27 @@ fn initial_pty_size(cols: Option<u16>, rows: Option<u16>) -> PtySize {
1218
}
1319
}
1420

21+
fn fallback_agent_lifecycle_from_output(
22+
state: &mut AgentLifecycleFallbackState,
23+
text: &str,
24+
) -> Option<(&'static str, &'static str, &'static str)> {
25+
if state.emitted_tool_started || text.trim().is_empty() {
26+
return None;
27+
}
28+
state.emitted_tool_started = true;
29+
Some(("tool_started", "AgentProcessOutput", r#"{"source":"agent_process_output"}"#))
30+
}
31+
32+
fn fallback_agent_lifecycle_from_exit(
33+
state: &mut AgentLifecycleFallbackState,
34+
) -> Option<(&'static str, &'static str, &'static str)> {
35+
if state.emitted_turn_completed || !state.emitted_tool_started {
36+
return None;
37+
}
38+
state.emitted_turn_completed = true;
39+
Some(("turn_completed", "AgentProcessExit", r#"{"source":"agent_process_exit"}"#))
40+
}
41+
1542
fn terminate_agent_runtime(runtime: Arc<AgentRuntime>) {
1643
if let Ok(mut writer) = runtime.writer.lock() {
1744
*writer = None;
@@ -202,8 +229,10 @@ pub(crate) fn agent_start(
202229
let workspace_id_out = workspace_id.clone();
203230
let session_out = session_id.clone();
204231
let session_out_num = session_id_num;
232+
let lifecycle_fallback_state = Arc::new(Mutex::new(AgentLifecycleFallbackState::default()));
205233
let app_handle = app.clone();
206234
let state_handle = app.clone();
235+
let lifecycle_fallback_state_out = lifecycle_fallback_state.clone();
207236
std::thread::spawn(move || {
208237
let mut reader = reader;
209238
let mut buf = [0u8; 4096];
@@ -215,6 +244,20 @@ pub(crate) fn agent_start(
215244
if text.is_empty() {
216245
continue;
217246
}
247+
if let Ok(mut lifecycle_state) = lifecycle_fallback_state_out.lock() {
248+
if let Some((kind, source_event, data)) =
249+
fallback_agent_lifecycle_from_output(&mut lifecycle_state, &text)
250+
{
251+
emit_agent_lifecycle(
252+
&app_handle,
253+
&workspace_id_out,
254+
&session_out,
255+
kind,
256+
source_event,
257+
data,
258+
);
259+
}
260+
}
218261
emit_agent(
219262
&app_handle,
220263
&workspace_id_out,
@@ -232,10 +275,25 @@ pub(crate) fn agent_start(
232275

233276
let app_handle = app.clone();
234277
let state_handle = app.clone();
278+
let lifecycle_fallback_state_out = lifecycle_fallback_state.clone();
235279
std::thread::spawn(move || {
236280
if let Ok(mut child) = runtime.child.lock() {
237281
let _ = child.wait();
238282
}
283+
if let Ok(mut lifecycle_state) = lifecycle_fallback_state_out.lock() {
284+
if let Some((kind, source_event, data)) =
285+
fallback_agent_lifecycle_from_exit(&mut lifecycle_state)
286+
{
287+
emit_agent_lifecycle(
288+
&app_handle,
289+
&workspace_id,
290+
&session_id,
291+
kind,
292+
source_event,
293+
data,
294+
);
295+
}
296+
}
239297
emit_agent(&app_handle, &workspace_id, &session_id, "exit", "exited");
240298
let state: State<AppState> = state_handle.state();
241299
let should_mark_idle = if let Ok(mut agents) = state.agents.lock() {
@@ -256,6 +314,43 @@ pub(crate) fn agent_start(
256314
Ok(AgentStartResult { started: true })
257315
}
258316

317+
#[cfg(test)]
318+
mod tests {
319+
use super::*;
320+
321+
#[test]
322+
fn fallback_agent_lifecycle_marks_first_output_as_tool_started_once() {
323+
let mut state = AgentLifecycleFallbackState::default();
324+
325+
assert_eq!(
326+
fallback_agent_lifecycle_from_output(&mut state, "fixture-running\n"),
327+
Some((
328+
"tool_started",
329+
"AgentProcessOutput",
330+
r#"{"source":"agent_process_output"}"#,
331+
)),
332+
);
333+
assert_eq!(fallback_agent_lifecycle_from_output(&mut state, "fixture-still-running\n"), None);
334+
}
335+
336+
#[test]
337+
fn fallback_agent_lifecycle_only_emits_completion_after_output_started() {
338+
let mut state = AgentLifecycleFallbackState::default();
339+
assert_eq!(fallback_agent_lifecycle_from_exit(&mut state), None);
340+
341+
let _ = fallback_agent_lifecycle_from_output(&mut state, "fixture-running\n");
342+
assert_eq!(
343+
fallback_agent_lifecycle_from_exit(&mut state),
344+
Some((
345+
"turn_completed",
346+
"AgentProcessExit",
347+
r#"{"source":"agent_process_exit"}"#,
348+
)),
349+
);
350+
assert_eq!(fallback_agent_lifecycle_from_exit(&mut state), None);
351+
}
352+
}
353+
259354
pub(crate) fn agent_send(
260355
workspace_id: String,
261356
session_id: String,

apps/web/src/features/agents/agent-runtime-actions.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,43 @@ export const commitAgentSessionTitle = ({
344344
return title;
345345
};
346346

347+
type PreviewAgentSessionTitleArgs = {
348+
tabId: string;
349+
sessionId: string;
350+
rawInput: string;
351+
locale: Locale;
352+
t: Translator;
353+
updateTab: UpdateTab;
354+
};
355+
356+
export const previewAgentSessionTitle = ({
357+
tabId,
358+
sessionId,
359+
rawInput,
360+
locale,
361+
t,
362+
updateTab,
363+
}: PreviewAgentSessionTitleArgs): string | null => {
364+
const title = sessionTitleFromInput(rawInput);
365+
if (!title) return null;
366+
367+
let applied = false;
368+
updateTab(tabId, (tab) => ({
369+
...tab,
370+
sessions: tab.sessions.map((session) => {
371+
if (session.id !== sessionId) return session;
372+
const canReplace = session.isDraft
373+
|| session.title === t("draftSessionTitle")
374+
|| isGeneratedSessionTitleForId(session.title, session.id);
375+
if (!canReplace) return session;
376+
applied = true;
377+
return { ...session, title };
378+
}),
379+
}));
380+
381+
return applied ? title : null;
382+
};
383+
347384
export const noteAgentStartupEvent = (
348385
refs: AgentRuntimeRefs,
349386
tabId: string,

apps/web/src/features/agents/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export {
1515
markAgentRuntimeStarted,
1616
noteAgentStartupEvent,
1717
noteAgentStartupLifecycle,
18+
previewAgentSessionTitle,
1819
setAgentTerminalRef,
1920
setDraftPromptInputRef,
2021
syncAgentPaneSize,

apps/web/src/features/workspace/WorkspaceScreen.tsx

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import {
5858
fitAgentTerminals,
5959
isAgentRuntimeRunning,
6060
markAgentRuntimeStarted,
61+
previewAgentSessionTitle,
6162
setAgentTerminalRef,
6263
setDraftPromptInputRef,
6364
syncAgentPaneSize,
@@ -409,6 +410,10 @@ export default function WorkspaceScreen({ locale, appSettings, onOpenSettings }:
409410
const [slashSkillItems, setSlashSkillItems] = useState<ClaudeSlashSkillEntry[]>([]);
410411
const [bootstrapReady, setBootstrapReady] = useState(false);
411412
const [controllerActionBusy, setControllerActionBusy] = useState<"takeover" | "reject" | null>(null);
413+
const [agentRecoveryBusy, setAgentRecoveryBusy] = useState<{
414+
sessionId: string;
415+
kind: "resume" | "restart";
416+
} | null>(null);
412417
const [optimisticTakeoverWorkspaceId, setOptimisticTakeoverWorkspaceId] = useState<string | null>(null);
413418
const t = useMemo(() => createTranslator(locale), [locale]);
414419
const deviceId = useMemo(() => getOrCreateDeviceId(), []);
@@ -1697,10 +1702,22 @@ export default function WorkspaceScreen({ locale, appSettings, onOpenSettings }:
16971702
const onRecoverActiveSession = async () => {
16981703
const currentTab = stateRef.current.tabs.find((tab) => tab.id === activeTab.id) ?? activeTab;
16991704
const session = currentTab.sessions.find((item) => item.id === activePaneSession.id) ?? activePaneSession;
1700-
const started = await agentStartMaybe(currentTab, session, currentTab.activePaneId);
1701-
if (!started) return;
1702-
if (started.started && started.startupToken !== null) {
1703-
await waitForAgentStartupDrain(agentRuntimeRefs, currentTab.id, session.id, started.startupToken);
1705+
const recoveryAction = resolveAgentRecoveryAction(currentTab.controller, session);
1706+
if (!recoveryAction) return;
1707+
setAgentRecoveryBusy({
1708+
sessionId: session.id,
1709+
kind: recoveryAction.kind,
1710+
});
1711+
try {
1712+
const started = await agentStartMaybe(currentTab, session, currentTab.activePaneId);
1713+
if (!started) return;
1714+
if (started.started && started.startupToken !== null) {
1715+
await waitForAgentStartupDrain(agentRuntimeRefs, currentTab.id, session.id, started.startupToken);
1716+
}
1717+
} finally {
1718+
setAgentRecoveryBusy((current) => (
1719+
current?.sessionId === session.id ? null : current
1720+
));
17041721
}
17051722
};
17061723

@@ -2226,13 +2243,30 @@ export default function WorkspaceScreen({ locale, appSettings, onOpenSettings }:
22262243

22272244
const onSubmitDraftPrompt = async (paneId: string, submittedValue?: string) => {
22282245
if (!guardWorkspaceMutation("agent_input")) return;
2246+
const activeTabSnapshot = stateRef.current.tabs.find((tab) => tab.id === stateRef.current.activeTabId);
2247+
const paneSessionId = activeTabSnapshot
2248+
? (findPaneSessionId(activeTabSnapshot.paneLayout, paneId) ?? activeTabSnapshot.activeSessionId)
2249+
: null;
2250+
const currentSessionSnapshot = paneSessionId && activeTabSnapshot
2251+
? activeTabSnapshot.sessions.find((session) => session.id === paneSessionId) ?? null
2252+
: null;
22292253
const content = (
22302254
submittedValue
22312255
?? draftPromptInputRefs.current.get(paneId)?.value
22322256
?? draftPromptInputs[paneId]
22332257
?? ""
22342258
).trim();
22352259
if (!content) return;
2260+
if (activeTabSnapshot && currentSessionSnapshot) {
2261+
previewAgentSessionTitle({
2262+
tabId: activeTabSnapshot.id,
2263+
sessionId: currentSessionSnapshot.id,
2264+
rawInput: content,
2265+
locale,
2266+
t,
2267+
updateTab,
2268+
});
2269+
}
22362270
setDraftPromptInputs((current) => ({
22372271
...current,
22382272
[paneId]: ""
@@ -2496,6 +2530,10 @@ export default function WorkspaceScreen({ locale, appSettings, onOpenSettings }:
24962530
? (hasTerminalOutput ? "live" : "steady")
24972531
: "idle";
24982532
const agentRecoveryAction = resolveAgentRecoveryAction(activeTab.controller, isArchiveView ? null : activePaneSession);
2533+
const activeAgentRecoveryBusy = !isArchiveView && agentRecoveryBusy?.sessionId === activePaneSession.id
2534+
? agentRecoveryBusy
2535+
: null;
2536+
const visibleAgentRecoveryAction = agentRecoveryAction ?? activeAgentRecoveryBusy;
24992537
const terminalRecoveryAction = resolveTerminalRecoveryAction(activeTab.controller, activeTerminal);
25002538
const workspaceStatusBanner = isObserverMode ? (
25012539
<div
@@ -2549,17 +2587,17 @@ export default function WorkspaceScreen({ locale, appSettings, onOpenSettings }:
25492587
</button>
25502588
</div>
25512589
</div>
2552-
) : (agentRecoveryAction || terminalRecoveryAction) ? (
2590+
) : (visibleAgentRecoveryAction || terminalRecoveryAction) ? (
25532591
<>
2554-
{agentRecoveryAction && (
2592+
{visibleAgentRecoveryAction && (
25552593
<div
25562594
className="workspace-status-banner recovery"
25572595
data-testid="workspace-agent-recovery-banner"
25582596
>
25592597
<div className="workspace-status-banner-copy">
25602598
<span className="workspace-status-banner-title">{t("workspaceAgentRecoveryTitle")}</span>
25612599
<span className="workspace-status-banner-text">
2562-
{agentRecoveryAction.kind === "resume"
2600+
{visibleAgentRecoveryAction.kind === "resume"
25632601
? t("workspaceAgentResumeBody")
25642602
: t("workspaceAgentRestartBody")}
25652603
</span>
@@ -2572,9 +2610,9 @@ export default function WorkspaceScreen({ locale, appSettings, onOpenSettings }:
25722610
onClick={() => {
25732611
void onRecoverActiveSession();
25742612
}}
2575-
disabled={controllerActionBusy !== null}
2613+
disabled={controllerActionBusy !== null || activeAgentRecoveryBusy !== null}
25762614
>
2577-
{agentRecoveryAction.kind === "resume"
2615+
{visibleAgentRecoveryAction.kind === "resume"
25782616
? t("workspaceAgentResumeAction")
25792617
: t("workspaceAgentRestartAction")}
25802618
</button>

0 commit comments

Comments
 (0)