@oar-sh/oar 0.9.2 → 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.
Files changed (94) hide show
  1. package/CHANGELOG.md +269 -0
  2. package/README.md +407 -424
  3. package/bin/oar.js +25 -10
  4. package/package.json +7 -4
  5. package/server/README.md +1 -1
  6. package/server/claude-worker/claude-session-process.mjs +981 -44
  7. package/server/claude-worker/claude-session-worker.mjs +45 -4
  8. package/server/claude-worker/claude-turn-publisher.mjs +81 -20
  9. package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
  10. package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
  11. package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
  12. package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
  13. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +101 -12
  14. package/server/copilot-worker/copilot-sdk-session-process.mjs +2269 -492
  15. package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
  16. package/server/db-schema.mjs +20 -0
  17. package/server/migrations/0004-steer-settle-markers.mjs +119 -0
  18. package/server/public/app/api-client.js +22 -2
  19. package/server/public/app/ask-user-view.js +102 -13
  20. package/server/public/app/attachments-view.js +37 -2
  21. package/server/public/app/background-task-timeout-options.mjs +9 -0
  22. package/server/public/app/background-tasks-view.mjs +33 -18
  23. package/server/public/app/bootstrap.js +34 -8
  24. package/server/public/app/composer-control-state.mjs +52 -34
  25. package/server/public/app/conversation-draft-sync.mjs +187 -0
  26. package/server/public/app/conversation-list-filter.mjs +86 -0
  27. package/server/public/app/conversation-provider-indicator.mjs +8 -0
  28. package/server/public/app/conversation-view.js +919 -194
  29. package/server/public/app/emoji-view.js +2 -0
  30. package/server/public/app/font-scaling.js +4 -37
  31. package/server/public/app/journal-view.js +145 -5
  32. package/server/public/app/model-selector-options.mjs +1 -1
  33. package/server/public/app/question-multi-select.mjs +52 -0
  34. package/server/public/app/relay-prompt-sanitizer.mjs +17 -2
  35. package/server/public/app/settings-modal.js +8 -6
  36. package/server/public/app/socket-handlers.js +19 -0
  37. package/server/public/app/steered-turn-merge.mjs +100 -0
  38. package/server/public/app/store.js +126 -15
  39. package/server/public/app/sync-outbox.mjs +16 -8
  40. package/server/public/app/tool-activity-emoji.mjs +36 -0
  41. package/server/public/app/transcript-viewport-anchor.mjs +210 -0
  42. package/server/public/index.html +206 -9
  43. package/server/public/sw.js +7 -0
  44. package/server/relay-tools.md +2 -0
  45. package/server/repositories/message-repository.mjs +61 -18
  46. package/server/repositories/question-repository.mjs +15 -0
  47. package/server/repositories/session-repository.mjs +7 -31
  48. package/server/routes/messages-routes.mjs +488 -46
  49. package/server/routes/sessions-routes.mjs +298 -55
  50. package/server/server-runtime.mjs +65 -25
  51. package/server/services/live-turn-picker.mjs +47 -0
  52. package/server/services/relay-prompt-sanitizer.mjs +17 -2
  53. package/server/services/relay-question-context.mjs +46 -0
  54. package/server/services/sdk-session-import-service.mjs +49 -7
  55. package/server/services/session-worker-registry-service.mjs +75 -0
  56. package/server/services/session-worker-supervisor-service.mjs +7 -1
  57. package/server/services/session-worker-websocket-service.mjs +23 -1
  58. package/server/services/steer-resend.mjs +45 -0
  59. package/shared/background-task-timeout.mjs +12 -4
  60. package/shared/control-poller.mjs +4 -1
  61. package/shared/question-multi-select.mjs +16 -0
  62. package/shared/relay-stop-failure.mjs +15 -0
  63. package/shared/slash-command-guard.mjs +76 -0
  64. package/shared/steer-note.mjs +66 -0
  65. package/shared/steer-settle-failure.mjs +89 -0
  66. package/shared/steer-settle-markers.mjs +18 -0
  67. package/shared/worker-crash-guard.mjs +40 -25
  68. package/shared/worker-runtime/heartbeat.mjs +55 -1
  69. package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
  70. package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
  71. package/server/claude-worker/claude-session-test-harness.mjs +0 -250
  72. package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
  73. package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
  74. package/server/copilot-worker/fixtures/abort-turn.json +0 -588
  75. package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
  76. package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
  77. package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
  78. package/server/copilot-worker/fixtures/happy-turn.json +0 -566
  79. package/server/copilot-worker/fixtures/quota-turn.json +0 -647
  80. package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
  81. package/server/copilot-worker/fixtures/resume-turn.json +0 -700
  82. package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
  83. package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
  84. package/server/routes/messages-routes-test-harness.mjs +0 -111
  85. package/server/services/fixtures/claude-auth-stub.cmd +0 -1
  86. package/server/services/fixtures/claude-auth-stub.mjs +0 -76
  87. package/server/services/fixtures/claude-auth-stub.sh +0 -4
  88. package/server/services/fixtures/cli-install-stub.cmd +0 -1
  89. package/server/services/fixtures/cli-install-stub.mjs +0 -98
  90. package/server/services/fixtures/cli-install-stub.sh +0 -4
  91. package/server/services/fixtures/grok-stub.cmd +0 -1
  92. package/server/services/fixtures/grok-stub.mjs +0 -89
  93. package/server/services/fixtures/grok-stub.sh +0 -4
  94. package/shared/fixtures/copilot-catalog-raw-2026-09-07.json +0 -2363
