@oar-sh/oar 0.9.1 → 0.9.3
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/.github/extensions/web-relay/extension.mjs +0 -3
- package/.github/extensions/web-relay/polling/heartbeat.mjs +2 -55
- package/.github/extensions/web-relay/polling/polling-loop.mjs +0 -1
- package/.github/extensions/web-relay/runtime/api-client.mjs +2 -34
- package/.github/extensions/web-relay/runtime/config-loader.mjs +2 -104
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +2 -170
- package/.github/extensions/web-relay/runtime/session-io.mjs +1 -71
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +2 -325
- package/.github/extensions/web-relay/skills/prompt-context.mjs +2 -160
- package/CHANGELOG.md +269 -0
- package/README.md +407 -424
- package/bin/oar.js +142 -11
- package/package.json +8 -4
- package/server/README.md +59 -22
- package/server/claude-worker/claude-sdk-adapter.mjs +7 -2
- package/server/claude-worker/claude-session-process.mjs +1013 -45
- package/server/claude-worker/claude-session-worker.mjs +54 -9
- package/server/claude-worker/claude-turn-publisher.mjs +110 -19
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/copilot-sdk-runtime.mjs +12 -3
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-attachments.mjs +35 -2
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-model-switch.mjs +425 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +20 -8
- package/server/copilot-worker/copilot-question-bridge.mjs +129 -16
- package/server/copilot-worker/copilot-sdk-adapter.mjs +59 -11
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +136 -12
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2753 -527
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +94 -25
- package/server/cursor-worker/cursor-session-worker.mjs +5 -5
- package/server/cursor-worker/cursor-turn-runner.mjs +50 -4
- package/server/db-schema.mjs +61 -1
- package/server/features.mjs +155 -41
- package/server/grok-worker/grok-session-worker.mjs +5 -5
- package/server/grok-worker/grok-turn-runner.mjs +45 -4
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/public/app/annotate-core.mjs +181 -0
- package/server/public/app/annotate-editor.js +617 -0
- package/server/public/app/api-client.js +86 -5
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +172 -3
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +34 -19
- package/server/public/app/bootstrap.js +153 -69
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/context-tier-options.mjs +24 -4
- package/server/public/app/conversation-draft-sync.mjs +187 -0
- package/server/public/app/conversation-list-filter.mjs +86 -0
- package/server/public/app/conversation-preferences.mjs +47 -10
- package/server/public/app/conversation-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +1032 -218
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/journal-view.js +190 -24
- package/server/public/app/model-selector-options.mjs +185 -1
- package/server/public/app/new-conversation-model-choice.mjs +29 -9
- package/server/public/app/pwa-install.js +64 -136
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/reasoning-effort-labels.mjs +13 -3
- package/server/public/app/relay-prompt-sanitizer.mjs +26 -3
- package/server/public/app/router.js +120 -11
- package/server/public/app/settings-modal.js +225 -20
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +47 -4
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +159 -16
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/tool-activity-emoji.mjs +36 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/app/update-ui.js +194 -0
- package/server/public/index.html +483 -24
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +2 -0
- package/server/relay.mjs +23 -21
- package/server/repositories/message-repository.mjs +78 -21
- package/server/repositories/question-repository.mjs +19 -1
- package/server/repositories/session-repository.mjs +7 -31
- package/server/routes/ask-user-routes.mjs +10 -0
- package/server/routes/messages-routes.mjs +689 -159
- package/server/routes/sessions-routes.mjs +574 -80
- package/server/server-runtime.mjs +393 -925
- package/server/services/claude-auth-service.mjs +7 -1
- package/server/services/cli-install-service.mjs +8 -27
- package/server/services/cli-process-runner.mjs +30 -0
- package/server/services/copilot-model-discovery-service.mjs +202 -0
- package/server/services/data-dir-resolution.mjs +47 -0
- package/server/services/grok-auth-service.mjs +9 -1
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/model-variant-catalog-service.mjs +1183 -0
- package/server/services/oar-cli-helpers.mjs +6 -2
- package/server/services/relay-prompt-sanitizer.mjs +30 -3
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/sdk-session-import-service.mjs +144 -16
- package/server/services/sdk-session-sync-service.mjs +84 -27
- package/server/services/session-worker-registry-service.mjs +116 -3
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +119 -24
- package/server/services/status-event-service.mjs +8 -2
- package/server/services/steer-resend.mjs +45 -0
- package/server/services/update-check-service.mjs +220 -0
- package/server/services/update-install-service.mjs +279 -0
- package/server/services/windows-autostart-service.mjs +42 -6
- package/server/services/windows-boot-autostart-service.mjs +487 -0
- package/shared/ask-user-bridge.mjs +37 -5
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/claude-model-catalog.mjs +49 -0
- package/shared/context-window-fallbacks.mjs +1 -0
- package/shared/control-poller.mjs +4 -1
- package/shared/copilot-model-order.mjs +182 -0
- package/shared/media-embed-instructions.mjs +35 -0
- package/shared/model-descriptors.mjs +181 -30
- package/shared/model-id.mjs +6 -0
- package/shared/pwa-app-name.mjs +44 -0
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -0
- package/shared/slash-command-guard.mjs +76 -0
- package/shared/steer-note.mjs +66 -0
- package/shared/steer-settle-failure.mjs +89 -0
- package/shared/steer-settle-markers.mjs +18 -0
- package/shared/update-semver.mjs +50 -0
- package/shared/worker-crash-guard.mjs +40 -14
- package/shared/worker-runtime/api-client.mjs +34 -0
- package/shared/worker-runtime/config-loader.mjs +104 -0
- package/shared/worker-runtime/heartbeat.mjs +109 -0
- package/shared/worker-runtime/prompt-context.mjs +160 -0
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/send-and-wait-errors.mjs +170 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +436 -0
- package/server/claude-worker/claude-session-test-harness.mjs +0 -236
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -329
- package/server/copilot-worker/fixtures/abort-turn.json +0 -588
- package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
- package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
- package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
- package/server/copilot-worker/fixtures/happy-turn.json +0 -566
- package/server/copilot-worker/fixtures/quota-turn.json +0 -647
- package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
- package/server/copilot-worker/fixtures/resume-turn.json +0 -700
- package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
- package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
- package/server/routes/messages-routes-test-harness.mjs +0 -111
- package/server/services/fixtures/claude-auth-stub.sh +0 -62
- package/server/services/fixtures/cli-install-stub.sh +0 -75
- package/server/services/fixtures/grok-stub.sh +0 -86
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
// Observed model + reasoning-effort switching for the SDK worker (audit #9 and
|
|
2
|
+
// the #13 policy: "model switching proceeds without confirmed success").
|
|
3
|
+
//
|
|
4
|
+
// The session facade's `setModel()` forwards to `rpc.model.switchTo` and
|
|
5
|
+
// DISCARDS the result (SDK 1.0.13, session.js ~1606), so a worker that calls it
|
|
6
|
+
// can never tell a committed switch from a refused one — and the old behaviour
|
|
7
|
+
// on any failure was to log and run the prompt on whatever model the session
|
|
8
|
+
// happened to be on. This module talks to the RPC surface directly and treats
|
|
9
|
+
// the result as the contract it is:
|
|
10
|
+
//
|
|
11
|
+
// * `modelId` — the model actually active after the call; a mismatch
|
|
12
|
+
// is a refusal, not a delay.
|
|
13
|
+
// * `deferred: true` — the switch was ENQUEUED behind an active turn and the
|
|
14
|
+
// live model is unchanged until the queue drains. The
|
|
15
|
+
// drain is observed by waiting (bounded) for the
|
|
16
|
+
// `session.model_change` event whose `data.newModel` is
|
|
17
|
+
// the requested model; the waiter is armed BEFORE the
|
|
18
|
+
// RPC is sent so a drain cannot slip between the result
|
|
19
|
+
// arriving and the subscription starting.
|
|
20
|
+
// * `confirmation` — the runtime wants an interactive decision (compaction
|
|
21
|
+
// preflight); a headless worker has nobody to ask.
|
|
22
|
+
//
|
|
23
|
+
// Reasoning effort rides the same machinery. `ReasoningEffort` has NO "none"
|
|
24
|
+
// member in 1.0.13 — the relay's `none` (and an absent value) mean "the model's
|
|
25
|
+
// default", mapped to the model's `defaultReasoningEffort` from a cached
|
|
26
|
+
// `rpc.model.list()` when known, and otherwise simply omitted. An effort-only
|
|
27
|
+
// change uses the `rpc.model.setReasoningEffort` RPC (@experimental; the host
|
|
28
|
+
// must pre-validate against the model's `supportedReasoningEfforts`, which is
|
|
29
|
+
// exactly what the cached catalog is for).
|
|
30
|
+
//
|
|
31
|
+
// Failure policy differs by session type, decided by the CALLER's `byok` flag:
|
|
32
|
+
//
|
|
33
|
+
// * hosted — an unconfirmed explicit selection THROWS
|
|
34
|
+
// (`relay.model-switch-unconfirmed`, naming requested vs actual), and the
|
|
35
|
+
// prompt is never sent on the wrong model.
|
|
36
|
+
// * BYOK — `apply` returns `{ ok: false }` instead of throwing, because the
|
|
37
|
+
// caller has a second mechanism (dispose + resume with a rebuilt
|
|
38
|
+
// `SessionConfig.provider`) that this module knows nothing about. An
|
|
39
|
+
// effort the model is known not to support is skipped SILENTLY for BYOK
|
|
40
|
+
// (openai-compatible ceilings and effort vocabularies differ per provider).
|
|
41
|
+
//
|
|
42
|
+
// No SDK import, no I/O of its own: everything arrives through the session
|
|
43
|
+
// handle and the event feed, which is what keeps the unit suite spawn-free.
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* How long a `deferred: true` switch may wait for its `session.model_change`
|
|
47
|
+
* drain before the selection counts as unconfirmed. Deliberately generous
|
|
48
|
+
* against a healthy runtime (a queued switch drains at the next model-call
|
|
49
|
+
* boundary, typically well under a second) and deliberately bounded: the
|
|
50
|
+
* alternative is sending the user's prompt to a model they explicitly
|
|
51
|
+
* deselected. Configurable as `COPILOT_SDK_RELAY_MODEL_SWITCH_TIMEOUT_MS`.
|
|
52
|
+
*/
|
|
53
|
+
import { supportedEffortsOf } from '../../shared/model-descriptors.mjs';
|
|
54
|
+
|
|
55
|
+
export const DEFAULT_MODEL_SWITCH_TIMEOUT_MS = 10_000;
|
|
56
|
+
|
|
57
|
+
export const MODEL_SWITCH_UNCONFIRMED_CODE = 'model-switch-unconfirmed';
|
|
58
|
+
export const MODEL_SWITCH_UNCONFIRMED_STABLE_CODE = 'relay.model-switch-unconfirmed';
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Relay effort → the vocabulary this module tracks internally.
|
|
62
|
+
*
|
|
63
|
+
* `null` means "the model's own default": the relay sends `none` (or nothing)
|
|
64
|
+
* for it, and SDK 1.0.13's `ReasoningEffort` union has no `none` member — so it
|
|
65
|
+
* must never reach the wire as a literal value.
|
|
66
|
+
*/
|
|
67
|
+
export function normalizeRelayEffort(value) {
|
|
68
|
+
const text = String(value || '').trim().toLowerCase();
|
|
69
|
+
if (!text || text === 'none') return null;
|
|
70
|
+
return text;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The effort-list reader lives with the shared descriptor rules (the model
|
|
74
|
+
// catalog snapshot publishes the same list), re-exported so existing callers
|
|
75
|
+
// and tests keep their import. `null` = no parseable list, and unknown must be
|
|
76
|
+
// PERMISSIVE here: the runtime is the authority on what it supports, this
|
|
77
|
+
// catalog is only a fast-fail.
|
|
78
|
+
export { supportedEffortsOf };
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The terminal failure for a selection the runtime would not (or could not
|
|
82
|
+
* provably) honour. Carried as a marked Error so `classifyCopilotTurnException`
|
|
83
|
+
* can route it to the existing terminal-error publish path without prose
|
|
84
|
+
* sniffing, with a stable code the UI can key on.
|
|
85
|
+
*/
|
|
86
|
+
export function createModelSwitchUnconfirmedError({
|
|
87
|
+
requestedModel = '',
|
|
88
|
+
requestedEffort = null,
|
|
89
|
+
actualModel = '',
|
|
90
|
+
detail = '',
|
|
91
|
+
} = {}) {
|
|
92
|
+
const requested = requestedEffort
|
|
93
|
+
? `"${requestedModel}" (reasoning effort "${requestedEffort}")`
|
|
94
|
+
: `"${requestedModel}"`;
|
|
95
|
+
const actual = actualModel ? `"${actualModel}"` : 'its previous model';
|
|
96
|
+
// An effort-only refusal on the model the session is already on reads
|
|
97
|
+
// nonsensically as "asked for X but still on X"; name the effort instead.
|
|
98
|
+
const effortOnly = !!requestedEffort && !!requestedModel && requestedModel === actualModel;
|
|
99
|
+
const error = new Error(effortOnly
|
|
100
|
+
? `System note: this message asked for reasoning effort "${requestedEffort}" on "${requestedModel}", `
|
|
101
|
+
+ `but the Copilot runtime did not confirm it${detail ? ` (${detail})` : ''}. `
|
|
102
|
+
+ 'The message was not sent; pick a supported effort (or another model) and resend it.'
|
|
103
|
+
: `System note: this message asked for model ${requested}, but the Copilot runtime did not confirm `
|
|
104
|
+
+ `the switch — the session is still on ${actual}${detail ? ` (${detail})` : ''}. `
|
|
105
|
+
+ 'The message was not sent on the wrong model; pick an available model and resend it.');
|
|
106
|
+
error.modelSwitchUnconfirmed = true;
|
|
107
|
+
error.code = MODEL_SWITCH_UNCONFIRMED_CODE;
|
|
108
|
+
error.stableCode = MODEL_SWITCH_UNCONFIRMED_STABLE_CODE;
|
|
109
|
+
error.requestedModel = requestedModel || null;
|
|
110
|
+
error.requestedEffort = requestedEffort || null;
|
|
111
|
+
error.actualModel = actualModel || null;
|
|
112
|
+
return error;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function isModelSwitchUnconfirmedError(error) {
|
|
116
|
+
return error?.modelSwitchUnconfirmed === true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** switchTo statuses that read as success when the result omits `modelId`. */
|
|
120
|
+
const SWITCH_OK_STATUS_RE = /^(?:ok|success|succeeded|applied|switched|completed)$/i;
|
|
121
|
+
|
|
122
|
+
export function createCopilotModelSwitcher({
|
|
123
|
+
switchTimeoutMs = DEFAULT_MODEL_SWITCH_TIMEOUT_MS,
|
|
124
|
+
dbg = () => {},
|
|
125
|
+
} = {}) {
|
|
126
|
+
// The last (model, effort) pair CONFIRMED on the live session — by an RPC
|
|
127
|
+
// result, by a drained `session.model_change`, or by the caller vouching for
|
|
128
|
+
// a session config it just built (`noteApplied`). `effort: null` means "the
|
|
129
|
+
// model's default". This is the single gate that keeps the common path
|
|
130
|
+
// (same model, same effort, every turn) at zero RPCs.
|
|
131
|
+
let applied = { model: '', effort: null };
|
|
132
|
+
// One `rpc.model.list()` per live session, fetched lazily and only when an
|
|
133
|
+
// effort actually needs validating or defaulting. Reset with the session.
|
|
134
|
+
let catalog = null;
|
|
135
|
+
// Deferred switches waiting for their `session.model_change` drain.
|
|
136
|
+
const drainWaiters = new Set();
|
|
137
|
+
|
|
138
|
+
function settleWaiters(model, value) {
|
|
139
|
+
for (const waiter of [...drainWaiters]) {
|
|
140
|
+
if (model === null || waiter.model === model) {
|
|
141
|
+
drainWaiters.delete(waiter);
|
|
142
|
+
waiter.resolve(value);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The session is gone (rebuild, idle shutdown): nothing confirmed survives it. */
|
|
148
|
+
function reset() {
|
|
149
|
+
applied = { model: '', effort: null };
|
|
150
|
+
catalog = null;
|
|
151
|
+
settleWaiters(null, false);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The caller vouches for the session's state without an RPC — a session
|
|
156
|
+
* CREATED with `config.model` (and, for BYOK, `config.reasoningEffort`) is
|
|
157
|
+
* on that model by construction.
|
|
158
|
+
*/
|
|
159
|
+
function noteApplied(model, effort = null) {
|
|
160
|
+
applied = { model: String(model || '').trim(), effort: normalizeRelayEffort(effort) };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function current() {
|
|
164
|
+
return { ...applied };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Fed every LIVE session event by the runner (the replay gate runs first —
|
|
169
|
+
* a historical `session.model_change` must not confirm a pending switch).
|
|
170
|
+
* Keeps the tracked pair truthful even for switches this worker did not ask
|
|
171
|
+
* for (a deferred switch draining after its wait expired, a runtime-side
|
|
172
|
+
* refusal fallback), and resolves any drain waiter for the new model.
|
|
173
|
+
*/
|
|
174
|
+
function observeEvent(event) {
|
|
175
|
+
if (event?.type !== 'session.model_change') return;
|
|
176
|
+
const newModel = String(event?.data?.newModel || '').trim();
|
|
177
|
+
if (!newModel) return;
|
|
178
|
+
applied = { model: newModel, effort: normalizeRelayEffort(event?.data?.reasoningEffort) };
|
|
179
|
+
settleWaiters(newModel, true);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Arm a bounded waiter for the drain of a deferred switch onto `model`. */
|
|
183
|
+
function armDrainWaiter(model) {
|
|
184
|
+
let resolveWaiter;
|
|
185
|
+
let timer = null;
|
|
186
|
+
const waiter = { model };
|
|
187
|
+
const promise = new Promise((resolve) => { resolveWaiter = resolve; });
|
|
188
|
+
waiter.resolve = (value) => {
|
|
189
|
+
clearTimeout(timer);
|
|
190
|
+
resolveWaiter(value);
|
|
191
|
+
};
|
|
192
|
+
timer = setTimeout(() => {
|
|
193
|
+
drainWaiters.delete(waiter);
|
|
194
|
+
waiter.resolve(false);
|
|
195
|
+
}, switchTimeoutMs);
|
|
196
|
+
timer.unref?.();
|
|
197
|
+
drainWaiters.add(waiter);
|
|
198
|
+
return {
|
|
199
|
+
promise,
|
|
200
|
+
cancel: () => {
|
|
201
|
+
drainWaiters.delete(waiter);
|
|
202
|
+
waiter.resolve(false);
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Populate the per-session catalog cache from one `rpc.model.list()` call. */
|
|
208
|
+
async function ensureCatalog(session) {
|
|
209
|
+
if (catalog) return;
|
|
210
|
+
const list = typeof session?.rpc?.model?.list === 'function'
|
|
211
|
+
? await session.rpc.model.list().catch((error) => {
|
|
212
|
+
dbg('rpc.model.list failed; effort validation degrades to the runtime', error?.message || String(error));
|
|
213
|
+
return null;
|
|
214
|
+
})
|
|
215
|
+
: null;
|
|
216
|
+
// A failed list is cached as empty rather than retried per turn: the
|
|
217
|
+
// degradation (send the effort, let the runtime validate) is safe.
|
|
218
|
+
catalog = new Map();
|
|
219
|
+
for (const entry of (Array.isArray(list?.list) ? list.list : [])) {
|
|
220
|
+
const id = String(entry?.id || '').trim();
|
|
221
|
+
if (id) catalog.set(id, entry);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The catalog entry for a model, from one cached per-session list() call. */
|
|
226
|
+
async function modelInfo(session, model) {
|
|
227
|
+
if (!model) return null;
|
|
228
|
+
await ensureCatalog(session);
|
|
229
|
+
return catalog.get(model) || null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The cached catalog as raw ModelInfo entries, fetching it on first use —
|
|
234
|
+
* the SAME single list() the effort validation reads, so a snapshot publish
|
|
235
|
+
* never adds a second RPC to a session that already validated an effort.
|
|
236
|
+
* Empty when the runtime refused the list; callers treat that as "nothing
|
|
237
|
+
* to publish" rather than an error.
|
|
238
|
+
*/
|
|
239
|
+
async function catalogEntries(session) {
|
|
240
|
+
await ensureCatalog(session);
|
|
241
|
+
return [...catalog.values()];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Bring the live session onto (model, effort), doing nothing when both
|
|
246
|
+
* already match.
|
|
247
|
+
*
|
|
248
|
+
* Returns `{ ok: true, changed }` on success. On failure: hosted sessions
|
|
249
|
+
* THROW the unconfirmed error (the caller must not send the prompt); BYOK
|
|
250
|
+
* sessions get `{ ok: false, detail }` so the caller can fall back to its
|
|
251
|
+
* dispose+resume rebuild.
|
|
252
|
+
*/
|
|
253
|
+
async function apply(session, { model = '', effort = undefined, byok = false } = {}) {
|
|
254
|
+
const targetModel = String(model || '').trim();
|
|
255
|
+
const targetEffortRaw = normalizeRelayEffort(effort);
|
|
256
|
+
const modelChanged = !!targetModel && targetModel !== applied.model;
|
|
257
|
+
// The model the effort applies to: the one we are switching to, else the
|
|
258
|
+
// one the session is already on. With neither there is nothing to do.
|
|
259
|
+
const effortModel = targetModel || applied.model;
|
|
260
|
+
if (!effortModel) return { ok: true, changed: false };
|
|
261
|
+
|
|
262
|
+
const fail = (detail, requestedEffort = targetEffortRaw) => {
|
|
263
|
+
if (byok) {
|
|
264
|
+
dbg('BYOK model RPC unconfirmed; caller falls back to rebuild', detail);
|
|
265
|
+
return { ok: false, detail };
|
|
266
|
+
}
|
|
267
|
+
throw createModelSwitchUnconfirmedError({
|
|
268
|
+
requestedModel: targetModel || applied.model,
|
|
269
|
+
requestedEffort,
|
|
270
|
+
actualModel: applied.model,
|
|
271
|
+
detail,
|
|
272
|
+
});
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
// ---- resolve the effort actually sent on the wire -----------------------
|
|
276
|
+
// `null` target = "the model's default": mapped to the catalog's
|
|
277
|
+
// `defaultReasoningEffort` when known, otherwise omitted entirely (and the
|
|
278
|
+
// local tracking resets, so the next explicit effort still reads as a
|
|
279
|
+
// delta). An explicit level is pre-validated against the model's
|
|
280
|
+
// `supportedReasoningEfforts` — the setReasoningEffort RPC is
|
|
281
|
+
// @experimental and documents that the HOST validates.
|
|
282
|
+
let effortToSend;
|
|
283
|
+
let trackedEffort;
|
|
284
|
+
if (targetEffortRaw === null) {
|
|
285
|
+
if (!modelChanged && applied.effort === null) {
|
|
286
|
+
effortToSend = undefined;
|
|
287
|
+
trackedEffort = null;
|
|
288
|
+
} else {
|
|
289
|
+
const info = await modelInfo(session, effortModel);
|
|
290
|
+
const supported = supportedEffortsOf(info);
|
|
291
|
+
if (supported?.includes('none')) {
|
|
292
|
+
// The wire catalog offers a literal "none" (GPT-family entries do,
|
|
293
|
+
// despite the TS union lacking the member): send it as the explicit
|
|
294
|
+
// reset, tracked as null so steady-state stays zero-RPC.
|
|
295
|
+
effortToSend = 'none';
|
|
296
|
+
trackedEffort = null;
|
|
297
|
+
} else {
|
|
298
|
+
const fallback = normalizeRelayEffort(info?.defaultReasoningEffort);
|
|
299
|
+
effortToSend = fallback || undefined;
|
|
300
|
+
trackedEffort = fallback;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
} else {
|
|
304
|
+
const info = await modelInfo(session, effortModel);
|
|
305
|
+
const supported = supportedEffortsOf(info);
|
|
306
|
+
// Fail ONLY on a positive exclusion. An entry with no parseable effort
|
|
307
|
+
// list — model absent from the catalog, an effort-less model, or a wire
|
|
308
|
+
// shape this code does not know — sends the level and lets the runtime
|
|
309
|
+
// confirm or refuse: failing closed here bricked every effort-carrying
|
|
310
|
+
// turn when the session list turned out to speak raw CAPI shape.
|
|
311
|
+
if (Array.isArray(supported) && !supported.includes(targetEffortRaw)) {
|
|
312
|
+
const advertised = supported.length ? ` (it supports: ${supported.join(', ')})` : '';
|
|
313
|
+
if (byok) {
|
|
314
|
+
// Openai-compatible providers define their own effort vocabularies
|
|
315
|
+
// (and many models simply have none); a level the catalog rejects is
|
|
316
|
+
// skipped rather than failed, per the BYOK contract.
|
|
317
|
+
dbg('BYOK model does not support the requested effort; skipping it', effortModel, targetEffortRaw);
|
|
318
|
+
effortToSend = undefined;
|
|
319
|
+
trackedEffort = applied.effort;
|
|
320
|
+
} else if (!modelChanged) {
|
|
321
|
+
return fail(`the model does not support reasoning effort "${targetEffortRaw}"${advertised}`);
|
|
322
|
+
} else {
|
|
323
|
+
// Model change + unsupported effort: the selection as a whole cannot
|
|
324
|
+
// be honoured — running the new model on a different effort than the
|
|
325
|
+
// one explicitly picked is the same silent substitution #13 forbids.
|
|
326
|
+
return fail(`"${targetModel}" does not support reasoning effort "${targetEffortRaw}"${advertised}`);
|
|
327
|
+
}
|
|
328
|
+
} else {
|
|
329
|
+
effortToSend = targetEffortRaw;
|
|
330
|
+
trackedEffort = targetEffortRaw;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const effortChanged = !modelChanged && effortToSend !== undefined && effortToSend !== applied.effort;
|
|
335
|
+
|
|
336
|
+
if (!modelChanged && !effortChanged) {
|
|
337
|
+
// Nothing to send; an unknown-default reset still updates the tracking.
|
|
338
|
+
if (targetEffortRaw === null) applied = { ...applied, effort: trackedEffort };
|
|
339
|
+
return { ok: true, changed: false };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ---- model change: rpc.model.switchTo, result consumed ------------------
|
|
343
|
+
if (modelChanged) {
|
|
344
|
+
const switchTo = session?.rpc?.model?.switchTo;
|
|
345
|
+
if (typeof switchTo !== 'function') {
|
|
346
|
+
// No RPC surface (an exotic bundle): the facade's throw-on-failure is
|
|
347
|
+
// the only signal left. Hosted trusts it; BYOK falls back to rebuild.
|
|
348
|
+
if (byok || typeof session?.setModel !== 'function') return fail('the runtime exposes no model-switch RPC');
|
|
349
|
+
await session.setModel(targetModel, effortToSend ? { reasoningEffort: effortToSend } : undefined)
|
|
350
|
+
.catch((error) => fail(error?.message || String(error)));
|
|
351
|
+
applied = { model: targetModel, effort: trackedEffort };
|
|
352
|
+
return { ok: true, changed: true };
|
|
353
|
+
}
|
|
354
|
+
// Armed BEFORE the RPC: a deferred switch can drain between the result
|
|
355
|
+
// arriving and any later subscription, and a missed drain here would
|
|
356
|
+
// fail a selection the runtime actually honoured.
|
|
357
|
+
const drain = armDrainWaiter(targetModel);
|
|
358
|
+
let result;
|
|
359
|
+
try {
|
|
360
|
+
result = await switchTo.call(session.rpc.model, {
|
|
361
|
+
modelId: targetModel,
|
|
362
|
+
...(effortToSend ? { reasoningEffort: effortToSend } : {}),
|
|
363
|
+
});
|
|
364
|
+
} catch (error) {
|
|
365
|
+
drain.cancel();
|
|
366
|
+
return fail(error?.message || String(error));
|
|
367
|
+
}
|
|
368
|
+
if (result?.confirmation) {
|
|
369
|
+
// A compaction preflight wants an interactive decision; a headless
|
|
370
|
+
// worker has nobody to put it to mid-dequeue.
|
|
371
|
+
drain.cancel();
|
|
372
|
+
return fail('the runtime requires interactive confirmation for this switch');
|
|
373
|
+
}
|
|
374
|
+
if (result?.deferred === true) {
|
|
375
|
+
// Enqueued behind an active turn: the live model is unchanged until
|
|
376
|
+
// the queue drains. Wait (bounded) for the model_change that proves it.
|
|
377
|
+
dbg('model switch deferred behind an active turn; awaiting the drain', targetModel);
|
|
378
|
+
const drained = await drain.promise;
|
|
379
|
+
if (!drained) {
|
|
380
|
+
return fail(`the queued switch did not apply within ${Math.round(switchTimeoutMs / 1000)}s`);
|
|
381
|
+
}
|
|
382
|
+
// `observeEvent` already updated `applied` from the event (including
|
|
383
|
+
// the effort the runtime settled on); do not overwrite it blind.
|
|
384
|
+
return { ok: true, changed: true };
|
|
385
|
+
}
|
|
386
|
+
drain.cancel();
|
|
387
|
+
const confirmedModel = String(result?.modelId || result?.modelState?.modelId || '').trim();
|
|
388
|
+
if (confirmedModel && confirmedModel !== targetModel) {
|
|
389
|
+
return fail(`the runtime reports "${confirmedModel}" as the active model`);
|
|
390
|
+
}
|
|
391
|
+
if (!confirmedModel && !SWITCH_OK_STATUS_RE.test(String(result?.status || ''))) {
|
|
392
|
+
return fail(String(result?.message || 'the runtime did not report the active model after the switch'));
|
|
393
|
+
}
|
|
394
|
+
applied = { model: targetModel, effort: trackedEffort };
|
|
395
|
+
return { ok: true, changed: true };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ---- effort-only change: rpc.model.setReasoningEffort -------------------
|
|
399
|
+
const setEffort = session?.rpc?.model?.setReasoningEffort;
|
|
400
|
+
if (typeof setEffort !== 'function') {
|
|
401
|
+
return fail('the runtime exposes no reasoning-effort RPC', effortToSend);
|
|
402
|
+
}
|
|
403
|
+
let effortResult;
|
|
404
|
+
try {
|
|
405
|
+
effortResult = await setEffort.call(session.rpc.model, { reasoningEffort: effortToSend });
|
|
406
|
+
} catch (error) {
|
|
407
|
+
return fail(error?.message || String(error), effortToSend);
|
|
408
|
+
}
|
|
409
|
+
const recorded = String(effortResult?.reasoningEffort || '').trim().toLowerCase();
|
|
410
|
+
if (recorded && recorded !== effortToSend) {
|
|
411
|
+
return fail(`the runtime recorded effort "${recorded}"`, effortToSend);
|
|
412
|
+
}
|
|
413
|
+
applied = { ...applied, effort: trackedEffort };
|
|
414
|
+
return { ok: true, changed: true };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
apply,
|
|
419
|
+
observeEvent,
|
|
420
|
+
noteApplied,
|
|
421
|
+
reset,
|
|
422
|
+
current,
|
|
423
|
+
catalogEntries,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
// block.
|
|
4
4
|
//
|
|
5
5
|
// This mirrors the extension's `createRelayPromptBuilder`
|
|
6
|
-
// (
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// (`shared/worker-runtime/prompt-context.mjs`, relocated from the extension
|
|
7
|
+
// tree for its retirement) and reuses its text verbatim through
|
|
8
|
+
// `buildModePrompt`, so a conversation reads the same guidance whichever
|
|
9
|
+
// engine ran it. It does NOT reuse the extension's builder
|
|
9
10
|
// wholesale for one reason: that builder also composes the message BODY via its
|
|
10
11
|
// own `buildPrompt`, which renders attachments as a text note. This worker
|
|
11
12
|
// passes real `MessageOptions.attachments` to the runtime
|
|
@@ -24,8 +25,9 @@ import {
|
|
|
24
25
|
applyPreviewInstructions,
|
|
25
26
|
createPreviewInstructionsProvider,
|
|
26
27
|
} from '../../shared/preview-instructions.mjs';
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
28
|
+
import { applyMediaEmbedInstructions } from '../../shared/media-embed-instructions.mjs';
|
|
29
|
+
import { buildModePrompt } from '../../shared/worker-runtime/prompt-context.mjs';
|
|
30
|
+
import { loadRelayInstructionsFromFile } from '../../shared/worker-runtime/config-loader.mjs';
|
|
29
31
|
|
|
30
32
|
export { createPreviewInstructionsProvider, loadRelayInstructionsFromFile };
|
|
31
33
|
|
|
@@ -54,6 +56,14 @@ export function loadDefaultRelayToolInstructions({ env = process.env } = {}) {
|
|
|
54
56
|
* its standing instructions every message. The mode marker itself is always
|
|
55
57
|
* present.
|
|
56
58
|
*
|
|
59
|
+
* Returns `{ prefix, commit }`, and the mode is recorded as prompted only when
|
|
60
|
+
* the caller invokes `commit()` — AFTER `session.send()` accepted the message
|
|
61
|
+
* (audit #32). Committing at build time was the bug: a failed send meant the
|
|
62
|
+
* runtime never received the guidance, yet the same-mode retry omitted it
|
|
63
|
+
* because the builder believed it had already been delivered. The rare double
|
|
64
|
+
* delivery the late commit allows (two builds racing before either send lands)
|
|
65
|
+
* costs a repeated instruction block, which is harmless; the omission was not.
|
|
66
|
+
*
|
|
57
67
|
* `agentMode` is threaded to the runtime natively as well
|
|
58
68
|
* (`copilotAgentModeForRelayMode`); the text is not a substitute for it but a
|
|
59
69
|
* reinforcement, same as on the extension path.
|
|
@@ -72,11 +82,13 @@ export function createCopilotPromptContextBuilder({
|
|
|
72
82
|
const previewBlock = typeof getPreviewInstructions === 'function'
|
|
73
83
|
? await Promise.resolve().then(getPreviewInstructions).catch(() => '')
|
|
74
84
|
: '';
|
|
75
|
-
instructions = applyPreviewInstructions(toolInstructions, previewBlock);
|
|
85
|
+
instructions = applyMediaEmbedInstructions(applyPreviewInstructions(toolInstructions, previewBlock));
|
|
76
86
|
}
|
|
77
87
|
const prefix = buildModePrompt(relayMode, instructions, { includeInstructions });
|
|
78
|
-
|
|
79
|
-
|
|
88
|
+
return {
|
|
89
|
+
prefix,
|
|
90
|
+
commit: () => { lastPromptedRelayMode = relayMode; },
|
|
91
|
+
};
|
|
80
92
|
};
|
|
81
93
|
}
|
|
82
94
|
|