@dotdrelle/wiki-manager 0.14.20 → 0.15.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,7 @@ import { promisify } from 'node:util';
5
5
  import YAML from 'yaml';
6
6
  import { cacertEnv, ensureCacertComposeOverride } from './cacert.js';
7
7
  import { checkMissingDockerImages } from './dockerImages.js';
8
- import { managerEnvFile, readEnvFile, resolveAgentsDataDir } from './env.js';
8
+ import { managerEnvFile, managerMcpEndpointsFile, readEnvFile, resolveAgentsDataDir } from './env.js';
9
9
  import { managerRoot } from './workspaces.js';
10
10
 
11
11
  const execFileAsync = promisify(execFile);
@@ -119,6 +119,12 @@ function composeEnv(session) {
119
119
  AGENTS_DATA_DIR: resolveAgentsDataDir(session),
120
120
  WORKSPACE_NAME: session.workspace,
121
121
  WIKI_WORKSPACE_PATH: session.workspacePath,
122
+ // The serve container reads external connectors from /mcp.endpoints.json.
123
+ // Mount the SAME file the runtime reads (managerStateDir), not the package
124
+ // dir's copy — otherwise serve and the runtime diverge (different servers,
125
+ // unresolved tokens) depending on where wiki-manager was launched from.
126
+ WIKI_MANAGER_MCP_ENDPOINTS_FILE:
127
+ process.env.WIKI_MANAGER_MCP_ENDPOINTS_FILE ?? managerMcpEndpointsFile(),
122
128
  ...(session.wikirc?.fileName && { WIKI_CONFIG_PATH: session.wikirc.fileName }),
123
129
  };
124
130
  }
@@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises';
2
2
  import { test } from 'node:test';
3
3
  import assert from 'node:assert/strict';
4
4
  import YAML from 'yaml';
5
+ import { configuredAgentImages } from './wikiSetup.js';
5
6
 
