Skip to content

Commit 4e65577

Browse files
authored
🤖 Fix thinking slider memory and auto-resume for slow models (#341)
## Summary Two bugs fixed: ### 1. Thinking slider didn't remember for Ctrl+Shift+T **Root Cause:** - Slider only updated `thinkingLevel:{workspaceId}` storage - Ctrl+Shift+T toggle reads/writes `lastThinkingByModel:{modelName}` - Slider changes weren't visible to the toggle **Fix:** - Added `handleThinkingLevelChange` that updates both storage locations - Only saves active levels ("low"/"medium"/"high"), not "off" - Matches the logic in `useAIViewKeybinds.ts` ### 2. Auto-resume didn't work after app restart during slow models **Root Cause:** - Models can take 30-60 seconds before first token - If app restarts during this time: - History has user message but no assistant response - `hasInterruptedStream()` returned false (no partial/error messages) - Auto-resume didn't trigger **Fix:** - Updated `hasInterruptedStream()` to return `true` when last message is a user message - Rationale: History never ends with user message in normal flow - Either assistant is responding (active stream, checked separately) - Or assistant has responded (completed message in history) - If history ends with user message → incomplete conversation → needs resume - **Respects user preference:** `autoRetry` flag is checked separately and persists across restarts ## Changes ``` src/components/ThinkingSlider.tsx | 16 ++- (thinking slider fix) src/utils/messages/retryEligibility.test.ts | 176 +++ (comprehensive tests) src/utils/messages/retryEligibility.ts | 8 +++ (auto-resume fix) 3 files changed, 198 insertions(+), 2 deletions(-) ``` ## Testing - ✅ 669 unit tests pass - ✅ 8 new tests for retry eligibility logic - ✅ Type checking clean - ✅ Integration tests pass _Generated with `cmux`_
1 parent fa2bb66 commit 4e65577

File tree

3 files changed

+199
-2
lines changed

3 files changed

+199
-2
lines changed

src/components/ThinkingSlider.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import React, { useEffect, useId } from "react";
22
import styled from "@emotion/styled";
3-
import type { ThinkingLevel } from "@/types/thinking";
3+
import type { ThinkingLevel, ThinkingLevelOn } from "@/types/thinking";
44
import { useThinkingLevel } from "@/hooks/useThinkingLevel";
55
import { TooltipWrapper, Tooltip } from "./Tooltip";
66
import { formatKeybind, KEYBINDS } from "@/utils/ui/keybinds";
77
import { getThinkingPolicyForModel } from "@/utils/thinking/policy";
8+
import { updatePersistedState } from "@/hooks/usePersistedState";
9+
import { getLastThinkingByModelKey } from "@/constants/storage";
810

911
const ThinkingSliderContainer = styled.div`
1012
display: flex;
@@ -193,6 +195,16 @@ export const ThinkingSliderComponent: React.FC<ThinkingControlProps> = ({ modelS
193195

194196
const value = thinkingLevelToValue(thinkingLevel);
195197

198+
const handleThinkingLevelChange = (newLevel: ThinkingLevel) => {
199+
setThinkingLevel(newLevel);
200+
// Also save to lastThinkingByModel for Ctrl+Shift+T toggle memory
201+
// Only save active levels (not "off") - matches useAIViewKeybinds logic
202+
if (newLevel !== "off") {
203+
const lastThinkingKey = getLastThinkingByModelKey(modelString);
204+
updatePersistedState(lastThinkingKey, newLevel as ThinkingLevelOn);
205+
}
206+
};
207+
196208
return (
197209
<TooltipWrapper>
198210
<ThinkingSliderContainer>
@@ -203,7 +215,9 @@ export const ThinkingSliderComponent: React.FC<ThinkingControlProps> = ({ modelS
203215
max="3"
204216
step="1"
205217
value={value}
206-
onChange={(e) => setThinkingLevel(valueToThinkingLevel(parseInt(e.target.value)))}
218+
onChange={(e) =>
219+
handleThinkingLevelChange(valueToThinkingLevel(parseInt(e.target.value)))
220+
}
207221
id={sliderId}
208222
role="slider"
209223
aria-valuemin={0}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { describe, it, expect } from "@jest/globals";
2+
import { hasInterruptedStream } from "./retryEligibility";
3+
import type { DisplayedMessage } from "@/types/message";
4+
5+
describe("hasInterruptedStream", () => {
6+
it("returns false for empty messages", () => {
7+
expect(hasInterruptedStream([])).toBe(false);
8+
});
9+
10+
it("returns true for stream-error message", () => {
11+
const messages: DisplayedMessage[] = [
12+
{
13+
type: "user",
14+
id: "user-1",
15+
historyId: "user-1",
16+
content: "Hello",
17+
historySequence: 1,
18+
},
19+
{
20+
type: "stream-error",
21+
id: "error-1",
22+
historyId: "assistant-1",
23+
error: "Connection failed",
24+
errorType: "network",
25+
historySequence: 2,
26+
},
27+
];
28+
expect(hasInterruptedStream(messages)).toBe(true);
29+
});
30+
31+
it("returns true for partial assistant message", () => {
32+
const messages: DisplayedMessage[] = [
33+
{
34+
type: "user",
35+
id: "user-1",
36+
historyId: "user-1",
37+
content: "Hello",
38+
historySequence: 1,
39+
},
40+
{
41+
type: "assistant",
42+
id: "assistant-1",
43+
historyId: "assistant-1",
44+
content: "Incomplete response",
45+
historySequence: 2,
46+
streamSequence: 0,
47+
isStreaming: false,
48+
isPartial: true,
49+
isLastPartOfMessage: true,
50+
isCompacted: false,
51+
},
52+
];
53+
expect(hasInterruptedStream(messages)).toBe(true);
54+
});
55+
56+
it("returns true for partial tool message", () => {
57+
const messages: DisplayedMessage[] = [
58+
{
59+
type: "user",
60+
id: "user-1",
61+
historyId: "user-1",
62+
content: "Hello",
63+
historySequence: 1,
64+
},
65+
{
66+
type: "tool",
67+
id: "tool-1",
68+
historyId: "assistant-1",
69+
toolName: "bash",
70+
toolCallId: "call-1",
71+
args: { script: "echo test" },
72+
status: "interrupted",
73+
isPartial: true,
74+
historySequence: 2,
75+
streamSequence: 0,
76+
isLastPartOfMessage: true,
77+
},
78+
];
79+
expect(hasInterruptedStream(messages)).toBe(true);
80+
});
81+
82+
it("returns true for partial reasoning message", () => {
83+
const messages: DisplayedMessage[] = [
84+
{
85+
type: "user",
86+
id: "user-1",
87+
historyId: "user-1",
88+
content: "Hello",
89+
historySequence: 1,
90+
},
91+
{
92+
type: "reasoning",
93+
id: "reasoning-1",
94+
historyId: "assistant-1",
95+
content: "Let me think...",
96+
historySequence: 2,
97+
streamSequence: 0,
98+
isStreaming: false,
99+
isPartial: true,
100+
isLastPartOfMessage: true,
101+
},
102+
];
103+
expect(hasInterruptedStream(messages)).toBe(true);
104+
});
105+
106+
it("returns false for completed messages", () => {
107+
const messages: DisplayedMessage[] = [
108+
{
109+
type: "user",
110+
id: "user-1",
111+
historyId: "user-1",
112+
content: "Hello",
113+
historySequence: 1,
114+
},
115+
{
116+
type: "assistant",
117+
id: "assistant-1",
118+
historyId: "assistant-1",
119+
content: "Complete response",
120+
historySequence: 2,
121+
streamSequence: 0,
122+
isStreaming: false,
123+
isPartial: false,
124+
isLastPartOfMessage: true,
125+
isCompacted: false,
126+
},
127+
];
128+
expect(hasInterruptedStream(messages)).toBe(false);
129+
});
130+
131+
it("returns true when last message is user message (app restarted during slow model)", () => {
132+
const messages: DisplayedMessage[] = [
133+
{
134+
type: "user",
135+
id: "user-1",
136+
historyId: "user-1",
137+
content: "Hello",
138+
historySequence: 1,
139+
},
140+
{
141+
type: "assistant",
142+
id: "assistant-1",
143+
historyId: "assistant-1",
144+
content: "Complete response",
145+
historySequence: 2,
146+
streamSequence: 0,
147+
isStreaming: false,
148+
isPartial: false,
149+
isLastPartOfMessage: true,
150+
isCompacted: false,
151+
},
152+
{
153+
type: "user",
154+
id: "user-2",
155+
historyId: "user-2",
156+
content: "Another question",
157+
historySequence: 3,
158+
},
159+
];
160+
expect(hasInterruptedStream(messages)).toBe(true);
161+
});
162+
163+
it("returns true when user message has no response (slow model scenario)", () => {
164+
const messages: DisplayedMessage[] = [
165+
{
166+
type: "user",
167+
id: "user-1",
168+
historyId: "user-1",
169+
content: "Hello",
170+
historySequence: 1,
171+
},
172+
];
173+
expect(hasInterruptedStream(messages)).toBe(true);
174+
});
175+
});

src/utils/messages/retryEligibility.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@ import type { DisplayedMessage } from "@/types/message";
88
* - useResumeManager: To determine if workspace is eligible for auto-retry
99
*
1010
* This ensures DRY - both use the same logic for what constitutes a retryable state.
11+
*
12+
* Returns true if:
13+
* 1. Last message is a stream-error
14+
* 2. Last message is a partial assistant/tool/reasoning message
15+
* 3. Last message is a user message (indicating we sent it but never got a response)
16+
* - This handles app restarts during slow model responses (models can take 30-60s to first token)
17+
* - User messages are only at the end when response hasn't started/completed
1118
*/
1219
export function hasInterruptedStream(messages: DisplayedMessage[]): boolean {
1320
if (messages.length === 0) return false;
@@ -16,6 +23,7 @@ export function hasInterruptedStream(messages: DisplayedMessage[]): boolean {
1623

1724
return (
1825
lastMessage.type === "stream-error" || // Stream errored out
26+
lastMessage.type === "user" || // No response received yet (e.g., app restarted during slow model)
1927
(lastMessage.type === "assistant" && lastMessage.isPartial === true) ||
2028
(lastMessage.type === "tool" && lastMessage.isPartial === true) ||
2129
(lastMessage.type === "reasoning" && lastMessage.isPartial === true)

0 commit comments

Comments
 (0)