Skip to content

Commit c92b633

Browse files
committed
fix: harden mock tokenization fallback
Introduce bounded tokenizer fallback for mock streams to avoid hangs. Add assertions and debug logging to surface invalid tokenizer results. Switch mock scenarios to openai:gpt-5 to align with tokenizer choice. Expose PLAYWRIGHT_ARGS in test-e2e make target to ease overrides.
1 parent 64603bc commit c92b633

File tree

8 files changed

+108
-18
lines changed

8 files changed

+108
-18
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ test-coverage: ## Run tests with coverage
220220

221221
test-e2e: ## Run end-to-end tests
222222
@$(MAKE) build
223-
@CMUX_E2E_LOAD_DIST=1 CMUX_E2E_SKIP_BUILD=1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 bun x playwright test --project=electron
223+
@CMUX_E2E_LOAD_DIST=1 CMUX_E2E_SKIP_BUILD=1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 bun x playwright test --project=electron $(PLAYWRIGHT_ARGS)
224224

225225
## Distribution
226226
dist: build ## Build distributable packages

src/main-desktop.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,7 @@ async function loadServices(): Promise<void> {
334334
);
335335
} else {
336336
console.log(
337-
`[${timestamp()}] Updater service disabled in dev mode (et DEBUG_UPDATER=1 or DEBUG_UPDATER=<version> to enable)`
337+
`[${timestamp()}] Updater service disabled in dev mode (set DEBUG_UPDATER=1 or DEBUG_UPDATER=<version> to enable)`
338338
);
339339
}
340340

src/services/mock/mockScenarioPlayer.ts

