mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-11 17:43:43 -10:00
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:
1 parent
af8c327924
commit
bee3295aa9
2 files changed
+123
-11
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user