@moxxy/sdk 0.11.0 → 0.12.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/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/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- 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/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/package.json +5 -1
- package/src/elision-state.ts +5 -0
- package/src/events.ts +36 -0
- package/src/index.ts +41 -0
- 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/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
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel-agnostic "rich tool result" payloads.
|
|
3
|
+
*
|
|
4
|
+
* A tool whose result is more than a line of text (a file diff, eventually a
|
|
5
|
+
* table or chart) returns a `ToolDisplayResult`: a short `forModel` string the
|
|
6
|
+
* model sees, plus a structured `display` every channel can render natively.
|
|
7
|
+
* The projection layer (see `mode-helpers.ts`) sends ONLY `forModel` to the
|
|
8
|
+
* model, so the rich payload never bloats the context window.
|
|
9
|
+
*
|
|
10
|
+
* The first (and currently only) display kind is `file-diff`, emitted by the
|
|
11
|
+
* built-in Write/Edit tools. It carries the changed slices of a file (a few
|
|
12
|
+
* lines of context around each edit) — never the whole file — so the payload
|
|
13
|
+
* stays bounded regardless of file size. Channels render it as a classic diff:
|
|
14
|
+
* line numbers, +/- markers, green/red line backgrounds.
|
|
15
|
+
*/
|
|
16
|
+
export function isToolDisplayResult(x) {
|
|
17
|
+
return (typeof x === 'object' &&
|
|
18
|
+
x !== null &&
|
|
19
|
+
typeof x.forModel === 'string' &&
|
|
20
|
+
isToolDisplay(x.display));
|
|
21
|
+
}
|
|
22
|
+
export function isToolDisplay(x) {
|
|
23
|
+
return isFileDiffDisplay(x);
|
|
24
|
+
}
|
|
25
|
+
export function isFileDiffDisplay(x) {
|
|
26
|
+
return (typeof x === 'object' &&
|
|
27
|
+
x !== null &&
|
|
28
|
+
x.kind === 'file-diff' &&
|
|
29
|
+
Array.isArray(x.hunks));
|
|
30
|
+
}
|
|
31
|
+
/** Human summary, e.g. "Added 10 lines, removed 1 line". */
|
|
32
|
+
export function fileDiffSummary(d) {
|
|
33
|
+
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
|
|
34
|
+
const parts = [];
|
|
35
|
+
if (d.added > 0 || d.removed === 0)
|
|
36
|
+
parts.push(`Added ${plural(d.added, 'line')}`);
|
|
37
|
+
if (d.removed > 0)
|
|
38
|
+
parts.push(`${parts.length ? 'removed' : 'Removed'} ${plural(d.removed, 'line')}`);
|
|
39
|
+
let summary = parts.join(', ');
|
|
40
|
+
if (d.truncated)
|
|
41
|
+
summary += ' (diff truncated)';
|
|
42
|
+
return summary;
|
|
43
|
+
}
|
|
44
|
+
/** Verb for the diff header — "Create" for new files, else "Update". */
|
|
45
|
+
export function fileDiffVerb(d) {
|
|
46
|
+
return d.mode === 'create' ? 'Create' : 'Update';
|
|
47
|
+
}
|
|
48
|
+
/** Gutter number a renderer should show for a line (new number, or old for deletions). */
|
|
49
|
+
export function diffGutterNo(line) {
|
|
50
|
+
return line.kind === 'del' ? line.oldNo : line.newNo;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Flatten a file diff's hunks into a single row list, inserting a `gap` marker
|
|
54
|
+
* between non-contiguous hunks (channels render it as a `⋯` separator).
|
|
55
|
+
*/
|
|
56
|
+
export function toDiffRows(d) {
|
|
57
|
+
const rows = [];
|
|
58
|
+
d.hunks.forEach((hunk, i) => {
|
|
59
|
+
if (i > 0)
|
|
60
|
+
rows.push({ kind: 'gap' });
|
|
61
|
+
for (const line of hunk.lines)
|
|
62
|
+
rows.push(line);
|
|
63
|
+
});
|
|
64
|
+
return rows;
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=tool-display.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tool-display.js","sourceRoot":"","sources":["../src/tool-display.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AA8CH,MAAM,UAAU,mBAAmB,CAAC,CAAU;IAC5C,OAAO,CACL,OAAO,CAAC,KAAK,QAAQ;QACrB,CAAC,KAAK,IAAI;QACV,OAAQ,CAA4B,CAAC,QAAQ,KAAK,QAAQ;QAC1D,aAAa,CAAE,CAA2B,CAAC,OAAO,CAAC,CACpD,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,CAAU;IACtC,OAAO,iBAAiB,CAAC,CAAC,CAAC,CAAC;AAC9B,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,CAAU;IAC1C,OAAO,CACL,OAAO,CAAC,KAAK,QAAQ;QACrB,CAAC,KAAK,IAAI;QACT,CAAwB,CAAC,IAAI,KAAK,WAAW;QAC9C,KAAK,CAAC,OAAO,CAAE,CAAyB,CAAC,KAAK,CAAC,CAChD,CAAC;AACJ,CAAC;AAED,4DAA4D;AAC5D,MAAM,UAAU,eAAe,CAAC,CAAkB;IAChD,MAAM,MAAM,GAAG,CAAC,CAAS,EAAE,CAAS,EAAU,EAAE,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC;IAClF,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,IAAI,CAAC,CAAC,KAAK,GAAG,CAAC,IAAI,CAAC,CAAC,OAAO,KAAK,CAAC;QAAE,KAAK,CAAC,IAAI,CAAC,SAAS,MAAM,CAAC,CAAC,CAAC,KAAK,EAAE,MAAM,CAAC,EAAE,CAAC,CAAC;IACnF,IAAI,CAAC,CAAC,OAAO,GAAG,CAAC;QAAE,KAAK,CAAC,IAAI,CAAC,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,SAAS,IAAI,MAAM,CAAC,CAAC,CAAC,OAAO,EAAE,MAAM,CAAC,EAAE,CAAC,CAAC;IACtG,IAAI,OAAO,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC/B,IAAI,CAAC,CAAC,SAAS;QAAE,OAAO,IAAI,mBAAmB,CAAC;IAChD,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,wEAAwE;AACxE,MAAM,UAAU,YAAY,CAAC,CAAkB;IAC7C,OAAO,CAAC,CAAC,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC;AACnD,CAAC;AAED,0FAA0F;AAC1F,MAAM,UAAU,YAAY,CAAC,IAAc;IACzC,OAAO,IAAI,CAAC,IAAI,KAAK,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC;AACvD,CAAC;AAKD;;;GAGG;AACH,MAAM,UAAU,UAAU,CAAC,CAAkB;IAC3C,MAAM,IAAI,GAAc,EAAE,CAAC;IAC3B,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE;QAC1B,IAAI,CAAC,GAAG,CAAC;YAAE,IAAI,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;QACtC,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,KAAK;YAAE,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACjD,CAAC,CAAC,CAAC;IACH,OAAO,IAAI,CAAC;AACd,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moxxy/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Typed public surface for the moxxy framework: event types, define* factories, lifecycle hook signatures.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"moxxy",
|
|
@@ -44,6 +44,10 @@
|
|
|
44
44
|
".": {
|
|
45
45
|
"types": "./dist/index.d.ts",
|
|
46
46
|
"import": "./dist/index.js"
|
|
47
|
+
},
|
|
48
|
+
"./tool-display": {
|
|
49
|
+
"types": "./dist/tool-display.d.ts",
|
|
50
|
+
"import": "./dist/tool-display.js"
|
|
47
51
|
}
|
|
48
52
|
},
|
|
49
53
|
"files": [
|
package/src/elision-state.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { MoxxyEvent } from './events.js';
|
|
2
|
+
import { isToolDisplayResult } from './tool-display.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Shared elision decision logic — the single source of truth for "is this event
|
|
@@ -16,6 +17,10 @@ export const TINY_TURN_CHARS = 200;
|
|
|
16
17
|
export function toolResultBytes(output: unknown, errorMessage?: string): number {
|
|
17
18
|
if (errorMessage !== undefined) return errorMessage.length;
|
|
18
19
|
if (typeof output === 'string') return output.length;
|
|
20
|
+
// Rich results (e.g. file diffs) only ever send their short `forModel`
|
|
21
|
+
// string to the model — measure THAT so the estimate matches projection
|
|
22
|
+
// and the bulky `display` payload never trips elision.
|
|
23
|
+
if (isToolDisplayResult(output)) return output.forModel.length;
|
|
19
24
|
try {
|
|
20
25
|
return JSON.stringify(output ?? '').length;
|
|
21
26
|
} catch {
|
package/src/events.ts
CHANGED
|
@@ -49,6 +49,40 @@ export interface AssistantMessageEvent extends EventBase {
|
|
|
49
49
|
readonly stopReason: 'end_turn' | 'tool_use' | 'max_tokens' | 'stop_sequence' | 'error';
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* A live reasoning/thinking delta. Parallels {@link AssistantChunkEvent}: it is
|
|
54
|
+
* streamed for the UI's live "thinking…" preview and is NOT retained in the
|
|
55
|
+
* display log (renderers accumulate it ephemerally, clearing on the matching
|
|
56
|
+
* {@link ReasoningMessageEvent} or the turn's `assistant_message`). Only
|
|
57
|
+
* emitted when the active provider+model supports reasoning AND it is enabled
|
|
58
|
+
* in that provider's config.
|
|
59
|
+
*/
|
|
60
|
+
export interface ReasoningChunkEvent extends EventBase {
|
|
61
|
+
readonly type: 'reasoning_chunk';
|
|
62
|
+
readonly delta: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A finalized reasoning summary for ONE provider call. Parallels
|
|
67
|
+
* {@link AssistantMessageEvent}: it is persisted + replayed, and because
|
|
68
|
+
* `collectProviderStream` runs once per loop round these land between tool
|
|
69
|
+
* batches (the "summary of the work done between calls" behavior). The
|
|
70
|
+
* `signature`/`redacted`/`encrypted` fields exist purely for history
|
|
71
|
+
* round-trip — Anthropic requires the signed `thinking` block be replayed
|
|
72
|
+
* first on a tool-use continuation; Codex/Anthropic redacted thinking carries
|
|
73
|
+
* an opaque blob replayed verbatim and never shown.
|
|
74
|
+
*/
|
|
75
|
+
export interface ReasoningMessageEvent extends EventBase {
|
|
76
|
+
readonly type: 'reasoning_message';
|
|
77
|
+
readonly content: string;
|
|
78
|
+
/** Anthropic thinking-block signature; absent for providers that don't sign. */
|
|
79
|
+
readonly signature?: string;
|
|
80
|
+
/** True when the reasoning is redacted/encrypted and must not be displayed. */
|
|
81
|
+
readonly redacted?: boolean;
|
|
82
|
+
/** Opaque provider blob (Anthropic redacted_thinking data / Codex encrypted_content) replayed as-is. */
|
|
83
|
+
readonly encrypted?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
52
86
|
export interface ToolCallRequestedEvent extends EventBase {
|
|
53
87
|
readonly type: 'tool_call_requested';
|
|
54
88
|
readonly callId: ToolCallId;
|
|
@@ -226,6 +260,8 @@ export type MoxxyEvent =
|
|
|
226
260
|
| UserPromptEvent
|
|
227
261
|
| AssistantChunkEvent
|
|
228
262
|
| AssistantMessageEvent
|
|
263
|
+
| ReasoningChunkEvent
|
|
264
|
+
| ReasoningMessageEvent
|
|
229
265
|
| ToolCallRequestedEvent
|
|
230
266
|
| ToolCallApprovedEvent
|
|
231
267
|
| ToolCallDeniedEvent
|
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,
|
|
@@ -334,6 +359,22 @@ export type {
|
|
|
334
359
|
export type { EmbeddingProvider, EmbedderDef } from './embedding.js';
|
|
335
360
|
export { CachedEmbeddingProvider } from './embedding-cache.js';
|
|
336
361
|
|
|
362
|
+
export { defineSurface } from './surface.js';
|
|
363
|
+
export type {
|
|
364
|
+
SurfaceKind,
|
|
365
|
+
SurfaceDataMessage,
|
|
366
|
+
SurfaceInputMessage,
|
|
367
|
+
SurfaceSize,
|
|
368
|
+
SurfaceInstance,
|
|
369
|
+
SurfaceAvailability,
|
|
370
|
+
SurfaceContext,
|
|
371
|
+
SurfaceDef,
|
|
372
|
+
SurfaceRegistry,
|
|
373
|
+
SurfaceInfo,
|
|
374
|
+
OpenSurfaceResult,
|
|
375
|
+
SurfaceHost,
|
|
376
|
+
} from './surface.js';
|
|
377
|
+
|
|
337
378
|
export type {
|
|
338
379
|
RequirementKind,
|
|
339
380
|
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
|
+
});
|