Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
253 changes: 232 additions & 21 deletions apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts

Large diffs are not rendered by default.

120 changes: 13 additions & 107 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,9 @@
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 Down Expand Up @@ -101,6 +98,8 @@ function createActionsDeps() {
setMessageLoadErrorBySession: () => undefined,
setMessageRetryPendingBySession: () => undefined,
setMessages: () => undefined,
addTransientMessage: () => undefined,
removeTransientMessage: () => undefined,
transcriptRangeRef: { current: undefined },
setNavSelection: () => undefined,
setLiveTurnBySession: () => undefined,
Expand All @@ -124,7 +123,7 @@ describe('composer first-send cleanup', () => {
let sends = 0;
const restoreWindow = installWindow({
sessions: {
send: async () => {
submitMessage: async () => {
sends += 1;
return { ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] } };
},
Expand Down Expand Up @@ -163,7 +162,7 @@ describe('composer first-send cleanup', () => {
},
},
sessions: {
send: async () => ({
submitMessage: async () => ({
ok: true,
attachments: [],
skillInvocation: { loaded: [], failed: [] },
Expand Down Expand Up @@ -213,7 +212,7 @@ describe('composer first-send cleanup', () => {
},
},
sessions: {
send: async () => ({
submitMessage: async () => ({
ok: true,
attachments: [],
skillInvocation: { loaded: [], failed: [] },
Expand Down Expand Up @@ -249,7 +248,7 @@ describe('composer first-send cleanup', () => {
},
},
sessions: {
send: async () => ({
submitMessage: async () => ({
ok: true,
attachments: [],
skillInvocation: { loaded: [], failed: [] },
Expand Down Expand Up @@ -294,7 +293,7 @@ describe('composer first-send cleanup', () => {
},
},
sessions: {
send: async () => ({
submitMessage: async () => ({
ok: true,
attachments: [],
skillInvocation: { loaded: [], failed: [] },
Expand Down Expand Up @@ -330,7 +329,7 @@ describe('composer first-send cleanup', () => {
newTasks: { create: async () => ({ id: 'session-1' }) },
sessions: {
// What `prepareSkillInvocation` does when Skill discovery fails.
send: async () => Promise.reject(new Error('Skill discovery failed')),
submitMessage: async () => Promise.reject(new Error('Skill discovery failed')),
remove: async (sessionId: string) => {
removed.push(sessionId);
},
Expand All @@ -351,7 +350,7 @@ describe('composer first-send cleanup', () => {
const restoreWindow = installWindow({
newTasks: { create: async () => ({ id: 'session-1' }) },
sessions: {
send: async () => ({
submitMessage: async () => ({
ok: true,
attachments: [],
skillInvocation: { loaded: [], failed: [] },
Expand Down Expand Up @@ -379,7 +378,7 @@ describe('composer first-send cleanup', () => {
const removed: string[] = [];
const restoreWindow = installWindow({
sessions: {
send: async () => Promise.reject(new Error('Skill discovery failed')),
submitMessage: async () => Promise.reject(new Error('Skill discovery failed')),
remove: async (sessionId: string) => {
removed.push(sessionId);
},
Expand Down Expand Up @@ -416,7 +415,7 @@ describe('composer first-send cleanup', () => {
const transcriptRangeRef = { current: transcript as DesktopTranscriptRangeController | undefined };
const restoreWindow = installWindow({
sessions: {
send: async () => {
submitMessage: async () => {
order.push('send');
return { ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] } };
},
Expand Down Expand Up @@ -463,7 +462,7 @@ function deferred<T>() {
describe('composer send failure feedback', () => {
const readinessFailure = () => ({
sessions: {
send: async () =>
submitMessage: async () =>
Promise.reject(new Error('NO_REAL_CONNECTION:missing_api_key: no ready connection')),
remove: async () => undefined,
},
Expand Down Expand Up @@ -491,11 +490,7 @@ 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 () => {
it('does not invent a live turn when the send never lands', async () => {
const turnState = createTurnState();
const restoreWindow = installWindow(readinessFailure());

Expand Down Expand Up @@ -532,92 +527,3 @@ describe('composer send failure feedback', () => {
assert.equal(setupToasts.length, 1, 'the user who is still looking must get the answer');
});
});

/**
* 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: [] },
}),
},
};
}

// 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',
);

// `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');
});
});
59 changes: 59 additions & 0 deletions apps/desktop/src/main/__tests__/app-shell-stop-action.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import assert from 'node:assert/strict';
import test from 'node:test';
import { createAppShellStopAction } from '../../renderer/app-shell-stop-action.js';

test('removes exactly the transient messages the Host retracts while stopping', async () => {
const removed: Array<{ sessionId: string; messageId: string }> = [];
const target = globalThis as unknown as { window?: unknown };
const previousWindow = target.window;
target.window = {
maka: {
sessions: {
stop: async () => ({
kind: 'interrupted',
retractedMessageIds: ['message-1', 'message-2'],
}),
},
},
};
try {
const stop = createAppShellStopAction({
uiLocale: 'en',
activeIdRef: { current: 'session-1' },
addPendingSessionAction: () => true,
clearPendingSessionAction: () => undefined,
setStopPendingBySession: () => undefined,
stopPendingRef: { current: new Set<string>() },
removeTransientMessage: (sessionId, messageId) => removed.push({ sessionId, messageId }),
toastApi: { error() {} },
});

await stop();

assert.deepEqual(removed, [
{ sessionId: 'session-1', messageId: 'message-1' },
{ sessionId: 'session-1', messageId: 'message-2' },
]);
} finally {
target.window = previousWindow;
}
});
72 changes: 64 additions & 8 deletions apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import { createAppShellSessionUiStateController } from '../../renderer/app-shell

test('queue_update events drive the independent desktop queue projection', () => {
const controller = createAppShellSessionUiStateController();
const transientMessages: unknown[] = [];
const removedTransientMessageIds: string[] = [];
const handlers = createAppShellSessionEventHandlers({
uiLocale: 'zh',
activeIdRef: { current: 'session-1' },
Expand All @@ -33,6 +35,9 @@ test('queue_update events drive the independent desktop queue projection', () =>
setLiveTurnBySession: controller.setLiveTurnBySession,
setInteractionBySession: controller.setInteractionBySession,
setMessageQueueBySession: controller.setMessageQueueBySession,
projectQueuedTransientMessages: (_sessionId, messages) => transientMessages.push(...messages),
removeTransientMessage: (_sessionId, messageId) =>
removedTransientMessageIds.push(messageId),
showModelSetupToast() {},
toastApi: { error() {} },
});
Expand All @@ -45,9 +50,6 @@ test('queue_update events drive the independent desktop queue projection', () =>
};
const inFlightEntry = {
...steeringEntry,
entryId: 'entry-delivering',
messageId: 'message-delivering',
content: { text: 'already delivering' },
state: 'in_flight' as const,
};

Expand All @@ -59,7 +61,7 @@ test('queue_update events drive the independent desktop queue projection', () =>
queueRevision: 3,
steering: ['adjust this run'],
followup: ['do this next'],
steeringEntries: [steeringEntry, inFlightEntry],
steeringEntries: [steeringEntry],
followupEntries: [{
entryId: 'entry-next',
messageId: 'message-next',
Expand All @@ -82,17 +84,71 @@ test('queue_update events drive the independent desktop queue projection', () =>
},
],
});
assert.deepEqual(transientMessages, [
{
type: 'user',
id: 'message-steer',
turnId: 'turn-1',
transientPlacement: 'current_turn',
ts: 1,
text: 'adjust this run',
},
{
type: 'user',
id: 'message-next',
turnId: 'message-next',
transientPlacement: 'next_turn',
ts: 1,
text: 'do this next',
},
]);

handlers.handleEvent('session-1', {
type: 'steering_message',
id: 'steering-message-steer',
turnId: 'turn-1',
messageId: 'message-steer',
ts: 2,
content: { text: 'adjust this run' },
});
assert.deepEqual(removedTransientMessageIds, ['message-steer']);

handlers.handleEvent('session-1', {
type: 'queue_update',
id: 'queue-2',
turnId: 'turn-1',
ts: 2,
ts: 3,
queueRevision: 4,
steering: [],
followup: [],
steering: ['adjust this run'],
followup: ['do this next'],
steeringEntries: [inFlightEntry],
followupEntries: [{
entryId: 'entry-next',
messageId: 'message-next',
content: { text: 'do this next' },
placement: 'next_turn',
state: 'queued',
}],
});
assert.deepEqual(controller.getState().messageQueueBySession['session-1']?.entries, [{
entryId: 'entry-next',
messageId: 'message-next',
content: { text: 'do this next' },
placement: 'next_turn',
state: 'queued',
}]);
assert.deepEqual(removedTransientMessageIds, ['message-steer']);
assert.equal(transientMessages.length, 3, 'in-flight queue projection must not re-add the row');

handlers.handleEvent('session-1', {
type: 'message_admission',
id: 'retracted-message-next',
turnId: 'turn-1',
ts: 4,
messageId: 'message-next',
outcome: 'retracted',
});
assert.equal(controller.getState().messageQueueBySession['session-1'], undefined);
assert.deepEqual(removedTransientMessageIds, ['message-steer', 'message-next']);
});

test('complete events deliver the durable context compaction outcome to Desktop', () => {
Expand Down
Loading