Skip to content

Commit 4019fc5

Browse files
authored
fix(desktop): open side conversations without a settled turn (#4508)
The Desktop side conversation forked the main session eagerly at mount, and only through the latest settled turn. During a session's first turn there is no settled turn, so the panel failed with "无法创建侧边对话,请稍后重试。" and stayed unusable until that turn finished. The fork now happens on the first send, and the conversation-copy boundary can express an empty copy. `sourceTurnId` becomes optional on the copy shape; an absent value means "copy no messages", and it is accepted only for the `side_conversation` intent, enforced both at the Runtime Host protocol decoder and at the persistence lineage invariant. An empty copy inherits the source model, cwd and permission mode and records `parentSessionId`, but carries over no messages, runs, events or in-progress Todo, and fabricates no `branchOfTurnId`. `session.revision.create` still requires `sourceTurnId`, so revisions stay through-turn only and their fingerprints are unchanged. Because the composer is live from the first frame, the "preparing" busy-tab pipeline (loading frame, `aria-busy`, drag and close disabling) has no remaining producer and is removed as part of the change. The CLI opens an empty side conversation instead of erroring, matching Desktop. `RUNTIME_HOST_COMPATIBILITY_EPOCH` is bumped for the optional-field wire shape. No on-disk migration is needed: released versions persisted `sourceTurnId`, which is still a valid shape, so existing sessions and in-flight copy leases load unchanged. Downgrade note: a header written by this build for an empty copy omits `sourceTurnId`, and an older release reading it throws `malformed fields`. The compatibility epoch does not cover the downgrade direction and the repo has no downgrade contract, so this is recorded as a known consequence rather than a guarded path. Fixes #4507 Generated-by: Claude Code
1 parent 898b86d commit 4019fc5

36 files changed

Lines changed: 943 additions & 562 deletions

apps/desktop/e2e/session-workbar.spec.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,14 @@ test('Side Chat survives collapse, confirms close, and cleans up on source switc
401401

402402
const companion = page.locator('.maka-quote-companion');
403403
await expect(companion).toBeVisible();
404+
405+
// The companion forks lazily on the first send, not when the panel opens.
406+
const sideComposer = companion.locator(COMPOSER_INPUT);
407+
await sideComposer.fill('inspect this source without changing it');
408+
await sideComposer.press('Enter');
409+
await expect(companion).toContainText(
410+
'Fake backend received: inspect this source without changing it',
411+
);
404412
const firstForkId = await waitForCompanionForkId(page, sessionId);
405413
await expect(sidebar.locator(`[data-session-id=${JSON.stringify(firstForkId)}]`)).toHaveCount(0);
406414

@@ -416,13 +424,6 @@ test('Side Chat survives collapse, confirms close, and cleans up on source switc
416424
await page.getByRole('button', { name: '展开任务工作栏' }).click();
417425
await expect(companion).toBeVisible();
418426

419-
const sideComposer = companion.locator(COMPOSER_INPUT);
420-
await sideComposer.fill('inspect this source without changing it');
421-
await sideComposer.press('Enter');
422-
await expect(companion).toContainText(
423-
'Fake backend received: inspect this source without changing it',
424-
);
425-
426427
const workbarToolbar = page.getByRole('toolbar', { name: '任务工作栏标签' }).first();
427428
const closeActiveSideChat = () =>
428429
workbarToolbar
@@ -449,6 +450,13 @@ test('Side Chat survives collapse, confirms close, and cleans up on source switc
449450
await expect(page.getByRole('list', { name: '打开工具' })).toBeVisible();
450451
await openSideChat.click();
451452
await expect(companion).toBeVisible();
453+
// Fork again on the reopened panel's first send.
454+
const reopenedComposer = companion.locator(COMPOSER_INPUT);
455+
await reopenedComposer.fill('inspect once more before switching away');
456+
await reopenedComposer.press('Enter');
457+
await expect(companion).toContainText(
458+
'Fake backend received: inspect once more before switching away',
459+
);
452460
const secondForkId = await waitForCompanionForkId(page, sessionId);
453461

454462
await sidebar.getByRole('button', { name: '新任务', exact: true }).click();

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

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,14 +92,28 @@ describe('permission response IPC boundary', () => {
9292
sourceTurnId: 'turn-2',
9393
turnId: 'turn-3',
9494
});
95+
// A through-turn branch keeps its sourceTurnId; a spurious copyId is dropped.
96+
assert.deepEqual(
97+
normalizeBranchFromTurnInput({ sourceTurnId: 'turn-legacy', copyId: 'ignored-here' }),
98+
{ sourceTurnId: 'turn-legacy' },
99+
);
95100
assert.deepEqual(
96101
normalizeBranchFromTurnInput({
97102
sourceTurnId: 'turn-3',
98103
name: ' Branch name ',
99104
sideConversation: true,
100105
ignored: 1,
101106
}),
102-
{ sourceTurnId: 'turn-3', name: 'Branch name', sideConversation: true },
107+
{
108+
sourceTurnId: 'turn-3',
109+
name: 'Branch name',
110+
sideConversation: true,
111+
},
112+
);
113+
// An empty side-conversation branch omits sourceTurnId entirely.
114+
assert.deepEqual(
115+
normalizeBranchFromTurnInput({ sideConversation: true }),
116+
{ sideConversation: true },
103117
);
104118
assert.deepEqual(
105119
normalizeRuntimeHostBranchFromTurnInput({
@@ -130,11 +144,23 @@ describe('permission response IPC boundary', () => {
130144

131145
const invalidActions: Array<() => unknown> = [
132146
() => normalizeRegenerateTurnInput({ sourceTurnId: 'turn-1', turnId: 1 }),
133-
() => normalizeBranchFromTurnInput({ sourceTurnId: 'turn-1', name: 1 }),
134-
() => normalizeBranchFromTurnInput({ sourceTurnId: 'turn-1', sideConversation: 'yes' }),
135-
() => normalizeBranchFromTurnInput({ sourceTurnId: 'x'.repeat(129) }),
147+
() =>
148+
normalizeBranchFromTurnInput({
149+
sourceTurnId: 'turn-1',
150+
name: 1,
151+
}),
152+
() =>
153+
normalizeBranchFromTurnInput({
154+
sourceTurnId: 'turn-1',
155+
sideConversation: 'yes',
156+
}),
157+
() =>
158+
normalizeBranchFromTurnInput({ sourceTurnId: 'x'.repeat(129) }),
136159
() => normalizeReviseBeforeTurnInput({ sourceTurnId: 1 }),
137-
() => normalizeRuntimeHostBranchFromTurnInput({ sourceTurnId: 'turn-1' }),
160+
() =>
161+
normalizeRuntimeHostBranchFromTurnInput({
162+
sourceTurnId: 'turn-1',
163+
}),
138164
() => normalizeRuntimeHostReviseBeforeTurnInput({ sourceTurnId: 'turn-1', copyId: '' }),
139165
];
140166
for (const action of invalidActions) assert.throws(action, /Invalid/);

apps/desktop/src/main/__tests__/quote-companion-disposal.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@
2020
import { deferred } from '@maka/core/test-only/async-primitives';
2121
import { strict as assert } from 'node:assert';
2222
import { afterEach, describe, it } from 'node:test';
23-
import type { SessionSummary, TurnRecord } from '@maka/core/session';
23+
import type {
24+
SessionSummary,
25+
TurnRecord,
26+
} from '@maka/core/session';
2427
import {
2528
abandonPendingCompanionCopy,
2629
createFakeWorkbarServices,

0 commit comments

Comments
 (0)