6
7
  test('workspace compose does not start a per-workspace agent runtime', async () => {
7
8
  const raw = await readFile(new URL('../../docker-compose.yml', import.meta.url), 'utf8');
@@ -26,10 +27,33 @@ test('agent compose services run as the host uid and gid', async () => {
26
27
  const agentsCompose = YAML.parse(agentsRaw);
27
28
  assert.equal(agentsCompose.services.cme.user, '${UID:-1000}:${GID:-1000}');
28
29
  assert.equal(agentsCompose.services.documents.user, '${UID:-1000}:${GID:-1000}');
30
+ assert.equal(agentsCompose.services.connectors.user, '${UID:-1000}:${GID:-1000}');
31
+ assert.deepEqual(agentsCompose.services.connectors.profiles, ['connectors']);
32
+ assert.equal(agentsCompose.services.connectors.environment.includes('GOOGLE_OAUTH_CALLBACK_URL=${GOOGLE_OAUTH_CALLBACK_URL:-}'), true);
33
+ assert.equal(agentsCompose.services.connectors.volumes.includes('${AGENTS_DATA_DIR:-./.agents-data}/connectors:/data'), true);
29
34
  assert.equal(agentsCompose.services.documents.environment.includes('DOCUMENT_LLM_BASE_URL=${DOCUMENT_LLM_BASE_URL:-https://albert.api.etalab.gouv.fr/v1}'), true);
30
35
  assert.equal(agentsCompose.services.documents.environment.includes('DOCUMENT_LLM_MODEL=${DOCUMENT_LLM_MODEL:-lightonai/LightOnOCR-2-1B}'), true);
31
36
  assert.equal(agentsCompose.services.documents.environment.includes('DOCUMENT_LLM_API_KEY=${DOCUMENT_LLM_API_KEY:-}'), true);
32
- // MailerSend was an external-connector experiment — removed from the
33
- // default stack (the agent repo still exists for ad-hoc use).
34
- assert.equal(agentsCompose.services.mailer, undefined);
37
+ });
38
+
39
+ test('serve proxies connector OAuth through the host agent endpoint', async () => {
40
+ const raw = await readFile(new URL('../../docker-compose.yml', import.meta.url), 'utf8');
41
+ const compose = YAML.parse(raw);
42
+ assert.equal(compose.services.serve.environment.includes('CONNECTORS_AGENT_URL=http://host.docker.internal:${CONNECTORS_MCP_PORT:-3338}'), true);
43
+ assert.equal(compose.services.serve.environment.includes('CONNECTORS_OAUTH_START_TOKEN=${OAUTH_START_TOKEN:-}'), true);
44
+ });
45
+
46
+ test('missing-image checks ignore agents behind inactive Compose profiles', () => {
47
+ const compose = {
48
+ services: {
49
+ cme: { image: 'example/cme:latest' },
50
+ connectors: { image: 'example/connectors:latest', profiles: ['connectors'] },
51
+ },
52
+ };
53
+
54
+ assert.deepEqual(configuredAgentImages(compose), ['example/cme:latest']);
55
+ assert.deepEqual(
56
+ configuredAgentImages(compose, new Set(['connectors'])),
57
+ ['example/cme:latest', 'example/connectors:latest'],
58
+ );
35
59
  });
package/src/core/env.js CHANGED
@@ -42,8 +42,8 @@ export function managerMcpEndpointsFile() {
42
42
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
43
43
 
44
44
  // First-run scaffolding: a fresh install directory has neither
45
- // mcp.endpoints.json nor .env, so the external agents (cme, mailer,
46
- // documents) silently never connect — /status shows no agents and Donna has
45
+ // mcp.endpoints.json nor .env, so the external agents (cme and documents)
46
+ // silently never connect — /status shows no agents and Donna has
47
47
  // no CME tools to configure anything with. Copy the packaged examples so a
48
48
  // fresh directory works out of the box with the default agent ports. Optional
49
49
  // credentials can be added later for the external services the user enables.
@@ -68,10 +68,23 @@ export function ensureManagerScaffold({ log = () => {} } = {}) {
68
68
  const example = JSON.parse(readFileSync(endpointsExample, 'utf8'));
69
69
  if (current && typeof current === 'object' && !Array.isArray(current)) {
70
70
  const missing = Object.keys(example).filter((key) => !(key in current));
71
+ const currentServers = current.mcpServers;
72
+ const exampleServers = example.mcpServers;
73
+ const missingServers = currentServers && typeof currentServers === 'object' && !Array.isArray(currentServers)
74
+ && exampleServers && typeof exampleServers === 'object' && !Array.isArray(exampleServers)
75
+ ? Object.keys(exampleServers).filter((key) => !(key in currentServers))
76
+ : [];
71
77
  if (missing.length > 0) {
72
78
  for (const key of missing) current[key] = example[key];
79
+ }
80
+ for (const key of missingServers) currentServers[key] = exampleServers[key];
81
+ if (missing.length > 0 || missingServers.length > 0) {
73
82
  writeFileSync(endpointsFile, `${JSON.stringify(current, null, 2)}\n`);
74
- created.push(`mcp.endpoints.json keys: ${missing.join(', ')}`);
83
+ const changes = [
84
+ missing.length > 0 ? `keys: ${missing.join(', ')}` : '',
85
+ missingServers.length > 0 ? `servers: ${missingServers.join(', ')}` : '',
86
+ ].filter(Boolean).join('; ');
87
+ created.push(`mcp.endpoints.json ${changes}`);
75
88
  }
76
89
  }
77
90
  } catch {
@@ -45,9 +45,14 @@ test('scaffold merges missing top-level keys into an existing endpoints file', (
45
45
  const created = ensureManagerScaffold();
46
46
  assert.ok(created.some((item) => item.includes('chatAccess')));
47
47
  const merged = JSON.parse(readFileSync(endpointsFile, 'utf8'));
48
- // Operator's servers untouched; new key added from the example.
49
- assert.deepEqual(merged.mcpServers, { custom: { url: 'http://localhost:9999/mcp/' } });
50
- assert.ok(merged.chatAccess?.servers?.wiki);
48
+ // Operator's server is untouched; packaged servers are added without
49
+ // replacing any existing definitions.
50
+ assert.deepEqual(merged.mcpServers.custom, { url: 'http://localhost:9999/mcp/' });
51
+ assert.ok(merged.mcpServers.cme);
52
+ assert.ok(merged.mcpServers.documents);
53
+ // Server keys must match the connected MCP endpoint keys (the tool-call
54
+ // prefix): the wiki server is "llm-wiki", not "wiki".
55
+ assert.ok(merged.chatAccess?.servers?.['llm-wiki']);
51
56
  });
52
57
  });
53
58
 
package/src/core/mcp.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { managerEnvFile, managerMcpEndpointsFile, readEnvFile } from './env.js';
3
3
 
4
- const WIKI_MANAGER_VERSION = '0.14.20';
4
+ const WIKI_MANAGER_VERSION = '0.15.25';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -64,11 +64,18 @@ function normalizeExternalUrlForRuntime(url) {
64
64
  return url;
65
65
  }
66
66
 
67
- // Config-driven policy for the /chat read-only toolset — NOT /agent, which has
68
- // the full toolset and ignores this. The endpoints file's "chatAccess" block
69
- // declares, per server, which tools /chat may call ("*" or a list), plus a
70
- // maxToolIterations budget. Operator-owned, agnostic allow-list. Returns null
71
- // when not configured — then /chat stays a plain, tool-less conversation.
67
+ // Config-driven policy for the /chat toolset — NOT /agent, which has the full
68
+ // toolset and ignores this. The endpoints file's "chatAccess" block declares,
69
+ // per server, which tools /chat may call ("*" or a list), plus a
70
+ // maxToolIterations budget. Every server uses the SAME shape: one `allow` key.
71
+ // Operator-owned, agnostic allow-list. Returns null when not configured — then
72
+ // /chat stays a plain, tool-less conversation.
73
+ //
74
+ // `allowActions` is a legacy key from the connectors work: it carved out a
75
+ // second list for tools the read-verb heuristic rejected, which made one
76
+ // server's entry shaped differently from every other. It is folded into
77
+ // `allow` on read so existing installs keep working without regenerating
78
+ // their endpoints file, but nothing writes it any more.
72
79
  export function readChatAccessConfig() {
73
80
  const filePath = managerMcpEndpointsFile();
74
81
  if (!existsSync(filePath)) return null;
@@ -81,10 +88,15 @@ export function readChatAccessConfig() {
81
88
  // "*" is also commonly written as a one-element array (["*"]) since every
82
89
  // other "allow" example in this config is an array of tool names — treat
83
90
  // both forms as the same wildcard rather than silently allowing nothing.
91
+ const legacyActions = Array.isArray(entry?.allowActions)
92
+ ? entry.allowActions.map(String).filter(Boolean)
93
+ : [];
84
94
  if (entry?.allow === '*' || (Array.isArray(entry?.allow) && entry.allow.length === 1 && entry.allow[0] === '*')) {
85
95
  servers[name] = { allow: '*' };
86
96
  } else if (Array.isArray(entry?.allow)) {
87
- servers[name] = { allow: entry.allow.map(String).filter(Boolean) };
97
+ servers[name] = { allow: [...new Set([...entry.allow.map(String).filter(Boolean), ...legacyActions])] };
98
+ } else if (legacyActions.length > 0) {
99
+ servers[name] = { allow: legacyActions };
88
100
  }
89
101
  }
90
102
  const maxToolIterations = Number.isFinite(Number(chatAccess.maxToolIterations)) && Number(chatAccess.maxToolIterations) > 0
@@ -284,7 +296,7 @@ async function mcpRequest(endpoint, method, params, signal, options = {}) {
284
296
  let response = await doRequest(method, params);
285
297
  let text = await response.text();
286
298
 
287
- if (response.status === 400 && /session ID/i.test(text)) {
299
+ if (response.status === 400 && /session(?:\s+ID)?/i.test(text)) {
288
300
  endpoint._sessionId = null;
289
301
  const initResponse = await fetch(endpoint.url, {
290
302
  method: 'POST',
@@ -532,6 +544,7 @@ export async function discoverMcpTools(mcpStatus) {
532
544
  name: tool.name,
533
545
  description: tool.description ?? '',
534
546
  inputSchema: tool.inputSchema,
547
+ annotations: tool.annotations,
535
548
  })),
536
549
  toolError: null,
537
550
  };
@@ -523,6 +523,61 @@ test('discoverMcpTools downgrades connected endpoint when tool discovery fails',
523
523
  }
524
524
  });
525
525
 
526
+ test('discoverMcpTools initializes SDK servers that report no valid session', async () => {
527
+ const originalFetch = globalThis.fetch;
528
+ const methods = [];
529
+ globalThis.fetch = async (_url, init) => {
530
+ const body = JSON.parse(init.body);
531
+ methods.push(body.method);
532
+ if (body.method === 'tools/list' && !init.headers['mcp-session-id']) {
533
+ return {
534
+ ok: false,
535
+ status: 400,
536
+ headers: { get: () => null },
537
+ text: async () => '{"jsonrpc":"2.0","error":{"code":-32000,"message":"No valid session"},"id":null}',
538
+ };
539
+ }
540
+ if (body.method === 'initialize') {
541
+ return {
542
+ ok: true,
543
+ status: 200,
544
+ headers: { get: (name) => name === 'mcp-session-id' ? 'session-1' : null },
545
+ text: async () => '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-06-18"}}',
546
+ };
547
+ }
548
+ if (body.method === 'notifications/initialized') {
549
+ return {
550
+ ok: true,
551
+ status: 202,
552
+ headers: { get: () => null },
553
+ text: async () => '',
554
+ };
555
+ }
556
+ return {
557
+ ok: true,
558
+ status: 200,
559
+ headers: { get: () => null },
560
+ text: async () => '{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"connectors_google_status"}]}}',
561
+ };
562
+ };
563
+
564
+ try {
565
+ const status = await discoverMcpTools({
566
+ connectors: {
567
+ status: 'configured',
568
+ url: 'http://127.0.0.1:3338/mcp/',
569
+ headers: { authorization: 'Bearer token' },
570
+ },
571
+ });
572
+
573
+ assert.equal(status.connectors.status, 'connected');
574
+ assert.deepEqual(status.connectors.tools.map((tool) => tool.name), ['connectors_google_status']);
575
+ assert.deepEqual(methods, ['tools/list', 'initialize', 'notifications/initialized', 'tools/list']);
576
+ } finally {
577
+ globalThis.fetch = originalFetch;
578
+ }
579
+ });
580
+
526
581
  test('callMcpTool parses SSE responses after keepalive comments', async () => {
527
582
  const originalFetch = globalThis.fetch;
528
583
  globalThis.fetch = async () => ({
@@ -6,12 +6,36 @@ import { promisify } from 'node:util';
6
6
  import YAML from 'yaml';
7
7
  import { checkMissingDockerImages } from './dockerImages.js';
8
8
  import { patchWikircProfile } from './wikirc.js';
9
- import { managerEnvFile, managerMcpEndpointsFile, resolveAgentsDataDir } from './env.js';
9
+ import { managerEnvFile, managerMcpEndpointsFile, readEnvFile, resolveAgentsDataDir } from './env.js';
10
10
  import { createWorkspace, findWorkspace, isValidWorkspaceName, listWorkspaces, managerRoot, workspacesDir } from './workspaces.js';
11
11
 
12
12
  const execFileAsync = promisify(execFile);
13
13
 
14
+ function enabled(value) {
15
+ return /^(?:1|true|yes|on)$/i.test(String(value ?? '').trim());
16
+ }
17
+
18
+ export function configuredAgentImages(config, activeProfiles = new Set()) {
19
+ return Object.values(config.services ?? {})
20
+ .filter((service) => {
21
+ const profiles = Array.isArray(service?.profiles) ? service.profiles : [];
22
+ return profiles.length === 0 || profiles.some((profile) => activeProfiles.has(profile));
23
+ })
24
+ .map((service) => service?.image)
25
+ .filter(Boolean);
26
+ }
27
+
14
28
  async function missingAgentImages() {
29
+ let managerEnv = {};
30
+ try {
31
+ managerEnv = readEnvFile(managerEnvFile());
32
+ } catch {
33
+ // A missing manager .env is valid during first-run setup.
34
+ }
35
+ const activeProfiles = new Set();
36
+ if (enabled(managerEnv.CONNECTORS_ENABLED ?? process.env.CONNECTORS_ENABLED)) {
37
+ activeProfiles.add('connectors');
38
+ }
15
39
  const composeFiles = [
16
40
  join(managerRoot(), 'agents.docker-compose.yml'),
17
41
  join(dirname(managerEnvFile()), 'agents.docker-compose.override.yml'),
@@ -19,7 +43,7 @@ async function missingAgentImages() {
19
43
  const images = [...new Set(composeFiles.flatMap((filePath) => {
20
44
  try {
21
45
  const config = YAML.parse(readFileSync(filePath, 'utf8')) ?? {};
22
- return Object.values(config.services ?? {}).map((service) => service?.image).filter(Boolean);
46
+ return configuredAgentImages(config, activeProfiles);
23
47
  } catch {
24
48
  return [];
25
49
  }
@@ -12,6 +12,11 @@ test('wiki-workspace autostarts host runtime before workspace services', async (
12
12
  assert.match(script, /start_workspace_services "\$workspace"\n\n local serve_port prod_port/);
13
13
  assert.match(script, /start_workspace_services "\$workspace"\n local serve_port production_port/);
14
14
  assert.match(script, /ensure_runtime_up\n printf 'Starting mcp-http/);
15
+ assert.match(script, /wait_for_runtime_health\(\) \{/);
16
+ assert.match(script, /if ! wait_for_runtime_health; then/);
17
+ assert.match(script, /runtime_port\(\) \{\n env_value "\$MANAGER_ENV_FILE" WIKI_MANAGER_RUNTIME_PORT/);
18
+ assert.match(script, /port="\$\(runtime_port\)"/);
19
+ assert.doesNotMatch(script, /sleep 0\.5\n printf 'agent-runtime started/);
15
20
  });
16
21
 
17
22
  test('wiki-workspace checks runtime pid command before killing', async () => {
@@ -32,6 +37,26 @@ test('wiki-workspace regenerates CA compose overrides instead of retaining remov
32
37
  assert.match(script, /Changes are overwritten on the next compose command/);
33
38
  });
34
39
 
40
+ test('wiki-workspace provisions connector secrets and persistent state', async () => {
41
+ const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
42
+
43
+ assert.match(script, /connectors_enabled\(\) \{/);
44
+ assert.match(script, /CONNECTORS_ENABLED/);
45
+ assert.doesNotMatch(script, /\$\{enabled,,\}/);
46
+ assert.match(script, /\[Tt\]\[Rr\]\[Uu\]\[Ee\]/);
47
+ assert.match(script, /profile_args\+=\(--profile connectors\)/);
48
+ assert.match(script, /COMPOSE_PROFILES= \\\n docker compose/);
49
+ assert.match(script, /if connectors_enabled; then/);
50
+ assert.match(script, /CONNECTORS_MCP_AUTH_TOKEN OAUTH_STATE_SECRET OAUTH_START_TOKEN/);
51
+ assert.match(script, /http:\/\/127\.0\.0\.1:\$\{connectors_port\}\/oauth\/google\/callback/);
52
+ assert.match(script, /set_env_value "\$MANAGER_ENV_FILE" GOOGLE_OAUTH_CALLBACK_URL/);
53
+ assert.match(script, /"\$agents_data_dir\/connectors"/);
54
+ assert.match(script, /config\.mcpServers\.connectors \?\?=/);
55
+ assert.match(script, /config\.chatAccess\.servers\.connectors \?\?=/);
56
+ assert.match(script, /delete config\.mcpServers\.connectors/);
57
+ assert.match(script, /delete config\.chatAccess\.servers\.connectors/);
58
+ });
59
+
35
60
  test('workspace creation keeps mutable manager files outside the installed package', async () => {
36
61
  const source = await readFile(new URL('./workspaces.js', import.meta.url), 'utf8');
37
62
 
@@ -3,7 +3,7 @@ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
3
3
  import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
4
4
  import { emitRuntimeLog, pollActivitiesOnce } from '../runtime/supervisor.js';
5
5
 
6
- const TERMINAL_STATUSES = new Set(['done', 'failed', 'cancelled', 'canceled', 'complete', 'completed', 'success', 'error']);
6
+ const TERMINAL_STATUSES = new Set(['done', 'failed', 'cancelled', 'canceled', 'complete', 'completed', 'success', 'succeeded', 'error']);
7
7
 
8
8
  export function createDispatcher({
9
9
  session = null,
@@ -115,7 +115,7 @@ export async function execute(task, assignment, {
115
115
  jobId,
116
116
  status: lastStatus?.result?.status ?? lastStatus?.status,
117
117
  outputs: lastStatus?.result?.outputRefs ?? [],
118
- error: lastStatus?.result?.error?.code ?? lastStatus?.result?.error?.message ?? null,
118
+ error: normalizeTaskError(lastStatus?.result?.error)?.code ?? null,
119
119
  detail: 'terminal status',
120
120
  }));
121
121
  return taskResultFromStatus(task, assignment, jobId, lastStatus, attempt);
@@ -135,7 +135,7 @@ export async function execute(task, assignment, {
135
135
  jobId,
136
136
  status: lastStatus?.result?.status ?? lastStatus?.status,
137
137
  outputs: lastStatus?.result?.outputRefs ?? [],
138
- error: lastStatus?.result?.error?.code ?? lastStatus?.result?.error?.message ?? null,
138
+ error: normalizeTaskError(lastStatus?.result?.error)?.code ?? null,
139
139
  detail: 'terminal status',
140
140
  }));
141
141
  return taskResultFromStatus(task, assignment, jobId, lastStatus, attempt);
@@ -213,20 +213,49 @@ function taskResultFromStatus(task, assignment, jobId, statusPayload, attempt =
213
213
  status: result.status ?? statusPayload?.status,
214
214
  outputRefs: Array.isArray(result.outputRefs) ? result.outputRefs : [],
215
215
  metrics: result.metrics ?? {},
216
- error: result.error ?? null,
216
+ error: normalizeTaskError(result.error),
217
217
  rawStatus: statusPayload,
218
218
  };
219
219
  }
220
220
 
221
+ // An agent may report a terminal failure either as an object ({code, message})
222
+ // or as a bare reason string — the contract mandates neither, and the
223
+ // executor-only agents report the latter. Consumers (runner logs, the UIs)
224
+ // only ever read `.code`/`.message`, so an unnormalized string silently
225
+ // became `null` and every failure surfaced as a bare "failed" with no reason.
226
+ // Normalize once, here, so the shape is uniform for every downstream reader.
227
+ export function normalizeTaskError(rawError, { fallbackCode = null, fallbackMessage = null } = {}) {
228
+ if (rawError && typeof rawError === 'object') {
229
+ const code = String(rawError.code ?? rawError.message ?? fallbackCode ?? 'failed');
230
+ return {
231
+ code,
232
+ message: String(rawError.message ?? rawError.code ?? fallbackMessage ?? code),
233
+ retryable: rawError.retryable === true
234
+ || transientError(rawError.code)
235
+ || transientError(rawError.message),
236
+ };
237
+ }
238
+ if (rawError === null || rawError === undefined || String(rawError).trim() === '') {
239
+ if (!fallbackCode) return null;
240
+ return {
241
+ code: String(fallbackCode),
242
+ message: String(fallbackMessage ?? fallbackCode),
243
+ retryable: transientError(fallbackCode),
244
+ };
245
+ }
246
+ const code = String(rawError);
247
+ return {
248
+ code,
249
+ message: String(fallbackMessage ?? code),
250
+ retryable: transientError(code),
251
+ };
252
+ }
253
+
221
254
  function rejectedTaskResult(task, assignment, payload, attempt = null) {
222
- const rawError = payload?.error;
223
- const error = rawError && typeof rawError === 'object'
224
- ? { ...rawError }
225
- : {
226
- code: String(rawError ?? 'execution_rejected'),
227
- message: String(payload?.message ?? rawError ?? 'agent_execute rejected task'),
228
- retryable: transientError(rawError ?? payload?.message),
229
- };
255
+ const error = normalizeTaskError(payload?.error, {
256
+ fallbackCode: 'execution_rejected',
257
+ fallbackMessage: payload?.message ?? 'agent_execute rejected task',
258
+ });
230
259
  return {
231
260
  ok: false,
232
261
  taskId: String(task.id ?? task.step),
@@ -236,11 +265,7 @@ function rejectedTaskResult(task, assignment, payload, attempt = null) {
236
265
  status: 'failed',
237
266
  outputRefs: [],
238
267
  metrics: {},
239
- error: {
240
- code: String(error.code ?? 'execution_rejected'),
241
- message: String(error.message ?? error.code ?? 'agent_execute rejected task'),
242
- retryable: error.retryable === true || transientError(error.code) || transientError(error.message),
243
- },
268
+ error,
244
269
  rawStatus: payload,
245
270
  };
246
271
  }
@@ -32,3 +32,130 @@ test('dispatcher returns a retryable logical failure when agent_execute reports
32
32
  assert.equal(result.error.code, 'workspace_busy');
33
33
  assert.equal(result.error.retryable, true);
34
34
  });
35
+
36
+ test('dispatcher completes when an executor-only agent reports succeeded', async () => {
37
+ const session = {
38
+ workspace: 'test',
39
+ mcp: {
40
+ connectors: {
41
+ status: 'connected',
42
+ tools: [
43
+ { name: 'agent_execute' },
44
+ { name: 'agent_status' },
45
+ { name: 'agent_cancel' },
46
+ ],
47
+ },
48
+ },
49
+ activities: {},
50
+ };
51
+ const dispatcher = createDispatcher({
52
+ session,
53
+ pollIntervalMs: 1,
54
+ callTool: async (_mcp, _server, tool) => {
55
+ if (tool === 'agent_execute') {
56
+ return { accepted: true, jobId: 'job-connectors', status: 'queued' };
57
+ }
58
+ return {
59
+ jobId: 'job-connectors',
60
+ status: 'succeeded',
61
+ terminal: true,
62
+ result: { status: 'succeeded', outputRefs: [] },
63
+ };
64
+ },
65
+ });
66
+
67
+ const result = await dispatcher.execute(
68
+ {
69
+ id: 'collect-mail',
70
+ label: 'Collect mail',
71
+ requiredCapability: 'external-source.collect',
72
+ operation: 'collect',
73
+ arguments: {},
74
+ },
75
+ { serverName: 'connectors', agentInstanceId: 'connectors' },
76
+ { attempt: { attemptId: 'collect-mail:attempt-1', locks: [], release() {} } },
77
+ );
78
+
79
+ assert.equal(result.ok, true);
80
+ assert.equal(result.status, 'succeeded');
81
+ assert.equal(result.jobId, 'job-connectors');
82
+ });
83
+
84
+ test('dispatcher normalizes a bare string error reported on a terminal agent_status', async () => {
85
+ const session = {
86
+ workspace: 'test',
87
+ mcp: {
88
+ connectors: {
89
+ status: 'connected',
90
+ tools: [
91
+ { name: 'agent_execute' },
92
+ { name: 'agent_status' },
93
+ { name: 'agent_cancel' },
94
+ ],
95
+ },
96
+ },
97
+ activities: {},
98
+ };
99
+ const dispatcher = createDispatcher({
100
+ session,
101
+ pollIntervalMs: 1,
102
+ callTool: async (_mcp, _server, tool) => {
103
+ if (tool === 'agent_execute') {
104
+ return { accepted: true, jobId: 'job-connectors', status: 'queued' };
105
+ }
106
+ return {
107
+ jobId: 'job-connectors',
108
+ status: 'failed',
109
+ terminal: true,
110
+ // Executor-only agents report the reason as a bare string, which the
111
+ // contract permits; it must not be swallowed into a bare "failed".
112
+ result: { status: 'failed', error: 'authentication_required' },
113
+ };
114
+ },
115
+ });
116
+
117
+ const result = await dispatcher.execute(
118
+ {
119
+ id: 'collect-mail',
120
+ requiredCapability: 'external-source.collect',
121
+ operation: 'collect',
122
+ arguments: {},
123
+ },
124
+ { serverName: 'connectors', agentInstanceId: 'connectors' },
125
+ { attempt: { attemptId: 'collect-mail:attempt-1', locks: [], release() {} } },
126
+ );
127
+
128
+ assert.equal(result.ok, false);
129
+ assert.equal(result.error.code, 'authentication_required');
130
+ assert.equal(result.error.message, 'authentication_required');
131
+ assert.equal(result.error.retryable, false);
132
+ });
133
+
134
+ test('dispatcher keeps a null error when a terminal status reports none', async () => {
135
+ const session = {
136
+ workspace: 'test',
137
+ mcp: {
138
+ connectors: {
139
+ status: 'connected',
140
+ tools: [{ name: 'agent_execute' }, { name: 'agent_status' }, { name: 'agent_cancel' }],
141
+ },
142
+ },
143
+ activities: {},
144
+ };
145
+ const dispatcher = createDispatcher({
146
+ session,
147
+ pollIntervalMs: 1,
148
+ callTool: async (_mcp, _server, tool) => (tool === 'agent_execute'
149
+ ? { accepted: true, jobId: 'job-connectors', status: 'queued' }
150
+ : { jobId: 'job-connectors', status: 'succeeded', terminal: true, result: { status: 'succeeded' } }),
151
+ });
152
+
153
+ const result = await dispatcher.execute(
154
+ { id: 'collect-mail', requiredCapability: 'external-source.collect', operation: 'collect', arguments: {} },
155
+ { serverName: 'connectors', agentInstanceId: 'connectors' },
156
+ { attempt: { attemptId: 'collect-mail:attempt-1', locks: [], release() {} } },
157
+ );
158
+
159
+ assert.equal(result.ok, true);
160
+ assert.equal(result.error, null);
161
+ });
@@ -72,6 +72,29 @@ export async function postRuntimeRun(input, {
72
72
  return response.json();
73
73
  }
74
74
 
75
+ export async function postRuntimeTurn(input, {
76
+ url = runtimeUrlFromEnv(),
77
+ token = runtimeToken(),
78
+ workspace = null,
79
+ mode = 'agent',
80
+ } = {}) {
81
+ const response = await fetch(runtimeEndpoint(url, '/turn', workspace), {
82
+ method: 'POST',
83
+ headers: {
84
+ ...runtimeHeaders(token),
85
+ 'Content-Type': 'application/json',
86
+ },
87
+ body: JSON.stringify({ input, workspace, mode }),
88
+ });
89
+ const payload = await response.json().catch(() => ({}));
90
+ if (!response.ok) {
91
+ const err = new Error(payload.error ?? `Runtime turn failed: HTTP ${response.status}`);
92
+ err.status = response.status;
93
+ throw err;
94
+ }
95
+ return payload;
96
+ }
97
+
75
98
  export async function postRuntimeDelegate(objective, {
76
99
  url = runtimeUrlFromEnv(),
77
100
  token = runtimeToken(),
@@ -841,7 +841,9 @@ async function runDispatchedTask(task, {
841
841
  assignment,
842
842
  jobId: result?.jobId,
843
843
  error: result?.error?.code ?? result?.error?.message ?? result?.status ?? 'failed',
844
- detail: 'task terminal',
844
+ // Carry the agent's own reason instead of a constant: "task terminal"
845
+ // told the operator nothing about WHY the task ended.
846
+ detail: result?.error?.message ?? result?.error?.code ?? 'task terminal',
845
847
  }));
846
848
  return { ok: false, taskId, result, assignment };
847
849
  }
@@ -1,6 +1,7 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
  import { For, createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
3
3
  import { StyledText, bg as styledBg, fg as styledFg, link as styledLink } from '@opentui/core';
4
+ import { execFile } from 'node:child_process';
4
5
  import { colorForRenderedLine, helpCommandParts, keyValueParts, renderPlainMarkdown } from './renderer';
5
6
  import { httpLinkParts, wrapHttpLinks } from './externalLinks.js';
6
7
 
@@ -60,6 +61,19 @@ function styledSegments(segments: Segment[]) {
60
61
  return chunk;
61
62
  }));
62
63
  }
64
+
65
+ function openSingleLineLink(segments: Segment[]) {
66
+ const urls = [...new Set(segments.map((segment) => segment.url).filter(Boolean))] as string[];
67
+ if (urls.length !== 1) return;
68
+ try {
69
+ const parsed = new URL(urls[0]);
70
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return;
71
+ const opener = process.platform === 'darwin' ? 'open' : 'xdg-open';
72
+ execFile(opener, [parsed.toString()], () => {});
73
+ } catch {
74
+ // Invalid URLs remain copyable text and never reach the system opener.
75
+ }
76
+ }
63
77
  type RenderedLine = {
64
78
  segments: Segment[];
65
79
  role?: string;
@@ -549,7 +563,12 @@ export function ConversationView(props: {
549
563
  ) : null}
550
564
  </box>
551
565
  ) : (
552
- <box height={1} flexDirection="row" overflow="hidden">
566
+ <box
567
+ height={1}
568
+ flexDirection="row"
569
+ overflow="hidden"
570
+ onMouseUp={() => openSingleLineLink(line.segments)}
571
+ >
553
572
  <text>{styledSegments(line.segments)}</text>
554
573
  </box>
555
574
  )