@@ -1,382 +0,0 @@
1
- // Spawn-free stand-ins for the Copilot SDK, per DEVELOPING.md: no test in this
2
- // directory starts a runtime process, imports the installed SDK, or talks to a
3
- // relay. The injection seam is a constructor option on
4
- // `createCopilotSdkSessionRunner` (`startClientImpl`), not a module mock.
5
- //
6
- // Deliberately NOT named `*.test.mjs` so `node --test`'s glob skips it.
7
- import fs from 'node:fs';
8
- import path from 'node:path';
9
- import { fileURLToPath } from 'node:url';
10
-
11
- import { createCopilotSdkSessionRunner } from './copilot-sdk-session-process.mjs';
12
- import { renderMediaEmbedInstructionBlock } from '../../shared/media-embed-instructions.mjs';
13
-
14
- const FIXTURE_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fixtures');
15
-
16
- /** Load a scrubbed phase-0 event dump as an array of `SessionEvent`s. */
17
- export function loadFixture(name) {
18
- return JSON.parse(fs.readFileSync(path.join(FIXTURE_DIR, `${name}.json`), 'utf8'));
19
- }
20
-
21
- export function tick(ms = 5) {
22
- return new Promise((resolve) => setTimeout(resolve, ms));
23
- }
24
-
25
- export async function waitFor(predicate, { timeoutMs = 3000, label = 'condition' } = {}) {
26
- const deadline = Date.now() + timeoutMs;
27
- while (Date.now() < deadline) {
28
- if (await predicate()) return true;
29
- await tick(2);
30
- }
31
- throw new Error(`waitFor timed out: ${label}`);
32
- }
33
-
34
- /**
35
- * Records every relay call; `failRoutes` makes a route throw, like a 500.
36
- *
37
- * `routeResponses` maps a route to its response body, or to a function of
38
- * `(body, callIndexForThatRoute)` — which is how a test drives a route that
39
- * answers differently per attempt (`/api/continuation-turn`'s retry loop).
40
- * Unlisted routes answer `{}`, as before.
41
- */
42
- export function makeApiStub({ failRoutes = new Set(), routeResponses = {} } = {}) {
43
- const calls = [];
44
- const routeCounts = new Map();
45
- async function api(method, routePath, body) {
46
- calls.push({ method, routePath, body });
47
- const route = String(routePath).split('?')[0];
48
- if (failRoutes.has(route)) throw new Error(`stub route failure: ${route}`);
49
- const attempt = routeCounts.get(route) || 0;
50
- routeCounts.set(route, attempt + 1);
51
- const canned = routeResponses[route];
52
- if (typeof canned === 'function') return canned(body, attempt);
53
- return canned === undefined ? {} : canned;
54
- }
55
- api.calls = calls;
56
- api.bodiesFor = (routePath) => calls.filter((c) => c.routePath === routePath).map((c) => c.body);
57
- return api;
58
- }
59
-
60
- /**
61
- * An api stub that mints synthetic queue rows, so a test can exercise the
62
- * continuation path end to end. `POST /api/continuation-turn` answers with a
63
- * fresh `messageId` and the `attemptId` the row was dequeued under, exactly as
64
- * the relay route does.
65
- */
66
- export function makeContinuationApiStub({
67
- conversationId = 'conv-1',
68
- messageIds = ['cont-1', 'cont-2', 'cont-3'],
69
- ...options
70
- } = {}) {
71
- const minted = [];
72
- const stub = makeApiStub({
73
- ...options,
74
- routeResponses: {
75
- '/api/continuation-turn': (body, attempt) => {
76
- const messageId = messageIds[Math.min(attempt, messageIds.length - 1)];
77
- const attemptId = `attempt-${messageId}`;
78
- minted.push({ messageId, attemptId, body });
79
- return { ok: true, messageId, conversationId, attemptId };
80
- },
81
- ...(options.routeResponses || {}),
82
- },
83
- });
84
- stub.mintedContinuations = minted;
85
- return stub;
86
- }
87
-
88
- /**
89
- * A fake CopilotSession. `send()` accepts one `MessageOptions` object (the
90
- * real signature: there is no second parameter) and, like the real SDK,
91
- * resolves immediately — the turn's completion arrives on the event callback,
92
- * which is what `onSend` is for.
93
- *
94
- * The `rpc.model` surface mirrors SDK 1.0.13's, which the runner talks to
95
- * DIRECTLY (the facade's `setModel` discards the switch result). Defaults model
96
- * a healthy runtime: `switchTo` confirms the requested model immediately,
97
- * `setReasoningEffort` echoes the level, `list` answers with `modelCatalog`
98
- * (empty by default — effort validation then degrades to attempting the RPC).
99
- * `modelRpc.{switchTo,setReasoningEffort,list}` override per test, each called
100
- * as `(params, session)`.
101
- */
102
- export function createFakeCopilotSession({ config, onSend = null, onAbort = null, modelRpc = {} }) {
103
- const session = {
104
- sessionId: config?.sessionId || 'fake-session',
105
- config,
106
- sends: [],
107
- abortCalls: 0,
108
- setModelCalls: [],
109
- disconnected: false,
110
- emit(event) {
111
- config?.onEvent?.(event);
112
- },
113
- replay(events) {
114
- for (const event of events) session.emit(event);
115
- },
116
- async send(options) {
117
- session.sends.push(options);
118
- // Scheduled rather than synchronous so the runner's `await session.send`
119
- // resolves before the events land, matching the real ordering.
120
- setTimeout(() => { onSend?.(session, options); }, 0);
121
- return 'fake-message-id';
122
- },
123
- async abort() {
124
- session.abortCalls += 1;
125
- setTimeout(() => { onAbort?.(session); }, 0);
126
- },
127
- async setModel(model) {
128
- session.setModelCalls.push(model);
129
- },
130
- async disconnect() {
131
- session.disconnected = true;
132
- },
133
- };
134
- session.rpc = {
135
- model: {
136
- switchToCalls: [],
137
- effortCalls: [],
138
- listCalls: 0,
139
- async switchTo(params) {
140
- session.rpc.model.switchToCalls.push(params);
141
- if (modelRpc.switchTo) return modelRpc.switchTo(params, session);
142
- return { modelId: params.modelId };
143
- },
144
- async setReasoningEffort(params) {
145
- session.rpc.model.effortCalls.push(params);
146
- if (modelRpc.setReasoningEffort) return modelRpc.setReasoningEffort(params, session);
147
- return { reasoningEffort: params.reasoningEffort };
148
- },
149
- async list(params) {
150
- session.rpc.model.listCalls += 1;
151
- if (modelRpc.list) return modelRpc.list(params, session);
152
- return { list: Array.isArray(modelRpc.catalog) ? modelRpc.catalog : [] };
153
- },
154
- },
155
- };
156
- return session;
157
- }
158
-
159
- /**
160
- * The two ways `resumeSession` fails, which the runner MUST tell apart.
161
- *
162
- * - `missing` — the runtime has no state under this id. Shaped like the
163
- * real thing: the runtime throws `Session not found: <id>`
164
- * and JSON-RPC wraps it as a generic InternalError (-32603),
165
- * so the message is the only signal.
166
- * - `transient` — the call itself failed (connection dropped). Falling
167
- * through to `createSession` here would blank a live
168
- * conversation.
169
- */
170
- export function makeResumeFailure(flavor, sessionId = 'conv-1') {
171
- if (flavor === 'transient') {
172
- const error = new Error('Pending response rejected since connection got disposed');
173
- error.code = -32097;
174
- return error;
175
- }
176
- const error = new Error(`Request session.resume failed with message: Session not found: ${sessionId}`);
177
- error.code = -32603;
178
- return error;
179
- }
180
-
181
- /**
182
- * A fake CopilotClient. `resumeAvailable: false` models a brand-new
183
- * conversation (the runtime has no state to resume), which is what makes the
184
- * runner fall through to `createSession`. `resumeFailure: 'transient'` models
185
- * the RPC itself failing, which must NOT fall through.
186
- */
187
- export function createFakeCopilotClient({
188
- resumeAvailable = false,
189
- resumeFailure = 'missing',
190
- onSend = null,
191
- onAbort = null,
192
- // `rpc.model` behaviour for every session this client mints; see
193
- // `createFakeCopilotSession`.
194
- modelRpc = {},
195
- } = {}) {
196
- const client = {
197
- stopped: 0,
198
- resumeAttempts: [],
199
- createAttempts: [],
200
- sessions: [],
201
- resumeAvailable,
202
- resumeFailure,
203
- async resumeSession(sessionId, config) {
204
- client.resumeAttempts.push({ sessionId, config });
205
- if (!client.resumeAvailable) throw makeResumeFailure(client.resumeFailure, sessionId);
206
- const session = createFakeCopilotSession({ config, onSend, onAbort, modelRpc });
207
- session.resumed = true;
208
- client.sessions.push(session);
209
- // A session that exists once exists forever, so later reconnects resume.
210
- client.resumeAvailable = true;
211
- return session;
212
- },
213
- async createSession(config) {
214
- client.createAttempts.push(config);
215
- const session = createFakeCopilotSession({ config, onSend, onAbort, modelRpc });
216
- client.sessions.push(session);
217
- client.resumeAvailable = true;
218
- return session;
219
- },
220
- async stop() {
221
- client.stopped += 1;
222
- return [];
223
- },
224
- };
225
- Object.defineProperty(client, 'session', {
226
- get: () => client.sessions[client.sessions.length - 1] || null,
227
- });
228
- return client;
229
- }
230
-
231
- export const FAKE_SDK_PATHS = {
232
- sdkDir: '/opt/copilot/pkg/linux-x64/1.0.82/copilot-sdk',
233
- sdkEntry: '/opt/copilot/pkg/linux-x64/1.0.82/copilot-sdk/index.js',
234
- versionDir: '/opt/copilot/pkg/linux-x64/1.0.82',
235
- runtimeEntry: '/opt/copilot/pkg/linux-x64/1.0.82/app.js',
236
- version: '1.0.82',
237
- };
238
-
239
- /** Frozen delivery message; tests derive variants by spread. */
240
- export const baseMessage = Object.freeze({
241
- id: 'q-1',
242
- conversationId: 'conv-1',
243
- relayMode: 'agent',
244
- text: 'hello',
245
- model: 'gpt-5-mini',
246
- });
247
-
248
- /**
249
- * A stand-in for the relay question bridge.
250
- *
251
- * `answers` maps a matcher to a canned reply, so a test can drive `ask_user`
252
- * and ask-mode tool approvals without a relay. Every call is recorded.
253
- */
254
- export function makeFakeQuestionBridge({
255
- userInputAnswer = 'the human answer',
256
- userInputTimedOut = false,
257
- approve = true,
258
- approvalTimedOut = false,
259
- approvalFeedback = 'The user declined this action.',
260
- // Structured elicitation: the validated form submission the fake "relay"
261
- // hands back, or null for a card that produced none.
262
- structuredAnswer = null,
263
- structuredTimedOut = false,
264
- onAsk = null,
265
- } = {}) {
266
- const bridge = {
267
- userInputCalls: [],
268
- approvalCalls: [],
269
- structuredCalls: [],
270
- cancelledCount: 0,
271
- async askUserInput(request, options) {
272
- bridge.userInputCalls.push({ request, options });
273
- if (onAsk) await onAsk(request, options);
274
- const choices = Array.isArray(request?.choices) ? request.choices : [];
275
- const answer = typeof userInputAnswer === 'function' ? userInputAnswer(request) : userInputAnswer;
276
- return {
277
- answer,
278
- wasFreeform: userInputTimedOut ? true : !choices.includes(answer),
279
- timedOut: userInputTimedOut,
280
- };
281
- },
282
- async askToolApproval(request, options) {
283
- bridge.approvalCalls.push({ request, options });
284
- if (onAsk) await onAsk(request, options);
285
- return {
286
- approved: approvalTimedOut ? false : approve,
287
- answer: approve ? 'Approve' : 'Deny',
288
- feedback: approvalFeedback,
289
- timedOut: approvalTimedOut,
290
- description: 'a tool',
291
- };
292
- },
293
- async askStructured(spec, options) {
294
- bridge.structuredCalls.push({ spec, options });
295
- if (onAsk) await onAsk(spec, options);
296
- const answer = typeof structuredAnswer === 'function' ? structuredAnswer(spec) : structuredAnswer;
297
- return {
298
- structuredAnswer: structuredTimedOut ? null : answer,
299
- answer: '',
300
- timedOut: structuredTimedOut,
301
- };
302
- },
303
- async cancelPendingQuestions() {
304
- bridge.cancelledCount += 1;
305
- return 0;
306
- },
307
- pendingQuestionCount: () => 0,
308
- };
309
- return bridge;
310
- }
311
-
312
- /**
313
- * The relay-context prefix the runner prepends to every prompt, for tests that
314
- * assert on the prompt. `relayToolInstructions` defaults to '' in the harness,
315
- * so only the mode marker and the mode's standing instructions appear, and the
316
- * instructions only on the FIRST turn of a given relay mode.
317
- */
318
- export function expectedPromptPrefix(mode = 'agent', { includeInstructions = true } = {}) {
319
- const marker = `[Relay mode: ${mode}]`;
320
- if (!includeInstructions) return marker;
321
- const instructions = {
322
- agent: 'Proceed as an interactive coding agent and use tools as needed. If you need clarification, pause and ask through the web relay instead of stalling silently. These instructions remain in effect until relay mode changes.',
323
- plan: 'Draft a concise plan only. Use read-only inspection tools (glob, rg, view) only when they materially improve plan quality; otherwise draft from provided context. Do not edit repository files or run mutating commands unless the user explicitly asks for implementation. If clarification is required, pause and ask through the web relay. These instructions remain in effect until relay mode changes.',
324
- ask: 'Prioritize clarification questions before doing any implementation work. If the request is ambiguous or underspecified, pause and ask through the web relay before making assumptions. Do not make broad assumptions when a question would materially change the result. These instructions remain in effect until relay mode changes.',
325
- autopilot: 'Act directly on the request and use tools when needed. Keep moving unless user input is truly blocking. These instructions remain in effect until relay mode changes.',
326
- }[mode];
327
- // The guidance turns also carry the media-embed capability block, appended
328
- // by applyMediaEmbedInstructions in copilot-prompt-context.mjs.
329
- return `${marker} ${instructions} ${renderMediaEmbedInstructionBlock()}`;
330
- }
331
-
332
- /** The message body with the relay-context prefix in front, as sent. */
333
- export function promptWithPrefix(body, mode = 'agent', options = {}) {
334
- return `${expectedPromptPrefix(mode, options)} ${body}`;
335
- }
336
-
337
- export function makeRunner({
338
- stub,
339
- client,
340
- startWarning = null,
341
- runtimeVersion = '1.0.82',
342
- questionBridge = makeFakeQuestionBridge(),
343
- ...overrides
344
- } = {}) {
345
- const started = [];
346
- return {
347
- started,
348
- questionBridge,
349
- runner: createCopilotSdkSessionRunner({
350
- api: stub,
351
- sdkSessionId: 'conv-1',
352
- cwd: '/tmp/relay-fixture-workspace',
353
- resolvePathsImpl: () => FAKE_SDK_PATHS,
354
- // Spawn-free and relay-free by default: no test reads the real
355
- // relay-tools.md off disk or asks a relay whether previews are on.
356
- relayToolInstructions: '',
357
- getPreviewInstructionsImpl: () => '',
358
- createQuestionBridgeImpl: () => questionBridge,
359
- startClientImpl: async (options) => {
360
- started.push(options);
361
- return {
362
- sdk: {},
363
- client,
364
- paths: options.paths,
365
- // The real adapter never blocks client start on the version probe;
366
- // it hands back a promise the runner logs when it settles.
367
- versionReady: Promise.resolve({ runtimeVersion, versionSkewWarning: startWarning }),
368
- };
369
- },
370
- ...overrides,
371
- }),
372
- };
373
- }
374
-
375
- /**
376
- * The events of a fixture up to and including the first one matching `type`.
377
- * Lets a test replay "everything through session.idle" without hand-listing.
378
- */
379
- export function eventsThrough(events, type) {
380
- const index = events.findIndex((event) => event.type === type);
381
- return index === -1 ? events.slice() : events.slice(0, index + 1);
382
- }