@worca/app 1.6.0-rc.2 → 1.6.0
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/package.json +1 -1
- package/src/core/ask/events.mjs +18 -3
- package/src/core/ask/models.mjs +8 -1
- package/src/core/ask/turn.mjs +47 -1
- package/src/core/bridge/upstream.mjs +29 -0
- package/src/core/failure-policy.mjs +3 -0
- package/src/core/model-test.mjs +1 -0
- package/src/core/recoverable-error.mjs +11 -2
- package/ui/public/ask-model.mjs +3 -0
- package/ui/public/ask-panel.mjs +46 -10
- package/ui/public/style.css +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@worca/app",
|
|
3
|
-
"version": "1.6.0
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Worca — deterministic multi-agent pipeline that drives Claude Code (headless) through Plan -> Refine -> Implement -> Review, with a CLI, an installable /worca skill, and a web UI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Sinisha Djukic",
|
package/src/core/ask/events.mjs
CHANGED
|
@@ -291,6 +291,7 @@ export function createTurnReducer({
|
|
|
291
291
|
let lastResult = null;
|
|
292
292
|
let reducerErrors = 0;
|
|
293
293
|
let summary = null;
|
|
294
|
+
let cliErrorText = ''; // what a <synthetic> CLI message said (its API-error line)
|
|
294
295
|
const pendingHooks = []; // promises returned by onProposal — settle() awaits them
|
|
295
296
|
|
|
296
297
|
// ── helpers ──
|
|
@@ -420,7 +421,18 @@ export function createTurnReducer({
|
|
|
420
421
|
const content = Array.isArray(msg.content) ? msg.content : [];
|
|
421
422
|
if (isMain) {
|
|
422
423
|
sawAssistant = true;
|
|
423
|
-
|
|
424
|
+
// A `<synthetic>` message is the CLI speaking for itself — the API-refusal
|
|
425
|
+
// line it fabricates when a call fails (model: "<synthetic>"). It is not
|
|
426
|
+
// the model's answer: it must never enter the answer text (it would read
|
|
427
|
+
// as a reply above the failure notice), so it is kept aside for the error
|
|
428
|
+
// notice's detail instead.
|
|
429
|
+
if (msg.model === '<synthetic>') {
|
|
430
|
+
const t = content
|
|
431
|
+
.filter((c) => c && c.type === 'text' && typeof c.text === 'string')
|
|
432
|
+
.map((c) => c.text)
|
|
433
|
+
.join('');
|
|
434
|
+
if (t) cliErrorText = cliErrorText ? `${cliErrorText}\n${t}` : t;
|
|
435
|
+
} else if (id) {
|
|
424
436
|
if (messages.has('__main__') && !messages.has(id)) { // deltas arrived before any message_start: adopt them
|
|
425
437
|
messages.set(id, messages.get('__main__'));
|
|
426
438
|
messages.delete('__main__');
|
|
@@ -710,7 +722,7 @@ export function createTurnReducer({
|
|
|
710
722
|
return {
|
|
711
723
|
text: mainText(), blocks: blocks.map(clone), usage: currentUsage(), costUsd: currentCost(), sessionId,
|
|
712
724
|
...terminal(), sawInit, sawAssistant, sawResult, agents: blocks.filter((b) => b.kind === 'agent').length,
|
|
713
|
-
runningAgents, labels: [...labels], reducerErrors,
|
|
725
|
+
runningAgents, labels: [...labels], reducerErrors, cliErrorText: cliErrorText || null,
|
|
714
726
|
};
|
|
715
727
|
},
|
|
716
728
|
finish() {
|
|
@@ -736,10 +748,13 @@ export function createTurnReducer({
|
|
|
736
748
|
a.costUsd = est[i].costUsd == null ? null : Math.round(est[i].costUsd * scale * 1e6) / 1e6;
|
|
737
749
|
});
|
|
738
750
|
}
|
|
739
|
-
|
|
751
|
+
// The result-text fallback only speaks for a REAL answer: an is_error
|
|
752
|
+
// result carries the API's refusal line, never the model's reply.
|
|
753
|
+
const text = mainText() || (lastResult && !lastResult.is_error && typeof lastResult.result === 'string' ? lastResult.result : '');
|
|
740
754
|
summary = {
|
|
741
755
|
text: redact(text), blocks: blocks.map(clone), usage: currentUsage(), costUsd: currentCost(), sessionId,
|
|
742
756
|
...terminal(), sawInit, sawAssistant, sawResult, agents: agents.length, labels: [...labels], reducerErrors,
|
|
757
|
+
cliErrorText: cliErrorText || null,
|
|
743
758
|
};
|
|
744
759
|
return summary;
|
|
745
760
|
},
|
package/src/core/ask/models.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { listModels as realListModels, EFFORTS } from '../config.mjs';
|
|
7
7
|
import { listPluginModels as realPluginModels, pluginModelSecretStatus as realSecretStatus } from '../plugin-models.mjs';
|
|
8
8
|
import { ASK_LIMITS } from './limits.mjs';
|
|
9
|
+
import { effortlessModels as realEffortless } from '../bridge/upstream.mjs';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* @param {{
|
|
@@ -13,6 +14,7 @@ import { ASK_LIMITS } from './limits.mjs';
|
|
|
13
14
|
* pluginModels?: ()=>Array<{plugin:string,id:string,secrets?:string[]}>,
|
|
14
15
|
* secretStatus?: (plugin:string)=>Array<{key:string,set:boolean}>,
|
|
15
16
|
* defaults?: {defaultModel:string, defaultEffort:string},
|
|
17
|
+
* effortless?: ()=>Set<string>,
|
|
16
18
|
* }} [deps]
|
|
17
19
|
*/
|
|
18
20
|
export function createAskModels({
|
|
@@ -20,6 +22,7 @@ export function createAskModels({
|
|
|
20
22
|
pluginModels = realPluginModels,
|
|
21
23
|
secretStatus = realSecretStatus,
|
|
22
24
|
defaults = ASK_LIMITS,
|
|
25
|
+
effortless = realEffortless,
|
|
23
26
|
} = {}) {
|
|
24
27
|
/**
|
|
25
28
|
* lc id -> the modelSecrets keys that model needs but that are NOT set.
|
|
@@ -71,6 +74,7 @@ export function createAskModels({
|
|
|
71
74
|
async function askCatalog({ withSecrets = true } = {}) {
|
|
72
75
|
const all = await listModels('');
|
|
73
76
|
const models = [];
|
|
77
|
+
const noEffort = effortless();
|
|
74
78
|
let missing = null; // lazily built on the first plugin entry
|
|
75
79
|
for (const m of all) {
|
|
76
80
|
if (!m || typeof m.id !== 'string') continue;
|
|
@@ -88,7 +92,10 @@ export function createAskModels({
|
|
|
88
92
|
hasEnv: m.hasEnv === true,
|
|
89
93
|
};
|
|
90
94
|
if (custom === 'plugin' && typeof m.plugin === 'string' && m.plugin) entry.plugin = m.plugin;
|
|
91
|
-
if (m.hidden === true) entry.hidden = true;
|
|
95
|
+
if (m.hidden === true) entry.hidden = true;
|
|
96
|
+
// Its upstream refused a reasoning effort (bridge/upstream.mjs leaves it out from then
|
|
97
|
+
// on): the picker shows the effort as not applicable. The effort still validates.
|
|
98
|
+
if (noEffort.has(m.id)) entry.noEffort = true; // the picker skips it; validation does not (#422)
|
|
92
99
|
// Only globals and plugin entries can arrive flagged: composeCatalog emits an
|
|
93
100
|
// UNSHADOWED built-in as {...m, custom:false, hasEnv:false} with no
|
|
94
101
|
// ...unreliable(lc) (src/core/config.mjs:200), so a built-in in model_cost_flags
|
package/src/core/ask/turn.mjs
CHANGED
|
@@ -47,6 +47,27 @@ export function createAskTurn(opts) { return new AskTurn(opts); }
|
|
|
47
47
|
|
|
48
48
|
const TERMINAL = new Set(['done', 'stopped', 'error']);
|
|
49
49
|
|
|
50
|
+
// The classified notice's raw-detail cap — the runner's own reject is already
|
|
51
|
+
// tail-capped tighter than this; the slice(-N) here only bounds the unusual
|
|
52
|
+
// non-runner error paths so a huge message can never bloat the persisted block.
|
|
53
|
+
const ERROR_DETAIL_MAX = 2000;
|
|
54
|
+
|
|
55
|
+
/** The human line a classified failure carries. The block is persisted and
|
|
56
|
+
* shared by every viewer, so the wording is level-neutral: it names where a
|
|
57
|
+
* remedy lives (including which interface mode gates it) instead of assuming
|
|
58
|
+
* one. Pure, exported for tests. */
|
|
59
|
+
export function humanErrorText(errorClass) {
|
|
60
|
+
switch (errorClass) {
|
|
61
|
+
case 'model': return "This model isn't available in your environment — Claude Code couldn't use it. Try another model, or add your custom model in Settings › Models (Expert mode).";
|
|
62
|
+
case 'auth': return 'Authentication failed — check the credentials behind this model.';
|
|
63
|
+
case 'usage_limit': return 'A usage limit was reached — wait for it to reset.';
|
|
64
|
+
case 'rate_limit': return 'The endpoint is rate-limiting — try again shortly.';
|
|
65
|
+
case 'quota': return 'A quota/billing problem was reported — check the account behind this model.';
|
|
66
|
+
case 'network': return 'The endpoint was unreachable — check your connection and retry.';
|
|
67
|
+
default: return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
50
71
|
class AskTurn extends EventEmitter {
|
|
51
72
|
constructor({
|
|
52
73
|
threadId, assistantMessageId, userMessageId,
|
|
@@ -546,6 +567,28 @@ class AskTurn extends EventEmitter {
|
|
|
546
567
|
for (const b of this.reducer.snapshot().blocks) {
|
|
547
568
|
if (b && b.kind === 'card' && b.state === 'building') this.reducer.updateBlock(b.id, { state: 'failed', error: 'the reply ended before the proposal was ready' });
|
|
548
569
|
}
|
|
570
|
+
// A classified failure tells the user what happened and what to do, as a
|
|
571
|
+
// notice block: it rides the terminal write below (like _limitNotice's
|
|
572
|
+
// notice), so the human line survives a reload, and the extra fields
|
|
573
|
+
// (errorClass, detail) let the chat render its recovery affordance. The
|
|
574
|
+
// wording is level-neutral — the block is shared by every viewer — and
|
|
575
|
+
// an UNCLASSIFIED error adds nothing: the raw message stays the only
|
|
576
|
+
// evidence, exactly as before.
|
|
577
|
+
if (kind === 'error' && errorClass) {
|
|
578
|
+
// The detail carries the full evidence: the runner's exit verdict, plus
|
|
579
|
+
// the CLI's own refusal line when it spoke synthetically (it is often
|
|
580
|
+
// the clearest statement of the cause). Skipped when the runner message
|
|
581
|
+
// already contains it — an empty stderr makes the runner echo it verbatim.
|
|
582
|
+
const cli = this.reducer.snapshot().cliErrorText;
|
|
583
|
+
const extra = cli && message && !String(message).includes(cli) ? cli : null;
|
|
584
|
+
const detail = [message, extra].filter((s) => s && String(s).trim()).join('\n');
|
|
585
|
+
this.reducer.addBlock({
|
|
586
|
+
kind: 'notice',
|
|
587
|
+
text: humanErrorText(errorClass),
|
|
588
|
+
errorClass,
|
|
589
|
+
detail: detail ? String(detail).slice(-ERROR_DETAIL_MAX) : null,
|
|
590
|
+
});
|
|
591
|
+
}
|
|
549
592
|
const summary = this.reducer.finish();
|
|
550
593
|
const finalStatus = kind === 'error' ? 'error' : status;
|
|
551
594
|
// Already AUTHORITATIVE: the reducer applied this turn's per-model cost
|
|
@@ -594,8 +637,11 @@ class AskTurn extends EventEmitter {
|
|
|
594
637
|
// out, the CLI may not even say so (`unrecognized_model` on a first-party id),
|
|
595
638
|
// so claude-auth asks `claude auth status` instead of trusting the text.
|
|
596
639
|
const signedOut = await d.failedBecauseSignedOut({ message, model: this.model }).catch(() => false);
|
|
640
|
+
// The persisted blocks ride along, mirroring ask-done: the live client
|
|
641
|
+
// must render the same classified notice a reload re-derives — an
|
|
642
|
+
// ask-error frame without them shows the raw message until refresh.
|
|
597
643
|
this._frame({
|
|
598
|
-
type: 'ask-error', message: message || 'unknown error',
|
|
644
|
+
type: 'ask-error', message: message || 'unknown error', blocks: summary.blocks,
|
|
599
645
|
...(errorClass !== undefined ? { errorClass } : {}),
|
|
600
646
|
...(signedOut ? { code: CLAUDE_SIGNED_OUT_CODE } : {}),
|
|
601
647
|
});
|
|
@@ -35,6 +35,23 @@ function applySchemaDrops(body, d) {
|
|
|
35
35
|
return d ? withoutTools(withToolSchemaKeywordsDropped(body, d.keywords), d.tools) : body;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// Models that refused a reasoning effort on the Anthropic passthrough (Copilot serves
|
|
39
|
+
// claude-haiku-4.5 without one): the CLI's output_config.effort is left out of their
|
|
40
|
+
// requests. Learned like schemaDrops, and listed so the Ask picker can grey the effort out.
|
|
41
|
+
const effortDrops = new Set(); // catalog ids
|
|
42
|
+
const EFFORT_REFUSED_RE = /does not support (?:reasoning )?effort|effort[^.]{0,40}not supported/i;
|
|
43
|
+
export function _resetEffortDrops() { effortDrops.clear(); }
|
|
44
|
+
/** Catalog ids whose upstream refused a reasoning effort since boot. */
|
|
45
|
+
export function effortlessModels() { return new Set(effortDrops); }
|
|
46
|
+
function withoutEffort(body) {
|
|
47
|
+
const oc = body && body.output_config;
|
|
48
|
+
if (!oc || typeof oc !== 'object' || !('effort' in oc)) return body;
|
|
49
|
+
const { effort: _drop, ...rest } = oc;
|
|
50
|
+
const out = { ...body };
|
|
51
|
+
if (Object.keys(rest).length) out.output_config = rest; else delete out.output_config;
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
38
55
|
/** Once-per-process warning (dropped fields, queue notices). */
|
|
39
56
|
function warnOnce(key, line, log) {
|
|
40
57
|
if (warned.has(key)) return;
|
|
@@ -157,6 +174,7 @@ export async function handleMessages({ entry, body, requestHeaders = {}, tag = '
|
|
|
157
174
|
let outBody;
|
|
158
175
|
if (us.api === 'anthropic') {
|
|
159
176
|
outBody = { ...body, model: us.model };
|
|
177
|
+
if (effortDrops.has(entry.id)) outBody = withoutEffort(outBody);
|
|
160
178
|
} else {
|
|
161
179
|
const responses = us.api === 'openai-responses';
|
|
162
180
|
const t = (responses ? toResponsesRequest : toChatRequest)(body, { upstreamModel: us.model, capabilities: us.capabilities });
|
|
@@ -236,6 +254,17 @@ export async function handleMessages({ entry, body, requestHeaders = {}, tag = '
|
|
|
236
254
|
payload = JSON.stringify(outBody);
|
|
237
255
|
res = await doFetch(prep);
|
|
238
256
|
}
|
|
257
|
+
// A reasoning effort the model does not take: leave it out, remember that, retry once.
|
|
258
|
+
if (res.status === 400 && us.api === 'anthropic' && outBody.output_config && 'effort' in outBody.output_config) {
|
|
259
|
+
const text = await res.clone().text().catch(() => '');
|
|
260
|
+
if (EFFORT_REFUSED_RE.test(text)) {
|
|
261
|
+
effortDrops.add(entry.id);
|
|
262
|
+
warnOnce(`effort:${entry.id}`, `[worca] bridge: model ${JSON.stringify(entry.id)} takes no reasoning effort — leaving it out of its requests`, log);
|
|
263
|
+
outBody = withoutEffort(outBody);
|
|
264
|
+
payload = JSON.stringify(outBody);
|
|
265
|
+
res = await doFetch(prep);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
239
268
|
} catch (err) {
|
|
240
269
|
if (signal && signal.aborted) return reply.end();
|
|
241
270
|
const e = mapNetworkError(err, { provider: us.provider });
|
|
@@ -84,6 +84,9 @@ export const FAILURE_POLICY = Object.freeze({
|
|
|
84
84
|
// A self-parked auto run pauses as RECOVERABLE (the class is kept: "resume when
|
|
85
85
|
// it clears"); a user who gives up on the prompt pauses as ERROR (a verdict).
|
|
86
86
|
auth: cell(pause(REASON.RECOVERABLE), prompt(pause(REASON.ERROR))),
|
|
87
|
+
// The model id itself was refused — retrying the same id is futile. The
|
|
88
|
+
// run parks as an error; resume retries the node once the model is fixed.
|
|
89
|
+
model: both(pause(REASON.ERROR)),
|
|
87
90
|
quota: cell(pause(REASON.RECOVERABLE), prompt(pause(REASON.ERROR))),
|
|
88
91
|
rate_limit: cell(retry(RECOVERY_MAX_AUTO_ATTEMPTS, pause(REASON.RECOVERABLE)), prompt(pause(REASON.ERROR))),
|
|
89
92
|
network: cell(retry(RECOVERY_MAX_AUTO_ATTEMPTS, pause(REASON.RECOVERABLE)), prompt(pause(REASON.ERROR))),
|
package/src/core/model-test.mjs
CHANGED
|
@@ -22,6 +22,7 @@ const SYSTEM = 'You are a connectivity check. Reply with exactly OK.';
|
|
|
22
22
|
export function hintFor(errorClass) {
|
|
23
23
|
switch (errorClass) {
|
|
24
24
|
case 'auth': return 'authentication failed — check the token/secret for this model';
|
|
25
|
+
case 'model': return 'this model id is not served by the endpoint — pick a model the endpoint serves, or add yours in Settings › Models';
|
|
25
26
|
case 'network': return 'endpoint unreachable — check ANTHROPIC_BASE_URL';
|
|
26
27
|
case 'rate_limit': return 'the endpoint is rate-limiting or overloaded — try again shortly';
|
|
27
28
|
case 'quota': return 'quota/billing problem — check the account behind this endpoint';
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// recoverable and retried. Structured error-code detection is out of scope.
|
|
16
16
|
//
|
|
17
17
|
// @param {Error|string|unknown} err
|
|
18
|
-
// @returns {'auth'|'usage_limit'|'rate_limit'|'quota'|'network'|null}
|
|
18
|
+
// @returns {'auth'|'model'|'usage_limit'|'rate_limit'|'quota'|'network'|null}
|
|
19
19
|
export function classifyError(err) {
|
|
20
20
|
// A producer that saw MORE evidence than the message carries stamps the
|
|
21
21
|
// verdict directly: claude-runner classifies the FULL stderr stream line-by-
|
|
@@ -32,6 +32,15 @@ export function classifyError(err) {
|
|
|
32
32
|
// it, so it pauses like the session limit below instead of retrying as a 429.
|
|
33
33
|
if (FREE_DAILY_RE.test(msg)) return 'usage_limit';
|
|
34
34
|
if (/\b401\b|invalid authentication|authentication_error|please run .*login|not logged in/i.test(msg)) return 'auth';
|
|
35
|
+
// The model id itself is the problem — refused by the endpoint it was sent to,
|
|
36
|
+
// or named by a catalog-miss error. The remedy is a different model id, never
|
|
37
|
+
// a retry. The stderr notice `[claude-code:unrecognized_model]` is deliberately
|
|
38
|
+
// NOT classified here: the runner treats it as a benign notice
|
|
39
|
+
// (claude-runner BENIGN_STDERR_PATTERNS) — it fires on every spawn whose id the
|
|
40
|
+
// CLI does not know, and never states the cause. The phrases are specific
|
|
41
|
+
// CLI/API wordings so an ordinary message that merely mentions a model stays
|
|
42
|
+
// unclassified.
|
|
43
|
+
if (/no access to this model|isn't described by this version's model catalog|model not found/i.test(msg)) return 'model';
|
|
35
44
|
// Session/usage caps that only clear after a multi-hour reset (the CLI prints
|
|
36
45
|
// "You've hit your session limit · resets 6pm"). Distinct from rate_limit (a
|
|
37
46
|
// few-second 429/overloaded burst) because retrying is futile — the orchestrator
|
|
@@ -136,7 +145,7 @@ export function brokerHint(err, cls = classifyError(err)) {
|
|
|
136
145
|
// SAME order as the regex chain above. First-match-wins there equals
|
|
137
146
|
// strongest-class-wins here, because every per-line match (the patterns are
|
|
138
147
|
// unanchored) is also a whole-text match.
|
|
139
|
-
const CLASS_ORDER = ['auth', 'usage_limit', 'rate_limit', 'quota', 'network'];
|
|
148
|
+
const CLASS_ORDER = ['auth', 'model', 'usage_limit', 'rate_limit', 'quota', 'network'];
|
|
140
149
|
|
|
141
150
|
/** Fold two classification results, keeping the higher-precedence class. */
|
|
142
151
|
export function strongestClass(a, b) {
|
package/ui/public/ask-model.mjs
CHANGED
|
@@ -188,6 +188,9 @@ export function createThreadModel({ threadId }) {
|
|
|
188
188
|
break;
|
|
189
189
|
case 'ask-error':
|
|
190
190
|
row.text = live.text;
|
|
191
|
+
// The terminal blocks ride the frame (as on ask-done): the classified
|
|
192
|
+
// notice arrives live, so the chat renders it without waiting for a reload.
|
|
193
|
+
if (Array.isArray(frame.blocks)) row.blocks = frame.blocks;
|
|
191
194
|
row.status = 'error';
|
|
192
195
|
row.errorMessage = frame.message || 'unknown error';
|
|
193
196
|
row.errorCode = frame.code || null;
|
package/ui/public/ask-panel.mjs
CHANGED
|
@@ -1234,7 +1234,9 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
1234
1234
|
const flagged = !!entry && (entry.costUnreliable === true
|
|
1235
1235
|
|| (Array.isArray(entry.secretsMissing) && entry.secretsMissing.length > 0));
|
|
1236
1236
|
el.modelBtnLabel.textContent = (entry ? entry.label : st.picker.model) + (flagged ? ' ⚠' : '');
|
|
1237
|
-
|
|
1237
|
+
// A model that takes no reasoning effort shows none (the bridge leaves it out).
|
|
1238
|
+
el.modelBtnEffort.textContent = entry && entry.noEffort ? '' : st.picker.effort;
|
|
1239
|
+
el.modelBtnEffort.hidden = !!(entry && entry.noEffort);
|
|
1238
1240
|
}
|
|
1239
1241
|
|
|
1240
1242
|
function coerceEffort(entry, effort) {
|
|
@@ -1311,8 +1313,10 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
1311
1313
|
.catch(() => { /* the next message stores the pick anyway */ });
|
|
1312
1314
|
}
|
|
1313
1315
|
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
+
// fresh: fetch again even with a catalog in hand. Models imported (or keys set on the key
|
|
1317
|
+
// page) after the first load reach the menu when it next opens, without a page reload.
|
|
1318
|
+
function loadCatalog({ fresh = false } = {}) {
|
|
1319
|
+
if (st.catalog && !fresh) return Promise.resolve(st.catalog);
|
|
1316
1320
|
if (st.catalogLoading) return st.catalogLoading;
|
|
1317
1321
|
st.catalogLoading = Promise.resolve()
|
|
1318
1322
|
.then(() => fetch('/api/ask/models'))
|
|
@@ -1420,7 +1424,9 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
1420
1424
|
if (m.id === st.picker.model) item.appendChild(make('span', 'ask-model-check', '✓'));
|
|
1421
1425
|
return item;
|
|
1422
1426
|
};
|
|
1427
|
+
let shownPane = 'main';
|
|
1423
1428
|
const renderPane = (pane) => {
|
|
1429
|
+
shownPane = pane;
|
|
1424
1430
|
panel.replaceChildren();
|
|
1425
1431
|
if (pane === 'effort') {
|
|
1426
1432
|
const back = menuItem('ask-pane-back', () => renderPane('main'));
|
|
@@ -1446,11 +1452,19 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
1446
1452
|
const { primary, rest } = splitCatalog();
|
|
1447
1453
|
for (const m of primary) panel.appendChild(modelItem(m));
|
|
1448
1454
|
panel.appendChild(make('div', 'ask-pop-divider'));
|
|
1449
|
-
const
|
|
1455
|
+
const noEffort = !!catalogEntry(st.picker.model)?.noEffort;
|
|
1456
|
+
const effortRow = menuItem('ask-effort-row', noEffort ? null : () => renderPane('effort'));
|
|
1450
1457
|
effortRow.setAttribute('data-ask-effort-row', '');
|
|
1451
1458
|
effortRow.appendChild(make('span', null, 'Effort'));
|
|
1452
|
-
|
|
1453
|
-
|
|
1459
|
+
if (noEffort) {
|
|
1460
|
+
// The model's provider refused a reasoning effort; worca leaves it out of the request.
|
|
1461
|
+
effortRow.disabled = true;
|
|
1462
|
+
effortRow.title = 'This model takes no reasoning effort, so none is sent.';
|
|
1463
|
+
effortRow.appendChild(make('span', 'ask-pop-row-value', 'not supported'));
|
|
1464
|
+
} else {
|
|
1465
|
+
effortRow.appendChild(make('span', 'ask-pop-row-value', st.picker.effort));
|
|
1466
|
+
effortRow.appendChild(make('span', 'ask-pop-row-chev', '›'));
|
|
1467
|
+
}
|
|
1454
1468
|
panel.appendChild(effortRow);
|
|
1455
1469
|
if (rest.length) {
|
|
1456
1470
|
const moreRow = menuItem('ask-more-models', () => renderPane('more'));
|
|
@@ -1462,7 +1476,11 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
1462
1476
|
}
|
|
1463
1477
|
focusFirst();
|
|
1464
1478
|
};
|
|
1465
|
-
|
|
1479
|
+
// What we have paints at once; the refetch repaints the main pane if it is still showing.
|
|
1480
|
+
const had = st.catalog;
|
|
1481
|
+
loadCatalog({ fresh: true }).then((c) => {
|
|
1482
|
+
if (c !== had && shownPane === 'main' && st.popover && st.popover.panel === panel) renderPane('main');
|
|
1483
|
+
});
|
|
1466
1484
|
renderPane('main');
|
|
1467
1485
|
}
|
|
1468
1486
|
|
|
@@ -1770,6 +1788,16 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
1770
1788
|
a.setAttribute('href', b.href);
|
|
1771
1789
|
n.appendChild(a);
|
|
1772
1790
|
}
|
|
1791
|
+
// The raw failure evidence, for those who debug: expert only — the human
|
|
1792
|
+
// line above is the explanation at every level.
|
|
1793
|
+
if (b.errorClass && b.detail) {
|
|
1794
|
+
const det = doc.createElement('details');
|
|
1795
|
+
det.className = 'ask-error-details';
|
|
1796
|
+
det.dataset.minLevel = 'expert';
|
|
1797
|
+
det.appendChild(make('summary', null, 'Details'));
|
|
1798
|
+
det.appendChild(make('div', 'ask-error-detail-text', b.detail));
|
|
1799
|
+
n.appendChild(det);
|
|
1800
|
+
}
|
|
1773
1801
|
return n;
|
|
1774
1802
|
}
|
|
1775
1803
|
|
|
@@ -3689,7 +3717,6 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
3689
3717
|
else if (b.kind === 'card') wrap.appendChild(buildCard(b, cur));
|
|
3690
3718
|
}
|
|
3691
3719
|
if (cur.status === 'error') {
|
|
3692
|
-
const explained = (cur.blocks || []).some((b) => b && b.kind === 'notice');
|
|
3693
3720
|
if (cur.errorCode === 'claude-signed-out' && typeof openClaudeSetup === 'function') {
|
|
3694
3721
|
// The CLI's raw "Not logged in" → one line whose link opens Connect Claude Code.
|
|
3695
3722
|
const line = make('div', 'ask-error-line', "Claude Code isn't signed in. ");
|
|
@@ -3698,8 +3725,17 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
3698
3725
|
link.addEventListener('click', (e) => { e.preventDefault(); openClaudeSetup(); });
|
|
3699
3726
|
line.appendChild(link);
|
|
3700
3727
|
wrap.appendChild(line);
|
|
3701
|
-
} else
|
|
3702
|
-
|
|
3728
|
+
} else {
|
|
3729
|
+
// A classified notice (errorClass on the block) IS the explanation —
|
|
3730
|
+
// it renders the human line and, at expert, the raw detail in its
|
|
3731
|
+
// own expander. The raw line here is only for the unclassified case.
|
|
3732
|
+
const classified = (cur.blocks || []).some((b) => b && b.kind === 'notice' && b.errorClass);
|
|
3733
|
+
if (!classified) {
|
|
3734
|
+
const explained = (cur.blocks || []).some((b) => b && b.kind === 'notice');
|
|
3735
|
+
if (cur.errorMessage) wrap.appendChild(make('div', 'ask-error-line', cur.errorMessage));
|
|
3736
|
+
else if (!explained) wrap.appendChild(make('div', 'ask-error-line', 'This turn ended with an error.'));
|
|
3737
|
+
}
|
|
3738
|
+
}
|
|
3703
3739
|
}
|
|
3704
3740
|
if (isLiveRow(cur)) {
|
|
3705
3741
|
wrap.appendChild(ensureThinking()); // last child: the bottom of the message
|
package/ui/public/style.css
CHANGED
|
@@ -4517,6 +4517,13 @@ body.rail-collapsed .ask-dock{left:76px;}
|
|
|
4517
4517
|
.ask-notice{font-size:12.5px;color:var(--ink-3);}
|
|
4518
4518
|
.ask-notice-link{color:var(--blue-ink);}
|
|
4519
4519
|
.ask-error-line{font-size:12.5px;color:var(--red-ink);}
|
|
4520
|
+
/* The classified-failure notice's raw-detail expander: expert only — the
|
|
4521
|
+
human line is the explanation at every level (ui-levels rule 1 keeps it). */
|
|
4522
|
+
.ask-error-details{margin-top:3px;}
|
|
4523
|
+
.ask-error-details summary{cursor:pointer;color:var(--ink-3);font-size:11.5px;user-select:none;}
|
|
4524
|
+
.ask-error-detail-text{margin-top:3px;padding:6px 8px;border:1px solid var(--line);border-radius:6px;
|
|
4525
|
+
background:var(--field);color:var(--ink-3);font-family:var(--mono);
|
|
4526
|
+
font-size:11px;line-height:1.5;white-space:pre-wrap;word-break:break-word;max-height:160px;overflow-y:auto;}
|
|
4520
4527
|
|
|
4521
4528
|
.ask-card{margin-top:2px;display:flex;flex-direction:column;gap:10px;padding:13px 15px;
|
|
4522
4529
|
border:1.5px solid var(--line);border-radius:var(--r-card);background:var(--panel);}
|
|
@@ -4856,6 +4863,8 @@ body.rail-collapsed .ask-dock{left:76px;}
|
|
|
4856
4863
|
.ask-pop-item{display:flex;align-items:center;gap:10px;width:100%;padding:9px 11px;border:0;border-radius:12px;
|
|
4857
4864
|
background:transparent;text-align:left;font-family:inherit;cursor:pointer;transition:.15s;}
|
|
4858
4865
|
.ask-pop-item:hover,.ask-pop-item:focus-visible{background:var(--field);outline:none;}
|
|
4866
|
+
.ask-pop-item:disabled{cursor:default;color:var(--ink-3);}
|
|
4867
|
+
.ask-pop-item:disabled:hover{background:transparent;}
|
|
4859
4868
|
.ask-model-name{font-weight:500;font-size:13.5px;color:var(--ink);}
|
|
4860
4869
|
.ask-model-check{margin-left:auto;color:var(--blue-ink);font-weight:600;}
|
|
4861
4870
|
.ask-pop-row-value{margin-left:auto;font-weight:400;font-size:12.5px;color:var(--ink-3);}
|