Functional-test fixes: agent tok/s and context, plain tool rows, Thinking Off proof, profile used, no download wording for installed models, plain chat, smaller agent prompt

- agent path: live stats frames and a final tok/s from the stream timing; the context is the last request (one source)
- tool rows say what happened in words (Wrote a file, Ran a command, Couldn't write the file) with the file or command; approval cards show it too
- a launch that returned without error reports 'Started.' instead of 'nothing to show'
- agent system prompt replaced by a short plain one; goal, skill, workflow and fork tools left out (first message about 7.5k -> about 4.3k tokens)
- Models: the running model reports the profile it was started with and the page shows it; Reasoning popover names the loaded native model
- installed model with other settings: 'Apply settings and load', no size, no download wording
- plain chat: agent:false or tools:false answers directly and never runs a tool
- tests: stats/context/verbs/launch/prompt size, server-side enable_thinking=false with the agent's request shape, plain chat over HTTP, ui-flows profile scenario
This commit is contained in:
Niko1221 committed 2026-10-16 04:20:20 +02:00
1 parent 76cdc271c0
commit 394de92915
26 files changed
+764 -39

No files matched your search

+55 -2
View File
@@ -30,7 +30,7 @@ const nodeExe = resolve(args.node ?? join(staged, 'node', process.platform === '
const work = mkdtempSync(join(tmpdir(), 'strata-ui-flows-'));
const shots = resolve(args.shots ?? join(work, 'shots'));
mkdirSync(shots, { recursive: true });
const only = new Set(String(args.only ?? 'download,models,tune,fail,delete,theme,restart,perf,polish,stop,latest').split(','));
const only = new Set(String(args.only ?? 'download,models,profile,tune,fail,delete,theme,restart,perf,polish,stop,latest').split(','));
const isWin = process.platform === 'win32';
const { chromium } = await import(pathToFileURL(join(repoHost, 'node_modules/playwright-core/index.mjs')).href).catch(() => import('playwright-core'));
const CHROME = process.env.CHROME ?? ['C:/Program Files/Google/Chrome/Application/chrome.exe', '/usr/bin/google-chrome'].find(existsSync);
@@ -266,6 +266,59 @@ await scenario('models', { installed: true }, async ({ base, api, browser, shot
check(page.problems.length === 0, 'no page error', page.problems.join('; '));
});
// ------------------------------------------------------------------------------------------------ the profile used, the popover, the pop-up
await scenario('profile', { installed: true }, async ({ base, api, browser, shot }) => {
const page = await newPage(browser, base);
await clickText(page, /Go straight to the app/i);
await page.getByText(/Conversation context/i).first().waitFor({ timeout: 30000 });
await delay(1500);
const work = () => page.getByRole('button', { name: /Leave 20% for my work/ }).first();
const full = () => page.getByRole('button', { name: /Full performance/ }).first();
await work().click();
check(await work().getAttribute('aria-pressed') === 'true', 'Leave 20% is chosen before loading');
await page.getByRole('button', { name: /^Load model$/ }).first().click();
let ready = false;
for (let i = 0; i < 90 && !ready; i++) { const s = (await api('/api/native')).body; ready = s.instances.some(x => x.status === 'ready'); await delay(500); }
check(ready, 'the model loads');
const inst = (await api('/api/native')).body.instances.find(x => x.status === 'ready');
check(inst?.resourceProfile === 'work', 'the running model reports the profile it was started with', String(inst?.resourceProfile));
await delay(1500);
check(await work().getAttribute('aria-pressed') === 'true' && await full().getAttribute('aria-pressed') === 'false', 'after the load the page still shows Leave 20%, not Full performance');
await page.reload({ waitUntil: 'domcontentloaded' });
await page.getByRole('button', { name: /^Unload$/ }).first().waitFor({ timeout: 30000 });
await delay(1000);
check(await work().getAttribute('aria-pressed') === 'true', 'and after a reload it is still the profile in use');
await shot(page, '1-profile-used');
// the reasoning popover names the loaded model
await page.evaluate(() => { location.hash = '#/chat'; });
const effort = page.getByRole('button', { name: /^Reasoning effort/ }).first();
await effort.waitFor({ timeout: 30000 });
await effort.click();
const header = await page.locator('.reasoning-menu-model strong').first().textContent({ timeout: 10000 });
check(header && !/No model loaded/i.test(header), 'the Reasoning popover names the loaded model, not "No model loaded"', String(header));
await shot(page, '2-reasoning-popover');
await page.keyboard.press('Escape');
// an installed model asked for with other settings: the pop-up never speaks of downloading
await page.evaluate(() => { location.hash = '#/models'; });
await page.getByRole('button', { name: /^Unload$/ }).first().waitFor({ timeout: 30000 });
await page.getByRole('button', { name: /^Unload$/ }).first().click();
await page.getByRole('button', { name: /^Load model$/ }).first().waitFor({ timeout: 30000 });
await page.getByRole('combobox', { name: 'Conversation context' }).first().click();
const options = page.getByRole('option');
const pick = (await options.allTextContents()).findIndex(t => /^8K$/.test(t.trim()));
if (pick >= 0) await options.nth(pick).click();
await page.getByRole('button', { name: /^Load model$|^Checking/ }).first().click();
const dialog = page.getByRole('dialog').first();
await dialog.waitFor({ timeout: 15000 }).catch(() => {});
const text = (await dialog.textContent().catch(() => '')) ?? '';
await shot(page, '3-apply-dialog');
if (text) {
check(!/Download and prepare|Download . load|approximately|[0-9] GB/i.test(text), 'for a downloaded model the pop-up has no download wording or size', text.replace(/\s+/g, ' ').slice(0, 600));
check(/Apply/.test(text), 'it says what happens: apply settings and load');
} else console.log('info the settings were the saved ones, so loading needed no pop-up at all');
check(page.problems.length === 0, 'no page error', page.problems.join('; '));
});
// ------------------------------------------------------------------------------------------------ tune + reading size
await scenario('tune', { installed: true, env: { FAKE_TOKEN_DELAY: '0.02', FAKE_REPLY: ['I am thinking about it.', '</think>', '', 'Here is a list:', '', '- one', '- two', '', '## Heading', '', 'Some `code` and **bold** text.', '', '| a | b |', '|---|---|', '| 1 | 2 |', '', '```', 'code line', '```', ''].join(String.fromCharCode(10)) } }, async ({ base, api, browser, dirs, shot }) => {
const page = await newPage(browser, base);
@@ -363,7 +416,7 @@ await scenario('delete', { installed: true }, async ({ base, api, browser, dirs,
await delay(600);
await shot(page, '1-delete-confirm');
const dialog = await page.getByRole('dialog').last().innerText();
check(/Delete this version\?/.test(dialog) && /Remove 83\.6 GB of downloaded files\. You can download this version again\./.test(dialog), 'the confirm dialog uses the existing words and the real size', dialog.replace(/\s+/g, ' ').slice(0, 200));
check(/Delete this version\?/.test(dialog) && /Remove 83\.6 GB of downloaded files\. You can download this version again\./.test(dialog), 'the confirm dialog uses the existing words and the real size', dialog.replace(/\s+/g, ' ').slice(0, 600));
check(/running model will be unloaded/i.test(dialog), 'and says the loaded model is unloaded first');
await page.getByRole('button', { name: /^Delete version$/ }).click();
let state = null;
+70 -4
View File
@@ -59,6 +59,35 @@ const WORDS = {
lsp: a => ['Looks up how the code fits together.', a.file_path],
};
/**
* What a card says it is doing, in the person's words: [while it runs, when it worked, when it failed]. The UI translates
* these fixed sentences and writes the target (a file path, the command) beside them, so a row never shows `pwsh · Ran`.
*/
const VERBS = {
write: ['Writing a file', 'Wrote a file', "Couldn't write the file"],
edit: ['Changing a file', 'Changed a file', "Couldn't change the file"],
read: ['Reading a file', 'Read a file', "Couldn't read the file"],
read_image: ['Looking at a picture', 'Looked at a picture', "Couldn't look at the picture"],
glob: ['Searching for files', 'Searched for files', "Couldn't search for files"],
grep: ['Searching inside files', 'Searched inside files', "Couldn't search inside files"],
pwsh: ['Running a command', 'Ran a command', 'The command failed'],
bash: ['Running a command', 'Ran a command', 'The command failed'],
web_fetch: ['Opening a web page', 'Opened a web page', "Couldn't open the web page"],
web_search: ['Searching the web', 'Searched the web', "Couldn't search the web"],
todo_write: ['Updating its to-do list', 'Updated its to-do list', "Couldn't update its to-do list"],
present: ['Showing you a result', 'Showed you a result', "Couldn't show the result"],
working_directory: ['Checking the folder', 'Checked the folder', "Couldn't check the folder"],
job_list: ['Checking background tasks', 'Checked background tasks', "Couldn't check background tasks"],
job_output: ['Reading a background task', 'Read a background task', "Couldn't read the background task"],
job_kill: ['Stopping a background task', 'Stopped a background task', "Couldn't stop the background task"],
};
const GENERIC_VERBS = ['Using a tool', 'Used a tool', 'The tool failed'];
/** The sentence for a card in this state (the first for a running or waiting card). */
export function toolVerb(name, state) {
const v = VERBS[name] ?? GENERIC_VERBS;
return state === 'error' ? v[2] : state === 'done' ? v[1] : v[0];
}
/** The ToolCall fields the card needs, from one Harness tool call. */
export function presentTool({ callId, name, args, reason = null }) {
const scope = toolScope(name);
@@ -69,7 +98,7 @@ export function presentTool({ callId, name, args, reason = null }) {
const effect = reason ?? (words ? words[0] : scope === 'shell' ? (args.description ?? 'Runs a command in the workspace.')
: scope === 'write' ? 'Changes a file in the workspace.'
: scope === 'read' ? 'Reads from the workspace.' : 'Uses a connected tool.');
return { id: callId, name, args, preview: clip(preview, 600), effect: clip(effect, 400), scope };
return { id: callId, name, args, preview: clip(preview, 600), effect: clip(effect, 400), scope, verb: toolVerb(name, 'running'), target: clip(name in VERBS ? preview : name, 300) };
}
/** A tool's failure in plain words, or null when there is nothing better than the original text. */
@@ -91,18 +120,24 @@ export function withPlain(raw) {
return plain ? `${plain}\n\nDetails: ${raw.replace(/^Error:\s*/, '')}` : raw;
}
/** A command whose whole point is to open something: it has no output to show when it worked. */
const LAUNCH = /(^|[\s;|&(])(start-process|start|invoke-item|ii|explorer(?:\.exe)?|xdg-open|open|notepad(?:\.exe)?|code)(?=[\s;|&)]|$)/i;
/**
* A command that succeeded and printed nothing says so in words (the card used to show "(no output)" under a "Ran" that could not
* tell the person whether anything happened). Opening a folder or a window is not something a command can prove it did, so the
* sentence says only what is known.
*/
export function outcomeText(tool, text) {
export function outcomeText(tool, text, args = {}) {
if (tool === 'pwsh' || tool === 'bash') {
// In the read-only modes PowerShell runs in Constrained Language Mode and complains about the Harness's own output-encoding line:
// noise appended after a command that worked. It says nothing about the command, so it is not shown.
text = text.replace(/\r?\n?\[stderr\]\r?\n(?:Cannot create type\. Only core types are supported in this language mode\.[\s\S]*?CannotCreateTypeConstrainedLanguage\s*)+$/, '');
if (!text.trim()) text = '(no output)';
}
// Starting a program or opening a folder prints nothing when it worked. The model used to read "finished, nothing to show" as "cannot tell
// whether it opened" and told the person so; a launch that returned without an error did start, and the result says that plainly.
if ((tool === 'pwsh' || tool === 'bash') && /^\(no output\)$/.test(text.trim()) && LAUNCH.test(String(args?.command ?? ''))) return 'Started. The program, folder or file was opened.';
if ((tool === 'pwsh' || tool === 'bash') && /^\(no output\)$/.test(text.trim())) return 'The command finished without any error. It printed nothing, so there is nothing more to show.';
return text;
}
@@ -131,8 +166,11 @@ export class TurnProjector {
this.tools = new Map(); // callId -> { card, calledAt }
this.tokensIn = 0;
this.tokensOut = 0;
this.contextTokens = null; // the LAST model request's prompt + reply: what the context holds now (the sums above count every request again)
this.usageSeen = false;
this.firstTokenAt = null;
this.streams = new Map(); // attemptId -> { first, last, chunks, chars }: the time a model request actually spent producing
this.lastStats = 0;
this.emittedText = new Map(); // attemptId -> chars sent live
this.ended = null; // the turn/end reason, once seen
this.denied = new Set(); // callIds a human or the policy rejected
@@ -143,9 +181,31 @@ export class TurnProjector {
const entry = this.tools.get(callId);
if (!entry) return null;
entry.card = { ...entry.card, ...patch };
entry.card.verb = toolVerb(entry.card.name, entry.card.state);
return { type: 'agent-tool', tool: entry.card };
}
/* The speed shown while a reply streams, and after it: everything the model generated (reasoning included) over the time its
requests spent producing it, not over the whole turn (tool runs and the wait for the prompt are not decoding). One stream per model
request; tokens are one per streamed chunk, or characters / 4 when a server batches. */
#produced(attemptId, text, out) {
const now = Date.now();
const s = this.streams.get(attemptId) ?? { first: now, last: now, chunks: 0, chars: 0 };
s.last = now; s.chunks += 1; s.chars += text.length;
this.streams.set(attemptId, s);
if (now - this.lastStats < 400) return;
const { tokens, seconds } = this.decode();
if (seconds < 0.5) return;
this.lastStats = now;
out.push({ type: 'stats', tokensOut: tokens, tps: Number((tokens / seconds).toFixed(1)), source: 'stream' });
}
decode() {
let tokens = 0, ms = 0;
for (const s of this.streams.values()) { tokens += s.chars / Math.max(1, s.chunks) < 8 ? s.chunks : Math.ceil(s.chars / 4); ms += s.last - s.first; }
return { tokens, seconds: ms / 1000 };
}
/** Process one `session/follow` item (event or assistant-stream). Returns frames. */
consume(item) {
const out = [];
@@ -155,16 +215,19 @@ export class TurnProjector {
const c = f.chunk;
if (c.type === 'text-delta' && c.text) {
this.firstTokenAt ??= Date.now();
this.#produced(f.attemptId, c.text, out);
this.emittedAny = true;
this.emittedText.set(f.attemptId, (this.emittedText.get(f.attemptId) ?? 0) + c.text.length);
out.push({ type: 'delta', text: c.text });
} else if (c.type === 'reasoning-delta' && c.text) {
this.firstTokenAt ??= Date.now();
this.#produced(f.attemptId, c.text, out);
out.push({ type: 'reasoning', text: c.text });
} else if (c.type === 'usage' && c.usage) {
this.usageSeen = true;
this.tokensIn += Number(c.usage.inputTokens) || 0;
this.tokensOut += Number(c.usage.outputTokens) || 0;
this.contextTokens = (Number(c.usage.inputTokens) || 0) + (Number(c.usage.outputTokens) || 0);
}
}
return out;
@@ -220,7 +283,7 @@ export class TurnProjector {
const frame = this.#card(callId, {
state: denied ? 'denied' : isError ? 'error' : 'done',
// A card the person (or the mode) refused keeps its plain sentence; the model's own error text ("the user rejected ...") is not for them.
result: this.byPolicy.has(callId) ? NOT_IN_MODE : denied ? (entry.card.result ?? 'Denied. Nothing ran.') : isError ? withPlain(resultText(d.message)) : outcomeText(entry.card.name, resultText(d.message)),
result: this.byPolicy.has(callId) ? NOT_IN_MODE : denied ? (entry.card.result ?? 'Denied. Nothing ran.') : isError ? withPlain(resultText(d.message)) : outcomeText(entry.card.name, resultText(d.message), entry.card.args),
durationMs: Math.max(0, (e.time ?? 0) - entry.calledAt),
});
if (frame) out.push(frame);
@@ -256,7 +319,10 @@ export class TurnProjector {
const usage = this.usageSeen
? { tokensIn: this.tokensIn, tokensOut: this.tokensOut, usageSource: 'reported' }
: { tokensIn: null, tokensOut: null, usageSource: 'unavailable' };
const base = { durationMs, ttftMs: this.firstTokenAt ? this.firstTokenAt - this.startedAt : null, model, contextMax };
const decoded = this.decode();
const generated = this.usageSeen && this.tokensOut > 0 ? this.tokensOut : decoded.tokens;
const tps = decoded.seconds >= 0.3 && generated > 0 ? Number((generated / decoded.seconds).toFixed(1)) : null;
const base = { durationMs, ttftMs: this.firstTokenAt ? this.firstTokenAt - this.startedAt : null, model, contextMax, tps, contextTokens: this.contextTokens };
switch (reason.kind) {
case 'completed':
return [{ type: 'done', ...base, ...usage, finishReason: 'stop' }];
+21 -9
View File
@@ -17,6 +17,10 @@
* preset (nothing can change); the Harness's plan mode needs an interactive review the app does not have.
* - `tool-bash` / `tool-pwsh`: exactly one, by operating system (the Harness chooses the same at load).
* - `tool-plugin-manager` stays disabled, as in `standard`.
* - Prompt size: a local model reads every tool schema and every instruction on every first message (about 9,000 tokens at
* the start, 20 s before the first word on a 64K model). The goal tools, the skill tool (no skill ships), and the
* workflow tool (3.4 KB of schema on its own) are left out; they are the largest and the least used. Helper agents,
* files, shell, search, todo, web fetch and present stay.
*/
/** Plugin ids of the pinned Harness `standard` preset, in its order (the drift check). */
@@ -26,7 +30,22 @@ export const STANDARD_PLUGIN_IDS = [
'tool-ask-user', 'tool-todo', 'tool-web', 'present', 'tool-plugin-manager',
];
/** What we leave out of it. */
export const REMOVED_PLUGIN_IDS = ['tool-schedule', 'tool-ask-user', 'planning'];
export const REMOVED_PLUGIN_IDS = ['tool-schedule', 'tool-ask-user', 'planning', 'skill-filesystem', 'tool-skill', 'command-goal', 'tool-goal'];
/** Plugins removed from inside the `delegation` group (the group stays: helper agents are a feature). */
export const REMOVED_DELEGATION_IDS = ['workflow-ptc', 'tool-workflow', 'tool-subagent-fork'];
/**
* The whole system prompt of an app chat. The Harness's own text is about 4.8 KB (about 1,200 tokens) of guidance for working on
* the Harness itself (its checkout, its web page, its plugins): a local model pays for it on every first message and the person
* can see its words in the chat ("DSH", "sandbox"). This one says what a chat needs and nothing else. The tools state their own rules.
*/
export const AGENT_PROMPT = [
'You are a helpful assistant that can also work on this computer through your tools. You are running as {{model}}.',
'Answer questions directly and briefly; use a tool only when the task needs one. The person reads your replies in a chat: use plain words, not tool names or technical jargon.',
'Look before you change: read a file before you edit or overwrite it, and prefer a small edit to a rewrite. Search with the glob and grep tools and read files with the read tool; use the shell for running programs.',
'When you start a program or open a folder or file for the person, the command returning without an error means it worked: say plainly that it is open. Do not say you could not confirm it. If a command fails, say what went wrong in one sentence and what you will try next.',
'Tokens prefixed with @ are files or folders the person pointed at. A failed or refused action changed nothing; say so, and do not claim work you did not do.',
].join('\n\n');
/**
* @param {{platform?: string, search?: boolean}} [options]
@@ -35,7 +54,7 @@ export const REMOVED_PLUGIN_IDS = ['tool-schedule', 'tool-ask-user', 'planning']
export function standardPreset({ platform = process.platform, search = false } = {}) {
const windows = platform === 'win32';
const plugins = [
{ id: 'persona', name: '@deepseek-ai/dsh-persona', config: { prefix: 'You are a coding agent powered by the {{model}} model.' } },
{ id: 'persona', name: '@deepseek-ai/dsh-persona', config: { prefix: AGENT_PROMPT, complete: true, includeRuntimeContext: false } },
{ id: 'agent-instructions', name: '@deepseek-ai/dsh-agent-instructions', config: { maxBytes: 65536 } },
{ id: 'time-context', name: '@deepseek-ai/dsh-time-context' },
windows
@@ -44,10 +63,6 @@ export function standardPreset({ platform = process.platform, search = false } =
{ id: 'tool-fs', name: '@deepseek-ai/dsh-tool-fs' },
{ id: 'tool-fs-search', name: '@deepseek-ai/dsh-tool-fs-search', config: { sampleOverCapGlobResults: false } },
{ id: 'tool-jobs', name: '@deepseek-ai/dsh-tool-jobs' },
{ id: 'skill-filesystem', name: '@deepseek-ai/dsh-skill-filesystem' },
{ id: 'tool-skill', name: '@deepseek-ai/dsh-tool-skill' },
{ id: 'command-goal', name: '@deepseek-ai/dsh-command-goal' },
{ id: 'tool-goal', name: '@deepseek-ai/dsh-tool-goal' },
{
id: 'compaction', name: 'cordis:group', group: true, isolate: { compaction: true, toolResultPruner: true },
config: [
@@ -62,9 +77,6 @@ export function standardPreset({ platform = process.platform, search = false } =
{ id: 'tool-subagent-control', name: '@deepseek-ai/dsh-tool-subagent-control' },
{ id: 'tool-subagent-list-agents', name: '@deepseek-ai/dsh-tool-subagent-control/list-agents' },
{ id: 'tool-subagent', name: '@deepseek-ai/dsh-tool-subagent', config: { provider: 'spawn', toolName: 'subagent', modelSelectionSettings: true } },
{ id: 'tool-subagent-fork', name: '@deepseek-ai/dsh-tool-subagent', config: { provider: 'fork', toolName: 'subagent_fork' } },
{ id: 'workflow-ptc', name: '@deepseek-ai/dsh-workflow-ptc', config: { provider: 'spawn' } },
{ id: 'tool-workflow', name: '@deepseek-ai/dsh-tool-workflow' },
],
},
{ id: 'tool-todo', name: '@deepseek-ai/dsh-tool-todo', config: { allowParallelInProgress: true } },
+2 -2
View File
@@ -1441,7 +1441,7 @@ async function api(req, res, path, method, url) {
detaches from the turn (it keeps running in the Harness); Stop is its own request. */
const chatId = typeof body.sessionId === 'string' && body.sessionId ? body.sessionId : null;
const agentSkip = process.env.STRATA_AGENT === 'off' ? 'The agent is turned off (STRATA_AGENT=off).'
: body.agent === false ? 'This chat runs without the agent.'
: body.agent === false || body.tools === false ? 'This chat runs without the agent.'
: !chatId ? 'This request did not name a chat.'
: images.length ? 'The agent does not take attached pictures yet; this message was sent directly.'
: !(body.remoteModel || native.hasLive()) ? 'The agent needs a loaded Strata model or an API model.'
@@ -1454,7 +1454,7 @@ async function api(req, res, path, method, url) {
return undefined;
}
res.write(`${JSON.stringify({ type: 'notice', code: 'agent-unavailable', message: `${capability.reason}`, ...(capability.detail ? { details: capability.detail } : {}) })}\n`);
} else if (agentSkip && chatId && body.agent !== false && process.env.STRATA_AGENT !== 'off') {
} else if (agentSkip && chatId && body.agent !== false && body.tools !== false && process.env.STRATA_AGENT !== 'off') {
res.write(`${JSON.stringify({ type: 'notice', code: 'agent-skipped', message: agentSkip })}\n`);
}
+1 -1
View File
@@ -206,7 +206,7 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
if(!args.multiple){if(busy(requests().filter(r=>instances.has(r.instanceId))))throw new Error('Finish or cancel native requests before switching models.');for(const id of [...instances.keys()])await unload(id);}else if(matching)await unload(matching.id);
if(job.state!=='running')return;
writeFileSync(configPath,JSON.stringify(cfg,null,2),{mode:0o600});
const instance={id,apiModelId,fingerprint,context:entry.context??0,aliases:cfg.aliases??[],vision:!!cfg.vision,model:args.model,label:entry.model_name??args.model,port:cfg.port,gpus:Array.isArray(cfg.gpu)?cfg.gpu:cfg.gpu===undefined?[]:[cfg.gpu],status:'loading',phase:'loading',parallel:cfg.parallel??1,mcpServers:cfg.mcp_servers??cfg.mcpServers??{},key,configPath,loadedAt:new Date().toISOString(),metrics:null,error:null};
const instance={id,apiModelId,fingerprint,context:entry.context??0,aliases:cfg.aliases??[],vision:!!cfg.vision,resourceProfile:cfg.ui_resource_profile??null,model:args.model,label:entry.model_name??args.model,port:cfg.port,gpus:Array.isArray(cfg.gpu)?cfg.gpu:cfg.gpu===undefined?[]:[cfg.gpu],status:'loading',phase:'loading',parallel:cfg.parallel??1,mcpServers:cfg.mcp_servers??cfg.mcpServers??{},key,configPath,loadedAt:new Date().toISOString(),metrics:null,error:null};
let proc=null,started=null,retried=null;
if(packaged){
/* The engine starts under Control's owner runtime, not as our child: a saved profile per slot, then `runtime start`. */
@@ -163,7 +163,7 @@ export function ComposerControls({ session, onPreview, onAttach, onError, onSlas
<button role="menuitem" className="menu__item" onClick={() => { setMenu(false); onSlash(); }}><span className="slash-command-icon" aria-hidden="true">/</span>{tr("Slash commands")}</button>
</FloatingMenu> : null)}
{tr(preferences ? <FloatingMenu anchor={preferences === 'mode' ? modeTrigger.current : thinkingTrigger.current} title={tr(preferences === 'mode' ? 'Mode' : 'Reasoning effort')} width={preferences === 'mode' ? 330 : 280} onClose={() => setPreferences(null)}>
{tr(preferences === 'mode' ? <>{tr(modes.map((option, index) => <button key={option.value} role="menuitemradio" aria-checked={mode.value === option.value} className="menu__item composer-mode-option" onClick={() => { setChatPreferences(session?.id, { mode: option.value }); setPreferences(null); }}><span><strong>{tr(option.label)}</strong><small>{tr(option.hint)}</small></span>{tr(mode.value === option.value ? <Icon name="check" size={16}/> : null)}<kbd className="menu-shortcut">{tr(index + 1)}</kbd></button>))}</> : <><div className="reasoning-menu-model"><Icon name={apiModel ? 'link' : modelFamilyIcon(loaded?.family)} size={18}/><strong>{tr(apiModel?.name ?? loaded?.family ?? 'No model loaded')}</strong></div><p className="composer-menu-heading">{tr("Reasoning effort")}</p>{tr(efforts.map(option => <button key={option} role="menuitemradio" aria-checked={effort === option} className="menu__item" onClick={() => { setChatPreferences(session?.id, { thinking: option }); setPreferences(null); }}>{tr(option)}<span className="spacer"/>{tr(effort === option ? <Icon name="check" size={16}/> : null)}</button>))}<p className="composer-menu-heading">{tr("Thinking budget")}</p>{tr(THINKING_BUDGETS.map(([value, label]) => <button key={value} role="menuitemradio" aria-checked={budget === value} disabled={effort === 'Off'} className="menu__item" onClick={() => { setChatPreferences(session?.id, { thinkingBudget: value }); setPreferences(null); }}>{tr(label)}<span className="spacer"/>{tr(budget === value ? <Icon name="check" size={16}/> : null)}</button>))}</>)}
{tr(preferences === 'mode' ? <>{tr(modes.map((option, index) => <button key={option.value} role="menuitemradio" aria-checked={mode.value === option.value} className="menu__item composer-mode-option" onClick={() => { setChatPreferences(session?.id, { mode: option.value }); setPreferences(null); }}><span><strong>{tr(option.label)}</strong><small>{tr(option.hint)}</small></span>{tr(mode.value === option.value ? <Icon name="check" size={16}/> : null)}<kbd className="menu-shortcut">{tr(index + 1)}</kbd></button>))}</> : <><div className="reasoning-menu-model"><Icon name={apiModel ? 'link' : modelFamilyIcon(loaded?.family ?? nativeModel?.label)} size={18}/><strong>{tr(apiModel?.name ?? (nativeModel && nativeModel.status === 'ready' ? nativeModel.label : null) ?? loaded?.family ?? 'No model loaded')}</strong></div><p className="composer-menu-heading">{tr("Reasoning effort")}</p>{tr(efforts.map(option => <button key={option} role="menuitemradio" aria-checked={effort === option} className="menu__item" onClick={() => { setChatPreferences(session?.id, { thinking: option }); setPreferences(null); }}>{tr(option)}<span className="spacer"/>{tr(effort === option ? <Icon name="check" size={16}/> : null)}</button>))}<p className="composer-menu-heading">{tr("Thinking budget")}</p>{tr(THINKING_BUDGETS.map(([value, label]) => <button key={value} role="menuitemradio" aria-checked={budget === value} disabled={effort === 'Off'} className="menu__item" onClick={() => { setChatPreferences(session?.id, { thinkingBudget: value }); setPreferences(null); }}>{tr(label)}<span className="spacer"/>{tr(budget === value ? <Icon name="check" size={16}/> : null)}</button>))}</>)}
</FloatingMenu> : null)}
<Dialog open={urlOpen} title={tr("Open a website")} onClose={() => setUrlOpen(false)} actions={<><Btn onClick={() => setUrlOpen(false)}>{tr("Cancel")}</Btn><Btn variant="primary" disabled={!url.trim()} onClick={() => {
+7 -2
View File
@@ -46,6 +46,9 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
setKv(installed.kv as typeof kv);
setLowRam((installed.lowRamMode ?? 'auto') as typeof lowRam);
} }, [installed?.model, installed?.ready, installed?.context, installed?.kv, installed?.lowRamMode]);
// What the running model was really started with is what the page shows: after Load the highlight must still be the profile that was applied.
useEffect(() => { if (loadedSelected?.resourceProfile)
setResourceProfile(loadedSelected.resourceProfile); }, [loadedSelected?.id, loadedSelected?.resourceProfile]);
const active = native?.jobs.find(j => j.state === 'running');
const job = native?.jobs.find(j => j.id === jobId) ?? (!jobId ? native?.jobs.filter(j => ['load', 'install'].includes(j.kind)).at(-1) : undefined);
const noFit = selectedSize?.on_this_pc === 'does not fit';
@@ -65,7 +68,9 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
setVision(installed.visionEnabled === false || installed.images === 'off' ? 'no' : installed.visionDevice ?? installed.images ?? 'no');
setKv(installed.kv ?? 'int8');
setLowRam(installed.lowRamMode ?? 'auto');
setResourceProfile(installed.resourceProfile ?? 'performance');
// A model never tuned has no saved choice: keep the page's own (Leave 20%) rather than flipping to Full performance.
if (installed.resourceProfile && !loadedSelected?.resourceProfile)
setResourceProfile(installed.resourceProfile);
} }, [installed?.model, installed?.context, installed?.kv, installed?.images, installed?.visionEnabled, installed?.visionDevice, installed?.lowRamMode, installed?.resourceProfile]);
useEffect(() => { if (jobId && job?.state === 'done' && (job.kind === 'load' || job.kind === 'install')) {
selectRemoteModel(null);
@@ -111,7 +116,7 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
</div>
<aside className="stack stack--lg"><section className="st-card"><h2 className="h4">{tr("This computer")}</h2><p>{tr(native?.hardware?.ram_gb || hardware?.memory.totalGb || '—')}{tr(" GB RAM")}</p>{tr(native?.hardware?.gpus.length ? native.hardware.gpus.map(g => <p className="small" key={g.index}>{tr(g.name)} · {tr(g.vram_gb)}{tr(" GB")}</p>) : <p className="small muted">{tr("No supported GPU reported by this installation.")}</p>)}<p className="small muted">{tr("Compatibility and sizes come from your installed Strata version.")}</p></section><section className="st-card"><h2 className="h4">{tr("Model files")}</h2><p className="small muted">{tr("Strata prepares all shards, the draft layer and shared files. Completed downloads are reused.")}</p><code className="native-path">{installed?.dataFolder ?? native?.downloadDirectory ?? native?.installed?.data_folder ?? 'Choose an installation'}</code><Btn size="sm" variant="ghost" onClick={() => { setSettingsSection('Storage'); go('settings'); }}>{tr("Storage settings")}</Btn></section><Btn size="sm" variant="ghost" onClick={onOtherModels}>{tr("Other local models\u2026")}</Btn></aside>
</div>
<Dialog open={!!plan} title={tr("Download and prepare")} compact onClose={() => !pending && setPlan(null)} actions={<><Btn disabled={pending} onClick={() => setPlan(null)}>{tr("Cancel")}</Btn><Btn variant="primary" disabled={pending || !!plan?.plan?.disk_short} onClick={() => void download()}>{tr("Download & load")}</Btn></>}><p><strong>{tr(plan?.plan?.title ?? selectedFamily?.title)}</strong>{tr(" \u00B7 approximately ")}{tr(plan?.plan?.download_gb ?? selectedSize?.download_gb)}{tr(" GB")}</p>{tr(plan?.plan?.disk_short ? <ErrorNotice message={plan.plan.disk_short}/> : null)}<p className="small muted">{tr("Existing files are checked and reused. Any currently loaded native models will be unloaded before preparing files.")}</p><p className="small muted">{tr("Your computer may feel slower while weights load. You can stop the download and resume later.")}</p>{tr(selectedSize?.images ? <p className="small muted">{tr("Vision encoder files are included. Picture reading loads only when enabled.")}</p> : null)}</Dialog>
<Dialog open={!!plan} title={tr(installed?.ready ? "Apply settings and load" : "Download and prepare")} compact onClose={() => !pending && setPlan(null)} actions={<><Btn disabled={pending} onClick={() => setPlan(null)}>{tr("Cancel")}</Btn><Btn variant="primary" disabled={pending || !!plan?.plan?.disk_short} onClick={() => void download()}>{tr(installed?.ready ? "Apply & load" : "Download & load")}</Btn></>}><p><strong>{tr(plan?.plan?.title ?? selectedFamily?.title)}</strong>{tr(installed?.ready ? null : <>{tr(" \u00B7 approximately ")}{tr(plan?.plan?.download_gb ?? selectedSize?.download_gb)}{tr(" GB")}</>)}</p>{tr(installed?.ready ? <p className="small muted">{tr("The model files are already on this computer. Nothing will be downloaded; only the settings you chose are applied.")}</p> : null)}{tr(plan?.plan?.disk_short ? <ErrorNotice message={plan.plan.disk_short}/> : null)}{tr(installed?.ready ? <p className="small muted">{tr("Any currently loaded native models will be unloaded first.")}</p> : <><p className="small muted">{tr("Existing files are checked and reused. Any currently loaded native models will be unloaded before preparing files.")}</p><p className="small muted">{tr("Your computer may feel slower while weights load. You can stop the download and resume later.")}</p></>)}{tr(selectedSize?.images ? <p className="small muted">{tr("Vision encoder files are included. Picture reading loads only when enabled.")}</p> : null)}</Dialog>
<NativeDeleteDialog id={deleting} onClose={() => setDeleting(null)}/>
<Dialog open={manage} title={tr("Manage models")} onClose={() => setManage(false)} actions={<Btn onClick={() => setManage(false)}>{tr("Done")}</Btn>}><div className="native-manage">
{tr(downloaded.length ? <div className="native-manage-list" role="list">{tr(downloaded.map(m => { const v = allSizes.find(x => x.id === m.model), loaded = native?.instances.some(i => i.model === m.model && i.status === 'ready'); return <div className="native-manage-row" role="listitem" key={m.model} data-selected={selectedSize?.id === m.model || undefined}>
+5 -1
View File
@@ -6,7 +6,11 @@ export function chatMetrics(session:Session|null, monitor:MonitorState|null, con
const latest=[...replies].reverse().find(m=>m.inputTokens!==undefined);
const remote=replies.at(-1)?.remoteModel===true;
const active=!remote&&monitor?.context?.sessionId===session?.id&&!!session;
const used=active?monitor!.context.usedTokens:latest?latest.inputTokens!+(latest.tokens??0):null;
// One source: what the context held after the last reply (the engine's own count for that request); the monitor's live figure only while a reply streams.
const finished=latest?(latest.contextUsed??latest.inputTokens!+(latest.tokens??0)):null;
const liveUsed=active&&Number.isFinite(monitor!.context.usedTokens)&&monitor!.context.usedTokens>0?monitor!.context.usedTokens:null;
const live=replies.some(m=>m.streaming)||(active&&['reading','thinking','generating'].includes(monitor!.phase));
const used=live?(liveUsed??finished):(finished??liveUsed);
const max=remote?(replies.at(-1)?.contextMax||null):configuredContext??(active?monitor!.context.maxTokens:latest?.contextMax??null);
const streaming=replies.find(m=>m.streaming);
const speed=streaming?.tps??[...replies].reverse().find(m=>m.tps!==undefined)?.tps??null;
+1 -1
View File
@@ -5,7 +5,7 @@ export interface NativeSize {id:string;model:string;about:string;download_gb:num
export interface NativeFamily {family:string;title:string;about:string;experimental:boolean;images:boolean;sizes:NativeSize[]}
export interface NativeField {id:string;label:string;group:string;type:'number'|'boolean'|'string'|'enum'|'array';min?:number;max?:number;choices?:string[];hint?:string;supported?:boolean}
export interface NativeJob {id:string;kind:string;guided?:boolean;model?:string;state:'running'|'done'|'error'|'cancelled';startedAt:string;summary:string;error:string|null;details?:string;result:Record<string,any>|null;progress?:Record<string,any>|null}
export interface NativeInstance {id:string;apiModelId:string;model:string;label:string;aliases?:string[];vision?:boolean;context?:number;port:number;gpus:number[];status:'loading'|'ready'|'unloading'|'error';phase:string;loadedAt:string;error:string|null;metrics:Record<string,any>|null}
export interface NativeInstance {id:string;apiModelId:string;model:string;label:string;aliases?:string[];vision?:boolean;resourceProfile?:'work'|'performance'|null;context?:number;port:number;gpus:number[];status:'loading'|'ready'|'unloading'|'error';phase:string;loadedAt:string;error:string|null;metrics:Record<string,any>|null}
export interface NativeState {available:boolean;repo:string;downloadDirectory?:string;version?:string|null;summary:string;error:string|null;catalog:{families:NativeFamily[];contexts:number[];recommendation:{family:string|null;model:string|null;context?:number;why?:string};context_note:string}|null;installed:{models:{model:string;config:string;model_name:string;ready:boolean;context?:number;kv?:string;images?:string;visionEnabled?:boolean;visionDevice?:string;lowRamMode?:string;resourceProfile?:'work'|'performance';dataFolder?:string;diskGb?:number;diskBytes?:number;missing_files?:string[]}[];data_folder:string;engine:{version:string|null}}|null;hardware:{ram_gb:number;gpus:{index:number;name:string;vram_gb:number;usable:boolean}[]}|null;fields:NativeField[];instances:NativeInstance[];defaultId:string|null;jobs:NativeJob[];capabilities:{responses?:boolean;updates:boolean;rollback:boolean;calibration:boolean;componentUpdates?:boolean}}
let state:NativeState|null=null,timer:ReturnType<typeof setInterval>|null=null,reading:Promise<void>|null=null;
const listeners=new Set<()=>void>();
+3
View File
@@ -1332,6 +1332,8 @@ export async function sendMessage(text: string) {
nativeModelId:nativeState()?.instances.length?nativeState()?.defaultId:undefined,
images:await Promise.all(pictures.map(photoData)),
useTools:state.sessions.find(s=>s.id===sessionId)?.preferences?.useTools===true,
// A chat set to plain talk (preferences.agent === false) never runs a tool: the host answers it directly.
...(state.sessions.find(s=>s.id===sessionId)?.preferences?.agent===false?{agent:false}:{}),
thinking:state.sessions.find(s=>s.id===sessionId)?.preferences?.thinking??'High',
thinkingBudget:state.sessions.find(s=>s.id===sessionId)?.preferences?.thinkingBudget??0,
// The Mode menu's choice for this chat, read when the turn starts; the host applies it to the agent.
@@ -1393,6 +1395,7 @@ export async function sendMessage(text: string) {
tokens: typeof frame.tokensOut === 'number' ? frame.tokensOut : undefined,
ttftMs: typeof frame.ttftMs === 'number' ? frame.ttftMs : undefined,
inputTokens: typeof frame.tokensIn === 'number' ? frame.tokensIn : undefined,
contextUsed: typeof frame.contextTokens === 'number' ? frame.contextTokens : undefined,
usageSource: frame.usageSource === 'reported' || frame.usageSource === 'demo' ? frame.usageSource : 'unavailable',
tps: typeof frame.tps === 'number' ? frame.tps : undefined,
}));
+7 -1
View File
@@ -325,6 +325,8 @@ export interface ChatMessage {
requestId?: string;
model?: string;
inputTokens?: number;
/** What the context held after this reply (the last model request's prompt + reply); an agent turn's inputTokens add every request up. */
contextUsed?: number;
contextMax?: number;
usageSource?: 'reported' | 'demo' | 'unavailable' | 'estimated';
skillsUsed?: string[];
@@ -352,6 +354,10 @@ export interface ToolCall {
preview: string;
effect: string;
scope: 'read' | 'write' | 'shell' | 'connected';
/** The card's headline in plain words for its current state ("Wrote a file"); the host sends it, the UI translates it. */
verb?: string;
/** What the action is on: a file path, the command, a web address. */
target?: string;
state: ToolState;
result?: string;
durationMs?: number;
@@ -388,7 +394,7 @@ export interface Session {
previewOpen?: string;
closedPreviews?: string[];
showcaseAgentStates?: boolean;
preferences?: { access?: 'read' | 'write' | 'full'; thinking?: string; thinkingBudget?: number; mode?: ChatMode; useTools?:boolean; /** This chat's API model; absent or null means the local model. */ remoteModel?: RemoteModelSelection | null };
preferences?: { access?: 'read' | 'write' | 'full'; thinking?: string; thinkingBudget?: number; mode?: ChatMode; useTools?:boolean; /** false: plain conversation, no tools of any kind. */ agent?: boolean; /** This chat's API model; absent or null means the local model. */ remoteModel?: RemoteModelSelection | null };
/** A branch does not have retained per-turn input usage. */
statsAvailable?: boolean;
id: string;
+53 -1
View File
@@ -2619,5 +2619,57 @@
"s2618": "Darf Änderungen vornehmen",
"s2619": "Aufgabe abgeschlossen",
"s2620": "Aufgabe braucht Ihr Ja",
"s2621": "Aufgabe fehlgeschlagen"
"s2621": "Aufgabe fehlgeschlagen",
"s2622": "Datei wird geschrieben",
"s2623": "Datei geschrieben",
"s2624": "Die Datei konnte nicht geschrieben werden",
"s2625": "Datei wird geändert",
"s2626": "Datei geändert",
"s2627": "Die Datei konnte nicht geändert werden",
"s2628": "Datei wird gelesen",
"s2629": "Datei gelesen",
"s2630": "Die Datei konnte nicht gelesen werden",
"s2631": "Bild wird angesehen",
"s2632": "Bild angesehen",
"s2633": "Das Bild konnte nicht angesehen werden",
"s2634": "Dateien werden gesucht",
"s2635": "Dateien gesucht",
"s2636": "Die Dateisuche ist fehlgeschlagen",
"s2637": "Dateiinhalte werden durchsucht",
"s2638": "Dateiinhalte durchsucht",
"s2639": "Die Suche in Dateien ist fehlgeschlagen",
"s2640": "Befehl wird ausgeführt",
"s2641": "Befehl ausgeführt",
"s2642": "Der Befehl ist fehlgeschlagen",
"s2643": "Webseite wird geöffnet",
"s2644": "Webseite geöffnet",
"s2645": "Die Webseite konnte nicht geöffnet werden",
"s2646": "Das Web wird durchsucht",
"s2647": "Das Web durchsucht",
"s2648": "Die Websuche ist fehlgeschlagen",
"s2649": "Aufgabenliste wird aktualisiert",
"s2650": "Aufgabenliste aktualisiert",
"s2651": "Die Aufgabenliste konnte nicht aktualisiert werden",
"s2652": "Ergebnis wird angezeigt",
"s2653": "Ergebnis angezeigt",
"s2654": "Das Ergebnis konnte nicht angezeigt werden",
"s2655": "Ordner wird geprüft",
"s2656": "Ordner geprüft",
"s2657": "Der Ordner konnte nicht geprüft werden",
"s2658": "Hintergrundaufgaben werden geprüft",
"s2659": "Hintergrundaufgaben geprüft",
"s2660": "Hintergrundaufgaben konnten nicht geprüft werden",
"s2661": "Hintergrundaufgabe wird gelesen",
"s2662": "Hintergrundaufgabe gelesen",
"s2663": "Die Hintergrundaufgabe konnte nicht gelesen werden",
"s2664": "Hintergrundaufgabe wird gestoppt",
"s2665": "Hintergrundaufgabe gestoppt",
"s2666": "Die Hintergrundaufgabe konnte nicht gestoppt werden",
"s2667": "Werkzeug wird verwendet",
"s2668": "Werkzeug verwendet",
"s2669": "Das Werkzeug ist fehlgeschlagen",
"s2670": "Einstellungen anwenden und laden",
"s2671": "Anwenden & laden",
"s2672": "Die Modelldateien sind bereits auf diesem Computer. Es wird nichts heruntergeladen, nur Ihre gewählten Einstellungen werden angewendet.",
"s2673": "Aktuell geladene native Modelle werden zuerst entladen."
}
+53 -1
View File
@@ -2619,5 +2619,57 @@
"s2618": "Allowed to make changes",
"s2619": "Task finished",
"s2620": "Task needs your yes",
"s2621": "Task failed"
"s2621": "Task failed",
"s2622": "Writing a file",
"s2623": "Wrote a file",
"s2624": "Couldn't write the file",
"s2625": "Changing a file",
"s2626": "Changed a file",
"s2627": "Couldn't change the file",
"s2628": "Reading a file",
"s2629": "Read a file",
"s2630": "Couldn't read the file",
"s2631": "Looking at a picture",
"s2632": "Looked at a picture",
"s2633": "Couldn't look at the picture",
"s2634": "Searching for files",
"s2635": "Searched for files",
"s2636": "Couldn't search for files",
"s2637": "Searching inside files",
"s2638": "Searched inside files",
"s2639": "Couldn't search inside files",
"s2640": "Running a command",
"s2641": "Ran a command",
"s2642": "The command failed",
"s2643": "Opening a web page",
"s2644": "Opened a web page",
"s2645": "Couldn't open the web page",
"s2646": "Searching the web",
"s2647": "Searched the web",
"s2648": "Couldn't search the web",
"s2649": "Updating its to-do list",
"s2650": "Updated its to-do list",
"s2651": "Couldn't update its to-do list",
"s2652": "Showing you a result",
"s2653": "Showed you a result",
"s2654": "Couldn't show the result",
"s2655": "Checking the folder",
"s2656": "Checked the folder",
"s2657": "Couldn't check the folder",
"s2658": "Checking background tasks",
"s2659": "Checked background tasks",
"s2660": "Couldn't check background tasks",
"s2661": "Reading a background task",
"s2662": "Read a background task",
"s2663": "Couldn't read the background task",
"s2664": "Stopping a background task",
"s2665": "Stopped a background task",
"s2666": "Couldn't stop the background task",
"s2667": "Using a tool",
"s2668": "Used a tool",
"s2669": "The tool failed",
"s2670": "Apply settings and load",
"s2671": "Apply & load",
"s2672": "The model files are already on this computer. Nothing will be downloaded; only the settings you chose are applied.",
"s2673": "Any currently loaded native models will be unloaded first."
}
+53 -1
View File
@@ -2619,5 +2619,57 @@
"s2618": "Puede hacer cambios",
"s2619": "Tarea terminada",
"s2620": "La tarea necesita tu visto bueno",
"s2621": "La tarea falló"
"s2621": "La tarea falló",
"s2622": "Escribiendo un archivo",
"s2623": "Archivo escrito",
"s2624": "No se pudo escribir el archivo",
"s2625": "Modificando un archivo",
"s2626": "Archivo modificado",
"s2627": "No se pudo modificar el archivo",
"s2628": "Leyendo un archivo",
"s2629": "Archivo leído",
"s2630": "No se pudo leer el archivo",
"s2631": "Mirando una imagen",
"s2632": "Imagen vista",
"s2633": "No se pudo ver la imagen",
"s2634": "Buscando archivos",
"s2635": "Archivos buscados",
"s2636": "No se pudo buscar archivos",
"s2637": "Buscando dentro de archivos",
"s2638": "Búsqueda dentro de archivos hecha",
"s2639": "No se pudo buscar dentro de archivos",
"s2640": "Ejecutando un comando",
"s2641": "Comando ejecutado",
"s2642": "El comando falló",
"s2643": "Abriendo una página web",
"s2644": "Página web abierta",
"s2645": "No se pudo abrir la página web",
"s2646": "Buscando en la web",
"s2647": "Búsqueda en la web hecha",
"s2648": "No se pudo buscar en la web",
"s2649": "Actualizando su lista de tareas",
"s2650": "Lista de tareas actualizada",
"s2651": "No se pudo actualizar la lista de tareas",
"s2652": "Mostrándote un resultado",
"s2653": "Resultado mostrado",
"s2654": "No se pudo mostrar el resultado",
"s2655": "Comprobando la carpeta",
"s2656": "Carpeta comprobada",
"s2657": "No se pudo comprobar la carpeta",
"s2658": "Comprobando tareas en segundo plano",
"s2659": "Tareas en segundo plano comprobadas",
"s2660": "No se pudieron comprobar las tareas en segundo plano",
"s2661": "Leyendo una tarea en segundo plano",
"s2662": "Tarea en segundo plano leída",
"s2663": "No se pudo leer la tarea en segundo plano",
"s2664": "Deteniendo una tarea en segundo plano",
"s2665": "Tarea en segundo plano detenida",
"s2666": "No se pudo detener la tarea en segundo plano",
"s2667": "Usando una herramienta",
"s2668": "Herramienta usada",
"s2669": "La herramienta falló",
"s2670": "Aplicar ajustes y cargar",
"s2671": "Aplicar y cargar",
"s2672": "Los archivos del modelo ya están en este ordenador. No se descargará nada; solo se aplican los ajustes que elegiste.",
"s2673": "Los modelos nativos cargados actualmente se descargarán primero."
}
+53 -1
View File
@@ -2619,5 +2619,57 @@
"s2618": "変更を許可する",
"s2619": "タスクが完了しました",
"s2620": "タスクに許可が必要です",
"s2621": "タスクが失敗しました"
"s2621": "タスクが失敗しました",
"s2622": "ファイルを書き込み中",
"s2623": "ファイルを書き込みました",
"s2624": "ファイルを書き込めませんでした",
"s2625": "ファイルを変更中",
"s2626": "ファイルを変更しました",
"s2627": "ファイルを変更できませんでした",
"s2628": "ファイルを読み込み中",
"s2629": "ファイルを読み込みました",
"s2630": "ファイルを読み込めませんでした",
"s2631": "画像を確認中",
"s2632": "画像を確認しました",
"s2633": "画像を確認できませんでした",
"s2634": "ファイルを検索中",
"s2635": "ファイルを検索しました",
"s2636": "ファイルを検索できませんでした",
"s2637": "ファイルの中身を検索中",
"s2638": "ファイルの中身を検索しました",
"s2639": "ファイルの中身を検索できませんでした",
"s2640": "コマンドを実行中",
"s2641": "コマンドを実行しました",
"s2642": "コマンドが失敗しました",
"s2643": "Webページを開いています",
"s2644": "Webページを開きました",
"s2645": "Webページを開けませんでした",
"s2646": "Webを検索中",
"s2647": "Webを検索しました",
"s2648": "Webを検索できませんでした",
"s2649": "やることリストを更新中",
"s2650": "やることリストを更新しました",
"s2651": "やることリストを更新できませんでした",
"s2652": "結果を表示中",
"s2653": "結果を表示しました",
"s2654": "結果を表示できませんでした",
"s2655": "フォルダーを確認中",
"s2656": "フォルダーを確認しました",
"s2657": "フォルダーを確認できませんでした",
"s2658": "バックグラウンドのタスクを確認中",
"s2659": "バックグラウンドのタスクを確認しました",
"s2660": "バックグラウンドのタスクを確認できませんでした",
"s2661": "バックグラウンドのタスクを読み込み中",
"s2662": "バックグラウンドのタスクを読み込みました",
"s2663": "バックグラウンドのタスクを読み込めませんでした",
"s2664": "バックグラウンドのタスクを停止中",
"s2665": "バックグラウンドのタスクを停止しました",
"s2666": "バックグラウンドのタスクを停止できませんでした",
"s2667": "ツールを使用中",
"s2668": "ツールを使用しました",
"s2669": "ツールが失敗しました",
"s2670": "設定を適用して読み込む",
"s2671": "適用して読み込む",
"s2672": "モデルのファイルはこのコンピューターにすでにあります。ダウンロードは行われず、選んだ設定だけが適用されます。",
"s2673": "現在読み込まれているネイティブモデルは先にアンロードされます。"
}
+53 -1
View File
@@ -2619,5 +2619,57 @@
"s2618": "변경 허용",
"s2619": "작업 완료",
"s2620": "작업에 승인이 필요합니다",
"s2621": "작업 실패"
"s2621": "작업 실패",
"s2622": "파일을 쓰는 중",
"s2623": "파일을 썼습니다",
"s2624": "파일을 쓸 수 없었습니다",
"s2625": "파일을 수정하는 중",
"s2626": "파일을 수정했습니다",
"s2627": "파일을 수정할 수 없었습니다",
"s2628": "파일을 읽는 중",
"s2629": "파일을 읽었습니다",
"s2630": "파일을 읽을 수 없었습니다",
"s2631": "그림을 보는 중",
"s2632": "그림을 확인했습니다",
"s2633": "그림을 볼 수 없었습니다",
"s2634": "파일을 찾는 중",
"s2635": "파일을 찾았습니다",
"s2636": "파일을 찾을 수 없었습니다",
"s2637": "파일 내용을 검색하는 중",
"s2638": "파일 내용을 검색했습니다",
"s2639": "파일 내용을 검색할 수 없었습니다",
"s2640": "명령을 실행하는 중",
"s2641": "명령을 실행했습니다",
"s2642": "명령이 실패했습니다",
"s2643": "웹 페이지를 여는 중",
"s2644": "웹 페이지를 열었습니다",
"s2645": "웹 페이지를 열 수 없었습니다",
"s2646": "웹을 검색하는 중",
"s2647": "웹을 검색했습니다",
"s2648": "웹을 검색할 수 없었습니다",
"s2649": "할 일 목록을 업데이트하는 중",
"s2650": "할 일 목록을 업데이트했습니다",
"s2651": "할 일 목록을 업데이트할 수 없었습니다",
"s2652": "결과를 보여 주는 중",
"s2653": "결과를 보여 주었습니다",
"s2654": "결과를 보여 줄 수 없었습니다",
"s2655": "폴더를 확인하는 중",
"s2656": "폴더를 확인했습니다",
"s2657": "폴더를 확인할 수 없었습니다",
"s2658": "백그라운드 작업을 확인하는 중",
"s2659": "백그라운드 작업을 확인했습니다",
"s2660": "백그라운드 작업을 확인할 수 없었습니다",
"s2661": "백그라운드 작업을 읽는 중",
"s2662": "백그라운드 작업을 읽었습니다",
"s2663": "백그라운드 작업을 읽을 수 없었습니다",
"s2664": "백그라운드 작업을 중지하는 중",
"s2665": "백그라운드 작업을 중지했습니다",
"s2666": "백그라운드 작업을 중지할 수 없었습니다",
"s2667": "도구를 사용하는 중",
"s2668": "도구를 사용했습니다",
"s2669": "도구가 실패했습니다",
"s2670": "설정 적용 후 불러오기",
"s2671": "적용 후 불러오기",
"s2672": "모델 파일이 이미 이 컴퓨터에 있습니다. 아무것도 다운로드하지 않으며 선택한 설정만 적용됩니다.",
"s2673": "현재 불러온 네이티브 모델은 먼저 언로드됩니다."
}
+53 -1
View File
@@ -2619,5 +2619,57 @@
"s2618": "Pode fazer alterações",
"s2619": "Tarefa concluída",
"s2620": "A tarefa precisa do seu sim",
"s2621": "A tarefa falhou"
"s2621": "A tarefa falhou",
"s2622": "Escrevendo um arquivo",
"s2623": "Arquivo escrito",
"s2624": "Não foi possível escrever o arquivo",
"s2625": "Alterando um arquivo",
"s2626": "Arquivo alterado",
"s2627": "Não foi possível alterar o arquivo",
"s2628": "Lendo um arquivo",
"s2629": "Arquivo lido",
"s2630": "Não foi possível ler o arquivo",
"s2631": "Olhando uma imagem",
"s2632": "Imagem vista",
"s2633": "Não foi possível ver a imagem",
"s2634": "Procurando arquivos",
"s2635": "Arquivos procurados",
"s2636": "Não foi possível procurar arquivos",
"s2637": "Procurando dentro dos arquivos",
"s2638": "Busca dentro dos arquivos feita",
"s2639": "Não foi possível buscar dentro dos arquivos",
"s2640": "Executando um comando",
"s2641": "Comando executado",
"s2642": "O comando falhou",
"s2643": "Abrindo uma página da web",
"s2644": "Página da web aberta",
"s2645": "Não foi possível abrir a página da web",
"s2646": "Pesquisando na web",
"s2647": "Pesquisa na web feita",
"s2648": "Não foi possível pesquisar na web",
"s2649": "Atualizando a lista de tarefas",
"s2650": "Lista de tarefas atualizada",
"s2651": "Não foi possível atualizar a lista de tarefas",
"s2652": "Mostrando um resultado",
"s2653": "Resultado mostrado",
"s2654": "Não foi possível mostrar o resultado",
"s2655": "Verificando a pasta",
"s2656": "Pasta verificada",
"s2657": "Não foi possível verificar a pasta",
"s2658": "Verificando tarefas em segundo plano",
"s2659": "Tarefas em segundo plano verificadas",
"s2660": "Não foi possível verificar as tarefas em segundo plano",
"s2661": "Lendo uma tarefa em segundo plano",
"s2662": "Tarefa em segundo plano lida",
"s2663": "Não foi possível ler a tarefa em segundo plano",
"s2664": "Parando uma tarefa em segundo plano",
"s2665": "Tarefa em segundo plano parada",
"s2666": "Não foi possível parar a tarefa em segundo plano",
"s2667": "Usando uma ferramenta",
"s2668": "Ferramenta usada",
"s2669": "A ferramenta falhou",
"s2670": "Aplicar configurações e carregar",
"s2671": "Aplicar e carregar",
"s2672": "Os arquivos do modelo já estão neste computador. Nada será baixado; apenas as configurações escolhidas serão aplicadas.",
"s2673": "Os modelos nativos atualmente carregados serão descarregados primeiro."
}
+53 -1
View File
@@ -2619,5 +2619,57 @@
"s2618": "Разрешено вносить изменения",
"s2619": "Задача выполнена",
"s2620": "Задаче нужно ваше согласие",
"s2621": "Задача не удалась"
"s2621": "Задача не удалась",
"s2622": "Записываю файл",
"s2623": "Файл записан",
"s2624": "Не удалось записать файл",
"s2625": "Изменяю файл",
"s2626": "Файл изменён",
"s2627": "Не удалось изменить файл",
"s2628": "Читаю файл",
"s2629": "Файл прочитан",
"s2630": "Не удалось прочитать файл",
"s2631": "Смотрю изображение",
"s2632": "Изображение просмотрено",
"s2633": "Не удалось посмотреть изображение",
"s2634": "Ищу файлы",
"s2635": "Файлы найдены",
"s2636": "Не удалось найти файлы",
"s2637": "Ищу внутри файлов",
"s2638": "Поиск внутри файлов выполнен",
"s2639": "Не удалось выполнить поиск внутри файлов",
"s2640": "Выполняю команду",
"s2641": "Команда выполнена",
"s2642": "Команда не выполнилась",
"s2643": "Открываю веб-страницу",
"s2644": "Веб-страница открыта",
"s2645": "Не удалось открыть веб-страницу",
"s2646": "Ищу в интернете",
"s2647": "Поиск в интернете выполнен",
"s2648": "Не удалось выполнить поиск в интернете",
"s2649": "Обновляю список дел",
"s2650": "Список дел обновлён",
"s2651": "Не удалось обновить список дел",
"s2652": "Показываю результат",
"s2653": "Результат показан",
"s2654": "Не удалось показать результат",
"s2655": "Проверяю папку",
"s2656": "Папка проверена",
"s2657": "Не удалось проверить папку",
"s2658": "Проверяю фоновые задачи",
"s2659": "Фоновые задачи проверены",
"s2660": "Не удалось проверить фоновые задачи",
"s2661": "Читаю фоновую задачу",
"s2662": "Фоновая задача прочитана",
"s2663": "Не удалось прочитать фоновую задачу",
"s2664": "Останавливаю фоновую задачу",
"s2665": "Фоновая задача остановлена",
"s2666": "Не удалось остановить фоновую задачу",
"s2667": "Использую инструмент",
"s2668": "Инструмент использован",
"s2669": "Инструмент не сработал",
"s2670": "Применить настройки и загрузить",
"s2671": "Применить и загрузить",
"s2672": "Файлы модели уже на этом компьютере. Ничего не будет скачиваться, применяются только выбранные настройки.",
"s2673": "Сначала будут выгружены все загруженные сейчас нативные модели."
}
+53 -1
View File
@@ -2619,5 +2619,57 @@
"s2618": "允许进行更改",
"s2619": "任务已完成",
"s2620": "任务需要你的确认",
"s2621": "任务失败"
"s2621": "任务失败",
"s2622": "正在写入文件",
"s2623": "已写入文件",
"s2624": "无法写入文件",
"s2625": "正在修改文件",
"s2626": "已修改文件",
"s2627": "无法修改文件",
"s2628": "正在读取文件",
"s2629": "已读取文件",
"s2630": "无法读取文件",
"s2631": "正在查看图片",
"s2632": "已查看图片",
"s2633": "无法查看图片",
"s2634": "正在查找文件",
"s2635": "已查找文件",
"s2636": "无法查找文件",
"s2637": "正在搜索文件内容",
"s2638": "已搜索文件内容",
"s2639": "无法搜索文件内容",
"s2640": "正在运行命令",
"s2641": "已运行命令",
"s2642": "命令运行失败",
"s2643": "正在打开网页",
"s2644": "已打开网页",
"s2645": "无法打开网页",
"s2646": "正在搜索网络",
"s2647": "已搜索网络",
"s2648": "无法搜索网络",
"s2649": "正在更新待办清单",
"s2650": "已更新待办清单",
"s2651": "无法更新待办清单",
"s2652": "正在向你展示结果",
"s2653": "已向你展示结果",
"s2654": "无法展示结果",
"s2655": "正在查看文件夹",
"s2656": "已查看文件夹",
"s2657": "无法查看文件夹",
"s2658": "正在查看后台任务",
"s2659": "已查看后台任务",
"s2660": "无法查看后台任务",
"s2661": "正在读取后台任务",
"s2662": "已读取后台任务",
"s2663": "无法读取后台任务",
"s2664": "正在停止后台任务",
"s2665": "已停止后台任务",
"s2666": "无法停止后台任务",
"s2667": "正在使用工具",
"s2668": "已使用工具",
"s2669": "工具运行失败",
"s2670": "应用设置并加载",
"s2671": "应用并加载",
"s2672": "模型文件已在这台电脑上,不会下载任何内容,只会应用你选择的设置。",
"s2673": "当前已加载的本地模型会先被卸载。"
}
+3 -2
View File
@@ -622,12 +622,13 @@ function ToolCard({ messageId, tool: t }: {
const scope = SCOPE[t.scope];
const pending = t.state === 'proposed';
if (!pending && t.state !== 'running')
return <details className="disclosure tool-summary"><summary><Icon name="chevron" size={14}/>{t.name}<span>·</span><Icon name={t.state === 'denied' ? 'x' : 'check'} size={14}/>{tr(t.state === 'done' && t.scope !== 'read' ? (t.live && !t.approved ? 'Ran' : 'Approved') : t.state === 'denied' ? 'Denied' : t.state === 'done' ? 'Done' : t.state)}</summary><div><code>{t.preview}</code><p className="small muted">{tr(t.result ?? t.effect)}</p></div></details>;
return <details className="disclosure tool-summary"><summary><Icon name="chevron" size={14}/>{tr(t.verb ?? t.name)}{t.target ? <><span>·</span><span className="tool-summary__target" title={t.target}>{t.target}</span></> : null}<span>·</span><Icon name={t.state === 'denied' || t.state === 'error' ? 'x' : 'check'} size={14}/>{tr(t.state === 'denied' ? 'Denied' : t.state === 'error' ? 'Failed' : t.live && t.state === 'done' && t.approved ? 'Approved' : 'Done')}</summary><div><code>{t.preview}</code><p className="small muted">{tr(t.result ?? t.effect)}</p></div></details>;
return <section className="toolcard toolcard--compact" data-state={t.state} aria-label={t.name}>
<details className="toolcard__disclosure">
<summary><Icon name={t.scope === 'shell' ? 'bolt' : 'tool'} size={17}/><span className="toolcard__title">{t.name}</span><span className="toolcard__scope xs muted">{tr(scope.label)}</span><Icon name="chevron" className="toolcard__chevron" size={15}/></summary>
<summary><Icon name={t.scope === 'shell' ? 'bolt' : 'tool'} size={17}/><span className="toolcard__title">{tr(t.verb ?? t.name)}</span><span className="toolcard__scope xs muted">{tr(scope.label)}</span><Icon name="chevron" className="toolcard__chevron" size={15}/></summary>
<div className="toolcard__body"><code className="toolcard__preview">{t.preview}</code><p className="small muted">{tr(t.effect)}</p>{tr(t.result ? <p className="small">{t.result}</p> : null)}</div>
</details>
{tr(pending && t.target ? <code className="toolcard__what" title={t.target}>{t.target}</code> : null)}
{tr(pending ? <div className="toolcard__quick-actions"><span className="xs muted">{tr("Needs your approval")}</span><Btn size="sm" onClick={() => resolveTool(messageId, t.id, false)}>{tr("Deny")}</Btn><Btn variant="primary" size="sm" icon="check" onClick={() => resolveTool(messageId, t.id, true)}>{tr("Approve")}</Btn></div> : <span className="xs muted toolcard__running" role="status">{tr("Running\u2026")}</span>)}
</section>;
}
+4
View File
@@ -7009,3 +7009,7 @@ textarea {
/* What you type and the small print under each message follow the reading size too, so the whole conversation changes together. */
.st-composer textarea { font-size: var(--st-content-fs, 14px); }
.st-msg__meta, .st-msg__meta time { font-size: calc(var(--st-content-fs, 14px) * .8125); }
/* What an approval is about, shown without opening the card: the file or the command. */
.toolcard__what { display: block; margin: 4px 0 2px; padding: 6px 10px; background: var(--st-surface-2); color: var(--st-ink); border: 1px solid var(--st-line); border-radius: var(--st-r-sm); white-space: pre-wrap; overflow-wrap: anywhere; max-height: 6.5em; overflow: auto; font-size: 12px; }
.tool-summary__target { min-width: 0; max-width: 28em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--st-mono, monospace); font-size: 12px; }
+13
View File
@@ -238,3 +238,16 @@ test('Mode over HTTP: the chat body carries the Mode menu choice; Plan refuses a
assert.equal(lines(join(s.ws, 'manual-http.txt')), 1);
assert.equal(manual.filter(f => f.type === 'agent-tool').at(-1).tool.approved, true);
});
test('item 9: a chat that asks for no tools never runs one (agent:false and tools:false answer directly)', { skip }, async t => {
const s = await setup(t);
for (const [chatId, flag] of [['chat-plain-a', { agent: false }], ['chat-plain-t', { tools: false }]]) {
s.worker.requests.length = 0;
const frames = await s.host.chat({ sessionId: chatId, message: 'SCRIPT:tool plain-never.txt x', remoteModel: s.remoteModel, workspaceId: null, history: [], ...flag });
assert.equal(frames.filter(f => f.type === 'agent-tool').length, 0, `${chatId}: no tool card`);
assert.ok(!frames.some(f => f.type === 'start' && f.agent === true), `${chatId}: not an agent turn`);
assert.ok(!frames.some(f => f.type === 'notice' && f.code === 'agent-skipped'), `${chatId}: asking for plain chat is not a surprise to explain`);
assert.equal(existsSync(join(s.ws, 'plain-never.txt')), false);
assert.ok(s.worker.requests.length >= 1 && s.worker.requests.every(r => r.toolNames.length === 0), `${chatId}: the model was offered no tool`);
}
});
+3 -4
View File
@@ -105,7 +105,7 @@ test('the agent is offered exactly these tools: everything useful, no scheduler,
const r = await rig(t);
await r.run('inv', 'SCRIPT:echo hi');
const offered = [...r.worker.requests].reverse().find(q => q.toolNames.length)?.toolNames.sort();
assert.deepEqual(offered, ['create_goal', 'edit', 'get_goal', 'glob', 'grep', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'present', 'pwsh', 'read', 'read_image', 'send_message', 'skill', 'subagent', 'subagent_fork', 'todo_write', 'update_goal', 'web_fetch', 'workflow', 'working_directory', 'write'].sort());
assert.deepEqual(offered, ['edit', 'glob', 'grep', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'present', 'pwsh', 'read', 'read_image', 'send_message', 'subagent', 'todo_write', 'web_fetch', 'working_directory', 'write'].sort());
assert.ok(!offered.some(n => n.startsWith('schedule_')) && !offered.includes('web_search') && !offered.includes('ask_user_question') && !offered.includes('exit_plan_mode'));
});
@@ -120,10 +120,9 @@ test('a tool from each family runs and shows a card the page can render', { skip
assert.match(last(await r.call('fam-s', 'grep', { pattern: 'needle' })).result, /Line 2/);
assert.equal(last(await r.call('fam-w', 'working_directory', {})).state, 'done');
assert.equal(last(await r.call('fam-j', 'job_list', {})).state, 'done');
// plan / goal / list family
// plan / list family
assert.equal(last(await r.call('fam-t', 'todo_write', { todos: [{ content: 'check the build', status: 'pending' }] })).state, 'done');
const goal = last(await r.call('fam-goal', 'create_goal', { objective: 'finish the notes' }));
assert.equal(goal.state, 'done'); assert.equal(goal.effect, 'Sets a goal to work towards.');
// (the goal tools are not offered since the prompt trim: tests/functional-fixes.test.mjs pins that)
// write family (Bypass: no card asks)
const wrote = await r.call('fam-fs', 'write', { file_path: fwd(join(r.ws, 'new.txt')), content: 'made\n' });
assert.equal(last(wrote).state, 'done'); assert.equal(readFileSync(join(r.ws, 'new.txt'), 'utf8').trim(), 'made');
+1 -1
View File
@@ -28,7 +28,7 @@ test('the approval and result texts of a chat card exist in every language, tran
test('the card shows "Ran" unless the person approved, and never "Demo approval"', () => {
const chat = readFileSync(resolve(root, 'src/screens/Chat.tsx'), 'utf8');
assert.ok(!/Demo approval/i.test(chat));
assert.match(chat, /t\.live && !t\.approved \? 'Ran' : 'Approved'/);
assert.match(chat, /t\.live && t\.state === 'done' && t\.approved \? 'Approved' : 'Done'/);
const store = readFileSync(resolve(root, 'src/lib/store.ts'), 'utf8');
assert.ok(!/Demo approval/i.test(store));
assert.match(store, /mode:state\.sessions\.find\(s=>s\.id===sessionId\)\?\.preferences\?\.mode\?\?'manual'/, 'the Mode menu choice travels with the turn');
+122
View File
@@ -0,0 +1,122 @@
/**
* Fixes from the real-engine functional test of the integrated build (notes-split-uitest.md, "Functional (real engine)"):
* tok/s and context in the agent path, plain-language tool rows, a launch that reports success, and the size and wording of
* what the agent sends to the model. The model is scripted; the Harness, the bridge and the projector are real.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { TurnProjector, outcomeText, toolVerb } from '../server/agent/events.mjs';
import { AGENT_PROMPT } from '../server/agent/preset.mjs';
import { startScriptedWorker } from './helpers/scripted-worker.mjs';
import { createAgentBridge } from '../server/agent/agent-bridge.mjs';
import { resolveHarness } from '../server/agent/harness-runtime.mjs';
const HARNESS = process.env.STRATA_HARNESS_DIR;
const skip = !HARNESS || !resolveHarness({ env: process.env }).ok ? 'STRATA_HARNESS_DIR is not set to a built Harness' : false;
const cleanup = dir => { try { rmSync(dir, { recursive: true, force: true, maxRetries: 3 }); } catch { /* best effort */ } };
const ev = (type, data, seq = 1, time = 1000) => ({ type: 'event', event: { type, seq, time, data } });
const chunk = (c, attemptId = 'a:1') => ({ type: 'assistant-stream', frame: { type: 'chunk', attemptId, chunk: c } });
/** Run `fn` with a clock the test moves by hand. */
function withClock(fn) {
const real = Date.now; let now = 1_000_000;
Date.now = () => now;
try { return fn(ms => { now += ms; }); } finally { Date.now = real; }
}
test('item 1: the agent path reports live tok/s while it streams and a final tok/s, not null', () => withClock(tick => {
const p = new TurnProjector({ startedAt: Date.now() });
const frames = [];
// 60 chunks of one token each, 25 ms apart: 40 tokens per second.
for (let i = 0; i < 60; i++) { tick(25); frames.push(...p.consume(chunk({ type: 'text-delta', index: 0, text: 'ab' }))); }
const stats = frames.filter(f => f.type === 'stats');
assert.ok(stats.length >= 2, 'live speed frames while the answer streams');
const last = stats.at(-1);
assert.equal(last.source, 'stream');
assert.ok(last.tps > 30 && last.tps < 50, `live tps ${last.tps}`);
p.consume(chunk({ type: 'usage', usage: { inputTokens: 900, outputTokens: 60 } }));
p.consume(ev('turn/end', { turn: 1, reason: { kind: 'completed' } }));
const done = p.finish({ model: 'M', contextMax: 65536 }).find(f => f.type === 'done');
assert.ok(done.tps > 30 && done.tps < 50, `final tps ${done.tps}`);
assert.equal(done.tokensOut, 60);
}));
test('item 1: reasoning tokens count towards the speed too (a thinking model streams reasoning first)', () => withClock(tick => {
const p = new TurnProjector({ startedAt: Date.now() });
for (let i = 0; i < 40; i++) { tick(20); p.consume(chunk({ type: 'reasoning-delta', index: 0, text: 'x' })); }
p.consume(ev('turn/end', { turn: 1, reason: { kind: 'completed' } }));
const done = p.finish({}).find(f => f.type === 'done');
assert.ok(done.tps > 40 && done.tps < 60, `tps ${done.tps}`);
}));
test('item 2: the context is the LAST request (prompt + reply), not the sum of every step', () => {
const p = new TurnProjector({ startedAt: 0 });
p.consume(chunk({ type: 'usage', usage: { inputTokens: 5000, outputTokens: 100 } }, 'a:1'));
p.consume(chunk({ type: 'usage', usage: { inputTokens: 5200, outputTokens: 300 } }, 'a:2'));
p.consume(chunk({ type: 'usage', usage: { inputTokens: 5600, outputTokens: 50 } }, 'a:3'));
p.consume(ev('turn/end', { turn: 1, reason: { kind: 'completed' } }));
const done = p.finish({ model: 'M', contextMax: 65536 }).find(f => f.type === 'done');
assert.equal(done.contextTokens, 5650, 'what the context holds after the turn');
assert.equal(done.tokensIn, 15800, 'the billed sum is still reported separately');
});
test('item 7: tool rows say what happened in plain words, with what it was about', () => {
const p = new TurnProjector({ startedAt: 0 });
const call = p.consume(ev('tool/call', { callId: 'w1', name: 'write', arguments: JSON.stringify({ file_path: 'C:/work/notes.md', content: 'x' }) }))[0].tool;
assert.equal(call.verb, 'Writing a file'); assert.equal(call.target, 'C:/work/notes.md');
const asked = p.consume(ev('approval/asked', { id: 'a1', callId: 'w1', reason: 'Create or replace the file C:/work/notes.md' }))[0].tool;
assert.equal(asked.state, 'proposed'); assert.equal(asked.target, 'C:/work/notes.md', 'the approval card shows the file');
const ok = p.consume(ev('tool/result', { message: { toolCallId: 'w1', content: [{ type: 'text', text: 'written' }] } }))[0].tool;
assert.equal(ok.verb, 'Wrote a file');
p.consume(ev('tool/call', { callId: 'w2', name: 'write', arguments: JSON.stringify({ file_path: 'D:/x.txt' }) }));
const bad = p.consume(ev('tool/result', { message: { toolCallId: 'w2', isError: true, content: [{ type: 'text', text: 'denied' }] } }))[0].tool;
assert.equal(bad.state, 'error'); assert.equal(bad.verb, "Couldn't write the file");
const sh = p.consume(ev('tool/call', { callId: 's1', name: 'pwsh', arguments: JSON.stringify({ command: 'Get-ChildItem C:/work' }) }))[0].tool;
assert.equal(sh.verb, 'Running a command'); assert.equal(sh.target, 'Get-ChildItem C:/work');
const g = p.consume(ev('tool/call', { callId: 'g1', name: 'glob', arguments: JSON.stringify({ pattern: '**/*.md' }) }))[0].tool;
assert.equal(g.verb, 'Searching for files'); assert.equal(g.target, '**/*.md');
assert.equal(toolVerb('glob', 'done'), 'Searched for files');
assert.equal(toolVerb('some_connected_tool', 'running'), 'Using a tool');
for (const name of ['write', 'edit', 'read', 'glob', 'grep', 'pwsh', 'bash', 'web_fetch', 'todo_write']) {
for (const state of ['running', 'done', 'error']) assert.ok(!/^(write|edit|read|glob|grep|pwsh|bash|web_fetch|todo_write)\b/.test(toolVerb(name, state)), `${name} ${state}: no raw tool name`);
}
});
test('item 8: starting a program that prints nothing is reported as started, plainly', () => {
for (const command of ['Start-Process explorer.exe C:\\work', 'explorer.exe C:\\work', 'start notepad', 'Invoke-Item C:\\work\\a.pdf', 'ii .', 'xdg-open .', 'open .']) {
assert.match(outcomeText('pwsh', '(no output)', { command }), /^Started\./, command);
}
assert.match(outcomeText('pwsh', '(no output)', { command: 'New-Item x.txt | Out-Null' }), /finished without any error/, 'other silent commands keep their wording');
assert.equal(outcomeText('pwsh', 'hello', { command: 'echo hello' }), 'hello', 'a command that printed something shows it');
const p = new TurnProjector({ startedAt: 0 });
p.consume(ev('tool/call', { callId: 'e1', name: 'pwsh', arguments: JSON.stringify({ command: 'Start-Process explorer.exe C:\\work' }) }));
const res = p.consume(ev('tool/result', { message: { toolCallId: 'e1', content: [{ type: 'text', text: '(no output)' }] } }))[0].tool;
assert.match(res.result, /^Started\./); assert.equal(res.verb, 'Ran a command');
});
test('item 7/10: our prompt has no internal terms and is short', () => {
assert.ok(AGENT_PROMPT.length < 1600, `${AGENT_PROMPT.length} chars`);
assert.doesNotMatch(AGENT_PROMPT, /DSH|DeepSeek|sandbox|isolated session|file policy|Harness/i);
assert.match(AGENT_PROMPT, /do not say you could not confirm/i);
});
test('items 3, 7, 10: what the agent sends the model is small, plain, and carries Thinking Off', { skip }, async t => {
const worker = await startScriptedWorker({});
const dir = mkdtempSync(join(tmpdir(), 'strata-fx-'));
const ws = join(dir, 'ws'); mkdirSync(ws, { recursive: true });
const local = [{ id: 'i', baseUrl: worker.url, modelId: 'scripted-model', context: 65536 }];
const bridge = createAgentBridge({ dataDir: dir, localInstances: () => local, connections: () => [], env: process.env, startTimeoutMs: 90_000 });
t.after(async () => { await bridge.close(); await worker.close(); cleanup(dir); });
worker.requests.length = 0;
await bridge.runTurn({ chatId: 'fx-1', message: 'SCRIPT:echo hi', workspacePath: ws, mode: 'plan', thinking: 'Off', onFrame: () => {} });
const req = worker.requests.find(r => r.toolNames.length);
assert.ok(req, 'a request with tools');
assert.equal(req.params.chat_template_kwargs.enable_thinking, false, 'Thinking Off reaches the model request');
for (const gone of ['workflow', 'create_goal', 'update_goal', 'get_goal', 'skill', 'subagent_fork']) assert.ok(!req.toolNames.includes(gone), `${gone} is not sent`);
for (const kept of ['pwsh', 'read', 'write', 'edit', 'glob', 'grep']) assert.ok(req.toolNames.includes(kept), `${kept} is still there`);
// 29,700 characters before the trim (about 7,500 tokens); the request now stays below 20,000 including the user's own message.
assert.ok(req.promptChars < 20_000, `first request is ${req.promptChars} characters`);
});
+21
View File
@@ -169,5 +169,26 @@ class EffortPaths(unittest.TestCase):
self.assertEqual(self.level("/v1/chat/completions", {"messages": history, "reasoning_effort": given}), want)
def test_the_agents_request_shape_with_thinking_off(self):
"""The app's agent (Harness) sends no reasoning_effort when Thinking is Off, only chat_template_kwargs {enable_thinking: false}, with tools, a
system message, earlier reasoning and a token cap. That one flag must reach the template as the empty think block, whatever else is in the
request or configured (a thinking model that went on reasoning with the menu on Off was this request form). The same body with
enable_thinking true / an effort keeps thinking."""
tools = [{"type": "function", "function": {"name": "pwsh", "description": "d", "parameters": {"type": "object", "properties": {}}}}]
history = [{"role": "developer", "content": "You are a helpful assistant."}, {"role": "user", "content": "first"},
{"role": "assistant", "content": "ok", "reasoning_content": "earlier thoughts"}, {"role": "user", "content": "now?"}]
agent = {"model": "m", "messages": history, "tools": tools, "stream": True, "stream_options": {"include_usage": True},
"max_completion_tokens": 4096}
for config in (None, "high", "low"):
self.svc.config_effort = config
with self.subTest(config=config):
self.assertEqual(self.level("/v1/chat/completions", {**agent, "chat_template_kwargs": {"enable_thinking": False}}), "none")
self.svc.config_effort = None
self.assertEqual(self.level("/v1/chat/completions", {**agent, "chat_template_kwargs": {"enable_thinking": True, "reasoning_effort": "low"}}), "low")
self.assertEqual(self.level("/v1/chat/completions", {**agent, "chat_template_kwargs": {"enable_thinking": True, "reasoning_effort": "high"}}), "xhigh")
# the same flag in the plain (no tools) body the direct chat sends alongside a budget
self.assertEqual(self.level("/v1/chat/completions", {"messages": MSG, "chat_template_kwargs": {"enable_thinking": False}, "reasoning_effort": "none"}), "none")
if __name__ == "__main__":
unittest.main()