Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
6eb5a46
fix(desktop): clarify message delivery and restore failed drafts
jackeyfaker77 Sep 8, 2026
43ec415
fix(desktop): preserve recovery intent and persist queue retractions
jackeyfaker77 Sep 9, 2026
c285378
fix(desktop): reconcile cancellation across host restarts
jackeyfaker77 Sep 9, 2026
938d616
test(desktop): assert durable Host queue entries
jackeyfaker77 Sep 12, 2026
9e3d611
fix(desktop): retire cancelled queue messages without a turn
jackeyfaker77 Sep 12, 2026
00331e9
fix(desktop): adapt delivery recovery to upstream contracts
jackeyfaker77 Sep 19, 2026
5846f4b
fix(desktop): persist successful Stop cleanup across Host epochs
jackeyfaker77 Sep 22, 2026
872942d
fix(desktop): avoid restoring messages already owned by the Host queue
jackeyfaker77 Sep 22, 2026
6062691
fix(desktop): keep message recovery within the conversation boundary
jackeyfaker77 Sep 22, 2026
74f49b5
test(desktop): align recovery journeys with upstream lifecycle
jackeyfaker77 Sep 22, 2026
e16b162
test(desktop): wait for the durable answer before going offline
jackeyfaker77 Sep 22, 2026
ed8783c
Merge upstream main and regenerate renderer architecture ledger
jackeyfaker77 Sep 22, 2026
1cdf490
fix(desktop): protect recovered drafts and queue delivery feedback
jackeyfaker77 Sep 22, 2026
9238cf0
fix(ui): keep local queue recovery readable in narrow panes
jackeyfaker77 Sep 22, 2026
98a720c
fix(desktop): guard live pending context after syncing upstream
jackeyfaker77 Sep 26, 2026
cd56e5d
fix(desktop): address local message recovery review findings
jackeyfaker77 Sep 26, 2026
63cae5f
merge: sync upstream and finalize recovery attachment lifecycle
jackeyfaker77 Sep 26, 2026
b8f653b
test(desktop): align recovery e2e with ordinary send placement
jackeyfaker77 Sep 26, 2026
9f66c84
fix(desktop): reconcile message recovery with upstream queue lifecycle
jackeyfaker77 Sep 28, 2026
96741de
chore(ui): refresh composer queue Astryx inventory
jackeyfaker77 Sep 28, 2026
0c14ca3
fix(desktop): preserve paused unsent messages during editing
jackeyfaker77 Sep 29, 2026
0d7f2b7
merge: integrate main queue rewrite with local message recovery
jackeyfaker77 Sep 29, 2026
b3fd1d9
fix(desktop): protect paused originals while editing
jackeyfaker77 Sep 29, 2026
4beb7a8
fix(desktop): keep paused outbox messages inert on downgrade
jackeyfaker77 Sep 29, 2026
3223af1
Merge main into PR 5058 preserving composer recovery protections
jackeyfaker77 Sep 29, 2026
e9107b8
fix(desktop): clarify paused message deletion guidance
jackeyfaker77 Sep 29, 2026
a345703
Merge main conversation ownership changes into PR 5058
jackeyfaker77 Oct 2, 2026
66d9146
Merge main and preserve recovery through composer staging owner
jackeyfaker77 Oct 2, 2026
49af978
test(desktop): preserve injected services in composer staging fixtures
jackeyfaker77 Oct 2, 2026
86f9b2d
test(desktop): provide conversation services in shared-session stories
jackeyfaker77 Oct 2, 2026
5ead6ce
fix(ui): preserve full text in replacement drafts
jackeyfaker77 Oct 2, 2026
867299d
Merge main and preserve recovery in submission owner
jackeyfaker77 Oct 7, 2026
afbee24
Merge main and preserve local recovery shutdown
jackeyfaker77 Oct 9, 2026
087baf6
test(desktop): preserve retry fixture services and toast observers
jackeyfaker77 Oct 9, 2026
36a6afe
Merge main and preserve recovery alongside revision quotes
jackeyfaker77 Oct 12, 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
4 changes: 2 additions & 2 deletions apps/desktop/e2e-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@
"electron": "needs a second real Session (Host round trip) to switch to; the focus and draft-restore halves alone would not earn a window"
},
"session-local-recovery.spec.ts": {
"tests": 3,
"electron": "SQLite admission crosses preload and main, survives both renderer reload and complete Electron restart, and cached history must render when the live IPC endpoint is unavailable"
"tests": 6,
"electron": "SQLite admission crosses preload and main, survives renderer reload and complete Electron restart, and cached history renders without live IPC. Failed-message recovery reads original bytes, fences drafts, and preserves one-shot slash intent through the real composer/Main path. Stop retractions remain retired across offline navigation and application restart. Failed-message alignment with transcript turns is covered by the FailedLocalMessage Storybook play function."
},
"session-workbar.spec.ts": {
"tests": 5,
Expand Down
7 changes: 4 additions & 3 deletions apps/desktop/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,7 @@ export async function withE2eWindow(
/** Attaches captured main/renderer console output when the test fails. */
testInfo?: TestInfo;
},
use: (page: Page, context: { userDataDir: string; app: ElectronApplication; restart(): Promise<Page> }) => Promise<void>,
use: (page: Page, context: { userDataDir: string; app: ElectronApplication; restart(beforeReady?: (app: ElectronApplication) => Promise<void>): Promise<Page> }) => Promise<void>,
): Promise<void> {
const userDataDir = await mkdtemp(path.join(tmpdir(), 'maka-e2e-'));
// Lives inside the throwaway userData dir so the existing teardown removes
Expand Down Expand Up @@ -532,7 +532,7 @@ export async function withE2eWindow(
throw new Error(`${detail}${mainDetail}${rendererDetail}`, { cause: error });
}
try {
await use(page, { userDataDir, app, restart: async () => {
await use(page, { userDataDir, app, restart: async (beforeReady) => {
await closeElectronApplication(app!, 5_000);
app = await electron.launch({
args: ['.', ...(visibleWindow ? inactiveWindowPlatformArgs() : [])],
Expand All @@ -545,6 +545,7 @@ export async function withE2eWindow(
if (rendererLogs.length > 30) rendererLogs.shift();
});
app.context().on('page', watchCrash);
await beforeReady?.(app);
await restored.waitForSelector(readinessSelector, { timeout: readinessTimeoutMs });
return restored;
} });
Expand Down Expand Up @@ -579,7 +580,7 @@ export async function withE2eWindow(
}

type E2eTestFixtures = {
sessionLocalWindow: { page: Page; app: ElectronApplication; restart(): Promise<Page> };
sessionLocalWindow: { page: Page; app: ElectronApplication; restart(beforeReady?: (app: ElectronApplication) => Promise<void>): Promise<Page> };
window: Page;
gitReviewWindow: { page: Page; projectRoot: string };
invocableSkillsWindow: Page;
Expand Down
316 changes: 314 additions & 2 deletions apps/desktop/e2e/session-local-recovery.spec.ts

Large diffs are not rendered by default.

64 changes: 64 additions & 0 deletions apps/desktop/src/main/__tests__/app-shell-revision-resend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ function userMessage(turnId: string, text: string): StoredMessage {
}

interface RevisionWorld {
ports: RevisionSendPorts<TurnRevisionDraft>;
revisionActions: {
beginEditUserMessage: (turnId: string) => void;
};
Expand Down Expand Up @@ -259,6 +260,7 @@ async function mountRevisionWorld(): Promise<RevisionWorld> {
retractedWorkspaceReferencesRef: { current: {} },
captureStaging: () => ({
draftKey: 'draft',
retainAttachments: () => () => {},
hasPendingContext: false,
hasStagedQuotes: false,
submittableAttachments: undefined,
Expand Down Expand Up @@ -355,6 +357,7 @@ async function mountRevisionWorld(): Promise<RevisionWorld> {
});

return {
ports,
revisionActions,
composer,
document,
Expand Down Expand Up @@ -413,3 +416,64 @@ test('resending an unchanged revision goes through reviseBeforeTurn and the norm
world.restoreWindow();
}
});

for (const text of ['edited message', '/swarm task edited message', '/graph task edited message']) {
test(`submit preserves replacement and attachment ownership: ${text}`, async () => {
const world = await mountRevisionWorld();
try {
const staging = { ...world.ports.captureStaging(), submittableAttachments: [{
stagingKey: 'restored-file', displayName: 'notes.txt', kind: 'doc', size: 4,
source: { type: 'approval', approvalId: 'restored-approval', name: 'notes.txt' },
}] as NonNullable<ReturnType<typeof world.ports.captureStaging>['submittableAttachments']> };
const events: string[] = [];
let finish!: (ok: boolean) => void;
const result = new Promise<boolean>((resolve) => { finish = resolve; });
const onSend = createRevisionAwareOnSend({
...world.ports,
captureStaging: () => ({
...staging,
clearSubmittedContext: () => { assert.fail('refused send must retain context'); },
retainAttachments: (pending) => {
assert.equal(pending, staging.submittableAttachments);
events.push('retain');
return () => { events.push('release'); };
},
}),
setNewTaskSendPending: (pending) => { events.push(`pending:${pending}`); },
send: async (_text, _pending, options) => {
assert.equal(_pending, staging.submittableAttachments);
assert.equal(options?.replacesLocalMessageId, 'paused-original');
events.push('send');
return result;
},
});
const sent = onSend(text, { replacesLocalMessageId: 'paused-original' });
assert.deepEqual(events, ['retain', 'pending:true', 'send']);
finish(false);
assert.equal(await sent, false);
assert.deepEqual(events, ['retain', 'pending:true', 'send', 'release', 'pending:false']);
} finally {
world.restoreWindow();
}
});
}

test('submit releases attachment ownership when sending throws', async () => {
const world = await mountRevisionWorld();
try {
const events: string[] = [];
const onSend = createRevisionAwareOnSend({
...world.ports,
captureStaging: () => ({ ...world.ports.captureStaging(), retainAttachments: () => {
events.push('retain');
return () => { events.push('release'); };
} }),
setNewTaskSendPending: (pending) => { events.push(`pending:${pending}`); },
send: async () => { throw new Error('send failed'); },
});
await assert.rejects(onSend('edited message'), /send failed/);
assert.deepEqual(events, ['retain', 'pending:true', 'release', 'pending:false']);
} finally {
world.restoreWindow();
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ const OPTIMISTIC_BUBBLE: TransientUserMessageProjection = {
transientPlacement: 'transcript',
};

const ACTIVE_SESSION: NonNullable<ComponentProps<typeof ChatView>['activeSession']> = {
id: 'session-1', name: 'pending', status: 'active', backend: 'ai-sdk',
labels: [], isFlagged: false, isArchived: false, hasUnread: false,
llmConnectionSlug: 'conn', connectionLocked: false, model: 'model', permissionMode: 'ask',
};

test('ChatView renders the optimistic bubble and running status before a session exists', () => {
const markup = renderChatView({
transientMessages: [{ ...OPTIMISTIC_BUBBLE, hostTurnId: 'turn-1' }],
Expand Down Expand Up @@ -111,11 +117,7 @@ test('a prompt on its way to the Host holds its Turn place beside a copy that di

test('ordinary sends stay in ChatView while queued prompts stay in the composer', () => {
const render = (message: TransientUserMessageProjection) => parseHTML(renderChatView({
activeSession: {
id: 'session-1', name: 'pending', status: 'active', backend: 'ai-sdk',
labels: [], isFlagged: false, isArchived: false, hasUnread: false,
llmConnectionSlug: 'conn', connectionLocked: false, model: 'model', permissionMode: 'ask',
},
activeSession: ACTIVE_SESSION,
transientMessages: [message],
}, createElement(Composer, {
pendingMessages: [message], onSend() {}, onStop() {},
Expand All @@ -138,3 +140,27 @@ test('ordinary sends stay in ChatView while queued prompts stay in the composer'
assert.ok(followUp.querySelector('.maka-composer-queue')?.textContent?.includes(OPTIMISTIC_BUBBLE.text));
assert.equal(followUp.querySelector('.maka-user-message'), null, 'a follow-up stays out of the transcript');
});

test('failed local feedback remains outside a Host Turn while ordinary prompts group by its identity', () => {
for (const failed of [false, true]) {
const message: TransientUserMessageProjection = {
...OPTIMISTIC_BUBBLE, hostTurnId: 'host-turn',
...(failed ? {
deliveryStatus: 'Message not sent', deliveryTone: 'danger',
deliveryActions: [{ label: 'Edit and resend', onClick() {} }],
} as const : {}),
};
const { document } = parseHTML(renderChatView({
activeSession: ACTIVE_SESSION,
messages: [{ type: 'turn_state', id: 'started', turnId: 'host-turn', ts: 2, status: 'running' }],
activeTurn: { turnId: 'host-turn' },
transientMessages: [message],
}));
const prompt = document.querySelector('[data-transient-message-id="turn-1"]')!;
assert.ok(prompt, 'the local prompt remains visible exactly once');
assert.equal(document.querySelectorAll('[data-transient-message-id="turn-1"]').length, 1);
assert.equal(Boolean(prompt.closest('[data-transcript-turn-id="host-turn"]')), !failed);
assert.equal(Boolean(prompt.querySelector('.maka-message-delivery')), failed);
if (failed) assert.match(prompt.textContent!, /Edit and resend/);
}
});
43 changes: 41 additions & 2 deletions apps/desktop/src/main/__tests__/composer-directories.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,14 @@
* under the License.
*/

import { useComposerAttachments } from '../../renderer/features/conversation/testing.js';
import { stubConversationServices, useComposerAttachments } from '../../renderer/features/conversation/testing.js';
import assert from 'node:assert/strict';
import { afterEach, test } from 'node:test';
import { act, createElement } from 'react';
import { LocaleProvider } from '@maka/ui';
import { normalizeSessionSendCommand } from '../permission-response-guard.js';
import {
ConversationServicesProvider,
type ComposerAttachmentService,
} from '../../renderer/features/conversation/index.js';
import { cleanupFakeDom, installReactRenderer } from './fake-dom.js';
Expand All @@ -34,6 +35,22 @@ type Picker = NonNullable<ComposerAttachmentService['pickDirectory']>;
type Options = { draftKey: string; hostId?: string; pick: Picker };
type State = ReturnType<typeof useComposerAttachments>;
const reference = { hostId: 'host-a', path: '/workspace/source' };
const services = stubConversationServices({
listMessages: async () => [],
readFailedMessage: async () => { throw new Error('unused'); },
releaseRecoveryAttachments: async () => {},
cancelMessage: async () => {}, reconcileMessage: async () => {}, subscribeChanges: () => () => {},
sessions: {
readSnapshot: async () => { throw new Error('unused'); },
readExecutionBoundary: async () => { throw new Error('unused'); },
},
runtimeHosts: { subscribeChanges: () => () => {} },
skills: { listInvocable: async () => [] },
workspace: { searchFiles: async () => ({ ok: false, reason: 'no_project' }) },
newTasks: { subscribeChanges: () => () => {}, listInvocableSkills: async () => [],
searchFiles: async () => ({ ok: false, reason: 'no_project' }) },
mcp: { subscribeChanges: () => () => {} },
});

async function mount(initial: Partial<Options> = {}) {
const { root } = installReactRenderer();
Expand All @@ -60,7 +77,9 @@ async function mount(initial: Partial<Options> = {}) {
}
const render = async (patch: Partial<Options> = {}) => {
options = { ...options, ...patch };
await act(() => root.render(createElement(LocaleProvider, { locale: 'en', children: createElement(Probe) })));
await act(() => root.render(createElement(LocaleProvider, { locale: 'en',
children: createElement(ConversationServicesProvider, { services, children: createElement(Probe) }),
})));
};
await render();
return { state: () => state, render, errors };
Expand Down Expand Up @@ -126,6 +145,26 @@ test('caps concurrent picker results and clearing a submitted draft keeps newer
assert.equal(probe.state().pendingDirectories.length, 1, 'must not clear a reference added after send');
});

test('live directory context is Host-scoped and submitted cleanup preserves newer references', async () => {
const probe = await mount();
await act(() => probe.state().directoryComposerProps.onPickDirectory!());
const submitted = probe.state();
assert.equal(submitted.hasPendingContextNow(), true);
await probe.render({ hostId: 'host-b' });
assert.equal(probe.state().hasPendingContextNow(), false);
await probe.render({ hostId: 'host-a', pick: async () => ({
ok: true, reference: { ...reference, path: '/workspace/new' },
}) });
await act(async () => {
await probe.state().directoryComposerProps.onPickDirectory!();
submitted.clearSubmittedContext();
assert.equal(probe.state().hasPendingContextNow(), true);
probe.state().directoryComposerProps.onRemoveDirectory(0);
assert.equal(probe.state().hasPendingContextNow(), false);
});
assert.deepEqual(probe.state().pendingDirectories, []);
});

test('IPC validates directory references without turning them into attachments or permissions', () => {
const normalized = normalizeSessionSendCommand({
type: 'send', text: 'inspect', directoryReferences: [reference],
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/main/__tests__/composer-mentions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ function installCatalogRenderer(t: TestContext) {
value: sessionCatalog,
children: createElement(ComposerStagingFixture, {
draftKey: sessionId,
conversationServices: services,
children: createElement(ComposerMentionsProvider, {
sessionId,
projectPath,
Expand Down
12 changes: 8 additions & 4 deletions apps/desktop/src/main/__tests__/composer-staging-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@
import { createElement, useMemo, type ReactNode } from 'react';
import { AstryxLocaleProvider, ToastProvider } from '@maka/ui';
import {
ComposerStagingProvider, ComposerStagingServicesProvider, createComposerStagingCommands,
type ComposerStagingCommands, type ComposerStagingServices,
ComposerStagingProvider, ComposerStagingServicesProvider, createComposerStagingCommands, ConversationServicesProvider,
type ComposerStagingCommands, type ComposerStagingServices, type ConversationServices,
} from '../../renderer/features/conversation/index.js';

import { stubConversationServices } from '../../renderer/features/conversation/testing.js';

const emptyServices: ComposerStagingServices = {
pickFiles: async () => ({ ok: false, reason: 'cancelled' }),
previewApproval: async () => ({ ok: false, reason: 'unavailable' }),
Expand All @@ -36,10 +38,12 @@ export function ComposerStagingFixture(props: {
readonly directoryHostId?: string;
readonly commands?: ComposerStagingCommands;
readonly services?: ComposerStagingServices;
readonly conversationServices?: ConversationServices;
readonly children?: ReactNode;
}) {
const fallbackCommands = useMemo(createComposerStagingCommands, []);
return createElement(AstryxLocaleProvider, {
const fallbackServices = useMemo(() => stubConversationServices(), []);
return createElement(ConversationServicesProvider, { services: props.conversationServices ?? fallbackServices, children: createElement(AstryxLocaleProvider, {
children: createElement(ToastProvider, {
children: createElement(ComposerStagingServicesProvider, {
services: props.services ?? emptyServices,
Expand All @@ -51,5 +55,5 @@ export function ComposerStagingFixture(props: {
}),
}),
}),
});
}) });
}
Loading