Skip to content
Merged
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
43 changes: 41 additions & 2 deletions apps/desktop/src/main/__tests__/quote-companion-cleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,44 @@ afterEach(async () => {
});

describe('quote companion cleanup authority', () => {
it('forgets a known rejected creation without trying to resume or remove it', async () => {
const workspaceRoot = await createWorkspace();
let resumes = 0;
let removals = 0;
const authority = createSessionCopyCleanupAuthority({
workspaceRoot,
resumeSessionCopy: async () => {
resumes += 1;
},
removeSession: async () => {
removals += 1;
},
});
const creation = {
sessionId: 'fork-rejected',
kind: 'branch' as const,
sourceSessionId: 'source-session',
sourceTurnId: 'source-turn',
intent: 'side_conversation' as const,
ownerId: 'web-contents:1',
};

await assert.rejects(
authority.ownCreation(creation, async () => {
throw new Error('session busy');
}),
/session busy/,
);
assert.deepEqual(await readPendingIds(workspaceRoot), ['fork-rejected']);

await authority.rejectCreation('fork-rejected');

assert.deepEqual(await readPendingIds(workspaceRoot), []);
assert.equal(resumes, 0);
assert.equal(removals, 0);
assert.equal(await authority.ownCreation(creation, async () => 'retried'), 'retried');
});

it('releases a rejected creation lease so the same identity can retry', async () => {
const workspaceRoot = await createWorkspace();
let removalFails = true;
Expand Down Expand Up @@ -124,6 +162,7 @@ describe('quote companion cleanup authority', () => {
kind: 'branch',
sourceSessionId: 'source-session',
sourceTurnId: 'source-turn',
intent: 'side_conversation',
ownerId: 'web-contents:2',
},
async () => {
Expand All @@ -138,7 +177,7 @@ describe('quote companion cleanup authority', () => {
workspaceRoot,
processId: 'process-after-crash',
resumeSessionCopy: async (creation) => {
events.push(`resume:${creation.sessionId}:${creation.sourceTurnId}`);
events.push(`resume:${creation.sessionId}:${creation.sourceTurnId}:${creation.intent}`);
},
removeSession: async (sessionId) => {
events.push(`remove:${sessionId}`);
Expand All @@ -150,7 +189,7 @@ describe('quote companion cleanup authority', () => {
failed: [],
});
assert.deepEqual(events, [
'resume:fork-unknown-create:source-turn',
'resume:fork-unknown-create:source-turn:side_conversation',
'remove:fork-unknown-create',
]);
assert.deepEqual(await readPendingIds(workspaceRoot), []);
Expand Down
23 changes: 22 additions & 1 deletion apps/desktop/src/main/__tests__/quote-companion-disposal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type { SessionSummary, TurnRecord } from '@maka/core/session';
import {
abandonPendingCompanionCopy,
createFakeWorkbarServices,
ensureCompanionFork,
performCompanionTurn,
type PerformCompanionTurnDeps,
type WorkbarServices,
Expand Down Expand Up @@ -92,6 +93,26 @@ afterEach(async () => {
});

describe('quote companion disposal fencing', () => {
it('preserves a retryable busy reason from Side Conversation creation', async () => {
const defaults = createFakeWorkbarServices();
const sideChat = {
...defaults.sideChat,
listTurns: async () => [settledTurn('source-turn')],
branchFromTurn: async () => ({ ok: false as const, reason: 'session_busy' as const }),
};

assert.deepEqual(
await ensureCompanionFork({
api: sideChat,
sourceSession,
panelId,
name: 'Side chat',
isDisposed: () => false,
}),
{ status: 'error', code: 'fork_source_busy' },
);
});

it('does not start a send when the panel was disposed after fork setup', async () => {
let sends = 0;
let armed = 0;
Expand Down Expand Up @@ -152,7 +173,7 @@ describe('quote companion disposal fencing', () => {
const sideChat = {
...defaults.sideChat,
listTurns: async () => [settledTurn('source-turn')],
branchFromTurn: () => pendingFork.promise,
branchFromTurn: async () => ({ ok: true as const, session: await pendingFork.promise }),
cleanupSessionCopy: async (sessionId: string) => {
cleaned.push(sessionId);
},
Expand Down
261 changes: 261 additions & 0 deletions apps/desktop/src/main/__tests__/quote-companion-retry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
/*
* 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 { afterEach, test } from 'node:test';
import { parseHTML } from 'linkedom';
import { act, createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { SessionChangedEvent, SessionSummary, TurnRecord } from '@maka/core/session';
import {
createFakeWorkbarServices,
useQuoteCompanion,
WorkbarServicesProvider,
type WorkbarServices,
} from '../../renderer/features/workbar/testing.js';

const originalGlobals = {
document: globalThis.document,
window: globalThis.window,
HTMLElement: globalThis.HTMLElement,
HTMLIFrameElement: globalThis.HTMLIFrameElement,
Event: globalThis.Event,
Node: globalThis.Node,
IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT,
};

let mountedRoot: Root | undefined;
const SOURCE_SESSION = session('source-session');

afterEach(async () => {
if (mountedRoot) {
await act(async () => {
mountedRoot?.unmount();
await Promise.resolve();
});
}
mountedRoot = undefined;
Object.assign(globalThis, originalGlobals);
});

test('retries a busy Side Conversation at the newest settled boundary and clears its banner', async () => {
const parsed = parseHTML('<html><body><div id="root"></div></body></html>');
const { document, window } = parsed;
Object.assign(globalThis, {
document,
window,
HTMLElement: window.HTMLElement,
HTMLIFrameElement: window.HTMLIFrameElement ?? class HTMLIFrameElement {},
Event: window.Event,
Node: window.Node,
IS_REACT_ACT_ENVIRONMENT: true,
});

let listCount = 0;
let sessionChange: ((event: SessionChangedEvent) => void) | undefined;
let releaseRetry: (() => void) | undefined;
const branchInputs: Array<{ sourceTurnId: string; copyId: string }> = [];
const defaults = createFakeWorkbarServices();
const services: WorkbarServices = {
...defaults,
sideChat: {
...defaults.sideChat,
listTurns: async () => {
listCount += 1;
return listCount === 1
? [settledTurn('turn-before-busy')]
: [settledTurn('turn-before-busy'), settledTurn('turn-after-busy')];
},
branchFromTurn: async (_sessionId, input) => {
branchInputs.push({ sourceTurnId: input.sourceTurnId, copyId: input.copyId });
if (branchInputs.length === 1) {
return { ok: false as const, reason: 'session_busy' as const };
}
await new Promise<void>((resolve) => {
releaseRetry = resolve;
});
return { ok: true as const, session: session('side-conversation') };
},
subscribeSessionChanges: (handler) => {
sessionChange = handler;
return () => {
if (sessionChange === handler) sessionChange = undefined;
};
},
},
};
const container = document.querySelector('#root');
assert.ok(container);
const root = createRoot(container);
mountedRoot = root;

await act(async () => {
root.render(
createElement(WorkbarServicesProvider, {
services,
children: createElement(QuoteCompanionProbe),
}),
);
await Promise.resolve();
});
await waitUntil(() => branchInputs.length === 1 && sessionChange !== undefined);
assert.match(container.textContent, /main conversation or a linked task is still running/i);
const probe = container.firstElementChild;
assert.ok(probe);

await act(async () => {
sessionChange?.({
reason: 'turn-status-change',
sessionId: 'source-session',
turnId: 'turn-after-busy',
ts: Date.now(),
});
await Promise.resolve();
});
await waitUntil(() => branchInputs.length === 2 && releaseRetry !== undefined);
assert.equal(probe.getAttribute('data-preparing'), 'false');
assert.match(container.textContent, /main conversation or a linked task is still running/i);

await act(async () => {
releaseRetry?.();
await Promise.resolve();
});
await waitUntil(
() => probe.getAttribute('data-companion-id') === 'side-conversation',
() =>
`branch inputs: ${JSON.stringify(branchInputs)}; companion: ${probe.getAttribute('data-companion-id')}; error: ${probe.getAttribute('data-error')}`,
);

assert.deepEqual(
branchInputs.map(({ sourceTurnId }) => sourceTurnId),
['turn-before-busy', 'turn-after-busy'],
);
assert.notEqual(branchInputs[0]?.copyId, branchInputs[1]?.copyId);
assert.equal(probe.getAttribute('data-error'), '');
});

test('does not restart foreground setup when the source Session object refreshes', async () => {
const parsed = parseHTML('<html><body><div id="root"></div></body></html>');
const { document, window } = parsed;
Object.assign(globalThis, {
document,
window,
HTMLElement: window.HTMLElement,
HTMLIFrameElement: window.HTMLIFrameElement ?? class HTMLIFrameElement {},
Event: window.Event,
Node: window.Node,
IS_REACT_ACT_ENVIRONMENT: true,
});

let branchCount = 0;
const defaults = createFakeWorkbarServices();
const services: WorkbarServices = {
...defaults,
sideChat: {
...defaults.sideChat,
listTurns: async () => [settledTurn('settled-turn')],
branchFromTurn: async () => {
branchCount += 1;
if (branchCount === 1) {
return { ok: false as const, reason: 'session_busy' as const };
}
return await new Promise<never>(() => undefined);
},
},
};
const container = document.querySelector('#root');
assert.ok(container);
const root = createRoot(container);
mountedRoot = root;

const render = (sourceSession: SessionSummary) =>
root.render(
createElement(WorkbarServicesProvider, {
services,
children: createElement(QuoteCompanionProbe, { sourceSession }),
}),
);

await act(async () => {
render(session('source-session'));
await Promise.resolve();
});
const probe = container.firstElementChild;
assert.ok(probe);
await waitUntil(
() => branchCount === 1 && probe.getAttribute('data-preparing') === 'false',
);

await act(async () => {
render(session('source-session'));
await Promise.resolve();
});

assert.equal(branchCount, 1);
assert.equal(probe.getAttribute('data-preparing'), 'false');
});

function QuoteCompanionProbe(props: { sourceSession?: SessionSummary }) {
const companion = useQuoteCompanion({
panelId: 'retry-panel',
pendingQuotes: [],
sourceSession: props.sourceSession ?? SOURCE_SESSION,
locale: 'en',
onQuotesConsumed: () => undefined,
});
return createElement('div', {
'data-error': companion.error ?? '',
'data-companion-id': companion.companionSession?.id ?? '',
'data-preparing': String(companion.preparing),
}, companion.error);
}

function session(id: string): SessionSummary {
return {
id,
name: id,
isFlagged: false,
isArchived: false,
labels: [],
hasUnread: false,
status: 'active',
backend: 'ai-sdk',
llmConnectionSlug: 'test',
connectionLocked: false,
model: 'test-model',
permissionMode: 'ask',
};
}

function settledTurn(turnId: string): TurnRecord {
return { turnId, status: 'completed', partialOutputRetained: false };
}

async function waitUntil(predicate: () => boolean, diagnostics?: () => string): Promise<void> {
for (let attempt = 0; attempt < 50; attempt += 1) {
if (predicate()) return;
await act(async () => {
await new Promise<void>((resolve) => setImmediate(resolve));
});
}
assert.fail(
`Timed out waiting for the Side Conversation state${diagnostics ? ` (${diagnostics()})` : ''}`,
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ test('drives the renderer Session catalog facade through real UDS framing', asyn
completeComputerUseTurn() {},
createSessionCopyCleanup: () => ({
ownCreation: (_creation, operation) => operation(),
rejectCreation: async () => undefined,
cleanup: async () => undefined,
schedule: async () => undefined,
abandonOwner: async () => undefined,
Expand Down Expand Up @@ -571,6 +572,7 @@ function ipcHarness() {
function unusedSessionCopyCleanup() {
return {
ownCreation: async <T>(_creation: unknown, operation: () => Promise<T>) => operation(),
async rejectCreation() {},
async cleanup() {},
async schedule() {},
async abandonOwner() {},
Expand Down
Loading