Skip to content

Commit f29dc2d

Browse files
committed
fix(desktop): stabilize burst follow-up messages
Route consecutive submissions through the Runtime Host queue authority, expose Queue and Steer controls, restore retracted message content, and deduplicate identical active error toasts. Generated-by: Codex
1 parent 4acfa26 commit f29dc2d

36 files changed

Lines changed: 1464 additions & 74 deletions

apps/desktop/e2e/streaming-remount.spec.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,20 @@ import {
2222
FAKE_HOLD_OPEN_REWRITE_PROMPT,
2323
FAKE_WAIT_FOR_STEERING_LARGE_RESPONSE_PROMPT,
2424
} from '@maka/runtime/test-only/fake-backend';
25-
import type { Locator } from '@playwright/test';
25+
import type { Locator, Page } from '@playwright/test';
2626
import { expect, COMPOSER_INPUT, test } from './fixtures';
2727

2828
function sessionRow(sidebar: Locator, sessionId: string): Locator {
2929
return sidebar.locator(`[data-session-id=${JSON.stringify(sessionId)}]`);
3030
}
3131

32+
async function selectSteerMode(page: Page): Promise<void> {
33+
const steerMode = page.getByRole('radio', { name: '插入消息' });
34+
await expect(steerMode).toBeVisible();
35+
await steerMode.click();
36+
await expect(steerMode).toBeChecked();
37+
}
38+
3239
test('remounting a live surface leaves accumulated output settled', async ({
3340
window: page,
3441
}) => {
@@ -80,6 +87,7 @@ test('remounting a live surface leaves accumulated output settled', async ({
8087
});
8188

8289
const steering = 'trigger rewrite after returning to this conversation';
90+
await selectSteerMode(page);
8391
await composer.fill(steering);
8492
await composer.press('Enter');
8593
const finalText = 'prefix <redacted> NEW streamed after the remount';
@@ -156,6 +164,7 @@ test('keeps a completed reply after an interrupted turn and conversation remount
156164
});
157165
const steering = 'use the detailed response';
158166
const completedReply = 'Large response complete.';
167+
await selectSteerMode(page);
159168
await composer.fill(steering);
160169
await composer.press('Enter');
161170
await expect(page.getByRole('log')).toContainText(completedReply);

apps/desktop/src/main/__tests__/desktop-session-projection.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,50 @@ test('projects typed linked Session ids without rewriting opaque tool data', ()
101101
);
102102
});
103103

