Skip to content

Commit df5673e

Browse files
committed
Revert "🤖 Remove interrupt sentinel - models resume naturally (#133)"
This reverts commit e732a87.
1 parent 04e4929 commit df5673e

File tree

4 files changed

+172
-23
lines changed

4 files changed

+172
-23
lines changed

src/services/aiService.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { log } from "./log";
1919
import {
2020
transformModelMessages,
2121
validateAnthropicCompliance,
22+
addInterruptedSentinel,
2223
filterEmptyAssistantMessages,
2324
} from "@/utils/messages/modelMessageTransform";
2425
import { applyCacheControl } from "@/utils/ai/cacheStrategy";
@@ -439,10 +440,13 @@ export class AIService extends EventEmitter {
439440
log.debug("Keeping reasoning parts for OpenAI (fetch wrapper handles item_references)");
440441
}
441442

443+
// Add [INTERRUPTED] sentinel to partial messages (for model context)
444+
const messagesWithSentinel = addInterruptedSentinel(filteredMessages);
445+
442446
// Convert CmuxMessage to ModelMessage format using Vercel AI SDK utility
443447
// Type assertion needed because CmuxMessage has custom tool parts for interrupted tools
444448
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
445-
const modelMessages = convertToModelMessages(filteredMessages as any);
449+
const modelMessages = convertToModelMessages(messagesWithSentinel as any);
446450
log.debug_obj(`${workspaceId}/2_model_messages.json`, modelMessages);
447451

448452
// Apply ModelMessage transforms based on provider requirements

src/utils/messages/modelMessageTransform.test.ts

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { describe, it, expect } from "@jest/globals";
22
import type { ModelMessage, AssistantModelMessage, ToolModelMessage } from "ai";
3-
import { transformModelMessages, validateAnthropicCompliance } from "./modelMessageTransform";
3+
import {
4+
transformModelMessages,
5+
validateAnthropicCompliance,
6+
addInterruptedSentinel,
7+
} from "./modelMessageTransform";
8+
import type { CmuxMessage } from "@/types/message";
49

