@moxxy/sdk 0.43.0 → 0.45.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/agent-account-limit.d.ts +41 -0
- package/dist/agent-account-limit.d.ts.map +1 -0
- package/dist/agent-account-limit.js +64 -0
- package/dist/agent-account-limit.js.map +1 -0
- package/dist/agent-computer-use.d.ts +10 -0
- package/dist/agent-computer-use.d.ts.map +1 -0
- package/dist/agent-computer-use.js +24 -0
- package/dist/agent-computer-use.js.map +1 -0
- package/dist/agent-context-window.d.ts +22 -0
- package/dist/agent-context-window.d.ts.map +1 -0
- package/dist/agent-context-window.js +40 -0
- package/dist/agent-context-window.js.map +1 -0
- package/dist/agent-no-reply.d.ts +11 -0
- package/dist/agent-no-reply.d.ts.map +1 -0
- package/dist/agent-no-reply.js +13 -0
- package/dist/agent-no-reply.js.map +1 -0
- package/dist/agent-step.d.ts +14 -0
- package/dist/agent-step.d.ts.map +1 -0
- package/dist/agent-step.js +26 -0
- package/dist/agent-step.js.map +1 -0
- package/dist/agent-work.d.ts +34 -0
- package/dist/agent-work.d.ts.map +1 -0
- package/dist/agent-work.js +80 -0
- package/dist/agent-work.js.map +1 -0
- package/dist/attachment-payload.d.ts +95 -0
- package/dist/attachment-payload.d.ts.map +1 -0
- package/dist/attachment-payload.js +155 -0
- package/dist/attachment-payload.js.map +1 -0
- package/dist/auto-approve.d.ts +9 -0
- package/dist/auto-approve.d.ts.map +1 -1
- package/dist/auto-approve.js +18 -0
- package/dist/auto-approve.js.map +1 -1
- package/dist/channel-auth.d.ts +3 -3
- package/dist/channel-auth.d.ts.map +1 -1
- package/dist/channel-auth.js +1 -1
- package/dist/channel.d.ts +1 -1
- package/dist/client-session.d.ts +9 -5
- package/dist/client-session.d.ts.map +1 -1
- package/dist/computer-control.d.ts +11 -11
- package/dist/computer-control.d.ts.map +1 -1
- package/dist/computer-control.js +1 -1
- package/dist/computer-control.js.map +1 -1
- package/dist/conversation-history.d.ts +50 -0
- package/dist/conversation-history.d.ts.map +1 -0
- package/dist/conversation-history.js +227 -0
- package/dist/conversation-history.js.map +1 -0
- package/dist/cron.d.ts +58 -0
- package/dist/cron.d.ts.map +1 -0
- package/dist/cron.js +320 -0
- package/dist/cron.js.map +1 -0
- package/dist/define.d.ts +2 -1
- package/dist/define.d.ts.map +1 -1
- package/dist/define.js +1 -0
- package/dist/define.js.map +1 -1
- package/dist/event-store.d.ts +1 -1
- package/dist/event-store.d.ts.map +1 -1
- package/dist/event-store.js +1 -0
- package/dist/event-store.js.map +1 -1
- package/dist/events.d.ts +23 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/file-diff.d.ts +31 -0
- package/dist/file-diff.d.ts.map +1 -0
- package/dist/file-diff.js +196 -0
- package/dist/file-diff.js.map +1 -0
- package/dist/index.d.ts +23 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -4
- package/dist/index.js.map +1 -1
- package/dist/mode/agent-step-loop.d.ts +13 -0
- package/dist/mode/agent-step-loop.d.ts.map +1 -0
- package/dist/mode/agent-step-loop.js +578 -0
- package/dist/mode/agent-step-loop.js.map +1 -0
- package/dist/mode/checkpoint-gate.d.ts +25 -0
- package/dist/mode/checkpoint-gate.d.ts.map +1 -0
- package/dist/mode/checkpoint-gate.js +150 -0
- package/dist/mode/checkpoint-gate.js.map +1 -0
- package/dist/mode/checkpoint.d.ts +6 -0
- package/dist/mode/checkpoint.d.ts.map +1 -1
- package/dist/mode/collect-stream.d.ts +2 -0
- package/dist/mode/collect-stream.d.ts.map +1 -1
- package/dist/mode/collect-stream.js +1 -1
- package/dist/mode/collect-stream.js.map +1 -1
- package/dist/mode/project-messages.d.ts +3 -1
- package/dist/mode/project-messages.d.ts.map +1 -1
- package/dist/mode/project-messages.js +122 -81
- package/dist/mode/project-messages.js.map +1 -1
- package/dist/mode/react-loop-options.d.ts +113 -0
- package/dist/mode/react-loop-options.d.ts.map +1 -0
- package/dist/mode/react-loop-options.js +2 -0
- package/dist/mode/react-loop-options.js.map +1 -0
- package/dist/mode/react-loop.d.ts +2 -99
- package/dist/mode/react-loop.d.ts.map +1 -1
- package/dist/mode/react-loop.js +7 -145
- package/dist/mode/react-loop.js.map +1 -1
- package/dist/mode.d.ts +19 -7
- package/dist/mode.d.ts.map +1 -1
- package/dist/mode.js +1 -1
- package/dist/mode.js.map +1 -1
- package/dist/npm-registry.d.ts +22 -0
- package/dist/npm-registry.d.ts.map +1 -0
- package/dist/npm-registry.js +54 -0
- package/dist/npm-registry.js.map +1 -0
- package/dist/permission.d.ts +31 -0
- package/dist/permission.d.ts.map +1 -1
- package/dist/process-invocation.d.ts +2 -0
- package/dist/process-invocation.d.ts.map +1 -1
- package/dist/process-invocation.js +2 -2
- package/dist/process-invocation.js.map +1 -1
- package/dist/process-tree.d.ts +9 -0
- package/dist/process-tree.d.ts.map +1 -0
- package/dist/process-tree.js +40 -0
- package/dist/process-tree.js.map +1 -0
- package/dist/provider-login-bridge.js +1 -1
- package/dist/provider-login-bridge.js.map +1 -1
- package/dist/provider.d.ts +408 -0
- package/dist/provider.d.ts.map +1 -1
- package/dist/question-draft.d.ts +19 -0
- package/dist/question-draft.d.ts.map +1 -0
- package/dist/question-draft.js +30 -0
- package/dist/question-draft.js.map +1 -0
- package/dist/question.d.ts +204 -0
- package/dist/question.d.ts.map +1 -0
- package/dist/question.js +141 -0
- package/dist/question.js.map +1 -0
- package/dist/questions.d.ts +7 -0
- package/dist/questions.d.ts.map +1 -0
- package/dist/questions.js +7 -0
- package/dist/questions.js.map +1 -0
- package/dist/resolvers.d.ts +14 -1
- package/dist/resolvers.d.ts.map +1 -1
- package/dist/resolvers.js +33 -21
- package/dist/resolvers.js.map +1 -1
- package/dist/schemas.d.ts +53 -53
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +3 -2
- package/dist/schemas.js.map +1 -1
- package/dist/server.d.ts +2 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3 -0
- package/dist/server.js.map +1 -1
- package/dist/session-like.d.ts +19 -1
- package/dist/session-like.d.ts.map +1 -1
- package/dist/starting-model.d.ts +12 -0
- package/dist/starting-model.d.ts.map +1 -0
- package/dist/starting-model.js +9 -0
- package/dist/starting-model.js.map +1 -0
- package/dist/tool-dispatch.d.ts.map +1 -1
- package/dist/tool-dispatch.js +10 -4
- package/dist/tool-dispatch.js.map +1 -1
- package/dist/tool-display.d.ts +79 -3
- package/dist/tool-display.d.ts.map +1 -1
- package/dist/tool-display.js +117 -3
- package/dist/tool-display.js.map +1 -1
- package/dist/tool-modes.d.ts +1 -1
- package/dist/tool-modes.d.ts.map +1 -1
- package/dist/tool-modes.js +8 -2
- package/dist/tool-modes.js.map +1 -1
- package/dist/tool-output.d.ts +15 -0
- package/dist/tool-output.d.ts.map +1 -0
- package/dist/tool-output.js +39 -0
- package/dist/tool-output.js.map +1 -0
- package/dist/tool.d.ts +14 -1
- package/dist/tool.d.ts.map +1 -1
- package/dist/tunnel.d.ts +3 -3
- package/dist/tunnel.d.ts.map +1 -1
- package/dist/tunnel.js +2 -2
- package/dist/workflow-approval.d.ts +9 -9
- package/dist/workflow-approval.d.ts.map +1 -1
- package/dist/workflow-approval.js +1 -1
- package/dist/workflow-approval.js.map +1 -1
- package/package.json +37 -5
- package/src/agent-account-limit.test.ts +87 -0
- package/src/agent-account-limit.ts +89 -0
- package/src/agent-computer-use.test.ts +26 -0
- package/src/agent-computer-use.ts +29 -0
- package/src/agent-context-window.ts +51 -0
- package/src/agent-no-reply.ts +16 -0
- package/src/agent-step.ts +26 -0
- package/src/agent-work.ts +102 -0
- package/src/attachment-payload.test.ts +165 -0
- package/src/attachment-payload.ts +187 -0
- package/src/auto-approve.test.ts +13 -1
- package/src/auto-approve.ts +19 -0
- package/src/channel-auth.ts +3 -3
- package/src/channel.ts +1 -1
- package/src/client-session.ts +9 -2
- package/src/computer-control.ts +1 -1
- package/src/conversation-history.test.ts +184 -0
- package/src/conversation-history.ts +272 -0
- package/src/cron.test.ts +280 -0
- package/src/cron.ts +355 -0
- package/src/define.test.ts +1 -1
- package/src/define.ts +3 -1
- package/src/event-store.ts +2 -1
- package/src/events.ts +23 -1
- package/src/file-diff.test.ts +103 -0
- package/src/file-diff.ts +208 -0
- package/src/index.ts +140 -1
- package/src/json-file-store.test.ts +1 -1
- package/src/loop-helpers.test.ts +15 -0
- package/src/mode/agent-step-loop.ts +645 -0
- package/src/mode/checkpoint-gate.ts +198 -0
- package/src/mode/checkpoint.ts +6 -0
- package/src/mode/collect-stream.ts +1 -1
- package/src/mode/project-messages.test.ts +118 -1
- package/src/mode/project-messages.ts +136 -77
- package/src/mode/react-loop-options.ts +124 -0
- package/src/mode/react-loop.ts +10 -296
- package/src/mode.ts +19 -7
- package/src/npm-registry.test.ts +95 -0
- package/src/npm-registry.ts +57 -0
- package/src/permission.ts +30 -0
- package/src/process-invocation.test.ts +25 -0
- package/src/process-invocation.ts +8 -3
- package/src/process-tree.ts +37 -0
- package/src/provider-login-bridge.ts +1 -1
- package/src/provider-schema-constraints.test.ts +1 -1
- package/src/provider-utils.test.ts +1 -1
- package/src/provider.ts +346 -0
- package/src/question-draft.test.ts +57 -0
- package/src/question-draft.ts +47 -0
- package/src/question.test.ts +130 -0
- package/src/question.ts +229 -0
- package/src/questions.ts +6 -0
- package/src/resolvers.test.ts +84 -1
- package/src/resolvers.ts +40 -27
- package/src/schemas.test.ts +5 -0
- package/src/schemas.ts +3 -2
- package/src/server.ts +3 -0
- package/src/session-like.ts +19 -1
- package/src/skill-mentions.test.ts +1 -1
- package/src/starting-model.test.ts +19 -0
- package/src/starting-model.ts +11 -0
- package/src/token-efficiency.test.ts +1 -1
- package/src/tool-dispatch.ts +10 -4
- package/src/tool-display.test.ts +119 -0
- package/src/tool-display.ts +162 -4
- package/src/tool-gating.test.ts +1 -1
- package/src/tool-icon.test.ts +1 -1
- package/src/tool-modes.test.ts +18 -1
- package/src/tool-modes.ts +6 -2
- package/src/tool-output.test.ts +46 -0
- package/src/tool-output.ts +46 -0
- package/src/tool.ts +14 -1
- package/src/tunnel.ts +3 -3
- package/src/workflow-approval.ts +1 -1
package/src/session-like.ts
CHANGED
|
@@ -3,8 +3,9 @@ import type { SessionId, TurnId } from './ids.js';
|
|
|
3
3
|
import type { CapabilitySpec } from './isolation.js';
|
|
4
4
|
import type { EventLogReader } from './log.js';
|
|
5
5
|
import type { ApprovalResolver, ModeBadge } from './mode.js';
|
|
6
|
+
import type { SessionQuestions } from './question.js';
|
|
6
7
|
import type { PermissionResolver } from './permission.js';
|
|
7
|
-
import type { ModelDescriptor, ProviderKeyValidation, ReasoningEffort } from './provider.js';
|
|
8
|
+
import type { AgentBackgroundWork, AgentScheduledWakeup, AgentCliReport, ModelDescriptor, ProviderKeyValidation, ReasoningEffort } from './provider.js';
|
|
8
9
|
import type { PluginSetupSpec } from './schemas.js';
|
|
9
10
|
import type { ToolIcon } from './tool-icon.js';
|
|
10
11
|
import type { ToolCompactPresentation } from './tool.js';
|
|
@@ -63,10 +64,17 @@ export type ProviderAuthKind = 'api-key' | 'oauth';
|
|
|
63
64
|
export interface ProviderInfo {
|
|
64
65
|
readonly name: string;
|
|
65
66
|
readonly models: ReadonlyArray<ModelDescriptor>;
|
|
67
|
+
/** The light model a new conversation starts on while the person chose no
|
|
68
|
+
* default (see `startingModel`); absent when the provider names none. */
|
|
69
|
+
readonly starterModel?: string;
|
|
66
70
|
/** 'oauth' when the provider declares an oauth login on its plugin
|
|
67
71
|
* definition, 'api-key' otherwise. Defaults to 'api-key' for
|
|
68
72
|
* providers that don't declare. */
|
|
69
73
|
readonly authKind: ProviderAuthKind;
|
|
74
|
+
/** For an agent connection, the agent's name as the person knows it ("Codex"). */
|
|
75
|
+
readonly agentName?: string;
|
|
76
|
+
/** For an agent connection, the last check of its CLI; absent until the first check ends, and from older runners. */
|
|
77
|
+
readonly agentCli?: AgentCliReport;
|
|
70
78
|
/** True when the provider's plugin can list its models live (e.g.
|
|
71
79
|
* via /v1/models). Lets the desktop's model picker show a
|
|
72
80
|
* "Fetch live" affordance only where it makes sense. */
|
|
@@ -175,6 +183,10 @@ export interface SessionInfo {
|
|
|
175
183
|
/** Turns running now, whichever client (or in-process channel) started them.
|
|
176
184
|
* Reported by a runner to its clients; absent from runners that predate it. */
|
|
177
185
|
readonly runningTurns?: ReadonlyArray<string>;
|
|
186
|
+
/** What the conversation's agent runs in the background now; absent from runners that predate it. */
|
|
187
|
+
readonly backgroundWork?: ReadonlyArray<AgentBackgroundWork>;
|
|
188
|
+
/** When the conversation's agent set itself to come back by itself; absent from runners that predate it. */
|
|
189
|
+
readonly scheduledWakeups?: ReadonlyArray<AgentScheduledWakeup>;
|
|
178
190
|
}
|
|
179
191
|
|
|
180
192
|
/**
|
|
@@ -558,6 +570,12 @@ export interface SessionLike {
|
|
|
558
570
|
pluginsAdmin?: PluginsAdminView;
|
|
559
571
|
/** Provider onboarding slice backing in-channel connect (key entry / OAuth). */
|
|
560
572
|
providerSetup?: ProviderSetupView;
|
|
573
|
+
/**
|
|
574
|
+
* Questions the agent asks the person. Open questions are read from the log
|
|
575
|
+
* with `openQuestionsFromEvents`; this slice attaches a surface and answers.
|
|
576
|
+
* Optional capability per the seam convention: callers MUST guard.
|
|
577
|
+
*/
|
|
578
|
+
readonly questions?: SessionQuestions;
|
|
561
579
|
/**
|
|
562
580
|
* Re-read the merged config from disk and live-apply the safe subset —
|
|
563
581
|
* backs the TUI /settings panel's write-then-apply. Structurally matches
|
|
@@ -5,7 +5,7 @@ import type { Skill, SkillFrontmatter } from './skill.js';
|
|
|
5
5
|
import { insertMention, mentionedSkills, mentionOptions, mentionQueryAt, skillAttachment, withoutTools } from './skill-mentions.js';
|
|
6
6
|
import type { SkillInfo } from './session-like.js';
|
|
7
7
|
import { defineTool } from './define.js';
|
|
8
|
-
import { z } from 'zod';
|
|
8
|
+
import { z } from 'zod/v3';
|
|
9
9
|
|
|
10
10
|
const skill = (frontmatter: Omit<SkillFrontmatter, 'description'>, body = 'Do it on the desktop.'): Skill => ({
|
|
11
11
|
id: asSkillId(`plugin/${frontmatter.name}`),
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { startingModel } from './starting-model.js';
|
|
3
|
+
|
|
4
|
+
describe('startingModel', () => {
|
|
5
|
+
const models = [{ id: 'big' }, { id: 'medium' }, { id: 'light' }];
|
|
6
|
+
|
|
7
|
+
it("is the provider's starter model when it lists it", () => {
|
|
8
|
+
expect(startingModel({ models, starterModel: 'light' })).toBe('light');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('is the first listed model without a starter model, or when the starter is not listed', () => {
|
|
12
|
+
expect(startingModel({ models })).toBe('big');
|
|
13
|
+
expect(startingModel({ models, starterModel: 'retired' })).toBe('big');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('is nothing for a provider that lists no models', () => {
|
|
17
|
+
expect(startingModel({ models: [], starterModel: 'light' })).toBeUndefined();
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model a new conversation starts on while the person chose no default:
|
|
3
|
+
* the provider's light starter model when it lists it, else the first it
|
|
4
|
+
* lists. One rule for the runner and for every surface that shows or picks it.
|
|
5
|
+
*/
|
|
6
|
+
export function startingModel(provider: {
|
|
7
|
+
readonly models: ReadonlyArray<{ readonly id: string }>;
|
|
8
|
+
readonly starterModel?: string;
|
|
9
|
+
}): string | undefined {
|
|
10
|
+
return provider.models.find((model) => model.id === provider.starterModel)?.id ?? provider.models[0]?.id;
|
|
11
|
+
}
|
package/src/tool-dispatch.ts
CHANGED
|
@@ -58,16 +58,22 @@ export async function* dispatchToolCall(
|
|
|
58
58
|
return;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
const offered = ctx.tools.get(t.name);
|
|
62
|
+
const scope = {
|
|
63
|
+
sessionId: String(ctx.sessionId),
|
|
64
|
+
turnId: String(ctx.turnId),
|
|
65
|
+
...(offered?.permission ? { toolPermission: offered.permission } : {}),
|
|
66
|
+
};
|
|
61
67
|
const decision = await ctx.permissions.check(
|
|
62
68
|
{ callId: asToolCallId(t.id), name: t.name, input: actualInput },
|
|
63
|
-
{
|
|
69
|
+
{ ...scope, toolDescription: offered?.description },
|
|
64
70
|
);
|
|
65
71
|
if (decision.mode === 'deny') {
|
|
66
72
|
// The session's resolver consults standing rules before asking anyone;
|
|
67
73
|
// name such a refusal as policy, so 'resolver' means the answer given.
|
|
68
74
|
const standing = await ctx.permissions.policyCheck?.(
|
|
69
75
|
{ callId: asToolCallId(t.id), name: t.name, input: actualInput },
|
|
70
|
-
|
|
76
|
+
scope,
|
|
71
77
|
);
|
|
72
78
|
yield* emitDenied(ctx, t, decision.reason ?? 'denied by resolver', standing?.mode === 'deny' ? 'policy' : 'resolver');
|
|
73
79
|
return;
|
|
@@ -77,7 +83,7 @@ export async function* dispatchToolCall(
|
|
|
77
83
|
ctx.signal.throwIfAborted();
|
|
78
84
|
const mandatory = await ctx.permissions.mandatoryCheck?.(
|
|
79
85
|
{ callId: asToolCallId(t.id), name: t.name, input: actualInput },
|
|
80
|
-
|
|
86
|
+
scope,
|
|
81
87
|
);
|
|
82
88
|
if (mandatory?.mode === 'deny') {
|
|
83
89
|
yield* emitDenied(ctx, t, mandatory.reason ?? 'workflow approval denied', 'resolver');
|
|
@@ -85,7 +91,7 @@ export async function* dispatchToolCall(
|
|
|
85
91
|
}
|
|
86
92
|
const policy = await ctx.permissions.policyCheck?.(
|
|
87
93
|
{ callId: asToolCallId(t.id), name: t.name, input: actualInput },
|
|
88
|
-
|
|
94
|
+
scope,
|
|
89
95
|
);
|
|
90
96
|
if (policy?.mode === 'deny') {
|
|
91
97
|
yield* emitDenied(ctx, t, policy.reason ?? 'denied by policy', 'policy');
|
package/src/tool-display.test.ts
CHANGED
|
@@ -3,9 +3,20 @@ import {
|
|
|
3
3
|
diffGutterNo,
|
|
4
4
|
fileDiffSummary,
|
|
5
5
|
fileDiffVerb,
|
|
6
|
+
imageToolOutput,
|
|
6
7
|
isFileDiffDisplay,
|
|
8
|
+
isQuestionAnswerDisplay,
|
|
9
|
+
isStepListDisplay,
|
|
7
10
|
isToolDisplayResult,
|
|
11
|
+
questionAnswerLines,
|
|
12
|
+
stepListOutput,
|
|
13
|
+
stepListProgress,
|
|
14
|
+
stepStatusOf,
|
|
15
|
+
stepsFromTodos,
|
|
8
16
|
toDiffRows,
|
|
17
|
+
toolImagesOf,
|
|
18
|
+
type QuestionAnswerDisplay,
|
|
19
|
+
type StepListDisplay,
|
|
9
20
|
type FileDiffDisplay,
|
|
10
21
|
} from './tool-display.js';
|
|
11
22
|
|
|
@@ -93,3 +104,111 @@ describe('guards', () => {
|
|
|
93
104
|
).toBe(true);
|
|
94
105
|
});
|
|
95
106
|
});
|
|
107
|
+
|
|
108
|
+
describe('question-answer display', () => {
|
|
109
|
+
const answered: QuestionAnswerDisplay = {
|
|
110
|
+
kind: 'question-answer',
|
|
111
|
+
questions: [
|
|
112
|
+
{ header: 'Colour', question: 'Which colour?', chosen: ['Blue'] },
|
|
113
|
+
{ question: 'Which parts?', chosen: ['Auth', 'Billing'], own: 'and the admin' },
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
it('is a tool display, so the model is shown only the text and the transcript the answers', () => {
|
|
118
|
+
expect(isQuestionAnswerDisplay(answered)).toBe(true);
|
|
119
|
+
expect(isToolDisplayResult({ forModel: 'The user answered: …', display: answered })).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('rejects a malformed payload instead of rendering it', () => {
|
|
123
|
+
expect(isQuestionAnswerDisplay({ kind: 'question-answer' })).toBe(false);
|
|
124
|
+
expect(isQuestionAnswerDisplay({ kind: 'question-answer', questions: [{ question: 'Q?' }] })).toBe(false);
|
|
125
|
+
expect(isQuestionAnswerDisplay({ kind: 'question-answer', questions: [{ question: 'Q?', chosen: [1] }] })).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('reads as one line per question for text surfaces', () => {
|
|
129
|
+
expect(questionAnswerLines(answered)).toEqual([
|
|
130
|
+
'Colour: Which colour? → Blue',
|
|
131
|
+
'Which parts? → Auth, Billing; own answer: and the admin',
|
|
132
|
+
]);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('images in a tool result', () => {
|
|
137
|
+
it('are read from the shape Moxxy tools return, with the text beside it', () => {
|
|
138
|
+
expect(toolImagesOf({ mediaType: 'image/png', base64: 'AAA', forModel: 'Screenshot of Arc' })).toEqual({
|
|
139
|
+
images: [{ mediaType: 'image/png', base64: 'AAA' }],
|
|
140
|
+
text: 'Screenshot of Arc',
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('are read from the provider-native image block too', () => {
|
|
145
|
+
expect(toolImagesOf({ type: 'image', mediaType: 'image/jpeg', data: 'BBB' })).toEqual({
|
|
146
|
+
images: [{ mediaType: 'image/jpeg', base64: 'BBB' }],
|
|
147
|
+
text: '',
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('are nothing for text, a file diff, or an object without pixels', () => {
|
|
152
|
+
expect(toolImagesOf('plain text')).toBeNull();
|
|
153
|
+
expect(toolImagesOf({ mediaType: 'image/png' })).toBeNull();
|
|
154
|
+
expect(toolImagesOf(null)).toBeNull();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('are what an agent connection hands over: the text the agent read and every image, in order', () => {
|
|
158
|
+
const shots = [
|
|
159
|
+
{ mediaType: 'image/jpeg', base64: 'ONE' },
|
|
160
|
+
{ mediaType: 'image/png', base64: 'TWO' },
|
|
161
|
+
{ mediaType: 'image/jpeg', base64: 'THREE' },
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
expect(toolImagesOf(imageToolOutput('Took three screenshots.', shots))).toEqual({ images: shots, text: 'Took three screenshots.' });
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('keep one copy of an image sent twice, as Codex sends each screenshot', () => {
|
|
168
|
+
const shot = { mediaType: 'image/jpeg', base64: 'SAME' };
|
|
169
|
+
const other = { mediaType: 'image/jpeg', base64: 'OTHER' };
|
|
170
|
+
|
|
171
|
+
expect(imageToolOutput('', [shot, shot, other, shot])).toEqual({ forModel: '', mediaType: 'image/jpeg', base64: 'SAME', moreImages: [other] });
|
|
172
|
+
expect(imageToolOutput('', [shot, shot])).toEqual({ forModel: '', mediaType: 'image/jpeg', base64: 'SAME' });
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("an agent's list of steps", () => {
|
|
177
|
+
it('reads a TodoWrite-style list, leaving out items it cannot show', () => {
|
|
178
|
+
expect(
|
|
179
|
+
stepsFromTodos([
|
|
180
|
+
{ content: 'Create a.txt', status: 'completed', priority: 'high' },
|
|
181
|
+
{ content: ' Create b.txt ', status: 'in_progress' },
|
|
182
|
+
{ content: 'Create c.txt', status: 'pending' },
|
|
183
|
+
{ content: '', status: 'pending' },
|
|
184
|
+
{ content: 'Odd', status: 'someday' },
|
|
185
|
+
]),
|
|
186
|
+
).toEqual([
|
|
187
|
+
{ text: 'Create a.txt', status: 'completed' },
|
|
188
|
+
{ text: 'Create b.txt', status: 'in_progress' },
|
|
189
|
+
{ text: 'Create c.txt', status: 'pending' },
|
|
190
|
+
]);
|
|
191
|
+
expect(stepsFromTodos('nope')).toBeUndefined();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("takes Codex's inProgress for in progress", () => {
|
|
195
|
+
expect(stepStatusOf('inProgress')).toBe('in_progress');
|
|
196
|
+
expect(stepStatusOf('done')).toBeUndefined();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('is a display a tool result can carry, and says how far it is', () => {
|
|
200
|
+
const output = stepListOutput('Updated task #1 status', [
|
|
201
|
+
{ text: 'a', status: 'completed' },
|
|
202
|
+
{ text: 'b', status: 'in_progress' },
|
|
203
|
+
{ text: 'c', status: 'pending' },
|
|
204
|
+
]);
|
|
205
|
+
|
|
206
|
+
expect(isToolDisplayResult(output)).toBe(true);
|
|
207
|
+
expect(isStepListDisplay({ kind: 'step-list', steps: [{ text: 'a', status: 'inProgress' }] })).toBe(false);
|
|
208
|
+
expect(stepListProgress(output.display as StepListDisplay)).toEqual({ done: 1, total: 3, current: { text: 'b', status: 'in_progress' } });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('has no current step once every step is done', () => {
|
|
212
|
+
expect(stepListProgress({ kind: 'step-list', steps: [{ text: 'a', status: 'completed' }, { text: 'b', status: 'cancelled' }] })).toEqual({ done: 2, total: 2 });
|
|
213
|
+
});
|
|
214
|
+
});
|
package/src/tool-display.ts
CHANGED
|
@@ -7,13 +7,22 @@
|
|
|
7
7
|
* The projection layer (see `mode-helpers.ts`) sends ONLY `forModel` to the
|
|
8
8
|
* model, so the rich payload never bloats the context window.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* `question-answer` (from `ask_user`) keeps what the person chose, so the
|
|
11
|
+
* transcript still shows it once the Question's card is gone.
|
|
12
|
+
*
|
|
13
|
+
* `file-diff` is emitted by the built-in Write/Edit tools, and by agent connections for the agent's own
|
|
14
|
+
* edits (built with `file-diff.ts`). It carries the changed slices of a file (a few
|
|
12
15
|
* lines of context around each edit) — never the whole file — so the payload
|
|
13
16
|
* stays bounded regardless of file size. Channels render it as a classic diff:
|
|
14
17
|
* line numbers, +/- markers, green/red line backgrounds.
|
|
18
|
+
*
|
|
19
|
+
* `step-list` is an agent's own list of steps (Claude's tasks or TodoWrite,
|
|
20
|
+
* Codex's plan, OpenCode's todos) as it stands after a change: every step,
|
|
21
|
+
* not only the changed one, so the last list of a turn is the whole picture.
|
|
15
22
|
*/
|
|
16
23
|
|
|
24
|
+
import { invariant } from './assert.js';
|
|
25
|
+
|
|
17
26
|
/** One rendered line of a diff. */
|
|
18
27
|
export interface DiffLine {
|
|
19
28
|
readonly kind: 'context' | 'add' | 'del';
|
|
@@ -46,8 +55,42 @@ export interface FileDiffDisplay {
|
|
|
46
55
|
readonly truncated?: boolean;
|
|
47
56
|
}
|
|
48
57
|
|
|
58
|
+
/** One question the person answered, as words: what was asked and what they chose. */
|
|
59
|
+
export interface AnsweredQuestion {
|
|
60
|
+
readonly header?: string;
|
|
61
|
+
readonly question: string;
|
|
62
|
+
/** Labels of the suggested answers they picked, in order. */
|
|
63
|
+
readonly chosen: ReadonlyArray<string>;
|
|
64
|
+
/** Their answer in their own words, when they wrote one. */
|
|
65
|
+
readonly own?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The answers to a Question (`ask_user`), kept so the transcript shows what the
|
|
70
|
+
* person chose after the card is gone.
|
|
71
|
+
*/
|
|
72
|
+
export interface QuestionAnswerDisplay {
|
|
73
|
+
readonly kind: 'question-answer';
|
|
74
|
+
readonly questions: ReadonlyArray<AnsweredQuestion>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type StepStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled';
|
|
78
|
+
|
|
79
|
+
export interface ListedStep {
|
|
80
|
+
readonly text: string;
|
|
81
|
+
readonly status: StepStatus;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** An agent's list of steps after a change to it. */
|
|
85
|
+
export interface StepListDisplay {
|
|
86
|
+
readonly kind: 'step-list';
|
|
87
|
+
readonly steps: ReadonlyArray<ListedStep>;
|
|
88
|
+
/** Why the agent changed its plan, when it said so (Codex). */
|
|
89
|
+
readonly explanation?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
49
92
|
/** Extensible union of structured tool-result payloads. */
|
|
50
|
-
export type ToolDisplay = FileDiffDisplay;
|
|
93
|
+
export type ToolDisplay = FileDiffDisplay | QuestionAnswerDisplay | StepListDisplay;
|
|
51
94
|
|
|
52
95
|
/**
|
|
53
96
|
* The shape a tool returns when it wants a rich, channel-rendered result.
|
|
@@ -68,7 +111,81 @@ export function isToolDisplayResult(x: unknown): x is ToolDisplayResult {
|
|
|
68
111
|
}
|
|
69
112
|
|
|
70
113
|
export function isToolDisplay(x: unknown): x is ToolDisplay {
|
|
71
|
-
return isFileDiffDisplay(x);
|
|
114
|
+
return isFileDiffDisplay(x) || isQuestionAnswerDisplay(x) || isStepListDisplay(x);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function isStepListDisplay(x: unknown): x is StepListDisplay {
|
|
118
|
+
if (typeof x !== 'object' || x === null) return false;
|
|
119
|
+
const d = x as { kind?: unknown; steps?: unknown };
|
|
120
|
+
if (d.kind !== 'step-list' || !Array.isArray(d.steps)) return false;
|
|
121
|
+
return d.steps.every((step: unknown) => {
|
|
122
|
+
if (typeof step !== 'object' || step === null) return false;
|
|
123
|
+
const item = step as { text?: unknown; status?: unknown };
|
|
124
|
+
return typeof item.text === 'string' && stepStatusOf(item.status) === item.status;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** An agent's word for a step's state, as Moxxy's: `in_progress` and Codex's `inProgress` alike. */
|
|
129
|
+
export function stepStatusOf(raw: unknown): StepStatus | undefined {
|
|
130
|
+
switch (raw) {
|
|
131
|
+
case 'pending':
|
|
132
|
+
case 'completed':
|
|
133
|
+
case 'cancelled':
|
|
134
|
+
case 'in_progress':
|
|
135
|
+
return raw;
|
|
136
|
+
case 'inProgress':
|
|
137
|
+
return 'in_progress';
|
|
138
|
+
default:
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The steps of a TodoWrite-style list (`{ content, status }` items, as Claude and OpenCode write them). */
|
|
144
|
+
export function stepsFromTodos(todos: unknown): ListedStep[] | undefined {
|
|
145
|
+
if (!Array.isArray(todos)) return undefined;
|
|
146
|
+
const steps = todos.flatMap((todo: unknown): ListedStep[] => {
|
|
147
|
+
if (typeof todo !== 'object' || todo === null) return [];
|
|
148
|
+
const item = todo as { content?: unknown; status?: unknown };
|
|
149
|
+
const status = stepStatusOf(item.status);
|
|
150
|
+
return typeof item.content === 'string' && item.content.trim() && status ? [{ text: item.content.trim(), status }] : [];
|
|
151
|
+
});
|
|
152
|
+
return steps;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** A tool output whose result is the agent's list of steps as it now stands. */
|
|
156
|
+
export function stepListOutput(forModel: string, steps: ReadonlyArray<ListedStep>, explanation?: string): ToolDisplayResult {
|
|
157
|
+
return { forModel, display: { kind: 'step-list', steps, ...(explanation ? { explanation } : {}) } };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** How far a list is: steps done (completed or cancelled), all steps, and the one being worked on or next. */
|
|
161
|
+
export function stepListProgress(d: StepListDisplay): { readonly done: number; readonly total: number; readonly current?: ListedStep } {
|
|
162
|
+
const done = d.steps.filter((step) => step.status === 'completed' || step.status === 'cancelled').length;
|
|
163
|
+
const current = d.steps.find((step) => step.status === 'in_progress') ?? d.steps.find((step) => step.status === 'pending');
|
|
164
|
+
return { done, total: d.steps.length, ...(current ? { current } : {}) };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function isQuestionAnswerDisplay(x: unknown): x is QuestionAnswerDisplay {
|
|
168
|
+
if (typeof x !== 'object' || x === null) return false;
|
|
169
|
+
const d = x as { kind?: unknown; questions?: unknown };
|
|
170
|
+
if (d.kind !== 'question-answer' || !Array.isArray(d.questions)) return false;
|
|
171
|
+
return d.questions.every((item: unknown) => {
|
|
172
|
+
if (typeof item !== 'object' || item === null) return false;
|
|
173
|
+
const q = item as { question?: unknown; chosen?: unknown; own?: unknown };
|
|
174
|
+
return (
|
|
175
|
+
typeof q.question === 'string' &&
|
|
176
|
+
Array.isArray(q.chosen) &&
|
|
177
|
+
q.chosen.every((label) => typeof label === 'string') &&
|
|
178
|
+
(q.own === undefined || typeof q.own === 'string')
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** One line per answered question, e.g. "Colour: Which colour? → Blue", for text surfaces. */
|
|
184
|
+
export function questionAnswerLines(d: QuestionAnswerDisplay): string[] {
|
|
185
|
+
return d.questions.map((item) => {
|
|
186
|
+
const said = [...(item.chosen.length > 0 ? [item.chosen.join(', ')] : []), ...(item.own ? [`own answer: ${item.own}`] : [])];
|
|
187
|
+
return `${item.header ? `${item.header}: ` : ''}${item.question} → ${said.join('; ')}`;
|
|
188
|
+
});
|
|
72
189
|
}
|
|
73
190
|
|
|
74
191
|
export function isFileDiffDisplay(x: unknown): x is FileDiffDisplay {
|
|
@@ -96,6 +213,47 @@ export function isFileDiffDisplay(x: unknown): x is FileDiffDisplay {
|
|
|
96
213
|
return true;
|
|
97
214
|
}
|
|
98
215
|
|
|
216
|
+
/** Pixels a tool result carries, as base64. */
|
|
217
|
+
export interface ToolImage {
|
|
218
|
+
readonly mediaType: string;
|
|
219
|
+
readonly base64: string;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The images in a tool output and the text beside them, or `null`. A tool
|
|
224
|
+
* returns `{ mediaType, base64, forModel?, moreImages? }` (Moxxy's screenshot
|
|
225
|
+
* tools, agent connections) or the provider-native `{ type: 'image', mediaType, data }`.
|
|
226
|
+
*/
|
|
227
|
+
export function toolImagesOf(output: unknown): { readonly images: ReadonlyArray<ToolImage>; readonly text: string } | null {
|
|
228
|
+
if (typeof output !== 'object' || output === null) return null;
|
|
229
|
+
const o = output as { type?: unknown; mediaType?: unknown; base64?: unknown; data?: unknown; forModel?: unknown; moreImages?: unknown };
|
|
230
|
+
if (typeof o.mediaType !== 'string') return null;
|
|
231
|
+
const base64 = typeof o.base64 === 'string' ? o.base64 : o.type === 'image' && typeof o.data === 'string' ? o.data : null;
|
|
232
|
+
if (base64 === null) return null;
|
|
233
|
+
const more = Array.isArray(o.moreImages) ? o.moreImages.filter(isToolImage) : [];
|
|
234
|
+
return { images: [{ mediaType: o.mediaType, base64 }, ...more], text: typeof o.forModel === 'string' ? o.forModel : '' };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function isToolImage(x: unknown): x is ToolImage {
|
|
238
|
+
if (typeof x !== 'object' || x === null) return false;
|
|
239
|
+
const image = x as { mediaType?: unknown; base64?: unknown };
|
|
240
|
+
return typeof image.mediaType === 'string' && typeof image.base64 === 'string';
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* A tool output holding the images a tool returned, in order, and the text the
|
|
245
|
+
* agent read beside them. An image sent more than once is kept once.
|
|
246
|
+
*/
|
|
247
|
+
export function imageToolOutput(
|
|
248
|
+
text: string,
|
|
249
|
+
images: ReadonlyArray<ToolImage>,
|
|
250
|
+
): { readonly forModel: string; readonly mediaType: string; readonly base64: string; readonly moreImages?: ReadonlyArray<ToolImage> } {
|
|
251
|
+
const distinct = images.filter((image, index) => images.findIndex((other) => other.base64 === image.base64) === index);
|
|
252
|
+
const [first, ...more] = distinct;
|
|
253
|
+
invariant(first !== undefined, 'an image tool output holds at least one image');
|
|
254
|
+
return { forModel: text, mediaType: first.mediaType, base64: first.base64, ...(more.length > 0 ? { moreImages: more } : {}) };
|
|
255
|
+
}
|
|
256
|
+
|
|
99
257
|
/** Human summary, e.g. "Added 10 lines, removed 1 line". */
|
|
100
258
|
export function fileDiffSummary(d: FileDiffDisplay): string {
|
|
101
259
|
const plural = (n: number, w: string): string => `${n} ${w}${n === 1 ? '' : 's'}`;
|
package/src/tool-gating.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { z } from 'zod';
|
|
2
|
+
import { z } from 'zod/v3';
|
|
3
3
|
import { defineTool } from './define.js';
|
|
4
4
|
import type { MoxxyEvent, MoxxyEventOfType, MoxxyEventType } from './events.js';
|
|
5
5
|
import { asEventId, asSessionId, asToolCallId, asTurnId, type TurnId } from './ids.js';
|
package/src/tool-icon.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { TOOL_ICONS, isToolIcon } from './tool-icon.js';
|
|
3
3
|
import { defineTool } from './define.js';
|
|
4
|
-
import { z } from 'zod';
|
|
4
|
+
import { z } from 'zod/v3';
|
|
5
5
|
|
|
6
6
|
describe('tool icons', () => {
|
|
7
7
|
it('is a closed vocabulary, so a surface can map it exhaustively', () => {
|
package/src/tool-modes.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { z } from 'zod';
|
|
2
|
+
import { z } from 'zod/v3';
|
|
3
3
|
import { defineTool } from './define.js';
|
|
4
4
|
import type { ToolRegistry } from './mode.js';
|
|
5
5
|
import { toolsForMode } from './tool-modes.js';
|
|
@@ -42,4 +42,21 @@ describe('toolsForMode', () => {
|
|
|
42
42
|
expect(toolsForMode(registry, 'goal').list().map((t) => t.name)).toEqual(['Read', 'goal_abandon']);
|
|
43
43
|
expect(toolsForMode(registry, 'collab-peer').get('collab_inbox')?.name).toBe('collab_inbox');
|
|
44
44
|
});
|
|
45
|
+
|
|
46
|
+
it('leaves a tool out of the modes it rules out, and offers it in every other', async () => {
|
|
47
|
+
const asks = defineTool({
|
|
48
|
+
name: 'ask_user',
|
|
49
|
+
description: 'asks',
|
|
50
|
+
inputSchema: z.object({}),
|
|
51
|
+
handler: async () => 'asked',
|
|
52
|
+
notInModes: ['goal'],
|
|
53
|
+
});
|
|
54
|
+
expect(asks.notInModes).toEqual(['goal']);
|
|
55
|
+
const withAsk: ToolRegistry = { list: () => [tool('Read'), asks], get: (name) => withAsk.list().find((t) => t.name === name), execute: async (name) => name };
|
|
56
|
+
|
|
57
|
+
expect(toolsForMode(withAsk, 'goal').list().map((t) => t.name)).toEqual(['Read']);
|
|
58
|
+
expect(toolsForMode(withAsk, 'goal').get('ask_user')).toBeUndefined();
|
|
59
|
+
await expect(toolsForMode(withAsk, 'goal').execute('ask_user', {}, signal)).rejects.toThrow(/ask_user is not available in goal mode/);
|
|
60
|
+
expect(toolsForMode(withAsk, 'default').get('ask_user')?.name).toBe('ask_user');
|
|
61
|
+
});
|
|
45
62
|
});
|
package/src/tool-modes.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import type { ToolRegistry } from './mode.js';
|
|
2
2
|
import type { ToolDef } from './tool.js';
|
|
3
3
|
|
|
4
|
-
const offeredIn = (tool: ToolDef | undefined, mode: string): boolean =>
|
|
4
|
+
const offeredIn = (tool: ToolDef | undefined, mode: string): boolean => {
|
|
5
|
+
if (!tool) return true;
|
|
6
|
+
if (tool.notInModes && tool.notInModes.includes(mode)) return false;
|
|
7
|
+
return !tool.modes || tool.modes.includes(mode);
|
|
8
|
+
};
|
|
5
9
|
|
|
6
10
|
/**
|
|
7
11
|
* The registry as a mode sees it: a tool that names its modes is left out of
|
|
8
|
-
* the others — not offered to the model, and refused if called anyway. It stays
|
|
12
|
+
* the others, and one that rules a mode out is left out of that one — not offered to the model, and refused if called anyway. It stays
|
|
9
13
|
* registered, so its permission and isolation are looked up as before.
|
|
10
14
|
*/
|
|
11
15
|
export function toolsForMode(tools: ToolRegistry, mode: string): ToolRegistry {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { toolOutputForModel } from './tool-output.js';
|
|
3
|
+
|
|
4
|
+
describe('what the model sees of a tool output', () => {
|
|
5
|
+
it('passes text through as it is', () => {
|
|
6
|
+
expect(toolOutputForModel('hello')).toEqual({ text: 'hello', images: [] });
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('writes other values as JSON', () => {
|
|
10
|
+
expect(toolOutputForModel({ n: 1 })).toEqual({ text: '{"n":1}', images: [] });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('survives a value JSON cannot write', () => {
|
|
14
|
+
const loop: { self?: unknown } = {};
|
|
15
|
+
loop.self = loop;
|
|
16
|
+
expect(toolOutputForModel(loop).text).toBe('[object Object]');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('gives only the short summary of a rich result', () => {
|
|
20
|
+
const output = {
|
|
21
|
+
forModel: 'Colour: Blue',
|
|
22
|
+
display: { kind: 'question-answer', questions: [{ question: 'Which colour?', chosen: ['Blue'] }] },
|
|
23
|
+
};
|
|
24
|
+
expect(toolOutputForModel(output)).toEqual({ text: 'Colour: Blue', images: [] });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('hands an image over as pixels, with its description when the tool gave one', () => {
|
|
28
|
+
expect(toolOutputForModel({ mediaType: 'image/png', base64: 'AAA', forModel: 'Screen 1440x900' })).toEqual({
|
|
29
|
+
text: 'Screen 1440x900',
|
|
30
|
+
images: [{ type: 'image', mediaType: 'image/png', data: 'AAA' }],
|
|
31
|
+
});
|
|
32
|
+
expect(toolOutputForModel({ type: 'image', mediaType: 'image/jpeg', data: 'BBB' })).toEqual({
|
|
33
|
+
text: '[image returned by tool — see attached image]',
|
|
34
|
+
images: [{ type: 'image', mediaType: 'image/jpeg', data: 'BBB' }],
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('hands over every image a result carries', () => {
|
|
39
|
+
const output = { forModel: 'Two windows', mediaType: 'image/png', base64: 'AAA', moreImages: [{ mediaType: 'image/jpeg', base64: 'BBB' }] };
|
|
40
|
+
|
|
41
|
+
expect(toolOutputForModel(output).images).toEqual([
|
|
42
|
+
{ type: 'image', mediaType: 'image/png', data: 'AAA' },
|
|
43
|
+
{ type: 'image', mediaType: 'image/jpeg', data: 'BBB' },
|
|
44
|
+
]);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ContentBlock } from './provider.js';
|
|
2
|
+
import { isToolDisplayResult, toolImagesOf } from './tool-display.js';
|
|
3
|
+
|
|
4
|
+
export type ToolImageBlock = Extract<ContentBlock, { type: 'image' }>;
|
|
5
|
+
|
|
6
|
+
/** What a model is shown of a successful tool output: text, plus the pixels of each image. */
|
|
7
|
+
export interface ToolOutputForModel {
|
|
8
|
+
readonly text: string;
|
|
9
|
+
readonly images: ReadonlyArray<ToolImageBlock>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const IMAGE_MARKER = '[image returned by tool — see attached image]';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The one reading of a tool output for a model, shared by the projection and
|
|
16
|
+
* by agent connections (which hand Moxxy tool results to a vendor agent).
|
|
17
|
+
*/
|
|
18
|
+
export function toolOutputForModel(output: unknown): ToolOutputForModel {
|
|
19
|
+
// A rich result (e.g. a file diff): the model only needs the short summary.
|
|
20
|
+
if (isToolDisplayResult(output)) return { text: output.forModel, images: [] };
|
|
21
|
+
// Images are handed over as image blocks so the model SEES the pixels.
|
|
22
|
+
const found = toolImagesOf(output);
|
|
23
|
+
if (found) {
|
|
24
|
+
const images = found.images.map((image): ToolImageBlock => ({ type: 'image', mediaType: image.mediaType, data: image.base64 }));
|
|
25
|
+
return { text: found.text || IMAGE_MARKER, images };
|
|
26
|
+
}
|
|
27
|
+
return { text: safeStringifyOutput(output), images: [] };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `output` comes straight from whatever a tool returned, so it can be circular
|
|
32
|
+
* or hold a BigInt, which makes `JSON.stringify` throw. Projection runs on every
|
|
33
|
+
* request over the append-only log, so a throw here would wedge the session.
|
|
34
|
+
*/
|
|
35
|
+
function safeStringifyOutput(output: unknown): string {
|
|
36
|
+
if (typeof output === 'string') return output;
|
|
37
|
+
try {
|
|
38
|
+
return JSON.stringify(output ?? '');
|
|
39
|
+
} catch {
|
|
40
|
+
try {
|
|
41
|
+
return String(output ?? '');
|
|
42
|
+
} catch {
|
|
43
|
+
return '[unserializable tool output]';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|