aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-10-27 14:41:20 +0100
committerAgando <roduccomm@gmail.com>2026-10-27 14:41:20 +0100
commitd2ec6fe99cc6f79013e689732fdc7f77089a8ed7 (patch)
treebfff314d01267162face828af2b7910bdae1a8c6
parentabe6cd64666e1fa61cc79ea4392cd78339e581d8 (diff)
fix(newos4): name the real Ollama failure, bound auto-connect, fix the review findings
Second pass over v4.6.105, driven by an independent review that reproduced each item. - ollama-manager.js: chat() now reports the captured cause ("Ollama error: Failed to fetch", "…: HTTP 500") instead of the generic "No provider responded"; _lastChatError is reset per call so an old reason is never replayed. chatWithTools carries the reason in its error string. - ollama-manager.js: _ensureConnected() bounds connect() with a deadline (connectTimeoutMs, default 10 s). connect() uses a bare fetch(), so a blackholed endpoint previously left the shared in-flight promise pending forever and hung every later message — the old code failed fast. The attempt is now try/finally, so a throwing terminal.log() can no longer poison _connecting and suppress the retry on the next message. - ollama-manager.js: an automatic connect no longer silently rewrites the user's saved model; the previous choice is restored in memory and in localStorage, and the user is told. - ollama-manager.js: the DeepSeek empty-content branch returned top-level tool_calls with content '', so the caller pushed an assistant message with no tool_calls and then a role:'tool' reply — an orphan tool message, which is the HTTP 400 that branch exists to prevent. tool_calls now live inside the message as well. - module-aiassist.js: a no-argument list_files returned the ROOT instead of the cwd (readDir('/') instead of ls()); it now means the current directory again. Error text keeps the path as the caller wrote it, and write_file distinguishes a read-only virtual path (/sys/…) from a missing parent instead of blaming the parent. Tests: test-ollama-error-text.js (new, 14 asserts, RED 9/5 before the edit), test-ai-fs-tools.js 7 → 16 asserts (write onto a directory, /sys write, list a file, '/', no-arg cwd, relative path), test-ollama-autoconnect.js 11 → 19 (blackholed endpoint settles + retries, saved model survives; the old "connecting hint" assertion is now falsifiable — the reviewer showed it also matched connect()'s pre-existing line). Suite: 10/10 files green. Live on v4.6.106: healthy auto-connect 42 ms with the hint; blackholed endpoint settles in 1242 ms with connectTimeoutMs=1200, prints "timed out" and "unreachable", _connecting cleared.
-rw-r--r--main.html6
-rw-r--r--modules/core/core-bundle.js91
-rw-r--r--modules/core/module-aiassist.js12
-rw-r--r--modules/core/module-loader.js2
-rw-r--r--modules/core/ollama-manager.js79
-rw-r--r--modules/test/test-ai-fs-tools.js52
-rw-r--r--modules/test/test-ollama-autoconnect.js34
-rw-r--r--modules/test/test-ollama-error-text.js114
8 files changed, 343 insertions, 47 deletions
diff --git a/main.html b/main.html
index 8f20ac2..a7c7b04 100644
--- a/main.html
+++ b/main.html
@@ -1349,13 +1349,13 @@
</script>
<!-- v4.5.63: A2A scratchpad fixes + agent-register arg fix -->
-<script src="modules/core/core-bundle.js?v=v4.6.105"></script>
+<script src="modules/core/core-bundle.js?v=v4.6.106"></script>
<!-- Async dependency-ordered module loader now only initializes terminal and loads optional ext modules -->
-<script src="modules/core/module-loader.js?v=v4.6.105"></script>
+<script src="modules/core/module-loader.js?v=v4.6.106"></script>
<!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work -->
-<script src="modules/ext/critical-ext-bundle.js?v=v4.6.105"></script>
+<script src="modules/ext/critical-ext-bundle.js?v=v4.6.106"></script>
<script>
// ═══ ONE TRUE VERSION SOURCE ═══
diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js
index 0d3a783..a057332 100644
--- a/modules/core/core-bundle.js
+++ b/modules/core/core-bundle.js
@@ -1617,14 +1617,37 @@ class OllamaManager {
try { await this._connecting; } catch (e) { /* fall through to the state check */ }
return this.connected;
}
- this.terminal.print('Connecting to Ollama…', 'info');
- this._connecting = this.connect();
- try { await this._connecting; } catch (e) {
- this.terminal.log('WARN', 'Ollama', 'Auto-connect failed: ' + e.message);
+ // An automatic connect must not silently rewrite the model the user picked.
+ const savedModel = (typeof localStorage !== 'undefined') ? localStorage.getItem('webx11_ollama_model') : null;
+ this.terminal.print('Ollama: connecting on first use…', 'info');
+ let timedOut = false;
+ try {
+ this._connecting = this.connect();
+ // connect() uses a bare fetch(), so a blackholed endpoint would hang this promise
+ // forever and — because the promise is shared — every later message with it.
+ const deadline = this.connectTimeoutMs || 10000;
+ const outcome = await Promise.race([
+ this._connecting.then(() => 'done', e => { throw e; }),
+ new Promise(resolve => setTimeout(() => resolve('timeout'), deadline)),
+ ]);
+ timedOut = outcome === 'timeout' && !this.connected;
+ } catch (e) {
+ // A throwing terminal.log() must not leave _connecting poisoned: log defensively.
+ try { this.terminal.log('WARN', 'Ollama', 'Auto-connect failed: ' + (e && e.message ? e.message : e)); } catch (_) {}
+ } finally {
+ this._connecting = null;
+ }
+ if (this.connected && savedModel && !(this.models || []).some(m => m.name === savedModel)) {
+ this.currentModel = savedModel;
+ if (this.currentProvider) this.currentProvider.model = savedModel;
+ // connect() may already have overwritten the stored preference; put it back.
+ try { localStorage.setItem('webx11_ollama_model', savedModel); } catch (_) {}
+ this.terminal.print('Keeping your saved model "' + savedModel + '" — this endpoint does not list it.', 'warning');
}
- this._connecting = null;
if (!this.connected) {
- this.terminal.print('Auto-connect failed — check the endpoint in the Ollama tab (or run ollamaconnect).', 'warning');
+ this.terminal.print(timedOut
+ ? 'Auto-connect timed out after ' + ((this.connectTimeoutMs || 10000) / 1000) + 's — the endpoint is not answering. Check the Ollama tab or run ollamaconnect.'
+ : 'Auto-connect failed — check the endpoint in the Ollama tab (or run ollamaconnect).', 'warning');
}
return this.connected;
}
@@ -1632,6 +1655,8 @@ class OllamaManager {
// ─── Chat (non-streaming) ────────────────────────────────────────────────
async chat(prompt, model = null) {
+ // Per-call state: never let a reason captured by an earlier failure be replayed here.
+ this._lastChatError = null;
if (!this.connected && !(await this._ensureConnected())) {
this.terminal.print('Ollama not connected: the endpoint is unreachable. Check the Ollama tab or run ollamaconnect.', 'error');
return null;
@@ -1661,7 +1686,11 @@ class OllamaManager {
}
if (lastError === null) {
- this.terminal.print(`Ollama error: No provider responded`, 'error');
+ // Name the real cause when one was captured; the generic wording is only a fallback.
+ const reason = this._lastChatError;
+ this.terminal.print(
+ reason ? `Ollama error: ${reason}` : 'Ollama error: No provider responded',
+ 'error');
}
return null;
}
@@ -1787,6 +1816,9 @@ class OllamaManager {
return fullText || null;
}
} catch (error) {
+ // Remember why this provider failed so chat() can name the real cause
+ // instead of falling back to the generic "No provider responded".
+ this._lastChatError = error.message || String(error);
if (fullText && fullText.length > 0) {
this.terminal.printStreamingResponse(`\n\n[Stream error: ${error.message}]`, true);
return fullText;
@@ -1974,6 +2006,8 @@ class OllamaManager {
}
async chatWithTools(messages, tools, model = null) {
+ // Per-call state: never let a reason captured by an earlier failure be replayed here.
+ this._lastChatError = null;
if (!this.connected && !(await this._ensureConnected())) {
this.terminal.print('Ollama not connected: the endpoint is unreachable. Check the Ollama tab or run ollamaconnect.', 'error');
return { message: null, tool_calls: null, error: 'Not connected' };
@@ -2004,7 +2038,12 @@ class OllamaManager {
}
}
- return lastError || { message: null, tool_calls: null, error: 'All providers failed' };
+ // Name the real cause when one was captured; the generic wording is only a fallback.
+ const reason = this._lastChatError;
+ return lastError || {
+ message: null, tool_calls: null,
+ error: reason ? `All providers failed: ${reason}` : 'All providers failed'
+ };
}
async _chatWithToolsWithProvider(provider, messages, tools, model) {
@@ -2090,16 +2129,21 @@ class OllamaManager {
// DeepSeek: handle null content with tool calls — send empty string
if (isDeepSeek && content === '' && toolCalls && toolCalls.length > 0) {
+ const normalised = toolCalls.map(tc => ({
+ id: tc.id,
+ type: tc.type || 'function',
+ function: {
+ name: tc.function?.name || '',
+ arguments: tc.function?.arguments || '{}'
+ }
+ }));
return {
- message: { role: 'assistant', content: '' },
- tool_calls: toolCalls.map(tc => ({
- id: tc.id,
- type: tc.type || 'function',
- function: {
- name: tc.function?.name || '',
- arguments: tc.function?.arguments || '{}'
- }
- })),
+ // tool_calls must live INSIDE the assistant message: the caller pushes
+ // result.message and then a role:'tool' reply, and a tool message whose
+ // tool_call_id has no matching assistant call is the HTTP 400 this
+ // branch exists to avoid.
+ message: { role: 'assistant', content: '', tool_calls: normalised },
+ tool_calls: normalised,
error: null
};
}
@@ -2183,6 +2227,7 @@ class OllamaManager {
return await this.chatWithToolsFallback(messages, tools, useModel);
}
this.terminal.print(`${provider.name} /api/chat error: ${error.message}`, 'error');
+ this._lastChatError = error.message || String(error);
return { message: null, tool_calls: null, error: error.message };
} finally {
this._abortController = null;
@@ -10849,10 +10894,11 @@ buildSystemPrompt() {
// MAINT-011: readDir() resolves the virtual mounts and returns null for a
// path that does not exist. The old hand-rolled walk over root.content could
// not see /sys, so a directory the UI shows answered "Directory not found".
- const entries = fsapi.readDir(p || '/');
+ // No argument means "the current directory", which ls() already does.
+ const entries = p ? fsapi.readDir(p) : fsapi.ls();
fsapi.currentPath = saved;
if (entries === null || entries === undefined) {
- return JSON.stringify({ error: `Directory not found: ${p || '/'}` });
+ return JSON.stringify({ error: `Directory not found: ${originalPath || p}` });
}
return JSON.stringify(entries.map(f => ({ name: f.name, type: f.type, size: f.size })));
}
@@ -10900,9 +10946,12 @@ buildSystemPrompt() {
const wrote = this.terminal.fs.writeFile(path, content);
this.terminal.fs.currentPath = savedW;
if (!wrote) {
- return `Write FAILED: ${path} — parent directory missing or target is a directory; nothing was written`;
+ const why = (typeof this.terminal.fs._isVirtualPath === 'function' && this.terminal.fs._isVirtualPath(wp))
+ ? 'read-only virtual path'
+ : 'parent directory missing or target is a directory';
+ return `Write FAILED: ${wp} — ${why}; nothing was written`;
}
- return `File written: ${path} (${content.length} chars)`;
+ return `File written: ${wp} (${content.length} chars)`;
}
case 'make_directory': {
const mp = a.path || '';
diff --git a/modules/core/module-aiassist.js b/modules/core/module-aiassist.js
index 303f9c2..ac8d297 100644
--- a/modules/core/module-aiassist.js
+++ b/modules/core/module-aiassist.js
@@ -2379,10 +2379,11 @@ buildSystemPrompt() {
// MAINT-011: readDir() resolves the virtual mounts and returns null for a
// path that does not exist. The old hand-rolled walk over root.content could
// not see /sys, so a directory the UI shows answered "Directory not found".
- const entries = fsapi.readDir(p || '/');
+ // No argument means "the current directory", which ls() already does.
+ const entries = p ? fsapi.readDir(p) : fsapi.ls();
fsapi.currentPath = saved;
if (entries === null || entries === undefined) {
- return JSON.stringify({ error: `Directory not found: ${p || '/'}` });
+ return JSON.stringify({ error: `Directory not found: ${originalPath || p}` });
}
return JSON.stringify(entries.map(f => ({ name: f.name, type: f.type, size: f.size })));
}
@@ -2430,9 +2431,12 @@ buildSystemPrompt() {
const wrote = this.terminal.fs.writeFile(path, content);
this.terminal.fs.currentPath = savedW;
if (!wrote) {
- return `Write FAILED: ${path} — parent directory missing or target is a directory; nothing was written`;
+ const why = (typeof this.terminal.fs._isVirtualPath === 'function' && this.terminal.fs._isVirtualPath(wp))
+ ? 'read-only virtual path'
+ : 'parent directory missing or target is a directory';
+ return `Write FAILED: ${wp} — ${why}; nothing was written`;
}
- return `File written: ${path} (${content.length} chars)`;
+ return `File written: ${wp} (${content.length} chars)`;
}
case 'make_directory': {
const mp = a.path || '';
diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js
index 1f790e3..365fa55 100644
--- a/modules/core/module-loader.js
+++ b/modules/core/module-loader.js
@@ -4,7 +4,7 @@
// Core module bundle loaded synchronously in main.html.
// This loader only handles version cache-busting, splash, terminal init, and optional ext modules.
- const LATEST_VER = 'v4.6.105';
+ const LATEST_VER = 'v4.6.106';
const CACHE = '?v=' + LATEST_VER;
const stored = localStorage.getItem('webx11_cache_ver');
if (stored !== LATEST_VER) {
diff --git a/modules/core/ollama-manager.js b/modules/core/ollama-manager.js
index 3d157ba..4512662 100644
--- a/modules/core/ollama-manager.js
+++ b/modules/core/ollama-manager.js
@@ -382,14 +382,37 @@ class OllamaManager {
try { await this._connecting; } catch (e) { /* fall through to the state check */ }
return this.connected;
}
- this.terminal.print('Connecting to Ollama…', 'info');
- this._connecting = this.connect();
- try { await this._connecting; } catch (e) {
- this.terminal.log('WARN', 'Ollama', 'Auto-connect failed: ' + e.message);
+ // An automatic connect must not silently rewrite the model the user picked.
+ const savedModel = (typeof localStorage !== 'undefined') ? localStorage.getItem('webx11_ollama_model') : null;
+ this.terminal.print('Ollama: connecting on first use…', 'info');
+ let timedOut = false;
+ try {
+ this._connecting = this.connect();
+ // connect() uses a bare fetch(), so a blackholed endpoint would hang this promise
+ // forever and — because the promise is shared — every later message with it.
+ const deadline = this.connectTimeoutMs || 10000;
+ const outcome = await Promise.race([
+ this._connecting.then(() => 'done', e => { throw e; }),
+ new Promise(resolve => setTimeout(() => resolve('timeout'), deadline)),
+ ]);
+ timedOut = outcome === 'timeout' && !this.connected;
+ } catch (e) {
+ // A throwing terminal.log() must not leave _connecting poisoned: log defensively.
+ try { this.terminal.log('WARN', 'Ollama', 'Auto-connect failed: ' + (e && e.message ? e.message : e)); } catch (_) {}
+ } finally {
+ this._connecting = null;
+ }
+ if (this.connected && savedModel && !(this.models || []).some(m => m.name === savedModel)) {
+ this.currentModel = savedModel;
+ if (this.currentProvider) this.currentProvider.model = savedModel;
+ // connect() may already have overwritten the stored preference; put it back.
+ try { localStorage.setItem('webx11_ollama_model', savedModel); } catch (_) {}
+ this.terminal.print('Keeping your saved model "' + savedModel + '" — this endpoint does not list it.', 'warning');
}
- this._connecting = null;
if (!this.connected) {
- this.terminal.print('Auto-connect failed — check the endpoint in the Ollama tab (or run ollamaconnect).', 'warning');
+ this.terminal.print(timedOut
+ ? 'Auto-connect timed out after ' + ((this.connectTimeoutMs || 10000) / 1000) + 's — the endpoint is not answering. Check the Ollama tab or run ollamaconnect.'
+ : 'Auto-connect failed — check the endpoint in the Ollama tab (or run ollamaconnect).', 'warning');
}
return this.connected;
}
@@ -397,6 +420,8 @@ class OllamaManager {
// ─── Chat (non-streaming) ────────────────────────────────────────────────
async chat(prompt, model = null) {
+ // Per-call state: never let a reason captured by an earlier failure be replayed here.
+ this._lastChatError = null;
if (!this.connected && !(await this._ensureConnected())) {
this.terminal.print('Ollama not connected: the endpoint is unreachable. Check the Ollama tab or run ollamaconnect.', 'error');
return null;
@@ -426,7 +451,11 @@ class OllamaManager {
}
if (lastError === null) {
- this.terminal.print(`Ollama error: No provider responded`, 'error');
+ // Name the real cause when one was captured; the generic wording is only a fallback.
+ const reason = this._lastChatError;
+ this.terminal.print(
+ reason ? `Ollama error: ${reason}` : 'Ollama error: No provider responded',
+ 'error');
}
return null;
}
@@ -552,6 +581,9 @@ class OllamaManager {
return fullText || null;
}
} catch (error) {
+ // Remember why this provider failed so chat() can name the real cause
+ // instead of falling back to the generic "No provider responded".
+ this._lastChatError = error.message || String(error);
if (fullText && fullText.length > 0) {
this.terminal.printStreamingResponse(`\n\n[Stream error: ${error.message}]`, true);
return fullText;
@@ -739,6 +771,8 @@ class OllamaManager {
}
async chatWithTools(messages, tools, model = null) {
+ // Per-call state: never let a reason captured by an earlier failure be replayed here.
+ this._lastChatError = null;
if (!this.connected && !(await this._ensureConnected())) {
this.terminal.print('Ollama not connected: the endpoint is unreachable. Check the Ollama tab or run ollamaconnect.', 'error');
return { message: null, tool_calls: null, error: 'Not connected' };
@@ -769,7 +803,12 @@ class OllamaManager {
}
}
- return lastError || { message: null, tool_calls: null, error: 'All providers failed' };
+ // Name the real cause when one was captured; the generic wording is only a fallback.
+ const reason = this._lastChatError;
+ return lastError || {
+ message: null, tool_calls: null,
+ error: reason ? `All providers failed: ${reason}` : 'All providers failed'
+ };
}
async _chatWithToolsWithProvider(provider, messages, tools, model) {
@@ -855,16 +894,21 @@ class OllamaManager {
// DeepSeek: handle null content with tool calls — send empty string
if (isDeepSeek && content === '' && toolCalls && toolCalls.length > 0) {
+ const normalised = toolCalls.map(tc => ({
+ id: tc.id,
+ type: tc.type || 'function',
+ function: {
+ name: tc.function?.name || '',
+ arguments: tc.function?.arguments || '{}'
+ }
+ }));
return {
- message: { role: 'assistant', content: '' },
- tool_calls: toolCalls.map(tc => ({
- id: tc.id,
- type: tc.type || 'function',
- function: {
- name: tc.function?.name || '',
- arguments: tc.function?.arguments || '{}'
- }
- })),
+ // tool_calls must live INSIDE the assistant message: the caller pushes
+ // result.message and then a role:'tool' reply, and a tool message whose
+ // tool_call_id has no matching assistant call is the HTTP 400 this
+ // branch exists to avoid.
+ message: { role: 'assistant', content: '', tool_calls: normalised },
+ tool_calls: normalised,
error: null
};
}
@@ -948,6 +992,7 @@ class OllamaManager {
return await this.chatWithToolsFallback(messages, tools, useModel);
}
this.terminal.print(`${provider.name} /api/chat error: ${error.message}`, 'error');
+ this._lastChatError = error.message || String(error);
return { message: null, tool_calls: null, error: error.message };
} finally {
this._abortController = null;
diff --git a/modules/test/test-ai-fs-tools.js b/modules/test/test-ai-fs-tools.js
index c427783..3968abe 100644
--- a/modules/test/test-ai-fs-tools.js
+++ b/modules/test/test-ai-fs-tools.js
@@ -65,6 +65,58 @@ const run = async (ai, name, args) => JSON.parse(await ai.runTool(name, args));
ok('D2 content landed', vfs.readFile('/missing/dir/f.txt') === 'x');
}
+ // E. write onto a directory node is refused, and the directory survives
+ {
+ const { ai, vfs } = mk();
+ vfs.mkdirRecursive('/adir');
+ vfs.writeFile('/adir/keep.txt', 'k');
+ const res = await ai.runTool('write_file', { path: '/adir', content: 'x' });
+ ok('E1 write onto a directory is refused', /Write FAILED/.test(String(res)), String(res));
+ ok('E2 directory contents intact', vfs.readFile('/adir/keep.txt') === 'k');
+ }
+
+ // F. a read-only virtual path names that as the reason
+ {
+ const { ai } = mk();
+ const res = await ai.runTool('write_file', { path: '/sys/info', content: 'x' });
+ ok('F1 virtual path reports read-only', /Write FAILED: \/sys\/info — read-only virtual path/.test(String(res)), String(res));
+ }
+
+ // G. list_files on a FILE is an error, on '/' is the root listing
+ {
+ const { ai, vfs } = mk();
+ vfs.writeFile('/file.txt', 'x');
+ const onFile = await run(ai, 'list_files', { path: '/file.txt' });
+ ok('G1 listing a file reports Directory not found', !!onFile.error && /\/file\.txt/.test(onFile.error), JSON.stringify(onFile));
+ const onRoot = await run(ai, 'list_files', { path: '/' });
+ ok('G2 root lists the virtual sys mount', Array.isArray(onRoot) && onRoot.some(e => e.name === 'sys'), JSON.stringify(onRoot).slice(0, 120));
+ }
+
+ // H. no argument means the CURRENT directory, not the root
+ {
+ const { ai, vfs } = mk();
+ vfs.mkdirRecursive('/work');
+ vfs.writeFile('/work/inside.txt', 'x');
+ vfs.writeFile('/root-only.txt', 'y');
+ vfs.currentPath = ['work'];
+ const cwd = await run(ai, 'list_files', {});
+ const names = (cwd.error ? [] : cwd.map(e => e.name));
+ ok('H1 no-arg lists the cwd', names.includes('inside.txt') && !names.includes('root-only.txt'), JSON.stringify(cwd).slice(0, 140));
+ ok('H2 cwd is restored after the call', JSON.stringify(vfs.currentPath) === '["work"]', JSON.stringify(vfs.currentPath));
+ }
+
+ // I. a relative path resolves from the cwd
+ {
+ const { ai, vfs } = mk();
+ vfs.mkdirRecursive('/docs');
+ vfs.writeFile('/docs/sub.txt', 'x');
+ vfs.currentPath = ['docs'];
+ const res = await run(ai, 'list_files', { path: 'sub.txt' });
+ ok('I1 a relative file path is resolved, not guessed', !!res.error, JSON.stringify(res));
+ const dirRes = await run(ai, 'list_files', { path: '.' });
+ ok('I2 "." lists the cwd', Array.isArray(dirRes) && dirRes.some(e => e.name === 'sub.txt'), JSON.stringify(dirRes).slice(0, 120));
+ }
+
if (fails === 0) {
console.log('test-ai-fs-tools: ' + passes + ' passed, 0 failed');
} else {
diff --git a/modules/test/test-ollama-autoconnect.js b/modules/test/test-ollama-autoconnect.js
index b9db4ff..12b0786 100644
--- a/modules/test/test-ollama-autoconnect.js
+++ b/modules/test/test-ollama-autoconnect.js
@@ -59,7 +59,7 @@ const json = (body, okFlag = true) => Promise.resolve({ ok: okFlag, status: okFl
const res = await om.chat('ping');
const text = out.map(o => o.text).join(' | ');
ok('A1 connect is attempted lazily', seen.some(u => u.includes('/api/tags')), seen.join(', '));
- ok('A2 the user sees a connecting hint', /Connecting to Ollama/.test(text), text.slice(0, 160));
+ ok('A2 the user sees a first-use hint', /connecting on first use/.test(text), text.slice(0, 160));
ok('A3 manager is connected after the attempt', om.connected === true);
ok('A4 the message is answered', String(res).includes('pong'), String(res).slice(0, 120));
ok('A5 no "ollamaconnect first" error', !/ollamaconnect first/.test(text), text.slice(0, 200));
@@ -89,6 +89,38 @@ const json = (body, okFlag = true) => Promise.resolve({ ok: okFlag, status: okFl
ok('C1 three concurrent messages probe /api/tags once', tagCalls === 1, 'tagCalls=' + tagCalls);
}
+ // D. a blackholed endpoint must not hang every later message
+ {
+ const { om, out } = mk(() => new Promise(() => {})); // never settles
+ om.connectTimeoutMs = 100;
+ const t0 = Date.now();
+ const res = await om.chat('ping');
+ const ms = Date.now() - t0;
+ const text = out.map(o => o.text).join(' | ');
+ ok('D1 chat settles on a silent endpoint', ms < 3000, ms + 'ms');
+ ok('D2 returns null', res === null, String(res));
+ ok('D3 the timeout is reported', /timed out/.test(text), text.slice(0, 220));
+ ok('D4 _connecting is cleared', om._connecting === null, String(om._connecting));
+ const before = out.length;
+ await om.chat('again');
+ ok('D5 the next message retries the connect', out.length > before, 'lines ' + before + ' -> ' + out.length);
+ }
+
+ // E. an automatic connect must not rewrite the model the user saved
+ {
+ store['webx11_ollama_model'] = 'my-saved-model';
+ const { om, out } = mk((url) => url.includes('/api/tags')
+ ? json({ models: [{ name: 'other-model' }] })
+ : json({ response: 'x', eval_count: 1, prompt_eval_count: 1 }));
+ om.currentModel = 'my-saved-model';
+ await om._ensureConnected();
+ const text = out.map(o => o.text).join(' | ');
+ ok('E1 the saved model survives an auto-connect', om.currentModel === 'my-saved-model', om.currentModel);
+ ok('E2 the stored preference is restored', store['webx11_ollama_model'] === 'my-saved-model', String(store['webx11_ollama_model']));
+ ok('E3 the user is told why', /Keeping your saved model/.test(text), text.slice(0, 200));
+ delete store['webx11_ollama_model'];
+ }
+
if (fails === 0) {
console.log('test-ollama-autoconnect: ' + passes + ' passed, 0 failed');
} else {
diff --git a/modules/test/test-ollama-error-text.js b/modules/test/test-ollama-error-text.js
new file mode 100644
index 0000000..01a17b4
--- /dev/null
+++ b/modules/test/test-ollama-error-text.js
@@ -0,0 +1,114 @@
+// modules/test/test-ollama-error-text.js — run: node
+// Proves a failed Ollama chat names the REAL cause ("Failed to fetch", "HTTP 500")
+// instead of the generic "No provider responded", and that a stale reason captured by
+// an earlier, unrelated failure is never replayed on a later call.
+const fsMod = require('fs'), path = require('path');
+const CORE = path.join(__dirname, '..', 'core');
+
+const store = {};
+global.localStorage = {
+ getItem: k => (k in store ? store[k] : null),
+ setItem: (k, v) => { store[k] = String(v); },
+ removeItem: k => { delete store[k]; },
+};
+global.window = { addEventListener: () => {}, location: { origin: 'https://webx11.duckdns.org' } };
+global.document = { addEventListener: () => {} };
+
+const OllamaManager = new Function(
+ fsMod.readFileSync(path.join(CORE, 'ollama-manager.js'), 'utf8') + '\nreturn OllamaManager;')();
+
+let passes = 0, fails = 0;
+function ok(name, cond, detail) {
+ if (cond) { passes++; return; }
+ fails++;
+ console.log('FAIL ' + name + (detail ? ' — ' + detail : ''));
+}
+
+// A manager that is already "connected" so chat() goes straight to the provider loop:
+// this test is about provider failure text, not about the lazy-connect gate.
+function mk() {
+ const out = [];
+ const term = {
+ print: (m, t) => out.push({ text: String(m), type: t || '' }),
+ log: () => {},
+ printStreamingResponse: t => out.push({ text: String(t), type: 'stream' }),
+ };
+ const om = Object.create(OllamaManager.prototype);
+ Object.assign(om, {
+ terminal: term,
+ connected: true, models: [], currentModel: 'm1',
+ endpoint: 'https://webx11.duckdns.org/ollama', fallbackEndpoints: [],
+ providers: [{ name: 'Ollama', type: 'ollama', endpoint: 'https://webx11.duckdns.org/ollama', model: 'm1', priority: 1, enabled: true }],
+ currentProvider: null, fallbackCount: 0, responseHooks: [], _usageLog: [], _usageBuffer: [],
+ _abortController: null, _connecting: null, streamEnabled: false,
+ saveSettings: () => {}, _saveProviders: () => {}, _logUsage: () => {}, _flushUsageLog: () => {},
+ });
+ om.currentProvider = om.providers[0];
+ return { om, out };
+}
+
+const json = (body, okFlag = true) => Promise.resolve({
+ ok: okFlag, status: okFlag ? 200 : 500, json: async () => body,
+});
+const textOf = out => out.map(o => o.text).join(' | ');
+const errorLines = out => out.filter(o => /Ollama error/.test(o.text)).map(o => o.text).join(' | ');
+
+(async () => {
+ // A. every provider endpoint rejects: the user must see the transport cause
+ {
+ const { om, out } = mk();
+ global.fetch = () => Promise.reject(new TypeError('Failed to fetch'));
+ const res = await om.chat('ping');
+ const text = textOf(out), line = errorLines(out);
+ ok('A1 chat() returns null', res === null, String(res));
+ ok('A2 error line mentions "Ollama error"', /Ollama error/.test(line), 'line=' + JSON.stringify(line));
+ ok('A3 error line names the real cause "Failed to fetch"', /Failed to fetch/.test(line), 'line=' + JSON.stringify(line));
+ ok('A4 generic "No provider responded" is not printed', !/No provider responded/.test(text), text.slice(0, 240));
+ }
+
+ // B. provider answers HTTP 500: the status code must survive into the message
+ {
+ const { om, out } = mk();
+ global.fetch = () => json({ error: 'kaboom' }, false);
+ const res = await om.chat('ping');
+ const line = errorLines(out);
+ ok('B1 chat() returns null', res === null, String(res));
+ ok('B2 error line carries "Ollama error"', /Ollama error/.test(line), 'line=' + JSON.stringify(line));
+ ok('B3 error line carries the HTTP status "500"', /500/.test(line), 'line=' + JSON.stringify(line));
+ ok('B4 generic "No provider responded" is not printed', !/No provider responded/.test(textOf(out)), textOf(out).slice(0, 240));
+ }
+
+ // C. the reason is per-call state: a later call must not replay it
+ {
+ const { om, out } = mk();
+
+ // C1: a genuine failure captures a reason
+ global.fetch = () => Promise.reject(new TypeError('Failed to fetch'));
+ const first = await om.chat('first');
+
+ // C2: a success in between
+ global.fetch = () => json({ response: 'pong', eval_count: 1, prompt_eval_count: 1 });
+ const second = await om.chat('second');
+ const failMentionsAfterSuccess = out.filter(o => /Failed to fetch/.test(o.text)).length;
+
+ // C3: a provider that fails WITHOUT throwing (empty body) — no reason to report,
+ // so the fallback wording must come back instead of the stale transport error.
+ global.fetch = () => json({ response: '', eval_count: 1, prompt_eval_count: 1 });
+ const third = await om.chat('third');
+ const lastLine = out.filter(o => /Ollama error/.test(o.text)).map(o => o.text).pop() || '';
+
+ ok('C1 first call returns null', first === null, String(first));
+ ok('C2 successful call still answers', String(second).includes('pong'), String(second).slice(0, 120));
+ ok('C3 the captured reason is logged exactly once', failMentionsAfterSuccess === 1, 'count=' + failMentionsAfterSuccess);
+ ok('C4 third call returns null', third === null, String(third));
+ ok('C5 no-reason failure falls back to the generic wording', /No provider responded/.test(lastLine), 'last=' + JSON.stringify(lastLine));
+ ok('C6 the stale "Failed to fetch" reason is not replayed', !/Failed to fetch/.test(lastLine), 'last=' + JSON.stringify(lastLine));
+ }
+
+ if (fails === 0) {
+ console.log('test-ollama-error-text: ' + passes + ' passed, 0 failed');
+ } else {
+ console.log('test-ollama-error-text: ' + passes + ' passed, ' + fails + ' FAILED');
+ process.exit(1);
+ }
+})();