Lines changed: 96 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import assert from "@/utils/assert";
12
import type { CmuxMessage } from "@/types/message";
23
import { createCmuxMessage } from "@/types/message";
34
import type { HistoryService } from "@/services/historyService";
45
import type { Result } from "@/types/result";
56
import { Ok, Err } from "@/types/result";
67
import type { SendMessageError } from "@/types/errors";
78
import type { AIService } from "@/services/aiService";
9+
import { log } from "@/services/log";
810
import type {
911
MockAssistantEvent,
1012
MockStreamErrorEvent,
@@ -17,6 +19,91 @@ import type { ToolCallStartEvent, ToolCallEndEvent } from "@/types/stream";
1719
import type { ReasoningDeltaEvent } from "@/types/stream";
1820
import { getTokenizerForModel } from "@/utils/main/tokenizer";
1921

22+
const MOCK_TOKENIZER_MODEL = "openai:gpt-5";
23+
const TOKENIZE_TIMEOUT_MS = 150;
24+
let tokenizerFallbackLogged = false;
25+
26+
function approximateTokenCount(text: string): number {
27+
const normalizedLength = text.trim().length;
28+
if (normalizedLength === 0) {
29+
return 0;
30+
}
31+
return Math.max(1, Math.ceil(normalizedLength / 4));
32+
}
33+
34+
async function tokenizeWithMockModel(text: string, context: string): Promise<number> {
35+
assert(typeof text === "string", `Mock scenario ${context} expects string input`);
36+
const approximateTokens = approximateTokenCount(text);
37+
let fallbackUsed = false;
38+
let timeoutId: NodeJS.Timeout | undefined;
39+
40+
const fallbackPromise = new Promise<number>((resolve) => {
41+
timeoutId = setTimeout(() => {
42+
fallbackUsed = true;
43+
resolve(approximateTokens);
44+
}, TOKENIZE_TIMEOUT_MS);
45+
});
46+
47+
const actualPromise = (async () => {
48+
const tokenizer = await getTokenizerForModel(MOCK_TOKENIZER_MODEL);
49+
assert(
50+
typeof tokenizer.encoding === "string" && tokenizer.encoding.length > 0,
51+
`Tokenizer for ${MOCK_TOKENIZER_MODEL} must expose a non-empty encoding`
52+
);
53+
const tokens = await tokenizer.countTokens(text);
54+
assert(
55+
Number.isFinite(tokens) && tokens >= 0,
56+
`Tokenizer for ${MOCK_TOKENIZER_MODEL} returned invalid token count`
57+
);
58+
return tokens;
59+
})();
60+
61+
let tokens: number;
62+
try {
63+
tokens = await Promise.race([actualPromise, fallbackPromise]);
64+
} catch (error) {
65+
if (timeoutId !== undefined) {
66+
clearTimeout(timeoutId);
67+
}
68+
const errorMessage = error instanceof Error ? error.message : String(error);
69+
throw new Error(
70+
`[MockScenarioPlayer] Failed to tokenize ${context} with ${MOCK_TOKENIZER_MODEL}: ${errorMessage}`
71+
);
72+
}
73+
74+
if (!fallbackUsed && timeoutId !== undefined) {
75+
clearTimeout(timeoutId);
76+
}
77+
78+
actualPromise
79+
.then((resolvedTokens) => {
80+
if (fallbackUsed && !tokenizerFallbackLogged) {
81+
tokenizerFallbackLogged = true;
82+
log.debug(
83+
`[MockScenarioPlayer] Tokenizer fallback used for ${context}; emitted ${approximateTokens}, background tokenizer returned ${resolvedTokens}`
84+
);
85+
}
86+
})
87+
.catch((error) => {
88+
if (fallbackUsed && !tokenizerFallbackLogged) {
89+
tokenizerFallbackLogged = true;
90+
const errorMessage = error instanceof Error ? error.message : String(error);
91+
log.debug(
92+
`[MockScenarioPlayer] Tokenizer fallback used for ${context}; background error: ${errorMessage}`
93+
);
94+
}
95+
});
96+
97+
if (fallbackUsed) {
98+
assert(
99+
Number.isFinite(tokens) && tokens >= 0,
100+
`Token fallback produced invalid count for ${context}`
101+
);
102+
}
103+
104+
return tokens;
105+
}
106+
20107
interface MockPlayerDeps {
21108
aiService: AIService;
22109
historyService: HistoryService;
@@ -159,8 +246,7 @@ export class MockScenarioPlayer {
159246
}
160247
case "reasoning-delta": {
161248
// Mock scenarios use the same tokenization logic as real streams for consistency
162-
const tokenizer = await getTokenizerForModel("gpt-4"); // Mock uses GPT-4 tokenizer
163-
const tokens = await tokenizer.countTokens(event.text);
249+
const tokens = await tokenizeWithMockModel(event.text, "reasoning-delta text");
164250
const payload: ReasoningDeltaEvent = {
165251
type: "reasoning-delta",
166252
workspaceId,
@@ -175,8 +261,7 @@ export class MockScenarioPlayer {
175261
case "tool-start": {
176262
// Mock scenarios use the same tokenization logic as real streams for consistency
177263
const inputText = JSON.stringify(event.args);
178-
const tokenizer = await getTokenizerForModel("gpt-4"); // Mock uses GPT-4 tokenizer
179-
const tokens = await tokenizer.countTokens(inputText);
264+
const tokens = await tokenizeWithMockModel(inputText, "tool-call args");
180265
const payload: ToolCallStartEvent = {
181266
type: "tool-call-start",
182267
workspaceId,
@@ -204,8 +289,13 @@ export class MockScenarioPlayer {
204289
}
205290
case "stream-delta": {
206291
// Mock scenarios use the same tokenization logic as real streams for consistency
207-
const tokenizer = await getTokenizerForModel("gpt-4"); // Mock uses GPT-4 tokenizer
208-
const tokens = await tokenizer.countTokens(event.text);
292+
let tokens: number;
293+
try {
294+
tokens = await tokenizeWithMockModel(event.text, "stream-delta text");
295+
} catch (error) {
296+
console.error("[MockScenarioPlayer] tokenize failed for stream-delta", error);
297+
throw error;
298+
}
209299
const payload: StreamDeltaEvent = {
210300
type: "stream-delta",
211301
workspaceId,

src/services/mock/scenarios/basicChat.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const listProgrammingLanguagesTurn: ScenarioTurn = {
1212
assistant: {
1313
messageId: "msg-basic-1",
1414
events: [
15-
{ kind: "stream-start", delay: 0, messageId: "msg-basic-1", model: "mock:planner" },
15+
{ kind: "stream-start", delay: 0, messageId: "msg-basic-1", model: "openai:gpt-5" },
1616
{
1717
kind: "stream-delta",
1818
delay: STREAM_BASE_DELAY,
@@ -37,7 +37,7 @@ const listProgrammingLanguagesTurn: ScenarioTurn = {
3737
kind: "stream-end",
3838
delay: STREAM_BASE_DELAY * 5,
3939
metadata: {
40-
model: "mock:planner",
40+
model: "openai:gpt-5",
4141
inputTokens: 64,
4242
outputTokens: 48,
4343
systemMessageTokens: 12,

src/services/mock/scenarios/permissionModes.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ const planRefactorTurn: ScenarioTurn = {
1919
kind: "stream-start",
2020
delay: 0,
2121
messageId: "msg-plan-refactor",
22-
model: "mock:planner",
22+
model: "openai:gpt-5",
2323
},
2424
{
2525
kind: "stream-delta",
@@ -45,7 +45,7 @@ const planRefactorTurn: ScenarioTurn = {
4545
kind: "stream-end",
4646
delay: STREAM_BASE_DELAY * 5,
4747
metadata: {
48-
model: "mock:planner",
48+
model: "openai:gpt-5",
4949
inputTokens: 180,
5050
outputTokens: 130,
5151
systemMessageTokens: 24,

src/services/mock/scenarios/review.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const summarizeBranchesTurn: ScenarioTurn = {
1616
assistant: {
1717
messageId: "msg-plan-1",
1818
events: [
19-
{ kind: "stream-start", delay: 0, messageId: "msg-plan-1", model: "mock:planner" },
19+
{ kind: "stream-start", delay: 0, messageId: "msg-plan-1", model: "openai:gpt-5" },
2020
{
2121
kind: "reasoning-delta",
2222
delay: STREAM_BASE_DELAY,
@@ -61,7 +61,7 @@ const summarizeBranchesTurn: ScenarioTurn = {
6161
kind: "stream-end",
6262
delay: STREAM_BASE_DELAY * 6,
6363
metadata: {
64-
model: "mock:planner",
64+
model: "openai:gpt-5",
6565
inputTokens: 128,
6666
outputTokens: 85,
6767
systemMessageTokens: 32,

src/services/mock/scenarios/slashCommands.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const compactConversationTurn: ScenarioTurn = {
2424
kind: "stream-start",
2525
delay: 0,
2626
messageId: "msg-slash-compact-1",
27-
model: "anthropic:claude-sonnet-4-5",
27+
model: "openai:gpt-5",
2828
},
2929
{
3030
kind: "stream-delta",
@@ -35,7 +35,7 @@ const compactConversationTurn: ScenarioTurn = {
3535
kind: "stream-end",
3636
delay: STREAM_BASE_DELAY * 2,
3737
metadata: {
38-
model: "anthropic:claude-sonnet-4-5",
38+
model: "openai:gpt-5",
3939
inputTokens: 220,
4040
outputTokens: 96,
4141
systemMessageTokens: 18,

src/services/mock/scenarios/toolFlows.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ const recallTestFileTurn: ScenarioTurn = {
269269
kind: "stream-start",
270270
delay: 0,
271271
messageId: "msg-tool-recall-test-file",
272-
model: "mock:planner",
272+
model: "openai:gpt-5",
273273
},
274274
{
275275
kind: "stream-delta",
@@ -280,7 +280,7 @@ const recallTestFileTurn: ScenarioTurn = {
280280
kind: "stream-end",
281281
delay: STREAM_BASE_DELAY * 2,
282282
metadata: {
283-
model: "mock:planner",
283+
model: "openai:gpt-5",
284284
inputTokens: 60,
285285
outputTokens: 34,
286286
systemMessageTokens: 10,

0 commit comments

Comments
 (0)