fix(web-shell): reuse the empty session a failed goal attempt leaves behind

Setting a goal starts a fresh session and then sends `/goal <condition>` into
it. The daemon session is not created by the "new session" step, though —
`clearSession` only detaches and clears local state. `ensureSessionForPrompt`
creates the session lazily inside `sendPrompt`, so a prompt that fails after
the session exists leaves a created-but-empty one behind.

The Goals form keeps the condition and invites a retry, and the retry called
`createNewSession()` again: the empty session from the previous attempt was
abandoned and another created in its place. A user retrying a few times against
a busy daemon ended up with a column of blank chats in the sidebar.

Remember the stranded session and reuse it when it is still the current one,
rather than creating another. Nothing is deleted — a session is only reused
when the failed attempt left it empty and it has not been switched away from.
Once a goal actually lands, the session belongs to it, so the next goal starts
a fresh one as before.
This commit is contained in:
wenshao committed 2026-07-11 18:32:17 +08:00
1 parent af8c327924
commit bee3295aa9
2 files changed
+123 -11

No files matched your search

+75
View File
@@ -2200,6 +2200,81 @@ describe('App /goal command', () => {
expect(container.querySelector('[data-testid="goals-page"]')).toBeNull();
});
it('reuses the empty session a failed goal attempt left behind', async () => {
// `sendPrompt` creates the daemon session lazily, so a prompt that fails
// after admission leaves a created-but-empty session. The form keeps the
// condition and invites a retry; if that retry started ANOTHER new session,
// every failed attempt would strand a blank chat in the sidebar.
const { container } = renderApp();
await flush();
testState.prompt = '/goal';
await clickSubmit(container);
await flush();
const onCreateGoal = testState.latestGoalsProps?.onCreateGoal;
if (!onCreateGoal) throw new Error('onCreateGoal was not captured');
mockSessionActions.clearSession.mockClear();
mockSessionActions.sendPrompt.mockRejectedValueOnce(
new Error('daemon says no'),
);
await act(async () => {
await expect(onCreateGoal('all tests pass')).rejects.toThrow(
'daemon says no',
);
});
expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(1);
// Retry: the session from the failed attempt is still current and empty, so
// it is reused rather than abandoned. No second clearSession.
await act(async () => {
await onCreateGoal('all tests pass');
});
expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(1);
expect(mockSessionActions.sendPrompt).toHaveBeenLastCalledWith(
'/goal all tests pass',
expect.anything(),
);
});
it('starts a fresh session again once a goal has actually been sent', async () => {
// The reuse above is only for a session stranded by a failure. Once a goal
// lands, that session belongs to it, and the next goal must not be dropped
// on top of the running one.
const { container } = renderApp();
await flush();
testState.prompt = '/goal';
await clickSubmit(container);
await flush();
const onCreateGoal = testState.latestGoalsProps?.onCreateGoal;
if (!onCreateGoal) throw new Error('onCreateGoal was not captured');
mockSessionActions.clearSession.mockClear();
mockSessionActions.sendPrompt.mockRejectedValueOnce(
new Error('daemon says no'),
);
await act(async () => {
await expect(onCreateGoal('first goal')).rejects.toThrow(
'daemon says no',
);
});
await act(async () => {
await onCreateGoal('first goal');
});
expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(1);
// A brand-new goal after a successful send: fresh session again.
await act(async () => {
await onCreateGoal('second goal');
});
expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(2);
});
it('does not drop the goal into the current session when the new session fails', async () => {
const { container } = renderApp();
await flush();
+48 -11
View File
@@ -1725,6 +1725,20 @@ export function App({
createSessionPromiseRef.current = null;
}
}, [connection.sessionId]);
/**
* The empty session a failed `/goal` submit left behind.
*
* Setting a goal starts a fresh session and then sends `/goal <condition>`
* into it, but the daemon session is not created by the "new session" step —
* `ensureSessionForPrompt` creates it lazily *inside* `sendPrompt`. So a
* prompt that fails after admission leaves a created-but-empty session.
*
* The Goals form keeps the condition and lets the user retry. Without this
* ref every retry would abandon the last empty session and create another,
* piling up blank chats in the sidebar. Remembering it lets the retry reuse
* it instead — no session is ever deleted.
*/
const strandedGoalSessionRef = useRef<string | undefined>(undefined);
const ensureSessionForPrompt = useCallback(() => {
if (connectionRef.current.sessionId) return Promise.resolve();
if (!createSessionPromiseRef.current) {
@@ -4997,23 +5011,46 @@ export function App({
// the first turn, so it has to travel the prompt path.
// Start a FRESH session so the goal loop doesn't take
// over the conversation the user was already having.
const created = await createNewSession();
// createNewSession already surfaced the failure; don't
// drop the goal into the wrong (still-current) session.
// `false` keeps the form open with the typed condition
// still in it — returning normally would read as
// "created" and reset it.
if (!created) return false;
onSessionIdChange?.(undefined);
//
// Unless a previous attempt already made one and then
// failed to send: that session is empty and still
// current, so reuse it. Creating another would strand
// it, and a user retrying a few times would end up with
// a column of blank chats in the sidebar.
const stranded = strandedGoalSessionRef.current;
const canReuseStranded =
stranded !== undefined &&
connectionRef.current.sessionId === stranded;
if (!canReuseStranded) {
const created = await createNewSession();
// createNewSession already surfaced the failure; don't
// drop the goal into the wrong (still-current) session.
// `false` keeps the form open with the typed condition
// still in it — returning normally would read as
// "created" and reset it.
if (!created) return false;
onSessionIdChange?.(undefined);
}
// Switch to the chat only once the prompt is admitted.
// Switching first unmounts the Goals page, and a later
// rejection would then have nowhere to render: the user
// would land in an empty session with no explanation.
// Letting this reject keeps the error in the form the
// user is looking at.
await sendPrompt(`/goal ${condition}`, undefined, {
clearComposerOnPromptStart: true,
});
try {
await sendPrompt(`/goal ${condition}`, undefined, {
clearComposerOnPromptStart: true,
});
} catch (error) {
// `sendPrompt` creates the session lazily, so by now
// one may exist even though the prompt never landed.
// Remember it so the retry reuses it rather than
// stranding it.
strandedGoalSessionRef.current =
connectionRef.current.sessionId;
throw error;
}
strandedGoalSessionRef.current = undefined;
setMainView('chat');
}}
onOpenSession={(sessionId) => {