@dotdrelle/wiki-manager 0.15.74 → 0.15.78
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.
- package/README.md +5 -6
- package/package.json +1 -1
- package/src/cli/wiki-manager.js +2 -2
- package/src/cli/wiki-manager.test.js +2 -2
- package/src/commands/slash.js +46 -9
- package/src/commands/slash.test.js +28 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/mcp.js +10 -4
- package/src/core/runtimeLog.js +11 -0
- package/src/core/runtimeLog.test.js +29 -1
- package/src/core/skillCompiler.test.js +3 -3
- package/src/core/wikiSetup.js +6 -11
- package/src/core/wikirc.test.js +6 -6
- package/src/core/workflow.js +4 -6
- package/src/core/workspaceInherit.js +0 -28
- package/src/core/workspaceInherit.test.js +10 -38
- package/src/orchestrator/agentRegistry.js +13 -2
- package/src/orchestrator/objectiveResolver.test.js +1 -1
- package/src/orchestrator/providers/runtimeProviders.js +23 -12
- package/src/runtime/server.js +2 -3
- package/src/runtime/server.test.js +5 -5
- package/src/runtime/skillChain.e2e.test.js +13 -8
- package/src/shell/RightPane.tsx +9 -8
- package/src/shell/repl.test.js +7 -0
package/README.md
CHANGED
|
@@ -23,11 +23,10 @@ deterministic dispatcher executes bounded tasks with idempotency, bounded
|
|
|
23
23
|
approvals, per-run budgets and automatic recovery after restart. The chat
|
|
24
24
|
stays available while runs execute; additional requests are queued.
|
|
25
25
|
|
|
26
|
-
Scope note: this is a single-user deployment baseline.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
choice with bearer-token and network protection.
|
|
26
|
+
Scope note: this is a single-user deployment baseline. Do not expose the
|
|
27
|
+
runtime as a shared write surface; it binds to `127.0.0.1` by default, and
|
|
28
|
+
`--host 0.0.0.0` must be an explicit deployment choice with bearer-token and
|
|
29
|
+
network protection.
|
|
31
30
|
|
|
32
31
|
---
|
|
33
32
|
|
|
@@ -170,7 +169,7 @@ Projects compared: [OpenWiki](https://github.com/langchain-ai/openwiki),
|
|
|
170
169
|
territory.
|
|
171
170
|
- Serve a true multi-user instance with per-user identity and an attributed
|
|
172
171
|
audit trail. This is a single-user deployment baseline (see the scope note
|
|
173
|
-
above)
|
|
172
|
+
above).
|
|
174
173
|
- Expose a graph-query API over the corpus the way GraphRAG does; retrieval is
|
|
175
174
|
BM25 plus a vector index feeding generation.
|
|
176
175
|
- Ship or host the multi-provider AI gateway — routing to several providers is
|
package/package.json
CHANGED
package/src/cli/wiki-manager.js
CHANGED
|
@@ -702,8 +702,8 @@ async function runHeadless(argv, agent) {
|
|
|
702
702
|
session._onStep = step;
|
|
703
703
|
|
|
704
704
|
let input = prompt;
|
|
705
|
-
//
|
|
706
|
-
//
|
|
705
|
+
// Headless must resolve executable skills through the same runtime resolver
|
|
706
|
+
// as the Shell and serve. Injecting the skill body into a
|
|
707
707
|
// local prompt bypasses the compiler, so a multi-capability skill such as
|
|
708
708
|
// wiki-sync would collapse into a single run here while producing two
|
|
709
709
|
// everywhere else — and the rewritten bodies are business intentions, not
|
|
@@ -396,8 +396,8 @@ test('argument extraction keeps a value the vocabulary allows', async () => {
|
|
|
396
396
|
});
|
|
397
397
|
|
|
398
398
|
test('headless waits for every run of a skill chain, not just the first', async () => {
|
|
399
|
-
//
|
|
400
|
-
//
|
|
399
|
+
// A multi-step user skill compiles into sequential runs: returning as soon as
|
|
400
|
+
// the first finishes would report success before the rest had started.
|
|
401
401
|
const client = chainClient([
|
|
402
402
|
{ controlQueue: [chainItem(0, 'running', { runId: 'run-a' }), chainItem(1, 'queued')] },
|
|
403
403
|
{ controlQueue: [chainItem(0, 'done'), chainItem(1, 'running', { runId: 'run-b' })] },
|
package/src/commands/slash.js
CHANGED
|
@@ -894,7 +894,7 @@ function componentInstallAction(missingImages) {
|
|
|
894
894
|
return missingImages.length > 0 ? 'downloaded-and-installed-missing-components' : null;
|
|
895
895
|
}
|
|
896
896
|
|
|
897
|
-
export function localizedOperationResult({ operation, target, status = 'succeeded', componentAction = null, images = [], detail = null }) {
|
|
897
|
+
export function localizedOperationResult({ operation, target, status = 'succeeded', componentAction = null, images = [], detail = null, style = 'compact' }) {
|
|
898
898
|
const facts = JSON.stringify({
|
|
899
899
|
operation,
|
|
900
900
|
target,
|
|
@@ -902,15 +902,21 @@ export function localizedOperationResult({ operation, target, status = 'succeede
|
|
|
902
902
|
...(componentAction ? { componentAction, images } : {}),
|
|
903
903
|
...(detail ? { detail } : {}),
|
|
904
904
|
});
|
|
905
|
+
const instructions = style === 'report'
|
|
906
|
+
? [
|
|
907
|
+
'Formule le résultat structuré suivant dans la langue et le ton demandés par le profil du workspace.',
|
|
908
|
+
"Décris ce qui a été fait : ce qui a été démarré, et l'état actuel de chaque service (en marche, en échec ou inconnu).",
|
|
909
|
+
'Réponds en deux ou trois phrases. Ne cite aucune commande, syntaxe shell, chemin de fichier, sortie docker ou identifiant.',
|
|
910
|
+
]
|
|
911
|
+
: [
|
|
912
|
+
'Formule le résultat structuré suivant dans la langue et le ton demandés par le profil du workspace.',
|
|
913
|
+
'Réponds par une seule phrase humaine et naturelle.',
|
|
914
|
+
'Ne mentionne aucune commande, syntaxe shell, étape suivante ou détail technique.',
|
|
915
|
+
];
|
|
905
916
|
return {
|
|
906
917
|
output: facts,
|
|
907
918
|
rawOutput: true,
|
|
908
|
-
agentTrigger: [
|
|
909
|
-
'Formule le résultat structuré suivant dans la langue et le ton demandés par le profil du workspace.',
|
|
910
|
-
'Réponds par une seule phrase humaine et naturelle.',
|
|
911
|
-
'Ne mentionne aucune commande, syntaxe shell, étape suivante ou détail technique.',
|
|
912
|
-
`Résultat: ${facts}`,
|
|
913
|
-
].join('\n'),
|
|
919
|
+
agentTrigger: [...instructions, `Résultat: ${facts}`].join('\n'),
|
|
914
920
|
};
|
|
915
921
|
}
|
|
916
922
|
|
|
@@ -1197,6 +1203,7 @@ export async function handleSlashCommand(line, context) {
|
|
|
1197
1203
|
const startsAgents = service === 'all';
|
|
1198
1204
|
const target = service === 'services' ? undefined : service;
|
|
1199
1205
|
try {
|
|
1206
|
+
let agentsResult = null;
|
|
1200
1207
|
if (startsAgents) {
|
|
1201
1208
|
// Validate the workspace half before mutating the global agents
|
|
1202
1209
|
// stack. Otherwise `/start all` with no active workspace starts the
|
|
@@ -1204,18 +1211,48 @@ export async function handleSlashCommand(line, context) {
|
|
|
1204
1211
|
if (!context.session.workspace || !context.session.workspacePath || !context.session.workspaceEnv?.WORKSPACE_NAME) {
|
|
1205
1212
|
throw new Error('No workspace loaded. Use /use <workspace>.');
|
|
1206
1213
|
}
|
|
1207
|
-
|
|
1214
|
+
agentsResult = await runAgentCommand(startAgents, 'start');
|
|
1208
1215
|
if (agentsResult?.failed) return agentsResult;
|
|
1209
1216
|
}
|
|
1210
1217
|
step(`Services: starting ${target ?? 'workspace services'}…`);
|
|
1211
|
-
const
|
|
1218
|
+
const started = [];
|
|
1219
|
+
const missingImages = await collectMissingImages(step, async (opts) => {
|
|
1220
|
+
const result = await startService(context.session, target, opts);
|
|
1221
|
+
if (Array.isArray(result?.targets)) started.push(...result.targets);
|
|
1222
|
+
});
|
|
1212
1223
|
step('Services: refreshing MCP runtime…');
|
|
1213
1224
|
await refreshMcpRuntimeStatus(context.session);
|
|
1225
|
+
// The report is about what was done, so it names what started and how
|
|
1226
|
+
// each service ended up. A failed state check says so instead of
|
|
1227
|
+
// leaving every state "unknown" without a word.
|
|
1228
|
+
let states = null;
|
|
1229
|
+
try {
|
|
1230
|
+
states = await serviceStates(context.session);
|
|
1231
|
+
} catch (err) {
|
|
1232
|
+
step(`Services: state check after start unavailable — ${rawFailureText(err)}`);
|
|
1233
|
+
}
|
|
1234
|
+
const agentsFacts = agentsResult ? parseJsonText(agentsResult.output) : null;
|
|
1235
|
+
const detail = {
|
|
1236
|
+
...(agentsFacts && typeof agentsFacts === 'object' && !Array.isArray(agentsFacts) ? { agents: agentsFacts } : {}),
|
|
1237
|
+
...(started.length > 0
|
|
1238
|
+
? {
|
|
1239
|
+
services: {
|
|
1240
|
+
started,
|
|
1241
|
+
states: started.map((name) => ({
|
|
1242
|
+
service: name,
|
|
1243
|
+
running: states?.[name] ? states[name].running : null,
|
|
1244
|
+
})),
|
|
1245
|
+
},
|
|
1246
|
+
}
|
|
1247
|
+
: {}),
|
|
1248
|
+
};
|
|
1214
1249
|
return localizedOperationResult({
|
|
1215
1250
|
operation: 'start',
|
|
1216
1251
|
target: startsAgents ? 'all-services-and-agents' : (target || 'workspace-services'),
|
|
1217
1252
|
componentAction: componentInstallAction(missingImages),
|
|
1218
1253
|
images: missingImages,
|
|
1254
|
+
detail: Object.keys(detail).length > 0 ? detail : null,
|
|
1255
|
+
style: 'report',
|
|
1219
1256
|
});
|
|
1220
1257
|
} catch (err) {
|
|
1221
1258
|
step(formatActivityError('services', 'start', err));
|
|
@@ -128,6 +128,34 @@ test('a started agent stack reloads the env and the MCP endpoints the script rew
|
|
|
128
128
|
assert.match(runAgent, /await refreshMcpRuntimeStatus\(context\.session\)/);
|
|
129
129
|
});
|
|
130
130
|
|
|
131
|
+
test('start report style asks Donna to describe what was done, not a bare sentence', () => {
|
|
132
|
+
const result = localizedOperationResult({
|
|
133
|
+
operation: 'start',
|
|
134
|
+
target: 'wiki',
|
|
135
|
+
style: 'report',
|
|
136
|
+
detail: { services: { started: ['wiki'], states: [{ service: 'wiki', running: true }] } },
|
|
137
|
+
});
|
|
138
|
+
assert.deepEqual(JSON.parse(result.output).detail, {
|
|
139
|
+
services: { started: ['wiki'], states: [{ service: 'wiki', running: true }] },
|
|
140
|
+
});
|
|
141
|
+
assert.match(result.agentTrigger, /Décris ce qui a été fait/);
|
|
142
|
+
assert.match(result.agentTrigger, /en marche, en échec ou inconnu/);
|
|
143
|
+
assert.doesNotMatch(result.agentTrigger, /une seule phrase/);
|
|
144
|
+
assert.doesNotMatch(result.agentTrigger, /\/start|Docker|compose/);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('/start captures what actually started and their resulting states', async () => {
|
|
148
|
+
const { readFile } = await import('node:fs/promises');
|
|
149
|
+
const source = await readFile(new URL('./slash.js', import.meta.url), 'utf8');
|
|
150
|
+
const start = source.slice(source.indexOf("case 'start': {"), source.indexOf("case 'stop': {"));
|
|
151
|
+
|
|
152
|
+
assert.match(start, /const started = \[\];/);
|
|
153
|
+
assert.match(start, /result\?\.targets/);
|
|
154
|
+
assert.match(start, /states = await serviceStates\(context\.session\)/);
|
|
155
|
+
assert.match(start, /running: states\?\.\[name\] \? states\[name\]\.running : null/);
|
|
156
|
+
assert.match(start, /style: 'report'/);
|
|
157
|
+
});
|
|
158
|
+
|
|
131
159
|
test('/start completes to the three documented targets', async () => {
|
|
132
160
|
const { completionDescription } = await import('../shell/repl.js');
|
|
133
161
|
const { matches } = completionContext('/start ', {});
|
package/src/core/buildInfo.json
CHANGED
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.15.
|
|
4
|
+
const WIKI_MANAGER_VERSION = '0.15.78';
|
|
5
5
|
|
|
6
6
|
function envValue(key) {
|
|
7
7
|
const filePath = managerEnvFile();
|
|
@@ -272,7 +272,14 @@ function clarifyToolDescription(_serverName, _toolName, description) {
|
|
|
272
272
|
|
|
273
273
|
async function listMcpTools(endpoint) {
|
|
274
274
|
if (!endpoint.url) throw new Error('missing endpoint URL');
|
|
275
|
-
|
|
275
|
+
// Opt-in only, unlike callMcpTool: this probe runs inside discoverMcpTools's
|
|
276
|
+
// Promise.all on every re-scan/delegation, so an endpoint with no explicit
|
|
277
|
+
// `retry` in mcp.endpoints.json must keep costing exactly one 8s timeout
|
|
278
|
+
// when unreachable, not silently inherit the global 2-attempt default and
|
|
279
|
+
// double that cost for every unconfigured server. Only an endpoint that
|
|
280
|
+
// explicitly declares `retry` gets more than one attempt here.
|
|
281
|
+
const retry = endpoint.retry ? resolveRetryPolicy(endpoint) : { maxAttempts: 1, backoffMs: 0 };
|
|
282
|
+
const payload = await withRetry(() => mcpRequest(endpoint, 'tools/list', {}), retry);
|
|
276
283
|
return payload?.result?.tools ?? [];
|
|
277
284
|
}
|
|
278
285
|
|
|
@@ -756,8 +763,7 @@ export function parseToolCallName(name) {
|
|
|
756
763
|
// tool name, route to it and report `normalized: true`; otherwise return
|
|
757
764
|
// `server: null` with the list of candidate servers so the caller can raise
|
|
758
765
|
// an explicit error. This is name normalization, never fuzzy matching — do
|
|
759
|
-
// not extend it to description/similarity-based selection
|
|
760
|
-
// §20 forbids that).
|
|
766
|
+
// not extend it to description/similarity-based selection.
|
|
761
767
|
export function resolveToolCallName(mcpStatus, name, extraServers = {}) {
|
|
762
768
|
const parsed = parseToolCallName(name);
|
|
763
769
|
if (parsed.server) return { ...parsed, normalized: false, candidates: [] };
|
package/src/core/runtimeLog.js
CHANGED
|
@@ -177,6 +177,17 @@ export function isDispatchPlumbingLine(line) {
|
|
|
177
177
|
return DISPATCH_PLUMBING_LINE.test(String(line ?? '').replace(/^runtime\s+/, ''));
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
// The agent's own reasoning traces — "Agent: planning next action…",
|
|
181
|
+
// "Agent: classified input as …", "Agent: streaming final answer…" — describe
|
|
182
|
+
// how the agent works, not what the business run does. They belong with the
|
|
183
|
+
// dispatch plumbing in the Agent status tab; the Runtime tab keeps the
|
|
184
|
+
// business flow (plan, task transitions, failures).
|
|
185
|
+
const AGENT_TRACE_LINE = /^(?:runtime\s+)?(?:\d{1,2}:\d{2}(?::\d{2})?\s*(?:·\s*)?)?Agent:\s/;
|
|
186
|
+
|
|
187
|
+
export function isAgentTraceLine(line) {
|
|
188
|
+
return AGENT_TRACE_LINE.test(String(line ?? ''));
|
|
189
|
+
}
|
|
190
|
+
|
|
180
191
|
export function runtimeLogMatchesFilter(line, filter = '') {
|
|
181
192
|
const query = String(filter ?? '').trim();
|
|
182
193
|
if (!query) return true;
|
|
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
|
|
|
2
2
|
import test from 'node:test';
|
|
3
3
|
|
|
4
4
|
import { createAgentEvent, dispatchAgentEvent } from './agentEvents.js';
|
|
5
|
-
import { compactRuntimeLogForDisplay, formatRuntimeLogPayload, isDispatchPlumbingLine, shortLogId } from './runtimeLog.js';
|
|
5
|
+
import { compactRuntimeLogForDisplay, formatRuntimeLogPayload, isAgentTraceLine, isDispatchPlumbingLine, shortLogId } from './runtimeLog.js';
|
|
6
6
|
import { emitRuntimeLog } from '../runtime/supervisor.js';
|
|
7
7
|
|
|
8
8
|
const CYCLE_EVENTS = [
|
|
@@ -156,6 +156,34 @@ test('isDispatchPlumbingLine recognises the shell-tagged "runtime " lines for th
|
|
|
156
156
|
assert.equal(isDispatchPlumbingLine('runtime 14:42:22 Plan validated for run r1'), false);
|
|
157
157
|
});
|
|
158
158
|
|
|
159
|
+
test('isAgentTraceLine recognises the "Agent:" traces for the Agent status tab', () => {
|
|
160
|
+
// The agent's own reasoning traces were rendered in the Runtime tab,
|
|
161
|
+
// indistinguishable from the business flow. They describe the agent's
|
|
162
|
+
// working, so the Agent status tab shows them with the dispatch plumbing.
|
|
163
|
+
for (const line of [
|
|
164
|
+
'14:42:18 Agent: planning next action…',
|
|
165
|
+
'14:42:19 Agent: classified input as enqueue',
|
|
166
|
+
'14:42:20 Agent: streaming final answer…',
|
|
167
|
+
'runtime 14:42:18 Agent: planning next action…',
|
|
168
|
+
]) {
|
|
169
|
+
assert.equal(isAgentTraceLine(line), true, `expected an agent trace: ${line}`);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('isAgentTraceLine leaves every other line for the Runtime tab', () => {
|
|
174
|
+
for (const line of [
|
|
175
|
+
'14:42:18 ▸ Polish proposition — started (knowledge.polish → agent-production)',
|
|
176
|
+
'14:42:19 ✓ Polish proposition — done (1 output)',
|
|
177
|
+
'14:42:21 Run failed: No agent provides capability workspace.restore.',
|
|
178
|
+
'14:42:22 Plan validated for run r1',
|
|
179
|
+
'14:42:23 Agentic runtime: no capabilities available',
|
|
180
|
+
'14:42:24 Plan: 3 task(s) declared from production fragment',
|
|
181
|
+
'runtime 14:42:21 Run failed: Agent was not ready',
|
|
182
|
+
]) {
|
|
183
|
+
assert.equal(isAgentTraceLine(line), false, `expected business flow: ${line}`);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
159
187
|
test('shortLogId caps an over-long task slug while shortening embedded UUIDs', () => {
|
|
160
188
|
const long = `${'x'.repeat(48)}-deadbeef`;
|
|
161
189
|
assert.match(shortLogId(long), /…$/);
|
|
@@ -30,8 +30,8 @@ test('validation rejects technical routing details', () => {
|
|
|
30
30
|
assert.throws(() => validateCompiledObjectives([{ text: 'agent: cme' }]), { code: 'skill_compile_failed' });
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
-
test('
|
|
34
|
-
const expected = { pipeline: 1, 'wiki-ingest': 1, 'wiki-build': 1, deliver: 1, diagnose: 1, status: 1, 'new-template': 1
|
|
33
|
+
test('every shipped scaffold skill compiles to a single intention', async () => {
|
|
34
|
+
const expected = { pipeline: 1, 'wiki-sync': 1, 'wiki-ingest': 1, 'wiki-build': 1, deliver: 1, diagnose: 1, status: 1, 'new-template': 1 };
|
|
35
35
|
for (const [name, count] of Object.entries(expected)) {
|
|
36
36
|
const raw = readFileSync(resolve('../llm-wiki/scaffold/workspace/.wiki/skills', `${name}.md`), 'utf8');
|
|
37
37
|
const { meta, body } = parseFrontmatter(raw);
|
|
@@ -53,7 +53,7 @@ test('every objective of a chain carries the user parameters, not just the last'
|
|
|
53
53
|
// Appending the parameters before splitting attached `source` to the ingest
|
|
54
54
|
// step and left the export step — the one that consumes it — without it.
|
|
55
55
|
const skill = {
|
|
56
|
-
name: '
|
|
56
|
+
name: 'collect-then-ingest',
|
|
57
57
|
params: ['source'],
|
|
58
58
|
body: 'Export the requested source.\n\nThen ingest what was exported.',
|
|
59
59
|
};
|
package/src/core/wikiSetup.js
CHANGED
|
@@ -6,7 +6,7 @@ import { promisify } from 'node:util';
|
|
|
6
6
|
import YAML from 'yaml';
|
|
7
7
|
import { checkMissingDockerImages } from './dockerImages.js';
|
|
8
8
|
import { loadWikircProfile, patchWikircProfile } from './wikirc.js';
|
|
9
|
-
import { buildInheritedWikircPatch
|
|
9
|
+
import { buildInheritedWikircPatch } from './workspaceInherit.js';
|
|
10
10
|
import { resolveAgentsComposeContext } from './agentsCompose.js';
|
|
11
11
|
import { managerEnvFile, managerMcpEndpointsFile, resolveAgentsDataDir } from './env.js';
|
|
12
12
|
import { createWorkspace, findWorkspace, isValidWorkspaceName, listWorkspaces, managerRoot, workspacesDir } from './workspaces.js';
|
|
@@ -245,9 +245,11 @@ export async function createNewWorkspace(name, targetPath, options = {}) {
|
|
|
245
245
|
}
|
|
246
246
|
|
|
247
247
|
/**
|
|
248
|
-
* @param options.inheritFrom name of the workspace to copy LLM config
|
|
249
|
-
*
|
|
250
|
-
*
|
|
248
|
+
* @param options.inheritFrom name of the workspace to copy LLM config from —
|
|
249
|
+
* normally the session's current workspace. Omitted, or unknown, means
|
|
250
|
+
* "scaffold defaults only", the previous behaviour. Confluence credentials
|
|
251
|
+
* are NOT inherited: agent-cme stores them agent-wide (shared across all
|
|
252
|
+
* workspaces), so a new workspace already sees them.
|
|
251
253
|
*/
|
|
252
254
|
export async function finalizeCreatedWorkspace(name, options = {}) {
|
|
253
255
|
const workspace = findWorkspace(name);
|
|
@@ -282,13 +284,6 @@ export async function inheritWorkspaceSetup(workspace, sourceName) {
|
|
|
282
284
|
// Unreadable or absent profile on either side — nothing to inherit.
|
|
283
285
|
}
|
|
284
286
|
|
|
285
|
-
try {
|
|
286
|
-
const copied = await copyCmeCredentials(resolveAgentsDataDir(), source.name, workspace.name);
|
|
287
|
-
if (copied) inherited.push('cme.app_data.json');
|
|
288
|
-
} catch {
|
|
289
|
-
// Credentials are a convenience, not a prerequisite.
|
|
290
|
-
}
|
|
291
|
-
|
|
292
287
|
return inherited;
|
|
293
288
|
}
|
|
294
289
|
|
package/src/core/wikirc.test.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
-
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import test from 'node:test';
|
|
@@ -300,6 +300,9 @@ test('finalizeCreatedWorkspace seeds a new workspace from the one in use', async
|
|
|
300
300
|
'',
|
|
301
301
|
].join('\n'));
|
|
302
302
|
|
|
303
|
+
// Confluence credentials are agent-wide now — a stale per-workspace
|
|
304
|
+
// app_data.json from a legacy layout must NOT be copied into the new
|
|
305
|
+
// workspace, and must not be reported as inherited.
|
|
303
306
|
mkdirSync(join(agentsData, 'cme', 'acme', 'cme'), { recursive: true });
|
|
304
307
|
writeFileSync(join(agentsData, 'cme', 'acme', 'cme', 'app_data.json'), '{"pat":"x"}', 'utf8');
|
|
305
308
|
|
|
@@ -318,11 +321,8 @@ test('finalizeCreatedWorkspace seeds a new workspace from the one in use', async
|
|
|
318
321
|
// The workspace keeps ITS own MCP credential, written just before.
|
|
319
322
|
assert.equal(parsed.mcp.accessKey, token);
|
|
320
323
|
assert.ok(inherited.includes('llm.baseUrl'));
|
|
321
|
-
assert.ok(inherited.includes('cme.app_data.json'));
|
|
322
|
-
assert.equal(
|
|
323
|
-
readFileSync(join(agentsData, 'cme', 'fresh', 'cme', 'app_data.json'), 'utf8'),
|
|
324
|
-
'{"pat":"x"}',
|
|
325
|
-
);
|
|
324
|
+
assert.ok(!inherited.includes('cme.app_data.json'));
|
|
325
|
+
assert.equal(existsSync(join(agentsData, 'cme', 'fresh', 'cme', 'app_data.json')), false);
|
|
326
326
|
} finally {
|
|
327
327
|
if (previousDir === undefined) delete process.env.WIKI_WORKSPACES_DIR;
|
|
328
328
|
else process.env.WIKI_WORKSPACES_DIR = previousDir;
|
package/src/core/workflow.js
CHANGED
|
@@ -14,13 +14,11 @@ import { isTerminal } from '../orchestrator/taskStatuses.js';
|
|
|
14
14
|
|
|
15
15
|
const RUNNING_STATUSES = new Set(['running', 'starting', 'queued', 'waiting', 'pending_approval']);
|
|
16
16
|
|
|
17
|
-
// Canonical workflow projection
|
|
17
|
+
// Canonical workflow projection.
|
|
18
18
|
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// for Serve and ShellTUI. Future releases can move the reducer internals behind
|
|
23
|
-
// this module without changing UI contracts.
|
|
19
|
+
// projectWorkflow consumes the existing event-sourced agentProjection instead
|
|
20
|
+
// of replacing it: agentProjection remains the compatibility reducer/hydration
|
|
21
|
+
// format; workflow is the canonical read model for Serve and ShellTUI.
|
|
24
22
|
export function projectWorkflow(state = {}, events = []) {
|
|
25
23
|
const run = currentRun(state, events);
|
|
26
24
|
const plan = Array.isArray(state.plan) ? state.plan : [];
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { copyFile, mkdir } from 'node:fs/promises';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
3
1
|
import { join } from 'node:path';
|
|
4
2
|
|
|
5
3
|
/**
|
|
@@ -121,29 +119,3 @@ export function buildInheritedWikircPatch(sourceConfig, targetConfig) {
|
|
|
121
119
|
if (Object.keys(vector).length > 0) patch.retrieval = { vector };
|
|
122
120
|
return { patch, inherited };
|
|
123
121
|
}
|
|
124
|
-
|
|
125
|
-
export function cmeCredentialsPath(agentsDataDir, workspaceName) {
|
|
126
|
-
return join(agentsDataDir, 'cme', workspaceName, 'cme', 'app_data.json');
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Carry the Confluence credentials of an existing workspace over to a new one.
|
|
131
|
-
*
|
|
132
|
-
* `app_data.json` only — NOT `sources-manifest.yaml`. The credentials are a
|
|
133
|
-
* property of the operator's Confluence account and are identical everywhere;
|
|
134
|
-
* which spaces and pages a workspace exports is precisely what makes it a
|
|
135
|
-
* different workspace, and copying that would silently re-export someone
|
|
136
|
-
* else's scope on the first run.
|
|
137
|
-
*/
|
|
138
|
-
export async function copyCmeCredentials(agentsDataDir, sourceWorkspace, targetWorkspace) {
|
|
139
|
-
if (!agentsDataDir || !sourceWorkspace || !targetWorkspace) return null;
|
|
140
|
-
if (sourceWorkspace === targetWorkspace) return null;
|
|
141
|
-
const from = cmeCredentialsPath(agentsDataDir, sourceWorkspace);
|
|
142
|
-
const to = cmeCredentialsPath(agentsDataDir, targetWorkspace);
|
|
143
|
-
if (!existsSync(from)) return null;
|
|
144
|
-
// An existing target file is a real configuration; never clobber it.
|
|
145
|
-
if (existsSync(to)) return null;
|
|
146
|
-
await mkdir(join(agentsDataDir, 'cme', targetWorkspace, 'cme'), { recursive: true });
|
|
147
|
-
await copyFile(from, to);
|
|
148
|
-
return to;
|
|
149
|
-
}
|
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
import test from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import {
|
|
4
|
-
import { tmpdir } from 'node:os';
|
|
5
|
-
import { join } from 'node:path';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
6
4
|
import { fileURLToPath } from 'node:url';
|
|
7
5
|
import {
|
|
8
6
|
PLACEHOLDER_VALUE_RE,
|
|
9
7
|
buildInheritedWikircPatch,
|
|
10
|
-
cmeCredentialsPath,
|
|
11
|
-
copyCmeCredentials,
|
|
12
8
|
isRealValue,
|
|
13
9
|
} from './workspaceInherit.js';
|
|
14
10
|
|
|
@@ -144,38 +140,14 @@ test('nothing to inherit yields an empty patch', () => {
|
|
|
144
140
|
assert.deepEqual(inherited, []);
|
|
145
141
|
});
|
|
146
142
|
|
|
147
|
-
test('
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const copied = await copyCmeCredentials(root, 'acme', 'fresh');
|
|
156
|
-
|
|
157
|
-
assert.equal(copied, cmeCredentialsPath(root, 'fresh'));
|
|
158
|
-
assert.equal(readFileSync(copied, 'utf8'), '{"auth":{"pat":"secret"}}');
|
|
159
|
-
assert.equal(existsSync(join(root, 'cme', 'fresh', 'sources-manifest.yaml')), false);
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
test('existing CME credentials on the target are never clobbered', async () => {
|
|
163
|
-
const root = mkdtempSync(join(tmpdir(), 'cme-inherit-keep-'));
|
|
164
|
-
mkdirSync(join(root, 'cme', 'acme', 'cme'), { recursive: true });
|
|
165
|
-
mkdirSync(join(root, 'cme', 'fresh', 'cme'), { recursive: true });
|
|
166
|
-
writeFileSync(join(root, 'cme', 'acme', 'cme', 'app_data.json'), '{"from":"source"}', 'utf8');
|
|
167
|
-
writeFileSync(join(root, 'cme', 'fresh', 'cme', 'app_data.json'), '{"from":"target"}', 'utf8');
|
|
168
|
-
|
|
169
|
-
assert.equal(await copyCmeCredentials(root, 'acme', 'fresh'), null);
|
|
170
|
-
assert.equal(
|
|
171
|
-
readFileSync(cmeCredentialsPath(root, 'fresh'), 'utf8'),
|
|
172
|
-
'{"from":"target"}',
|
|
143
|
+
test('Confluence credentials are not part of workspace inheritance', () => {
|
|
144
|
+
// agent-cme stores credentials agent-wide since the shared-config change:
|
|
145
|
+
// a new workspace sees them without any copy. Inheritance must not smuggle
|
|
146
|
+
// an app_data.json (or a sources manifest) into per-workspace state.
|
|
147
|
+
const { patch, inherited } = buildInheritedWikircPatch(
|
|
148
|
+
{ llm: WORKING_LLM },
|
|
149
|
+
{ llm: SCAFFOLD_LLM },
|
|
173
150
|
);
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
test('copying is a no-op without a source, a target, or a source file', async () => {
|
|
177
|
-
const root = mkdtempSync(join(tmpdir(), 'cme-inherit-noop-'));
|
|
178
|
-
assert.equal(await copyCmeCredentials(root, 'absent', 'fresh'), null);
|
|
179
|
-
assert.equal(await copyCmeCredentials(root, null, 'fresh'), null);
|
|
180
|
-
assert.equal(await copyCmeCredentials(root, 'acme', 'acme'), null);
|
|
151
|
+
assert.equal(patch['cme.app_data.json'], undefined);
|
|
152
|
+
assert.ok(!inherited.some((key) => key.toLowerCase().includes('cme')));
|
|
181
153
|
});
|
|
@@ -72,8 +72,19 @@ export function createAgentRegistry({
|
|
|
72
72
|
const discovered = [];
|
|
73
73
|
const endpoints = Object.entries(session?.mcp ?? {});
|
|
74
74
|
const activeServers = new Set(endpoints.map(([serverName]) => serverName));
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
// Each probe is an independent network call (agent_describe against one
|
|
76
|
+
// server); none reads another server's result. Probing them concurrently
|
|
77
|
+
// turns the wall-clock cost from the SUM of every server's probe latency
|
|
78
|
+
// into the latency of the SLOWEST one — the sequential version made every
|
|
79
|
+
// delegation get linearly slower as more MCP servers were connected.
|
|
80
|
+
// registerAgent still runs afterward in the original endpoint order, one
|
|
81
|
+
// at a time, so event ordering and the shared-map mutations it performs
|
|
82
|
+
// are unchanged.
|
|
83
|
+
const probed = await Promise.all(
|
|
84
|
+
endpoints.map(([serverName, endpoint]) =>
|
|
85
|
+
discoverServerAgent(session, serverName, endpoint, { callTool, signal, now })),
|
|
86
|
+
);
|
|
87
|
+
for (const agent of probed) {
|
|
77
88
|
discovered.push(registerAgent(session, agent, { agentsByInstance, instanceByServer, lastProbeFailed }));
|
|
78
89
|
}
|
|
79
90
|
for (const [serverName, instanceId] of instanceByServer) {
|
|
@@ -92,7 +92,7 @@ test('resolveObjective disambiguates "export" of a Confluence source via alias,
|
|
|
92
92
|
assert.equal(result.provider.agentInstanceId, 'cme-1');
|
|
93
93
|
});
|
|
94
94
|
|
|
95
|
-
test('resolveObjective resolves
|
|
95
|
+
test('resolveObjective resolves an ingest objective deterministically despite notification and guardrails', async () => {
|
|
96
96
|
const session = sessionWith([
|
|
97
97
|
provider('production-1', knowledge),
|
|
98
98
|
provider('production-2', publish),
|
|
@@ -22,35 +22,37 @@ export async function discoverRuntimeProviderAgents(runtimeProviders) {
|
|
|
22
22
|
const providers = Array.isArray(runtimeProviders)
|
|
23
23
|
? runtimeProviders
|
|
24
24
|
: (runtimeProviders?.list?.() ?? []);
|
|
25
|
-
const agents = [];
|
|
26
|
-
const unavailable = [];
|
|
27
|
-
// Configured-but-not-served capabilities, per runtime (deepagents providers
|
|
28
|
-
// fill `lastDiscovery`). A drift is not an outage: the runtime stays
|
|
29
|
-
// available with the capabilities it really serves, and the difference is
|
|
30
|
-
// reported so an operator can see WHY agent.research is not routable.
|
|
31
|
-
const drift = [];
|
|
32
25
|
|
|
33
|
-
|
|
26
|
+
// One provider's describe()/discoverCapabilities() never reads another
|
|
27
|
+
// provider's result, so probe them concurrently (same reasoning as
|
|
28
|
+
// agentRegistry.discover()) instead of paying the sum of every runtime's
|
|
29
|
+
// latency in sequence. Each entry still produces its own ordered
|
|
30
|
+
// {agents, unavailable, drift} slice, flattened below in the original
|
|
31
|
+
// `providers` order so output ordering is unchanged.
|
|
32
|
+
const results = await Promise.all(providers.map(async (entry) => {
|
|
34
33
|
const provider = entry?.provider ?? entry;
|
|
35
34
|
const runtimeId = String(entry?.id ?? provider?.runtime ?? 'external-runtime');
|
|
35
|
+
const agents = [];
|
|
36
|
+
const unavailable = [];
|
|
37
|
+
const drift = [];
|
|
36
38
|
let description;
|
|
37
39
|
try {
|
|
38
40
|
assertRuntimeProvider(provider);
|
|
39
41
|
description = await provider.describe();
|
|
40
42
|
} catch (error) {
|
|
41
43
|
unavailable.push({ runtimeId, error: error instanceof Error ? error.message : String(error) });
|
|
42
|
-
|
|
44
|
+
return { agents, unavailable, drift };
|
|
43
45
|
}
|
|
44
46
|
if (description?.health === 'unavailable') {
|
|
45
47
|
unavailable.push({ runtimeId, error: description?.error ?? 'runtime reports unavailable' });
|
|
46
|
-
|
|
48
|
+
return { agents, unavailable, drift };
|
|
47
49
|
}
|
|
48
50
|
let capabilities;
|
|
49
51
|
try {
|
|
50
52
|
capabilities = await provider.discoverCapabilities();
|
|
51
53
|
} catch (error) {
|
|
52
54
|
unavailable.push({ runtimeId, error: error instanceof Error ? error.message : String(error) });
|
|
53
|
-
|
|
55
|
+
return { agents, unavailable, drift };
|
|
54
56
|
}
|
|
55
57
|
const health = ['available', 'degraded'].includes(description?.health)
|
|
56
58
|
? description.health
|
|
@@ -62,7 +64,16 @@ export async function discoverRuntimeProviderAgents(runtimeProviders) {
|
|
|
62
64
|
if (discovery && Array.isArray(discovery.missing) && discovery.missing.length > 0) {
|
|
63
65
|
drift.push({ runtimeId, missing: [...discovery.missing], served: [...(discovery.served ?? [])] });
|
|
64
66
|
}
|
|
65
|
-
|
|
67
|
+
return { agents, unavailable, drift };
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
// Configured-but-not-served capabilities, per runtime (deepagents providers
|
|
71
|
+
// fill `lastDiscovery`). A drift is not an outage: the runtime stays
|
|
72
|
+
// available with the capabilities it really serves, and the difference is
|
|
73
|
+
// reported so an operator can see WHY agent.research is not routable.
|
|
74
|
+
const agents = results.flatMap((r) => r.agents);
|
|
75
|
+
const unavailable = results.flatMap((r) => r.unavailable);
|
|
76
|
+
const drift = results.flatMap((r) => r.drift);
|
|
66
77
|
|
|
67
78
|
return { agents, unavailable, drift };
|
|
68
79
|
}
|
package/src/runtime/server.js
CHANGED
|
@@ -1422,9 +1422,8 @@ function rejectPlanPatch(context, store, patchId, reason) {
|
|
|
1422
1422
|
};
|
|
1423
1423
|
}
|
|
1424
1424
|
|
|
1425
|
-
// Classifier for control
|
|
1426
|
-
// LLM-backed
|
|
1427
|
-
// is that, now: the only deterministic matches left are the runtime's own
|
|
1425
|
+
// Classifier for the control lane's free-text messages. The classification is
|
|
1426
|
+
// LLM-backed: the only deterministic matches left are the runtime's own
|
|
1428
1427
|
// control verbs (cancel, an explicit "later/queue", status and plan-change
|
|
1429
1428
|
// wording). Deciding "is this a NEW task to queue vs plain conversation" is a
|
|
1430
1429
|
// semantic judgement about the workspace's domain, so it is never a keyword
|
|
@@ -1606,7 +1606,7 @@ test('runtime server handle drains a pre-existing hydrated control request', asy
|
|
|
1606
1606
|
test('POST /run compiles a workspace skill into a sequential runtime chain', async (t) => {
|
|
1607
1607
|
const root = mkdtempSync(join(tmpdir(), 'runtime-skill-'));
|
|
1608
1608
|
mkdirSync(join(root, '.wiki', 'skills'), { recursive: true });
|
|
1609
|
-
writeFileSync(join(root, '.wiki', 'skills', '
|
|
1609
|
+
writeFileSync(join(root, '.wiki', 'skills', 'sync-ingest.md'), '---\nname: sync-ingest\nparams:\n - source\n---\nExport the source.\n\nThen ingest the files.');
|
|
1610
1610
|
const session = { workspace: 'acme', workspacePath: root, controlQueue: [] };
|
|
1611
1611
|
const context = { workspace: 'acme', session, running: false, currentAbortController: null };
|
|
1612
1612
|
let startedBody = null;
|
|
@@ -1623,7 +1623,7 @@ test('POST /run compiles a workspace skill into a sequential runtime chain', asy
|
|
|
1623
1623
|
throw err;
|
|
1624
1624
|
}
|
|
1625
1625
|
try {
|
|
1626
|
-
const response = await fetch(`http://127.0.0.1:${handle.port}/run?workspace=acme`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ input: '/
|
|
1626
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/run?workspace=acme`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ input: '/sync-ingest docs' }) });
|
|
1627
1627
|
const body = await response.json();
|
|
1628
1628
|
assert.equal(response.status, 202);
|
|
1629
1629
|
assert.equal(body.kind, 'skill_chain');
|
|
@@ -1633,15 +1633,15 @@ test('POST /run compiles a workspace skill into a sequential runtime chain', asy
|
|
|
1633
1633
|
assert.equal(session.controlQueue[1].status, 'queued');
|
|
1634
1634
|
assert.equal(session.controlQueue[0].chainId, session.controlQueue[1].chainId);
|
|
1635
1635
|
assert.equal('capabilityPlan' in session.controlQueue[0], false);
|
|
1636
|
-
assert.equal(session.controlQueue[0].input, '/
|
|
1636
|
+
assert.equal(session.controlQueue[0].input, '/sync-ingest source="docs"');
|
|
1637
1637
|
assert.equal(session.controlQueue[0].skillExecution, 'orchestrated');
|
|
1638
1638
|
assert.match(startedBody.input, /Export the source/);
|
|
1639
|
-
assert.equal(startedBody.publicInput, '/
|
|
1639
|
+
assert.equal(startedBody.publicInput, '/sync-ingest source="docs"');
|
|
1640
1640
|
assert.equal(startedBody.skillChain.execution, 'orchestrated');
|
|
1641
1641
|
assert.equal(startedBody.requireApproval, true);
|
|
1642
1642
|
assert.notEqual(startedBody.autoApprove, true);
|
|
1643
1643
|
assert.deepEqual(session.agentProjection.conversation, [
|
|
1644
|
-
{ role: 'user', content: '/
|
|
1644
|
+
{ role: 'user', content: '/sync-ingest docs' },
|
|
1645
1645
|
]);
|
|
1646
1646
|
assert.doesNotMatch(JSON.stringify(session.agentEvents), /Export the source|Then ingest the files/);
|
|
1647
1647
|
} finally {
|
|
@@ -132,26 +132,31 @@ test('E2E-001 pipeline: one objective, one run, internal plan left untouched', a
|
|
|
132
132
|
assert.equal(env.chain().length, 1);
|
|
133
133
|
});
|
|
134
134
|
|
|
135
|
-
test('E2E-002
|
|
136
|
-
|
|
135
|
+
test('E2E-002 multi-step skill: two objectives, two ordered runs, one chainId', async (t) => {
|
|
136
|
+
// No shipped skill splits any more (each is one intention); a user-authored
|
|
137
|
+
// body with a strong connector still must.
|
|
138
|
+
const env = await harness(t, {
|
|
139
|
+
skills: {
|
|
140
|
+
'sync-ingest': '---\nname: sync-ingest\nparams: []\n---\nExport every configured Confluence source.\n\nThen ingest the exported Markdown into the wiki.',
|
|
141
|
+
},
|
|
142
|
+
});
|
|
137
143
|
if (!env) return;
|
|
138
144
|
|
|
139
|
-
const { body } = await env.post('/run?workspace=acme', { input: '/
|
|
145
|
+
const { body } = await env.post('/run?workspace=acme', { input: '/sync-ingest' });
|
|
140
146
|
await env.settle();
|
|
141
147
|
|
|
142
148
|
assert.equal(body.objectives, 2);
|
|
143
149
|
assert.equal(env.runs.length, 2, 'the second objective must run after the first');
|
|
144
150
|
assert.match(env.runs[0].input, /^Export every configured Confluence source/);
|
|
145
|
-
assert.match(env.runs[1].input, /^
|
|
146
|
-
//
|
|
147
|
-
// no run may receive a source selector (the export step must stay "all").
|
|
151
|
+
assert.match(env.runs[1].input, /^Ingest the exported Markdown/);
|
|
152
|
+
// The skill carries no parameter, so no run may receive a selector.
|
|
148
153
|
for (const run of env.runs) assert.doesNotMatch(run.input, /User parameters:/);
|
|
149
154
|
const items = env.chain();
|
|
150
155
|
assert.equal(items.length, 2);
|
|
151
156
|
assert.equal(items[0].chainId, items[1].chainId);
|
|
152
157
|
assert.equal(items[0].chainId, body.chainId);
|
|
153
158
|
assert.deepEqual(items.map((item) => item.status), ['done', 'done']);
|
|
154
|
-
assert.deepEqual(items.map((item) => item.skillName), ['
|
|
159
|
+
assert.deepEqual(items.map((item) => item.skillName), ['sync-ingest', 'sync-ingest']);
|
|
155
160
|
});
|
|
156
161
|
|
|
157
162
|
test('E2E-002b parameters reach every objective of a multi-step skill', async (t) => {
|
|
@@ -220,13 +225,13 @@ test('E2E-003 cancel: the running step and its chain stop, unrelated queue survi
|
|
|
220
225
|
// that silently fragments would show up as extra runs, not as extra objectives.
|
|
221
226
|
const PERFORMANCE_TABLE = {
|
|
222
227
|
pipeline: 1,
|
|
228
|
+
'wiki-sync': 1,
|
|
223
229
|
'wiki-ingest': 1,
|
|
224
230
|
'wiki-build': 1,
|
|
225
231
|
deliver: 1,
|
|
226
232
|
diagnose: 1,
|
|
227
233
|
status: 1,
|
|
228
234
|
'new-template': 1,
|
|
229
|
-
'wiki-sync': 2,
|
|
230
235
|
};
|
|
231
236
|
|
|
232
237
|
for (const [name, expected] of Object.entries(PERFORMANCE_TABLE)) {
|
package/src/shell/RightPane.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
import { createMemo, createSignal, Index, Show } from 'solid-js';
|
|
3
|
-
import { compactRuntimeLogForDisplay, filterRuntimeLogs, isDispatchPlumbingLine } from '../core/runtimeLog.js';
|
|
3
|
+
import { compactRuntimeLogForDisplay, filterRuntimeLogs, isAgentTraceLine, isDispatchPlumbingLine } from '../core/runtimeLog.js';
|
|
4
4
|
import { fit } from './textFit';
|
|
5
5
|
|
|
6
6
|
type PlanStep = { step: number; description: string; status: string };
|
|
@@ -409,13 +409,14 @@ export function LogPanel(props: { logs: string[]; width: number; filter?: string
|
|
|
409
409
|
const [activeLogTab, setActiveLogTab] = createSignal<'flow' | 'agent-status'>('flow');
|
|
410
410
|
const lineWidth = () => Math.max(8, props.width - 2);
|
|
411
411
|
// "Agent status" collects the dispatch plumbing — capability resolution,
|
|
412
|
-
// agent selection, agent_execute/agent_status polling, job acceptance —
|
|
413
|
-
// the
|
|
414
|
-
// ▸/✓/✗/↻ task lines).
|
|
415
|
-
//
|
|
416
|
-
//
|
|
417
|
-
//
|
|
418
|
-
|
|
412
|
+
// agent selection, agent_execute/agent_status polling, job acceptance — and
|
|
413
|
+
// the agent's own reasoning traces ("Agent: …"), so the "Runtime" tab is
|
|
414
|
+
// left with the readable business flow (plan + the ▸/✓/✗/↻ task lines).
|
|
415
|
+
// isDispatchPlumbingLine (shared with agentEvents' dedup) recognises the
|
|
416
|
+
// formatRuntimeLogPayload shape structurally; the old token enumeration
|
|
417
|
+
// only ever matched agent_status/agent_execute and missed every dotted
|
|
418
|
+
// event ('job.accepted' → 'ACCEPTED', …).
|
|
419
|
+
const isAgentStatus = (line: string) => isDispatchPlumbingLine(line) || isAgentTraceLine(line);
|
|
419
420
|
// Filtering preserves the runtime history order. logRenderLines performs
|
|
420
421
|
// the single block-level reversal shared by both tabs.
|
|
421
422
|
const filteredLogs = () => filterRuntimeLogs(props.logs, props.filter ?? '')
|
package/src/shell/repl.test.js
CHANGED
|
@@ -101,6 +101,13 @@ test('ShellUI renders newest-first order in both Runtime and Agent status', asyn
|
|
|
101
101
|
assert.match(filteredLogs, /activeLogTab\(\) === 'agent-status'/);
|
|
102
102
|
assert.match(filteredLogs, /isAgentStatus\(line\)/);
|
|
103
103
|
assert.doesNotMatch(filteredLogs, /\.reverse\(\)/);
|
|
104
|
+
const logPanelBody = source.slice(
|
|
105
|
+
source.indexOf('export function LogPanel'),
|
|
106
|
+
source.indexOf('const filteredLogs ='),
|
|
107
|
+
);
|
|
108
|
+
// Agent reasoning traces route to the Agent status tab with the dispatch
|
|
109
|
+
// plumbing — the Runtime tab keeps the business flow.
|
|
110
|
+
assert.match(logPanelBody, /isDispatchPlumbingLine\(line\) \|\| isAgentTraceLine\(line\)/);
|
|
104
111
|
const renderedLogs = source.slice(
|
|
105
112
|
source.indexOf('function logRenderLines'),
|
|
106
113
|
source.indexOf('function logEntryLines'),
|