Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
eedf865
fix: persist steering at admission
Astro-Han Aug 23, 2026
82995c4
refactor(desktop): unify steering admission
Astro-Han Aug 23, 2026
1138a5a
refactor(cli): remove message fallback ownership
Astro-Han Aug 23, 2026
8bc31f5
refactor: project steering from durable transcript
Astro-Han Aug 23, 2026
a2c835e
fix(cli): render steering only as conversation
Astro-Han Aug 23, 2026
db176b1
fix: make sent steering non-retractable
Astro-Han Aug 23, 2026
ec78f13
fix: preserve steering across terminal ownership
Astro-Han Aug 23, 2026
5b160f9
test(cli): align queue fake with steering semantics
Astro-Han Aug 23, 2026
f8678ae
fix: preserve embedded steering event identity
Astro-Han Aug 23, 2026
38c58d2
test: settle durable steering projection cleanup
Astro-Han Aug 23, 2026
eb16eb2
fix(runtime-host): recover persisted steering admission
Astro-Han Aug 23, 2026
57c538e
fix(cli): refresh live transcript on durable advance
Astro-Han Aug 23, 2026
df0b251
fix(runtime-host): recover durable steering automatically
Astro-Han Aug 23, 2026
1c1c362
fix(runtime-host): persist steering admission lifecycle
Astro-Han Aug 23, 2026
171c5d4
fix(desktop): make Host own message admission
Astro-Han Aug 23, 2026
729aa0a
test(desktop): exercise Host message admission over UDS
Astro-Han Aug 23, 2026
c4fb4df
fix(desktop): preserve control and companion turn identity
Astro-Han Aug 23, 2026
b12061b
test(desktop): assert settled remount behavior
Astro-Han Aug 23, 2026
08ea477
fix(runtime-host): close steering recovery cuts
Astro-Han Aug 24, 2026
f3cd5f8
fix(runtime-host): unify durable message lifecycle
Astro-Han Aug 24, 2026
c4ad910
fix(runtime-host): preserve restart message settlement
Astro-Han Aug 24, 2026
f5fa862
fix(runtime-host): resume partial root materialization
Astro-Han Aug 24, 2026
341f785
fix(runtime-host): preflight follow-up promotion
Astro-Han Aug 24, 2026
4ea3018
fix(cli): deduplicate durable steering rows
Astro-Han Aug 24, 2026
d8cc8c1
fix(desktop): bind companion events to message admission
Astro-Han Aug 24, 2026
06fee01
refactor(runtime-host): remove duplicate live message state
Astro-Han Aug 24, 2026
8a0b1c3
fix(desktop): replay events after companion admission
Astro-Han Aug 24, 2026
00c6294
refactor(cli): reuse rendered message identity
Astro-Han Aug 24, 2026
d493577
fix(runtime-host): return the committed queue revision
Astro-Han Aug 24, 2026
0ed34fb
fix(runtime-host): preserve queue editing after rebase
Astro-Han Aug 24, 2026
5dd9e29
fix(runtime-host): preserve folded steering provenance
Astro-Han Aug 24, 2026
20eaff6
fix(runtime-host): persist queued message edits
Astro-Han Aug 24, 2026
7128a97
fix(runtime-host): atomically edit durable messages
Astro-Han Aug 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 8 additions & 21 deletions apps/desktop/e2e/streaming-remount.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,29 +77,14 @@ test('remounting a live surface leaves accumulated output settled', async ({
),
).toBe(0);

await liveBubble.evaluate((element) => {
const observed = { texts: [] as string[] };
(window as typeof window & { __makaStreamingRemountObserved?: typeof observed })
.__makaStreamingRemountObserved = observed;
new MutationObserver(() => {
observed.texts.push(element.textContent ?? '');
}).observe(element, { childList: true, characterData: true, subtree: true });
});

const steering = 'trigger rewrite after returning to this conversation';
await steerActiveTurn(composer, steering);
const finalText = 'prefix <redacted> NEW streamed after the remount';
await expect(liveBubble).toContainText(finalText);

const observed = await page.evaluate(() => (
window as typeof window & {
__makaStreamingRemountObserved?: {
texts: string[];
};
}
).__makaStreamingRemountObserved);
expect(observed?.texts.some((text) => text.includes('<redacted>') && !text.includes(finalText)))
.toBe(true);
await expect(liveBubble).not.toContainText(accumulatedOutput);
// React may batch the one rewrite delta into its final redacted paint. The
// product contract is the settled text, not a particular intermediate frame
// or DOM node identity across the transcript handoff.
});