510
describe("modelMessageTransform", () => {
611
describe("transformModelMessages", () => {
@@ -396,6 +401,122 @@ describe("modelMessageTransform", () => {
396401
});
397402
});
398403

404+
describe("addInterruptedSentinel", () => {
405+
it("should insert user message after partial assistant message", () => {
406+
const messages: CmuxMessage[] = [
407+
{
408+
id: "user-1",
409+
role: "user",
410+
parts: [{ type: "text", text: "Hello" }],
411+
metadata: { timestamp: 1000 },
412+
},
413+
{
414+
id: "assistant-1",
415+
role: "assistant",
416+
parts: [{ type: "text", text: "Let me help..." }],
417+
metadata: { timestamp: 2000, partial: true },
418+
},
419+
];
420+
421+
const result = addInterruptedSentinel(messages);
422+
423+
// Should have 3 messages: user, assistant, [INTERRUPTED] user
424+
expect(result).toHaveLength(3);
425+
expect(result[0].id).toBe("user-1");
426+
expect(result[1].id).toBe("assistant-1");
427+
expect(result[2].id).toBe("interrupted-assistant-1");
428+
expect(result[2].role).toBe("user");
429+
expect(result[2].parts).toEqual([{ type: "text", text: "[INTERRUPTED]" }]);
430+
expect(result[2].metadata?.synthetic).toBe(true);
431+
expect(result[2].metadata?.timestamp).toBe(2000);
432+
});
433+
434+
it("should not insert sentinel for non-partial assistant messages", () => {
435+
const messages: CmuxMessage[] = [
436+
{
437+
id: "user-1",
438+
role: "user",
439+
parts: [{ type: "text", text: "Hello" }],
440+
metadata: { timestamp: 1000 },
441+
},
442+
{
443+
id: "assistant-1",
444+
role: "assistant",
445+
parts: [{ type: "text", text: "Complete response" }],
446+
metadata: { timestamp: 2000, partial: false },
447+
},
448+
];
449+
450+
const result = addInterruptedSentinel(messages);
451+
452+
// Should remain unchanged (no sentinel)
453+
expect(result).toHaveLength(2);
454+
expect(result).toEqual(messages);
455+
});
456+
457+
it("should insert sentinel for reasoning-only partial messages", () => {
458+
const messages: CmuxMessage[] = [
459+
{
460+
id: "user-1",
461+
role: "user",
462+
parts: [{ type: "text", text: "Calculate something" }],
463+
metadata: { timestamp: 1000 },
464+
},
465+
{
466+
id: "assistant-1",
467+
role: "assistant",
468+
parts: [{ type: "reasoning", text: "Let me think about this..." }],
469+
metadata: { timestamp: 2000, partial: true },
470+
},
471+
];
472+
473+
const result = addInterruptedSentinel(messages);
474+
475+
// Should have 3 messages: user, assistant (reasoning only), [INTERRUPTED] user
476+
expect(result).toHaveLength(3);
477+
expect(result[2].role).toBe("user");
478+
expect(result[2].parts).toEqual([{ type: "text", text: "[INTERRUPTED]" }]);
479+
});
480+
481+
it("should handle multiple partial messages", () => {
482+
const messages: CmuxMessage[] = [
483+
{
484+
id: "user-1",
485+
role: "user",
486+
parts: [{ type: "text", text: "First" }],
487+
metadata: { timestamp: 1000 },
488+
},
489+
{
490+
id: "assistant-1",
491+
role: "assistant",
492+
parts: [{ type: "text", text: "Response 1..." }],
493+
metadata: { timestamp: 2000, partial: true },
494+
},
495+
{
496+
id: "user-2",
497+
role: "user",
498+
parts: [{ type: "text", text: "Second" }],
499+
metadata: { timestamp: 3000 },
500+
},
501+
{
502+
id: "assistant-2",
503+
role: "assistant",
504+
parts: [{ type: "text", text: "Response 2..." }],
505+
metadata: { timestamp: 4000, partial: true },
506+
},
507+
];
508+
509+
const result = addInterruptedSentinel(messages);
510+
511+
// Should have 6 messages (4 original + 2 sentinels)
512+
expect(result).toHaveLength(6);
513+
expect(result[2].id).toBe("interrupted-assistant-1");
514+
expect(result[2].role).toBe("user");
515+
expect(result[5].id).toBe("interrupted-assistant-2");
516+
expect(result[5].role).toBe("user");
517+
});
518+
});
519+
399520
describe("reasoning part stripping for OpenAI", () => {
400521
it("should strip reasoning parts for OpenAI provider", () => {
401522
const messages: ModelMessage[] = [

src/utils/messages/modelMessageTransform.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,40 @@ export function stripReasoningForOpenAI(messages: CmuxMessage[]): CmuxMessage[]
6161
});
6262
}
6363

64+
/**
65+
* Add [INTERRUPTED] sentinel to partial messages by inserting a user message.
66+
* This helps the model understand that a message was interrupted and incomplete.
67+
* The sentinel is ONLY for model context, not shown in UI.
68+
*
69+
* We insert a separate user message instead of modifying the assistant message
70+
* because if the assistant message only has reasoning (no text), it will be
71+
* filtered out, and we'd lose the interruption context. A user message always
72+
* survives filtering.
73+
*/
74+
export function addInterruptedSentinel(messages: CmuxMessage[]): CmuxMessage[] {
75+
const result: CmuxMessage[] = [];
76+
77+
for (const msg of messages) {
78+
result.push(msg);
79+
80+
// If this is a partial assistant message, insert [INTERRUPTED] user message after it
81+
if (msg.role === "assistant" && msg.metadata?.partial) {
82+
result.push({
83+
id: `interrupted-${msg.id}`,
84+
role: "user",
85+
parts: [{ type: "text", text: "[INTERRUPTED]" }],
86+
metadata: {
87+
timestamp: msg.metadata.timestamp,
88+
// Mark as synthetic so it can be identified if needed
89+
synthetic: true,
90+
},
91+
});
92+
}
93+
}
94+
95+
return result;
96+
}
97+
6498
/**
6599
* Split assistant messages with mixed text and tool calls into separate messages
66100
* to comply with Anthropic's requirement that tool_use blocks must be immediately

tests/ipcMain/resumeStream.test.ts

Lines changed: 11 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const describeIntegration = shouldRunIntegrationTests() ? describe : describe.sk
1414

1515
// Validate API keys before running tests
1616
if (shouldRunIntegrationTests()) {
17-
validateApiKeys(["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]);
17+
validateApiKeys(["ANTHROPIC_API_KEY"]);
1818
}
1919

2020
describeIntegration("IpcMain resumeStream integration tests", () => {
@@ -23,29 +23,19 @@ describeIntegration("IpcMain resumeStream integration tests", () => {
2323
jest.retryTimes(3, { logErrorsBeforeRetry: true });
2424
}
2525

26-
test.concurrent.each([
27-
{
28-
provider: "anthropic" as const,
29-
model: "claude-sonnet-4-5",
30-
expectedWord: "RESUMPTION_TEST_SUCCESS",
31-
},
32-
{
33-
provider: "openai" as const,
34-
model: "gpt-4o",
35-
expectedWord: "RESUMPTION_TEST_OPENAI_SUCCESS",
36-
},
37-
])(
38-
"should resume interrupted stream without new user message ($provider)",
39-
async ({ provider, model, expectedWord }) => {
40-
const { env, workspaceId, cleanup } = await setupWorkspace(provider);
26+
test.concurrent(
27+
"should resume interrupted stream without new user message",
28+
async () => {
29+
const { env, workspaceId, cleanup } = await setupWorkspace("anthropic");
4130
try {
4231
// Start a stream with a bash command that outputs a specific word
32+
const expectedWord = "RESUMPTION_TEST_SUCCESS";
4333
void sendMessageWithModel(
4434
env.mockIpcRenderer,
4535
workspaceId,
4636
`Run this bash command: sleep 5 && echo '${expectedWord}'`,
47-
provider,
48-
model
37+
"anthropic",
38+
"claude-sonnet-4-5"
4939
);
5040

5141
// Wait for stream to start
@@ -70,8 +60,8 @@ describeIntegration("IpcMain resumeStream integration tests", () => {
7060
env.mockIpcRenderer,
7161
workspaceId,
7262
"",
73-
provider,
74-
model
63+
"anthropic",
64+
"claude-sonnet-4-5"
7565
);
7666
expect(interruptResult.success).toBe(true);
7767

@@ -100,7 +90,7 @@ describeIntegration("IpcMain resumeStream integration tests", () => {
10090
const resumeResult = (await env.mockIpcRenderer.invoke(
10191
IPC_CHANNELS.WORKSPACE_RESUME_STREAM,
10292
workspaceId,
103-
{ model: `${provider}:${model}` }
93+
{ model: "anthropic:claude-sonnet-4-5" }
10494
)) as Result<void, SendMessageError>;
10595
expect(resumeResult.success).toBe(true);
10696

0 commit comments

Comments
 (0)