@moxxy/sdk 0.11.0 → 0.13.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/dist/assert.d.ts +19 -0
- package/dist/assert.d.ts.map +1 -0
- package/dist/assert.js +29 -0
- package/dist/assert.js.map +1 -0
- package/dist/elision-state.d.ts.map +1 -1
- package/dist/elision-state.js +6 -0
- package/dist/elision-state.js.map +1 -1
- package/dist/events.d.ts +33 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/fs-utils.d.ts +8 -0
- package/dist/fs-utils.d.ts.map +1 -1
- package/dist/fs-utils.js +29 -0
- package/dist/fs-utils.js.map +1 -1
- package/dist/index.d.ts +11 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -2
- package/dist/index.js.map +1 -1
- package/dist/mode-helpers.d.ts +12 -0
- package/dist/mode-helpers.d.ts.map +1 -1
- package/dist/mode-helpers.js +105 -3
- package/dist/mode-helpers.js.map +1 -1
- package/dist/mode.d.ts +9 -0
- package/dist/mode.d.ts.map +1 -1
- package/dist/mode.js.map +1 -1
- package/dist/plugin.d.ts +10 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/provider-login-bridge.d.ts +53 -0
- package/dist/provider-login-bridge.d.ts.map +1 -0
- package/dist/provider-login-bridge.js +95 -0
- package/dist/provider-login-bridge.js.map +1 -0
- package/dist/provider.d.ts +53 -0
- package/dist/provider.d.ts.map +1 -1
- package/dist/semver.d.ts +16 -0
- package/dist/semver.d.ts.map +1 -0
- package/dist/semver.js +28 -0
- package/dist/semver.js.map +1 -0
- package/dist/subagent.d.ts +6 -0
- package/dist/subagent.d.ts.map +1 -1
- package/dist/surface.d.ts +159 -0
- package/dist/surface.d.ts.map +1 -0
- package/dist/surface.js +27 -0
- package/dist/surface.js.map +1 -0
- package/dist/tool-display.d.ts +73 -0
- package/dist/tool-display.d.ts.map +1 -0
- package/dist/tool-display.js +66 -0
- package/dist/tool-display.js.map +1 -0
- package/dist/view-renderer.d.ts +6 -0
- package/dist/view-renderer.d.ts.map +1 -1
- package/dist/view-renderer.js +18 -1
- package/dist/view-renderer.js.map +1 -1
- package/package.json +7 -3
- package/src/assert.test.ts +35 -0
- package/src/assert.ts +28 -0
- package/src/elision-state.ts +5 -0
- package/src/events.ts +36 -0
- package/src/fs-utils.test.ts +47 -1
- package/src/fs-utils.ts +31 -0
- package/src/index.ts +51 -2
- package/src/loop-helpers.test.ts +40 -0
- package/src/mode-helpers.ts +114 -3
- package/src/mode.ts +7 -0
- package/src/plugin.ts +10 -1
- package/src/provider-login-bridge.test.ts +78 -0
- package/src/provider-login-bridge.ts +105 -0
- package/src/provider.ts +45 -2
- package/src/semver.test.ts +43 -0
- package/src/semver.ts +27 -0
- package/src/subagent.ts +6 -0
- package/src/surface.ts +173 -0
- package/src/tool-display.test.ts +71 -0
- package/src/tool-display.ts +118 -0
- package/src/view-renderer.test.ts +57 -0
- package/src/view-renderer.ts +18 -1
package/src/fs-utils.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { mkdtemp, readFile, readdir, stat } from 'node:fs/promises';
|
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
-
import { moxxyHome, moxxyPath, writeFileAtomic } from './fs-utils.js';
|
|
5
|
+
import { moxxyHome, moxxyPath, writeFileAtomic, writeFileAtomicSync } from './fs-utils.js';
|
|
6
6
|
|
|
7
7
|
describe('writeFileAtomic', () => {
|
|
8
8
|
let dir: string;
|
|
@@ -41,6 +41,52 @@ describe('writeFileAtomic', () => {
|
|
|
41
41
|
});
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
+
describe('writeFileAtomicSync', () => {
|
|
45
|
+
let dir: string;
|
|
46
|
+
beforeEach(async () => {
|
|
47
|
+
dir = await mkdtemp(join(tmpdir(), 'moxxy-fss-'));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('writes content and creates missing parent dirs', async () => {
|
|
51
|
+
const target = join(dir, 'nested', 'deep', 'file.json');
|
|
52
|
+
writeFileAtomicSync(target, '{"a":1}');
|
|
53
|
+
expect(await readFile(target, 'utf8')).toBe('{"a":1}');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('overwrites an existing file and leaves no temp file behind', async () => {
|
|
57
|
+
const target = join(dir, 'file.txt');
|
|
58
|
+
writeFileAtomicSync(target, 'first');
|
|
59
|
+
writeFileAtomicSync(target, 'second');
|
|
60
|
+
expect(await readFile(target, 'utf8')).toBe('second');
|
|
61
|
+
const leftovers = (await readdir(dir)).filter((n) => n.includes('.tmp'));
|
|
62
|
+
expect(leftovers).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('enforces the requested mode past umask', async () => {
|
|
66
|
+
const target = join(dir, 'secret.json');
|
|
67
|
+
writeFileAtomicSync(target, 'shh', { mode: 0o600 });
|
|
68
|
+
const mode = (await stat(target)).mode & 0o777;
|
|
69
|
+
expect(mode).toBe(0o600);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('writes binary data unchanged', async () => {
|
|
73
|
+
const target = join(dir, 'bytes.bin');
|
|
74
|
+
writeFileAtomicSync(target, new Uint8Array([0, 1, 2, 255]));
|
|
75
|
+
expect(Array.from(await readFile(target))).toEqual([0, 1, 2, 255]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('cleans up the temp file and throws when rename fails', async () => {
|
|
79
|
+
// A directory in place of the final file makes renameSync fail; the temp
|
|
80
|
+
// file must be removed and the error surfaced rather than swallowed.
|
|
81
|
+
const { mkdir } = await import('node:fs/promises');
|
|
82
|
+
const target = join(dir, 'as-dir');
|
|
83
|
+
await mkdir(target);
|
|
84
|
+
expect(() => writeFileAtomicSync(target, 'nope')).toThrow();
|
|
85
|
+
const leftovers = (await readdir(dir)).filter((n) => n.includes('.tmp'));
|
|
86
|
+
expect(leftovers).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
44
90
|
describe('moxxyHome / moxxyPath', () => {
|
|
45
91
|
const original = process.env.MOXXY_HOME;
|
|
46
92
|
afterEach(() => {
|
package/src/fs-utils.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmodSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
3
|
import { chmod, mkdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
4
|
import { homedir } from 'node:os';
|
|
4
5
|
import { dirname, join } from 'node:path';
|
|
@@ -40,6 +41,36 @@ export async function writeFileAtomic(
|
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Synchronous twin of {@link writeFileAtomic}, for the few call sites that
|
|
46
|
+
* cannot be async — boot/bootstrap and self-update gates that run before the
|
|
47
|
+
* event loop is the right tool. Same crash-atomic guarantee: write a unique
|
|
48
|
+
* sibling temp file, then `rename` it over the target. Hand-rolled
|
|
49
|
+
* `mkdirSync + writeFileSync(tmp) + renameSync` copies should call this instead.
|
|
50
|
+
*/
|
|
51
|
+
export function writeFileAtomicSync(
|
|
52
|
+
target: string,
|
|
53
|
+
data: string | Uint8Array,
|
|
54
|
+
opts: WriteFileAtomicOptions = {},
|
|
55
|
+
): void {
|
|
56
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
57
|
+
const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
58
|
+
try {
|
|
59
|
+
writeFileSync(tmp, data, { encoding: opts.encoding ?? 'utf8' });
|
|
60
|
+
// chmod explicitly: writeFileSync's mode option is masked by umask, but a
|
|
61
|
+
// 0o600 secret file must be exactly 0o600 regardless of the host umask.
|
|
62
|
+
if (opts.mode != null) chmodSync(tmp, opts.mode);
|
|
63
|
+
renameSync(tmp, target);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
try {
|
|
66
|
+
rmSync(tmp, { force: true });
|
|
67
|
+
} catch {
|
|
68
|
+
/* best-effort cleanup */
|
|
69
|
+
}
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
43
74
|
/**
|
|
44
75
|
* The moxxy home directory: `$MOXXY_HOME` when set, else `~/.moxxy`. Single
|
|
45
76
|
* source of truth so the env override is honored uniformly — previously half
|
package/src/index.ts
CHANGED
|
@@ -19,6 +19,8 @@ export type {
|
|
|
19
19
|
UserPromptAttachment,
|
|
20
20
|
AssistantChunkEvent,
|
|
21
21
|
AssistantMessageEvent,
|
|
22
|
+
ReasoningChunkEvent,
|
|
23
|
+
ReasoningMessageEvent,
|
|
22
24
|
ToolCallRequestedEvent,
|
|
23
25
|
ToolCallApprovedEvent,
|
|
24
26
|
ToolCallDeniedEvent,
|
|
@@ -136,7 +138,30 @@ export type {
|
|
|
136
138
|
ProviderOAuthStatus,
|
|
137
139
|
ProviderAuthDescriptor,
|
|
138
140
|
} from './provider.js';
|
|
141
|
+
export {
|
|
142
|
+
encodeLoginPrompt,
|
|
143
|
+
decodeLoginPrompt,
|
|
144
|
+
createLoginStreamScanner,
|
|
145
|
+
} from './provider-login-bridge.js';
|
|
146
|
+
export type { LoginPromptRequest, LoginStreamItem } from './provider-login-bridge.js';
|
|
139
147
|
export type { CacheStrategyDef, CacheStrategyContext } from './cache-strategy.js';
|
|
148
|
+
export type {
|
|
149
|
+
DiffLine,
|
|
150
|
+
DiffHunk,
|
|
151
|
+
DiffRow,
|
|
152
|
+
FileDiffDisplay,
|
|
153
|
+
ToolDisplay,
|
|
154
|
+
ToolDisplayResult,
|
|
155
|
+
} from './tool-display.js';
|
|
156
|
+
export {
|
|
157
|
+
isToolDisplayResult,
|
|
158
|
+
isToolDisplay,
|
|
159
|
+
isFileDiffDisplay,
|
|
160
|
+
fileDiffSummary,
|
|
161
|
+
fileDiffVerb,
|
|
162
|
+
diffGutterNo,
|
|
163
|
+
toDiffRows,
|
|
164
|
+
} from './tool-display.js';
|
|
140
165
|
export type {
|
|
141
166
|
ViewNode,
|
|
142
167
|
ViewAction,
|
|
@@ -148,7 +173,7 @@ export type {
|
|
|
148
173
|
ViewTagSpec,
|
|
149
174
|
ViewRendererDef,
|
|
150
175
|
} from './view-renderer.js';
|
|
151
|
-
export { VIEW_PRIMITIVES, VIEW_COMPONENTS, DEFAULT_VIEW_TAGS, isSafeViewUrl } from './view-renderer.js';
|
|
176
|
+
export { VIEW_PRIMITIVES, VIEW_COMPONENTS, DEFAULT_VIEW_TAGS, isSafeViewUrl, countNodes } from './view-renderer.js';
|
|
152
177
|
export type {
|
|
153
178
|
TunnelProviderDef,
|
|
154
179
|
TunnelHandle,
|
|
@@ -158,8 +183,16 @@ export type {
|
|
|
158
183
|
} from './tunnel.js';
|
|
159
184
|
export { spawnCliTunnel, isCliTunnelAvailable } from './tunnel.js';
|
|
160
185
|
export { isRetryableError, toFriendlyError, zodToJsonSchema, estimateTextTokens, type StopReason } from './provider-utils.js';
|
|
161
|
-
export {
|
|
186
|
+
export {
|
|
187
|
+
writeFileAtomic,
|
|
188
|
+
writeFileAtomicSync,
|
|
189
|
+
moxxyHome,
|
|
190
|
+
moxxyPath,
|
|
191
|
+
type WriteFileAtomicOptions,
|
|
192
|
+
} from './fs-utils.js';
|
|
162
193
|
export { createMutex, type Mutex } from './mutex.js';
|
|
194
|
+
export { assertNever } from './assert.js';
|
|
195
|
+
export { compareSemver, parseSemverCore } from './semver.js';
|
|
163
196
|
export { readRequestBody, bearerTokenMatches } from './http-utils.js';
|
|
164
197
|
export {
|
|
165
198
|
resolveChannelToken,
|
|
@@ -334,6 +367,22 @@ export type {
|
|
|
334
367
|
export type { EmbeddingProvider, EmbedderDef } from './embedding.js';
|
|
335
368
|
export { CachedEmbeddingProvider } from './embedding-cache.js';
|
|
336
369
|
|
|
370
|
+
export { defineSurface } from './surface.js';
|
|
371
|
+
export type {
|
|
372
|
+
SurfaceKind,
|
|
373
|
+
SurfaceDataMessage,
|
|
374
|
+
SurfaceInputMessage,
|
|
375
|
+
SurfaceSize,
|
|
376
|
+
SurfaceInstance,
|
|
377
|
+
SurfaceAvailability,
|
|
378
|
+
SurfaceContext,
|
|
379
|
+
SurfaceDef,
|
|
380
|
+
SurfaceRegistry,
|
|
381
|
+
SurfaceInfo,
|
|
382
|
+
OpenSurfaceResult,
|
|
383
|
+
SurfaceHost,
|
|
384
|
+
} from './surface.js';
|
|
385
|
+
|
|
337
386
|
export type {
|
|
338
387
|
RequirementKind,
|
|
339
388
|
RequirementState,
|
package/src/loop-helpers.test.ts
CHANGED
|
@@ -151,6 +151,46 @@ describe('projectMessagesFromLog', () => {
|
|
|
151
151
|
content: [{ type: 'text', text: 'running it now' }],
|
|
152
152
|
});
|
|
153
153
|
});
|
|
154
|
+
|
|
155
|
+
it('projects only `forModel` for a rich tool result (file diff), never the display payload', () => {
|
|
156
|
+
// A Write/Edit returns { forModel, display:{kind:'file-diff', hunks:[…]} }.
|
|
157
|
+
// The model must see ONLY the short summary — the bulky hunks stay
|
|
158
|
+
// channel-side, so the context window never carries the full diff.
|
|
159
|
+
const display = {
|
|
160
|
+
forModel: 'edited /repo/a.ts — added 1 line, removed 1 line',
|
|
161
|
+
display: {
|
|
162
|
+
kind: 'file-diff' as const,
|
|
163
|
+
path: 'a.ts',
|
|
164
|
+
mode: 'update' as const,
|
|
165
|
+
added: 1,
|
|
166
|
+
removed: 1,
|
|
167
|
+
hunks: [
|
|
168
|
+
{
|
|
169
|
+
oldStart: 1,
|
|
170
|
+
oldLines: 1,
|
|
171
|
+
newStart: 1,
|
|
172
|
+
newLines: 1,
|
|
173
|
+
lines: [{ kind: 'del' as const, text: 'before', oldNo: 1 }, { kind: 'add' as const, text: 'after', newNo: 1 }],
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
const log = reader([
|
|
179
|
+
event(0, { type: 'user_prompt', turnId: t1, source: 'user', text: 'edit it' }),
|
|
180
|
+
event(1, { type: 'tool_call_requested', turnId: t1, source: 'model', callId: 'c1', name: 'Edit', input: {} }),
|
|
181
|
+
event(2, { type: 'tool_result', turnId: t1, source: 'tool', callId: 'c1', ok: true, output: display }),
|
|
182
|
+
]);
|
|
183
|
+
|
|
184
|
+
const messages = projectMessagesFromLog({ log });
|
|
185
|
+
const toolResult = messages.find((m) => m.role === 'tool_result');
|
|
186
|
+
const block = toolResult!.content[0]!;
|
|
187
|
+
expect(block).toMatchObject({ type: 'tool_result', content: display.forModel });
|
|
188
|
+
// The hunks / file content must NOT leak into the projected text.
|
|
189
|
+
if (block.type === 'tool_result') {
|
|
190
|
+
expect(block.content).not.toContain('hunks');
|
|
191
|
+
expect(block.content).not.toContain('before');
|
|
192
|
+
}
|
|
193
|
+
});
|
|
154
194
|
});
|
|
155
195
|
|
|
156
196
|
function reader(events: ReadonlyArray<MoxxyEvent>): EventLogReader {
|
package/src/mode-helpers.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
toolResultStub,
|
|
12
12
|
toolResultStubbed,
|
|
13
13
|
} from './elision-state.js';
|
|
14
|
+
import { isToolDisplayResult } from './tool-display.js';
|
|
14
15
|
import { applyLazyTools } from './tool-gating.js';
|
|
15
16
|
import { runCompactionIfNeeded } from './compactor-helpers.js';
|
|
16
17
|
import { runElisionIfNeeded } from './elision-helpers.js';
|
|
@@ -196,6 +197,14 @@ export function projectMessages(
|
|
|
196
197
|
|
|
197
198
|
let pendingAssistant: ProviderMessage | null = null;
|
|
198
199
|
let pendingAssistantMaxSeq = -1;
|
|
200
|
+
// Reasoning block awaiting attachment to the current assistant turn. Only set
|
|
201
|
+
// for REPLAYABLE reasoning (Anthropic signature / redacted-or-Codex encrypted
|
|
202
|
+
// blob) — render-only reasoning is never sent back. Attached as content[0] of
|
|
203
|
+
// the turn's assistant message (Anthropic requires the signed thinking block
|
|
204
|
+
// first on an interleaved-thinking tool-use continuation; a missing/unsigned
|
|
205
|
+
// one is a hard 400). Dropped at any turn/compaction boundary it doesn't reach.
|
|
206
|
+
let pendingReasoning: Extract<ContentBlock, { type: 'reasoning' }> | null = null;
|
|
207
|
+
let pendingReasoningSeq = -1;
|
|
199
208
|
const flush = (): void => {
|
|
200
209
|
if (!pendingAssistant) return;
|
|
201
210
|
const flushed = pendingAssistant;
|
|
@@ -237,6 +246,7 @@ export function projectMessages(
|
|
|
237
246
|
if (!emittedCompactions.has(compaction)) {
|
|
238
247
|
emittedCompactions.add(compaction);
|
|
239
248
|
flush();
|
|
249
|
+
pendingReasoning = null;
|
|
240
250
|
messages.push({
|
|
241
251
|
role: 'user',
|
|
242
252
|
content: [{ type: 'text', text: `[summary of earlier turns]\n${compaction.summary}` }],
|
|
@@ -249,6 +259,7 @@ export function projectMessages(
|
|
|
249
259
|
switch (e.type) {
|
|
250
260
|
case 'user_prompt': {
|
|
251
261
|
flush();
|
|
262
|
+
pendingReasoning = null;
|
|
252
263
|
// Elided + conversational: collapse to a stub (anchor/tiny kept full).
|
|
253
264
|
if (conversationalStubbed(e, el)) {
|
|
254
265
|
messages.push({
|
|
@@ -286,9 +297,26 @@ export function projectMessages(
|
|
|
286
297
|
recordStable(e.seq);
|
|
287
298
|
break;
|
|
288
299
|
}
|
|
300
|
+
case 'reasoning_message': {
|
|
301
|
+
// Render-only reasoning (no signature/encrypted) is never replayed —
|
|
302
|
+
// it exists only for the live/scrollback "Thinking" view. Replayable
|
|
303
|
+
// reasoning is stashed for content[0] of this turn's assistant message.
|
|
304
|
+
if (e.signature || e.encrypted) {
|
|
305
|
+
pendingReasoning = {
|
|
306
|
+
type: 'reasoning',
|
|
307
|
+
text: e.content,
|
|
308
|
+
...(e.signature ? { signature: e.signature } : {}),
|
|
309
|
+
...(e.redacted ? { redacted: true } : {}),
|
|
310
|
+
...(e.encrypted ? { encrypted: e.encrypted } : {}),
|
|
311
|
+
};
|
|
312
|
+
pendingReasoningSeq = e.seq;
|
|
313
|
+
}
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
289
316
|
case 'assistant_message':
|
|
290
317
|
flush();
|
|
291
318
|
if (conversationalStubbed(e, el)) {
|
|
319
|
+
pendingReasoning = null;
|
|
292
320
|
messages.push({
|
|
293
321
|
role: 'assistant',
|
|
294
322
|
content: [{ type: 'text', text: conversationalStub('assistant', e.seq) }],
|
|
@@ -303,14 +331,31 @@ export function projectMessages(
|
|
|
303
331
|
// tool_use blocks are projected from tool_call_requested events —
|
|
304
332
|
// which also un-wedges historical logs that already contain one.
|
|
305
333
|
if (e.content.trim().length === 0) {
|
|
334
|
+
pendingReasoning = null;
|
|
306
335
|
recordStable(e.seq);
|
|
307
336
|
break;
|
|
308
337
|
}
|
|
309
|
-
|
|
338
|
+
{
|
|
339
|
+
const content: Array<ProviderMessage['content'][number]> = [];
|
|
340
|
+
if (pendingReasoning) {
|
|
341
|
+
content.push(pendingReasoning);
|
|
342
|
+
pendingReasoning = null;
|
|
343
|
+
}
|
|
344
|
+
content.push({ type: 'text', text: e.content });
|
|
345
|
+
messages.push({ role: 'assistant', content });
|
|
346
|
+
}
|
|
310
347
|
recordStable(e.seq);
|
|
311
348
|
break;
|
|
312
349
|
case 'tool_call_requested': {
|
|
313
|
-
pendingAssistant
|
|
350
|
+
if (!pendingAssistant) {
|
|
351
|
+
// Seed the assistant turn so the signed reasoning block is content[0],
|
|
352
|
+
// ahead of every tool_use (Anthropic's interleaved-thinking ordering).
|
|
353
|
+
pendingAssistant = { role: 'assistant', content: pendingReasoning ? [pendingReasoning] : [] };
|
|
354
|
+
if (pendingReasoning) {
|
|
355
|
+
pendingAssistantMaxSeq = Math.max(pendingAssistantMaxSeq, pendingReasoningSeq);
|
|
356
|
+
pendingReasoning = null;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
314
359
|
pendingAssistantMaxSeq = Math.max(pendingAssistantMaxSeq, e.seq);
|
|
315
360
|
(pendingAssistant.content as Array<ProviderMessage['content'][number]>).push({
|
|
316
361
|
type: 'tool_use',
|
|
@@ -330,6 +375,10 @@ export function projectMessages(
|
|
|
330
375
|
text = toolResultStub(e.callId, toolResultBytes(e.output), recalled);
|
|
331
376
|
} else if (e.error) {
|
|
332
377
|
text = `[error:${e.error.kind}] ${e.error.message}`;
|
|
378
|
+
} else if (isToolDisplayResult(e.output)) {
|
|
379
|
+
// Rich result (e.g. a file diff): the model only needs the short
|
|
380
|
+
// `forModel` summary — the structured `display` is for channels.
|
|
381
|
+
text = e.output.forModel;
|
|
333
382
|
} else {
|
|
334
383
|
text = typeof e.output === 'string' ? e.output : JSON.stringify(e.output ?? '');
|
|
335
384
|
}
|
|
@@ -361,6 +410,18 @@ export interface StreamResult {
|
|
|
361
410
|
readonly error: { readonly message: string; readonly retryable: boolean } | null;
|
|
362
411
|
/** Token usage reported by the provider on `message_end`, including cache hits/writes. */
|
|
363
412
|
readonly usage?: TokenUsage;
|
|
413
|
+
/**
|
|
414
|
+
* Reasoning/thinking summary for this provider call, when the model emitted
|
|
415
|
+
* any. The mode emits it as a `reasoning_message` event (so it persists and
|
|
416
|
+
* round-trips). `signature`/`encrypted` carry Anthropic's signed thinking
|
|
417
|
+
* block / redacted blob; `redacted` marks display-suppressed reasoning.
|
|
418
|
+
*/
|
|
419
|
+
readonly reasoning?: {
|
|
420
|
+
readonly text: string;
|
|
421
|
+
readonly signature?: string;
|
|
422
|
+
readonly redacted?: boolean;
|
|
423
|
+
readonly encrypted?: string;
|
|
424
|
+
};
|
|
364
425
|
}
|
|
365
426
|
|
|
366
427
|
/**
|
|
@@ -431,12 +492,17 @@ export async function collectProviderStream(
|
|
|
431
492
|
// message-derived system text. Prefilling it would duplicate the prompt.
|
|
432
493
|
// It stays as the side channel `onBeforeProviderCall` hooks use to inject
|
|
433
494
|
// per-request system text (e.g. the memory consolidation nudge).
|
|
495
|
+
// Forward the per-provider reasoning preference, but only when THIS model
|
|
496
|
+
// advertises `supportsReasoning` — providers ignore the knob otherwise, but
|
|
497
|
+
// gating here keeps requests clean and avoids unsupported-param errors.
|
|
498
|
+
const reqReasoning = descriptor?.supportsReasoning ? ctx.reasoning : undefined;
|
|
434
499
|
const req = {
|
|
435
500
|
model: ctx.model,
|
|
436
501
|
messages: effectiveMessages,
|
|
437
502
|
...(toolList ? { tools: toolList } : {}),
|
|
438
503
|
...(cacheHints && cacheHints.length > 0 ? { cacheHints } : {}),
|
|
439
504
|
...(opts.maxTokens !== undefined ? { maxTokens: opts.maxTokens } : {}),
|
|
505
|
+
...(reqReasoning ? { reasoning: reqReasoning } : {}),
|
|
440
506
|
signal: ctx.signal,
|
|
441
507
|
};
|
|
442
508
|
const transformed = await ctx.hooks.dispatchBeforeProviderCall(req, {
|
|
@@ -453,6 +519,14 @@ export async function collectProviderStream(
|
|
|
453
519
|
let stopReason: StopReason = 'end_turn';
|
|
454
520
|
let error: StreamResult['error'] = null;
|
|
455
521
|
let usage: TokenUsage | undefined;
|
|
522
|
+
// Reasoning/thinking accumulation for this single provider call. Emitted as a
|
|
523
|
+
// finalized `reasoning_message` by the mode (turn-iterator / goal-loop) so it
|
|
524
|
+
// persists and round-trips; `signature`/`encrypted` carry Anthropic's signed
|
|
525
|
+
// thinking block / redacted blob for replay.
|
|
526
|
+
let reasoningText = '';
|
|
527
|
+
let reasoningSignature: string | undefined;
|
|
528
|
+
let reasoningRedacted = false;
|
|
529
|
+
let reasoningEncrypted: string | undefined;
|
|
456
530
|
|
|
457
531
|
let stream: AsyncIterable<ProviderEvent>;
|
|
458
532
|
try {
|
|
@@ -498,6 +572,25 @@ export async function collectProviderStream(
|
|
|
498
572
|
error = { message: event.message, retryable: event.retryable };
|
|
499
573
|
break;
|
|
500
574
|
}
|
|
575
|
+
case 'reasoning_delta': {
|
|
576
|
+
reasoningText += event.delta;
|
|
577
|
+
// Live preview only — parallels `assistant_chunk`; renderers
|
|
578
|
+
// accumulate ephemerally and clear on the finalized reasoning_message.
|
|
579
|
+
await ctx.emit({
|
|
580
|
+
type: 'reasoning_chunk',
|
|
581
|
+
sessionId: ctx.sessionId,
|
|
582
|
+
turnId: ctx.turnId,
|
|
583
|
+
source: 'model',
|
|
584
|
+
delta: event.delta,
|
|
585
|
+
});
|
|
586
|
+
break;
|
|
587
|
+
}
|
|
588
|
+
case 'reasoning_signature': {
|
|
589
|
+
if (event.signature) reasoningSignature = event.signature;
|
|
590
|
+
if (event.encrypted) reasoningEncrypted = event.encrypted;
|
|
591
|
+
if (event.redacted) reasoningRedacted = true;
|
|
592
|
+
break;
|
|
593
|
+
}
|
|
501
594
|
case 'message_start':
|
|
502
595
|
case 'tool_use_delta':
|
|
503
596
|
default:
|
|
@@ -516,7 +609,25 @@ export async function collectProviderStream(
|
|
|
516
609
|
if (!partial.name) continue;
|
|
517
610
|
finalToolUses.push({ id, name: partial.name, input: partial.input ?? {} });
|
|
518
611
|
}
|
|
519
|
-
|
|
612
|
+
// Surface reasoning when there's visible text OR an opaque blob to replay
|
|
613
|
+
// (a redacted_thinking block has no text but must still round-trip).
|
|
614
|
+
const reasoning =
|
|
615
|
+
reasoningText.trim().length > 0 || reasoningEncrypted
|
|
616
|
+
? {
|
|
617
|
+
text: reasoningText,
|
|
618
|
+
...(reasoningSignature ? { signature: reasoningSignature } : {}),
|
|
619
|
+
...(reasoningRedacted ? { redacted: true } : {}),
|
|
620
|
+
...(reasoningEncrypted ? { encrypted: reasoningEncrypted } : {}),
|
|
621
|
+
}
|
|
622
|
+
: undefined;
|
|
623
|
+
return {
|
|
624
|
+
text,
|
|
625
|
+
toolUses: finalToolUses,
|
|
626
|
+
stopReason,
|
|
627
|
+
error,
|
|
628
|
+
...(usage ? { usage } : {}),
|
|
629
|
+
...(reasoning ? { reasoning } : {}),
|
|
630
|
+
};
|
|
520
631
|
}
|
|
521
632
|
|
|
522
633
|
/**
|
package/src/mode.ts
CHANGED
|
@@ -77,6 +77,13 @@ export interface ModeContext {
|
|
|
77
77
|
readonly elision?: ElisionSettings;
|
|
78
78
|
/** When true, send only always-on + loaded tool schemas; index the rest. */
|
|
79
79
|
readonly lazyTools?: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* Per-provider reasoning/thinking preference, resolved from the active
|
|
82
|
+
* provider's config at session build. Forwarded to the provider as
|
|
83
|
+
* `ProviderRequest.reasoning` by {@link collectProviderStream}, gated on the
|
|
84
|
+
* active model's `supportsReasoning`. Absent/false → reasoning off.
|
|
85
|
+
*/
|
|
86
|
+
readonly reasoning?: { readonly effort?: 'low' | 'medium' | 'high' } | boolean;
|
|
80
87
|
readonly permissions: PermissionResolver;
|
|
81
88
|
/**
|
|
82
89
|
* Optional generic "ask the user a question" gate. Any loop strategy can
|
package/src/plugin.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type { LifecycleHooks } from './hooks.js';
|
|
|
9
9
|
import type { ModeDef } from './mode.js';
|
|
10
10
|
import type { ProviderDef } from './provider.js';
|
|
11
11
|
import type { MoxxyRequirement } from './requirements.js';
|
|
12
|
+
import type { SurfaceDef } from './surface.js';
|
|
12
13
|
import type { ToolDef } from './tool.js';
|
|
13
14
|
import type { TranscriberDef } from './transcriber.js';
|
|
14
15
|
import type { SynthesizerDef } from './synthesizer.js';
|
|
@@ -16,7 +17,7 @@ import type { ViewRendererDef } from './view-renderer.js';
|
|
|
16
17
|
import type { TunnelProviderDef } from './tunnel.js';
|
|
17
18
|
import type { WorkflowExecutorDef } from './workflow.js';
|
|
18
19
|
|
|
19
|
-
export type PluginKind = 'tools' | 'provider' | 'mode' | 'compactor' | 'cache-strategy' | 'view-renderer' | 'tunnel-provider' | 'mcp' | 'cli' | 'channel' | 'hooks' | 'agent' | 'command' | 'transcriber' | 'synthesizer' | 'embedder' | 'isolator' | 'workflow-executor';
|
|
20
|
+
export type PluginKind = 'tools' | 'provider' | 'mode' | 'compactor' | 'cache-strategy' | 'view-renderer' | 'tunnel-provider' | 'mcp' | 'cli' | 'channel' | 'surface' | 'hooks' | 'agent' | 'command' | 'transcriber' | 'synthesizer' | 'embedder' | 'isolator' | 'workflow-executor';
|
|
20
21
|
|
|
21
22
|
export interface PluginSpec {
|
|
22
23
|
readonly name: string;
|
|
@@ -44,6 +45,14 @@ export interface PluginSpec {
|
|
|
44
45
|
*/
|
|
45
46
|
readonly tunnelProviders?: ReadonlyArray<TunnelProviderDef>;
|
|
46
47
|
readonly channels?: ReadonlyArray<ChannelDef>;
|
|
48
|
+
/**
|
|
49
|
+
* Interactive surfaces contributed by the plugin — long-lived panes a human
|
|
50
|
+
* and the agent drive together (an embedded terminal, an in-window browser).
|
|
51
|
+
* Registered into the session's `SurfaceRegistry`; the runner exposes them to
|
|
52
|
+
* thin clients over the `surface.*` protocol family. The plugin's own tools
|
|
53
|
+
* reach the same underlying resource through module state. See {@link SurfaceDef}.
|
|
54
|
+
*/
|
|
55
|
+
readonly surfaces?: ReadonlyArray<SurfaceDef>;
|
|
47
56
|
/**
|
|
48
57
|
* Speech-to-text backends contributed by the plugin. Selected by name via
|
|
49
58
|
* `session.transcribers.setActive(name)`; channels with audio input use
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
createLoginStreamScanner,
|
|
4
|
+
decodeLoginPrompt,
|
|
5
|
+
encodeLoginPrompt,
|
|
6
|
+
type LoginStreamItem,
|
|
7
|
+
} from './provider-login-bridge.js';
|
|
8
|
+
|
|
9
|
+
describe('encode/decode login prompt', () => {
|
|
10
|
+
it('round-trips a request', () => {
|
|
11
|
+
const marker = encodeLoginPrompt({ question: 'Paste token:', mask: true });
|
|
12
|
+
// NUL-bracketed.
|
|
13
|
+
expect(marker.startsWith('\u0000')).toBe(true);
|
|
14
|
+
expect(marker.endsWith('\u0000')).toBe(true);
|
|
15
|
+
const inner = marker.slice(1, -1);
|
|
16
|
+
expect(decodeLoginPrompt(inner)).toEqual({ question: 'Paste token:', mask: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('rejects non-marker segments', () => {
|
|
20
|
+
expect(decodeLoginPrompt('just text')).toBeNull();
|
|
21
|
+
expect(decodeLoginPrompt('{"tag":"other","question":"x"}')).toBeNull();
|
|
22
|
+
expect(decodeLoginPrompt('{not json')).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** Drain a scanner across the given chunks into a flat item list. */
|
|
27
|
+
function scanAll(chunks: string[]): LoginStreamItem[] {
|
|
28
|
+
const scanner = createLoginStreamScanner();
|
|
29
|
+
return chunks.flatMap((c) => [...scanner.push(c)]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('createLoginStreamScanner', () => {
|
|
33
|
+
it('passes plain output straight through', () => {
|
|
34
|
+
expect(scanAll(['hello world'])).toEqual([{ type: 'output', text: 'hello world' }]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('extracts a marker with surrounding output', () => {
|
|
38
|
+
const stream = 'opening browser\n' + encodeLoginPrompt({ question: 'Paste code:', mask: false }) + 'done\n';
|
|
39
|
+
expect(scanAll([stream])).toEqual([
|
|
40
|
+
{ type: 'output', text: 'opening browser\n' },
|
|
41
|
+
{ type: 'prompt', prompt: { question: 'Paste code:', mask: false } },
|
|
42
|
+
{ type: 'output', text: 'done\n' },
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('reassembles a marker split across chunks', () => {
|
|
47
|
+
const marker = encodeLoginPrompt({ question: 'Paste token:', mask: true });
|
|
48
|
+
const mid = Math.floor(marker.length / 2);
|
|
49
|
+
const items = scanAll(['prefix', marker.slice(0, mid), marker.slice(mid), 'suffix']);
|
|
50
|
+
expect(items).toEqual([
|
|
51
|
+
{ type: 'output', text: 'prefix' },
|
|
52
|
+
{ type: 'prompt', prompt: { question: 'Paste token:', mask: true } },
|
|
53
|
+
{ type: 'output', text: 'suffix' },
|
|
54
|
+
]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('handles two prompts in sequence', () => {
|
|
58
|
+
const a = encodeLoginPrompt({ question: 'first', mask: false });
|
|
59
|
+
const b = encodeLoginPrompt({ question: 'second', mask: true });
|
|
60
|
+
expect(scanAll([a + b])).toEqual([
|
|
61
|
+
{ type: 'prompt', prompt: { question: 'first', mask: false } },
|
|
62
|
+
{ type: 'prompt', prompt: { question: 'second', mask: true } },
|
|
63
|
+
]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('holds back a lone opening NUL until its partner arrives', () => {
|
|
67
|
+
const scanner = createLoginStreamScanner();
|
|
68
|
+
const marker = encodeLoginPrompt({ question: 'q', mask: false });
|
|
69
|
+
// Feed everything except the closing NUL: nothing should surface yet.
|
|
70
|
+
expect([...scanner.push('text' + marker.slice(0, -1))]).toEqual([
|
|
71
|
+
{ type: 'output', text: 'text' },
|
|
72
|
+
]);
|
|
73
|
+
// Closing NUL completes the marker.
|
|
74
|
+
expect([...scanner.push('\u0000')]).toEqual([
|
|
75
|
+
{ type: 'prompt', prompt: { question: 'q', mask: false } },
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
});
|