test('keeps a completed reply after an interrupted turn and conversation remount', async ({
Expand Down Expand Up @@ -196,7 +181,7 @@ test('returning to a live conversation settles output accumulated while away', a

const accumulatedOutput = 'Fake backend waiting for the test to stop the Turn.';
const liveBubble = page.locator('.maka-bubble-streaming');
await expect(liveBubble).toContainText(accumulatedOutput);
await expect(liveBubble).toContainText(accumulatedOutput, { timeout: 20_000 });

const sidebar = page.getByRole('navigation', { name: '任务列表' });
await page.getByRole('button', { name: '展开侧边栏' }).click();
Expand Down Expand Up @@ -231,7 +216,9 @@ test('returning to a live conversation settles output accumulated while away', a
unsubscribe();
resolve();
});
void window.maka.sessions.steer(sessionId, steering).catch((error) => {
void window.maka.sessions
.enqueue(sessionId, 'current_turn', { text: steering })
.catch((error) => {
window.clearTimeout(timeout);
unsubscribe();
reject(error);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,6 @@ function createActionsDeps() {
setMessages: () => undefined,
transcriptRangeRef: { current: undefined },
setNavSelection: () => undefined,
setLiveTurnBySession: () => undefined,
setInteractionBySession: () => undefined,
showModelSetupToast: () => undefined,
toastApi: { error: () => undefined, info: () => undefined },
Expand All @@ -119,10 +118,21 @@ function createActionsDeps() {
const EMPTY_SKILL_INVOCATION = { loaded: [], failed: [], receipts: [] };

describe('busy-raced send settlement', () => {
it('a steered send on an existing session disarms its turn and shows no optimistic message', async () => {
it('keeps the visible live tail while the Host admits a new message', async () => {
const activeIdRef = { current: 'session-a' as string | undefined };
const turnState = createTurnState();
const messageState = createMessageState();
const visibleTail: LiveTurnProjection = {
turnId: 'existing-turn',
phase: 'streamed',
steps: [
{
stepId: 'assistant-tail',
text: { text: 'still visible', truncated: false, complete: false },
tools: [],
},
],
};
turnState.setLiveTurnBySession(() => ({ 'session-a': visibleTail }));
const restoreWindow = installWindow({
sessions: {
send: async (_sessionId: string, command: { turnId: string }) => ({
Expand All @@ -136,12 +146,44 @@ describe('busy-raced send settlement', () => {
},
});
try {
const actions = createAppShellChatActions({
const deps = {
...createActionsDeps(),
activeIdRef,
setLiveTurnBySession: turnState.setLiveTurnBySession,
};
const actions = createAppShellChatActions(deps);

assert.equal(await actions.send('also check the tests'), true);
assert.deepEqual(turnState.liveTurnBySession['session-a'], visibleTail);
} finally {
restoreWindow();
}
});

it('a steered send on an existing session shows no optimistic message', async () => {
const activeIdRef = { current: 'session-a' as string | undefined };
const turnState = createTurnState();
const messageState = createMessageState();
const restoreWindow = installWindow({
sessions: {
send: async (_sessionId: string, command: { turnId: string }) => ({
ok: true,
steered: true,
turnId: command.turnId,
attachments: [],
inlineReferences: [],
skillInvocation: EMPTY_SKILL_INVOCATION,
}),
},
});
try {
const deps = {
...createActionsDeps(),
activeIdRef,
setMessages: messageState.setMessages,
});
setLiveTurnBySession: turnState.setLiveTurnBySession,
};
const actions = createAppShellChatActions(deps);
assert.equal(await actions.send('also check the tests'), true);
assert.equal(turnState.liveTurnBySession['session-a'], undefined);
assert.deepEqual(messageState.messages, []);
Expand All @@ -150,7 +192,7 @@ describe('busy-raced send settlement', () => {
}
});

it('rebinds the unconfirmed arm onto a Host-chosen turn id', async () => {
it('does not invent an empty live turn while the Host admits a message', async () => {
const activeIdRef = { current: 'session-a' as string | undefined };
const turnState = createTurnState();
const messageState = createMessageState();
Expand All @@ -166,16 +208,15 @@ describe('busy-raced send settlement', () => {
},
});
try {
const actions = createAppShellChatActions({
const deps = {
...createActionsDeps(),
activeIdRef,
setLiveTurnBySession: turnState.setLiveTurnBySession,
setMessages: messageState.setMessages,
});
setLiveTurnBySession: turnState.setLiveTurnBySession,
};
const actions = createAppShellChatActions(deps);
assert.equal(await actions.send('also check the tests'), true);
const live = turnState.liveTurnBySession['session-a'];
assert.equal(live?.turnId, 'host-turn');
assert.equal(live?.unconfirmed, true);
assert.equal(turnState.liveTurnBySession['session-a'], undefined);
const optimistic = messageState.messages.filter((message) => message.type === 'user');
assert.equal(optimistic.length, 1);
assert.equal(optimistic[0]?.turnId, 'host-turn');
Expand Down Expand Up @@ -210,14 +251,12 @@ describe('busy-raced send settlement', () => {
const actions = createAppShellChatActions({
...createActionsDeps(),
activeIdRef,
setLiveTurnBySession: turnState.setLiveTurnBySession,
setMessages: messageState.setMessages,
});
assert.equal(await actions.send('also check the tests'), true);
const live = turnState.liveTurnBySession['session-a'];
assert.equal(live?.turnId, 'host-turn');
assert.equal(live?.phase, 'streamed');
assert.equal(live?.unconfirmed, undefined);
} finally {
restoreWindow();
}
Expand Down Expand Up @@ -255,7 +294,6 @@ describe('busy-raced send settlement', () => {
if (sessionId !== undefined) activated.push(sessionId);
activeIdRef.current = sessionId;
},
setLiveTurnBySession: turnState.setLiveTurnBySession,
setMessages: messageState.setMessages,
});
assert.equal(await actions.send('also check the tests'), true);
Expand Down Expand Up @@ -293,11 +331,10 @@ describe('busy-raced send settlement', () => {
setActiveId: (sessionId: string | undefined) => {
activeIdRef.current = sessionId;
},
setLiveTurnBySession: turnState.setLiveTurnBySession,
setMessages: messageState.setMessages,
});
assert.equal(await actions.send('also check the tests'), true);
assert.equal(turnState.liveTurnBySession['session-new']?.turnId, 'host-turn');
assert.equal(turnState.liveTurnBySession['session-new'], undefined);
const optimistic = messageState.messages.filter((message) => message.type === 'user');
assert.equal(optimistic.length, 1);
assert.equal(optimistic[0]?.turnId, 'host-turn');
Expand Down
145 changes: 20 additions & 125 deletions apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,8 @@
import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';

import type { SessionSummary } from '@maka/core/session';
import type { LiveTurnProjection } from '@maka/ui';
import type { DesktopTranscriptRangeController } from '../../renderer/desktop-transcript-range-store.js';
import { createAppShellChatActions } from '../../renderer/app-shell-chat-actions.js';
import { createAppShellSessionUiStateController } from '../../renderer/app-shell-session-ui-state.js';
import { settledSessionTransientIds } from '../../renderer/settled-session-transients.js';

function installWindow(maka: unknown): () => void {
const target = globalThis as unknown as { window?: unknown };
Expand All @@ -64,23 +60,6 @@ function installWindow(maka: unknown): () => void {
};
}

/**
* The live-turn arm as a real map rather than a black-hole stub: a send that
* never lands must leave nothing behind, and that cannot be asserted against a
* no-op setter.
*/
function createTurnState() {
const liveTurnBySession: Record<string, LiveTurnProjection> = {};
return {
liveTurnBySession,
setLiveTurnBySession(updater: (c: Record<string, LiveTurnProjection>) => Record<string, LiveTurnProjection>) {
const next = updater({ ...liveTurnBySession });
for (const key of Object.keys(liveTurnBySession)) delete liveTurnBySession[key];
Object.assign(liveTurnBySession, next);
},
};
}

function createActionsDeps() {
return {
uiLocale: 'en' as const,
Expand All @@ -103,7 +82,6 @@ function createActionsDeps() {
setMessages: () => undefined,
transcriptRangeRef: { current: undefined },
setNavSelection: () => undefined,
setLiveTurnBySession: () => undefined,
setInteractionBySession: () => undefined,
showModelSetupToast: () => undefined,
toastApi: { error: () => undefined, info: () => undefined },
Expand Down Expand Up @@ -491,28 +469,6 @@ describe('composer send failure feedback', () => {
assert.deepEqual(setupToasts, [], 'a stale surface must not be navigated to 设置 · 模型');
});

// A send that never reaches the runtime must take its arm with it. A leftover
// arm still carries its `unconfirmed` claim, which would make
// `settledSessionTransientIds` protect a turn that does not exist — leaving a
// Stop button nothing can clear.
it('leaves no arm behind when the send never lands', async () => {
const turnState = createTurnState();
const restoreWindow = installWindow(readinessFailure());

try {
const actions = createAppShellChatActions({
...createActionsDeps(),
activeIdRef: { current: 'session-a' },
setLiveTurnBySession: turnState.setLiveTurnBySession,
});
assert.equal(await actions.send('hello'), false);
} finally {
restoreWindow();
}

assert.deepEqual(turnState.liveTurnBySession, {}, 'the arm must be disarmed');
});

it('still answers the surface that is actually waiting', async () => {
const setupToasts: string[] = [];
const restoreWindow = installWindow(readinessFailure());
Expand All @@ -533,91 +489,30 @@ describe('composer send failure feedback', () => {
});
});

/**
* The bug this guards, as the sequence that actually produced it: send arms the
* turn, a session list that was already in flight lands still carrying the
* pre-send status, and the settle reconcile runs against it.
*
* Nothing in that list is wrong — the runtime writes `status: 'running'` only at
* the end of `AgentRun.begin` and announces it to nobody until `onRunStarted`.
* The list simply predates the answer. Reading it as a settle used to drop the
* arm, so the first content event rebuilt the projection as `'streamed'` and the
* prominent "正在处理…" silently became the calm "继续中…".
*
* Asserted through the real `send`, the real state controller, and the real
* settle rule, because the defect lived in how those three compose — each one is
* individually correct.
*/
describe('a send in flight versus a stale session list', () => {
const sessionId = 'session-a';

function sendingWindow() {
return {
sessions: {
send: async () => ({
ok: true,
attachments: [],
skillInvocation: { loaded: [], failed: [] },
}),
describe('transcript refresh ownership', () => {
it('waits for durable transcript data without republishing the subscription snapshot', async () => {
let publications = 0;
const controller = {
ready: async () => undefined,
waitForDurableMessage: async () => true,
store: {
hasDurableMessage: () => true,
snapshot: () => ({ sessionId: 'session-a', messages: [] }),
},
} as unknown as DesktopTranscriptRangeController;
const actions = createAppShellChatActions({
...createActionsDeps(),
activeIdRef: { current: 'session-a' },
transcriptRangeRef: { current: controller },
setMessages: () => {
publications += 1;
},
};
}

// The list as it reads before the runtime's `running` write — identical to how
// it reads after the turn is over, which is exactly why the status alone
// cannot settle anything.
const preSendList = [{ id: sessionId, status: 'active', statusUpdatedAt: 100 }] as SessionSummary[];

async function armViaSend(controller: ReturnType<typeof createAppShellSessionUiStateController>) {
const restoreWindow = installWindow(sendingWindow());
try {
const actions = createAppShellChatActions({
...createActionsDeps(),
activeIdRef: { current: sessionId },
setLiveTurnBySession: controller.setLiveTurnBySession,
});
assert.equal(await actions.send('hello'), true);
} finally {
restoreWindow();
}
const armed = controller.getState().liveTurnBySession[sessionId];
assert.equal(armed?.unconfirmed, true, 'the send must arm an unconfirmed turn');
return armed!.turnId;
}

function settle(controller: ReturnType<typeof createAppShellSessionUiStateController>) {
return settledSessionTransientIds({
activeId: sessionId,
sessions: preSendList,
liveTurnBySession: controller.getState().liveTurnBySession,
});
}

it('keeps the armed turn, and settles it once the authority names that turn', async () => {
const controller = createAppShellSessionUiStateController();
const turnId = await armViaSend(controller);

assert.deepEqual(settle(controller), [], 'a list older than the answer must not settle the turn');
assert.equal(
controller.getState().liveTurnBySession[sessionId]?.phase,
'waiting',
'the first-token wait must survive the stale refresh',
await actions.refreshMessages('session-a', { requiredAssistantMessageId: 'assistant-a' }),
true,
);

// `sessions:changed` naming this turn — what `onRunStarted` now emits once
// the run has begun. This is the same controller entry point the shell
// wires that subscription to.
controller.confirmLiveTurn(sessionId, turnId);

assert.deepEqual(settle(controller), [sessionId], 'an answered turn settles under the plain status rules');
});

it('ignores an answer about a turn other than the one in flight', async () => {
const controller = createAppShellSessionUiStateController();
await armViaSend(controller);

controller.confirmLiveTurn(sessionId, 'turn-from-another-client');

assert.deepEqual(settle(controller), [], 'only this send\'s own turn may release its claim');
assert.equal(publications, 0, 'the transcript subscription is the only messages publisher');
});
});
Loading
Loading