Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
73b1b89
fix(remote): send the saved key with every connection and capability …
claude Oct 3, 2026
375da8f
fix(remote): report a refused key instead of an empty model list (ite…
claude Oct 3, 2026
16479c4
fix(remote): an address saved with /v1 checks and probes the right pa…
claude Oct 3, 2026
eddb566
fix(remote): the server list checks each server with its saved key (i…
claude Oct 3, 2026
fe810da
fix(images): remote JPEG and WebP images can be deleted (item 3)
claude Oct 3, 2026
9022536
fix(tools): a refused web search reports a failure, not empty results…
claude Oct 3, 2026
05c2e43
fix(remote): a refused LM Studio thinking probe is an authentication …
claude Oct 3, 2026
0576bf4
fix(remote): a failed key read marks the server's check as failed (it…
claude Oct 3, 2026
8964bc6
fix(remote): keep keyless discovery on private HTTP; explain refusals…
claude Oct 3, 2026
8707dc7
fix(tools): treat HTTP 4xx/5xx search pages as failures by status (it…
claude Oct 3, 2026
10076bd
test(remote): keep two remote-model journeys deterministic on a loade…
claude Oct 3, 2026
354f7a9
fix(remote): keep saved models when a keyed HTTP server refuses disco…
claude Oct 5, 2026
e757604
refactor(remote): one mapping for the two Ollama-shaped discovery paths
claude Oct 5, 2026
7980637
fix(remote): explain a withheld key on every discovery refusal
claude Oct 5, 2026
33c86e0
fix(remote): await Ollama mapping inside its discovery try
claude Oct 5, 2026
08dda04
fix(images): deleting a chat removes its remote JPEG and WebP images …
claude Oct 5, 2026
b38788d
fix(remote): a keyed server on private HTTP fails its check with the …
claude Oct 5, 2026
e885105
chore(chat): mark three fire-and-forget generation calls with void
claude Oct 5, 2026
17f739b
test(images): deleting a chat removes its remote JPEG and WebP files …
claude Oct 5, 2026
bd822af
test(remote): a saved key on private HTTP is explained on every check…
claude Oct 5, 2026
d5b6f4e
test(remote): a saved key works across check, models, restart and pic…
claude Oct 5, 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* Deleting a chat removes the images generated in it, whatever their format (backlog item 3).
*
* A remote server can return JPEG or WebP. The native image store only knows <id>.png, so the
* delete must use each image's saved path. The real chat and app stores and the real chats screen
* run; the device filesystem and the diffusion module are the in-memory native boundary.
*/
import {
installNativeBoundary,
requireRTL,
} from '../../harness/nativeBoundary';

jest.mock('@react-navigation/native', () => ({
useNavigation: () => ({
navigate: () => {},
goBack: () => {},
setOptions: () => {},
addListener: () => () => {},
}),
useFocusEffect: () => {},
useIsFocused: () => true,
}));

describe('Deleting a chat with generated images', () => {
it('removes its remote JPEG and WebP files and keeps another chat’s image', async () => {
const boundary = installNativeBoundary({ fs: true });
const fs = boundary.fs!;
const AsyncStorage = require('@react-native-async-storage/async-storage');
await AsyncStorage.clear();
const updatedAt = '2026-09-15T12:00:00.000Z';
const chat = (id: string, title: string) => ({
id,
title,
modelId: 'remote-model',
messages: [
{ id: `${id}-m`, role: 'user', content: title, timestamp: Date.parse(updatedAt) },
],
createdAt: updatedAt,
updatedAt,
});
await AsyncStorage.setItem(
'local-llm-chat-storage',
JSON.stringify({
state: {
conversations: [chat('chat-trip', 'Trip photos'), chat('chat-logo', 'Logo ideas')],
activeConversationId: null,
},
version: 2,
}),
);

const React = require('react');
const rtl = requireRTL();
const { useChatStore } = require('../../../src/stores/chatStore');
const { useAppStore } = require('../../../src/stores/appStore');
await useChatStore.persist.rehydrate();

const imagesDir = `${fs.DocumentDirectoryPath}/generated_images`;
const saveImage = (id: string, extension: string, conversationId: string) => {
const imagePath = `${imagesDir}/${id}.${extension}`;
fs.seedFile(imagePath, 2048);
useAppStore.getState().addGeneratedImage({
id,
prompt: 'a lake at dawn',
imagePath,
width: 512,
height: 512,
steps: 1,
seed: 1,
modelId: 'remote-model',
createdAt: updatedAt,
conversationId,
});
return imagePath;
};
const tripJpeg = saveImage('img-trip-1', 'jpg', 'chat-trip');
const tripWebp = saveImage('img-trip-2', 'webp', 'chat-trip');
const logoJpeg = saveImage('img-logo-1', 'jpg', 'chat-logo');

const { ChatsListScreen } = require('../../../src/screens/ChatsListScreen');
const chats = rtl.render(React.createElement(ChatsListScreen));

rtl.fireEvent.press(chats.getByLabelText('Select chats'));
rtl.fireEvent.press(chats.getByText('Trip photos'));
expect(chats.getByText('1 selected')).toBeTruthy();
rtl.fireEvent.press(chats.getByLabelText('Delete selected chats'));
await rtl.act(() => new Promise(resolve => setTimeout(resolve, 350)));
rtl.fireEvent.press(chats.getByText('Delete'));

await rtl.waitFor(async () => {
expect(await fs.exists(tripJpeg)).toBe(false);
expect(await fs.exists(tripWebp)).toBe(false);
});
expect(await fs.exists(logoJpeg)).toBe(true);
expect(chats.queryByText('Trip photos')).toBeNull();
expect(chats.getByText('Logo ideas')).toBeTruthy();
await rtl.act(() => new Promise(resolve => setTimeout(resolve, 250)));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,17 @@ import { fireEvent, render, waitFor } from '@testing-library/react-native';
import { RemoteModelOptionsSection } from '../../../src/components/models/RemoteModelOptionsSection';
import { remoteServerManager } from '../../../src/services/remoteServerManager';

// The Desktop's HTTP API is the only boundary. It answers "unreachable" from the start of every test,
// so nothing here can reach a real LAN address (on a CI runner that hangs instead of failing fast).
const unreachable = (async () => { throw new TypeError('Network request failed'); }) as typeof fetch;
// A loaded CI runner renders these screens several times slower than a laptop.
const RENDER_WAIT = { timeout: 8000 };

describe('remote model choice failures', () => {
const originalFetch = global.fetch;
beforeEach(() => {
global.fetch = unreachable;
});
afterEach(async () => {
global.fetch = originalFetch;
await remoteServerManager.clearAllServers();
Expand All @@ -26,13 +35,12 @@ describe('remote model choice failures', () => {

it('says what still works when the Desktop cannot be reached', async () => {
const { view, choose } = await showImageChoice();
global.fetch = (async () => { throw new TypeError('Network request failed'); }) as typeof fetch;
choose();
await waitFor(() => {
expect(view.queryByText(/Could not reach Office Desktop\. Models on this phone still work/)).not.toBeNull();
});
}, RENDER_WAIT);
view.unmount();
});
}, 30000);

it('shows the Desktop rejection instead of calling it offline', async () => {
const { view, choose } = await showImageChoice();
Expand All @@ -44,8 +52,8 @@ describe('remote model choice failures', () => {
choose();
await waitFor(() => {
expect(view.queryByText('This model is not available to this device.')).not.toBeNull();
});
}, RENDER_WAIT);
expect(view.queryByText(/Could not reach Office Desktop/)).toBeNull();
view.unmount();
});
}, 30000);
});
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ const LM_STUDIO_SSE =
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n' +
'data: [DONE]\n\n';

// A loaded CI runner renders the streamed turn several times slower than a laptop; the waits below
// are headroom for that, not for the behaviour under test.
const RENDER_WAIT = { timeout: 20000 };

describe('T049 (rendered) — remote LM Studio reasoning is shown (DEV-B16)', () => {
it('renders the answer and the reasoning the remote model streamed', async () => {
const h = await setupChatScreen({ engine: 'llama', platform: 'android' });
Expand All @@ -41,13 +45,13 @@ describe('T049 (rendered) — remote LM Studio reasoning is shown (DEV-B16)', ()
await h.tapSend('what is 6 times 7');

// The remote answer arrives (proves the remote send + transport ran).
await h.rtl.waitFor(() => { expect(h.view!.queryByText(/The answer is 42/)).not.toBeNull(); }, { timeout: 6000 });
await h.rtl.waitFor(() => { expect(h.view!.queryByText(/The answer is 42/)).not.toBeNull(); }, RENDER_WAIT);

h.rtl.fireEvent.press(h.view!.getByTestId('assistant-work-toggle'));
// SPEC: the reasoning the model actually sent is shown to the user (in the thinking block).
// The panel can appear after the streamed answer; wait for its rendered state.
await h.rtl.waitFor(() => {
expect(h.view!.queryByText(/Thinking Process/)).not.toBeNull();
}, { timeout: 6000 });
});
}, RENDER_WAIT);
}, 60000);
});
70 changes: 70 additions & 0 deletions __tests__/integration/remote/keyedHttpServer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* A saved API key on a private HTTP server (backlog item 1b).
*
* Keys are only sent over HTTPS, so on plain HTTP the app never uses the saved key. Every check
* must say so instead of reporting "Connected" or "the server rejected your key". The real
* endpoint check and model discovery run; the remote server is the only fake, answering at the
* fetch boundary the way a keyed llama.cpp/Ollama server on the LAN would.
*/
import { testEndpoint } from '../../../src/services/httpClientUtils';
import { fetchModelsFromServer } from '../../../src/stores/remoteServerHelpers';
import { HTTP_API_KEY_ERROR } from '../../../src/services/remoteTransportPolicy';
import type { RemoteServer } from '../../../src/types/remoteServer';

const ENDPOINT = 'http://192.168.1.40:11434';
const realFetch = globalThis.fetch;

/** A LAN server that lists its models openly but refuses every other request without a key. */
function startLanServer(): { requests: string[] } {
const requests: string[] = [];
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url);
requests.push(`${init?.method ?? 'GET'} ${url.pathname}`);
if (url.pathname === '/v1/models') {
return new Response(JSON.stringify({ models: [{ name: 'llama3.2:3b' }] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response('Unauthorized', { status: 401 });
}) as typeof globalThis.fetch;
return { requests };
}

const server = (apiKey?: string): RemoteServer => ({
id: 'lan-ollama',
name: 'LAN Ollama',
endpoint: ENDPOINT,
apiKey,
providerType: 'openai-compatible',
createdAt: '2026-09-28T10:00:00.000Z',
});

afterEach(() => {
globalThis.fetch = realFetch;
});

describe('a saved key on a private HTTP server', () => {
it('fails the connection check with the HTTPS message, without contacting the server', async () => {
const lan = startLanServer();

const result = await testEndpoint(ENDPOINT, 5000, 'saved-key');

expect(result).toEqual({ success: false, error: HTTP_API_KEY_ERROR });
expect(lan.requests).toEqual([]);
});

it('still connects to the same server when no key is saved', async () => {
startLanServer();

const result = await testEndpoint(ENDPOINT, 5000);

expect(result.success).toBe(true);
});

it('explains a refused capability check with the HTTPS rule, not as a rejected key', async () => {
startLanServer();

await expect(fetchModelsFromServer(server('saved-key'))).rejects.toThrow(HTTP_API_KEY_ERROR);
});
});
Loading
Loading