104+
test('projects queued Session attachments into the Desktop host namespace', () => {
105+
const host = { hostId: 'remote-root' };
106+
const projected = projectDesktopSessionEvent(host, {
107+
type: 'queue_update',
108+
id: 'event-queue',
109+
turnId: 'turn-1',
110+
ts: 3,
111+
queueRevision: 2,
112+
steering: [],
113+
followup: ['inspect this'],
114+
steeringEntries: [],
115+
followupEntries: [{
116+
entryId: 'entry-1',
117+
messageId: 'message-1',
118+
content: {
119+
text: 'inspect this',
120+
attachments: [{
121+
kind: 'other',
122+
name: 'notes.txt',
123+
mimeType: 'text/plain',
124+
bytes: 5,
125+
ref: {
126+
kind: 'session_file',
127+
sessionId: 'session-1',
128+
relativePath: 'artifact-1',
129+
},
130+
}],
131+
},
132+
placement: 'next_turn',
133+
state: 'queued',
134+
}],
135+
});
136+
137+
assert.deepEqual(
138+
(projected as Extract<SessionEvent, { type: 'queue_update' }>).followupEntries?.[0]
139+
?.content.attachments?.[0].ref,
140+
{
141+
kind: 'session_file',
142+
sessionId: JSON.stringify(['remote-root', 'session-1']),
143+
relativePath: 'artifact-1',
144+
},
145+
);
146+
});
147+
104148
function summary(id: string): SessionSummary {
105149
return {
106150
id,
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import { strict as assert } from 'node:assert';
21+
import { describe, it } from 'node:test';
22+
import {
23+
hasActiveTurnAtSubmit,
24+
mergeWorkspaceReferences,
25+
resolveFollowUpModeAtSubmit,
26+
} from '../../renderer/follow-up-submit-routing.js';
27+
28+
describe('follow-up submit routing', () => {
29+
it('uses the synchronous turn arm before React publishes streaming state', () => {
30+
assert.equal(
31+
hasActiveTurnAtSubmit({
32+
liveTurn: { turnId: 'turn-1' },
33+
runningTurnIds: [],
34+
}),
35+
true,
36+
);
37+
});
38+
39+
it('ignores a terminal projection whose only running id is the same turn', () => {
40+
assert.equal(
41+
hasActiveTurnAtSubmit({
42+
liveTurn: { turnId: 'turn-1', terminal: true },
43+
runningTurnIds: ['turn-1'],
44+
}),
45+
false,
46+
);
47+
});
48+
49+
it('routes burst input through the selected follow-up lane', () => {
50+
assert.equal(
51+
resolveFollowUpModeAtSubmit({
52+
defaultMode: 'queue',
53+
hasActiveTurn: true,
54+
}),
55+
'queue',
56+
);
57+
assert.equal(
58+
resolveFollowUpModeAtSubmit({
59+
requestedMode: 'steer',
60+
defaultMode: 'queue',
61+
hasActiveTurn: true,
62+
}),
63+
'steer',
64+
);
65+
});
66+
67+
it('starts a normal turn only when no active-turn witness exists', () => {
68+
assert.equal(
69+
resolveFollowUpModeAtSubmit({
70+
defaultMode: 'queue',
71+
hasActiveTurn: false,
72+
}),
73+
undefined,
74+
);
75+
});
76+
77+
it('restores workspace references after queued text returns to the draft', () => {
78+
assert.deepEqual(
79+
mergeWorkspaceReferences(
80+
'preface\n\nreview @src/app.ts',
81+
undefined,
82+
[{
83+
kind: 'workspace_file',
84+
value: '@src/app.ts',
85+
label: 'src/app.ts',
86+
start: 7,
87+
}],
88+
),
89+
[{ value: '@src/app.ts', start: 16 }],
90+
);
91+
});
92+
});
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import assert from 'node:assert/strict';
21+
import test from 'node:test';
22+
import { createAppShellSessionEventHandlers } from '../../renderer/app-shell-session-events.js';
23+
import { createAppShellSessionUiStateController } from '../../renderer/app-shell-session-ui-state.js';
24+
25+
test('queue_update events drive the independent desktop queue projection', () => {
26+
const controller = createAppShellSessionUiStateController();
27+
const handlers = createAppShellSessionEventHandlers({
28+
uiLocale: 'zh',
29+
activeIdRef: { current: 'session-1' },
30+
liveTurnBySessionRef: controller.liveTurnBySessionRef,
31+
refreshMessages: async () => true,
32+
refreshSessions: async () => [],
33+
setLiveTurnBySession: controller.setLiveTurnBySession,
34+
setInteractionBySession: controller.setInteractionBySession,
35+
setMessageQueueBySession: controller.setMessageQueueBySession,
36+
showModelSetupToast() {},
37+
toastApi: { error() {} },
38+
});
39+
const steeringEntry = {
40+
entryId: 'entry-steer',
41+
messageId: 'message-steer',
42+
content: { text: 'adjust this run' },
43+
placement: 'current_turn' as const,
44+
state: 'queued' as const,
45+
};
46+
47+
handlers.handleEvent('session-1', {
48+
type: 'queue_update',
49+
id: 'queue-1',
50+
turnId: 'turn-1',
51+
ts: 1,
52+
queueRevision: 3,
53+
steering: ['adjust this run'],
54+
followup: ['do this next'],
55+
steeringEntries: [steeringEntry],
56+
followupEntries: [{
57+
entryId: 'entry-next',
58+
messageId: 'message-next',
59+
content: { text: 'do this next' },
60+
placement: 'next_turn',
61+
state: 'queued',
62+
}],
63+
});
64+
65+
assert.deepEqual(controller.getState().messageQueueBySession['session-1'], {
66+
queueRevision: 3,
67+
steering: [steeringEntry],
68+
followup: [{
69+
entryId: 'entry-next',
70+
messageId: 'message-next',
71+
content: { text: 'do this next' },
72+
placement: 'next_turn',
73+
state: 'queued',
74+
}],
75+
});
76+
77+
handlers.handleEvent('session-1', {
78+
type: 'queue_update',
79+
id: 'queue-2',
80+
turnId: 'turn-1',
81+
ts: 2,
82+
queueRevision: 4,
83+
steering: [],
84+
followup: [],
85+
});
86+
assert.equal(controller.getState().messageQueueBySession['session-1'], undefined);
87+
});

apps/desktop/src/main/__tests__/new-task-staged-content.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,31 @@ test('a completing send clears the attachments it submitted', async () => {
158158
assert.equal(probe.latest().pendingAttachments.length, 0);
159159
});
160160

161+
test('retracted queue attachments can be restored and submitted without re-ingest', async () => {
162+
const probe = await mountProbe((options) =>
163+
useAppShellComposerAttachments({ ...options, toastApi: { error() {} } }),
164+
);
165+
166+
await probe.render('session-1');
167+
await act(() =>
168+
probe.latest().restoreAttachments([
169+
{
170+
kind: 'other',
171+
name: 'notes.txt',
172+
mimeType: 'text/plain',
173+
bytes: 5,
174+
ref: {
175+
kind: 'session_file',
176+
sessionId: 'session-1',
177+
relativePath: 'attachments/notes.txt',
178+
},
179+
},
180+
]),
181+
);
182+
183+
assert.equal(probe.latest().pendingAttachments[0]?.source.type, 'retained');
184+
});
185+
161186
test('files chosen in the native dialog land in the composer now on screen', async () => {
162187
const probe = await mountProbe((options) =>
163188
useAppShellComposerAttachments({ ...options, toastApi: { error() {} } }),

apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,19 @@ describe('permission response IPC boundary', () => {
134134
displayText: 'review @packages/ui/src/chat turn.tsx',
135135
skillIds: ['weekly-report', 'project:maka:writer'],
136136
attachmentItems: [{ approvalId: 'a', name: 'n' }],
137+
retainedAttachments: [
138+
{
139+
kind: 'other',
140+
name: 'notes.txt',
141+
mimeType: 'text/plain',
142+
bytes: 5,
143+
ref: {
144+
kind: 'session_file',
145+
sessionId: 'session-1',
146+
relativePath: 'attachments/notes.txt',
147+
},
148+
},
149+
],
137150
turnOrchestration: { mode: 'swarm', source: 'slash_command', ignored: true },
138151
quotes: [
139152
{ text: 'the excerpt', label: ' Assistant ', sourceTurnId: 'turn-9', extra: true },
@@ -155,6 +168,19 @@ describe('permission response IPC boundary', () => {
155168
displayText: 'review @packages/ui/src/chat turn.tsx',
156169
skillIds: ['weekly-report', 'project:maka:writer'],
157170
attachmentItems: [{ approvalId: 'a', name: 'n' }],
171+
retainedAttachments: [
172+
{
173+
kind: 'other',
174+
name: 'notes.txt',
175+
mimeType: 'text/plain',
176+
bytes: 5,
177+
ref: {
178+
kind: 'session_file',
179+
sessionId: 'session-1',
180+
relativePath: 'attachments/notes.txt',
181+
},
182+
},
183+
],
158184
turnOrchestration: { mode: 'swarm', source: 'slash_command' },
159185
quotes: [{ text: 'the excerpt', label: 'Assistant', sourceTurnId: 'turn-9' }],
160186
workspaceFileReferences: [
@@ -178,6 +204,7 @@ describe('permission response IPC boundary', () => {
178204
null,
179205
{ type: 'send', text: '' },
180206
{ type: 'send', text: 'x'.repeat(128_001) },
207+
{ type: 'send', text: 'ok', retainedAttachments: [{ name: 'broken' }] },
181208
{ type: 'send', text: 'hello', turnId: 1 },
182209
{ type: 'send', text: 'hello', skillIds: ['/bad'] },
183210
{ type: 'send', text: 'hello', turnOrchestration: { mode: 'swarm', source: 'prompt' } },

0 commit comments

Comments
 (0)