@gram-ai/elements 1.28.0 → 1.30.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/components/MessageContent.d.ts +20 -0
- package/dist/components/MessageContent.parser.d.ts +12 -0
- package/dist/components/MessageContent.test.d.ts +1 -0
- package/dist/elements.cjs +1 -1
- package/dist/elements.css +1 -1
- package/dist/elements.js +14 -13
- package/dist/{index-C4bFBGfl.cjs → index-COzPF-WM.cjs} +45 -45
- package/dist/index-COzPF-WM.cjs.map +1 -0
- package/dist/{index-D93pV0_o.js → index-CRhpKl-G.js} +5218 -5201
- package/dist/index-CRhpKl-G.js.map +1 -0
- package/dist/{index-CtZz13Cf.js → index-QUz5guSg.js} +11835 -11604
- package/dist/index-QUz5guSg.js.map +1 -0
- package/dist/index-fVcTljYT.cjs +194 -0
- package/dist/index-fVcTljYT.cjs.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/lib/contextCompaction.d.ts +58 -0
- package/dist/lib/contextCompaction.test.d.ts +1 -0
- package/dist/lib/errorTracking.config.d.ts +2 -0
- package/dist/lib/tools.byte-cap.test.d.ts +1 -0
- package/dist/lib/tools.d.ts +19 -0
- package/dist/lib/tools.test.d.ts +1 -0
- package/dist/plugins/index.d.ts +4 -1
- package/dist/plugins/index.test.d.ts +1 -0
- package/dist/plugins.cjs +1 -1
- package/dist/plugins.js +1 -1
- package/dist/{profiler-Ccma0l1p.js → profiler-DifNjGGB.js} +2 -2
- package/dist/{profiler-Ccma0l1p.js.map → profiler-DifNjGGB.js.map} +1 -1
- package/dist/{profiler-CjNa3A1d.cjs → profiler-KLtVMM14.cjs} +2 -2
- package/dist/{profiler-CjNa3A1d.cjs.map → profiler-KLtVMM14.cjs.map} +1 -1
- package/dist/{startRecording-DAURU74n.js → startRecording-C6xu9UA9.js} +2 -2
- package/dist/{startRecording-DAURU74n.js.map → startRecording-C6xu9UA9.js.map} +1 -1
- package/dist/{startRecording-jSovclaq.cjs → startRecording-YENzw_0G.cjs} +2 -2
- package/dist/{startRecording-jSovclaq.cjs.map → startRecording-YENzw_0G.cjs.map} +1 -1
- package/dist/types/index.d.ts +49 -0
- package/dist/types/plugins.d.ts +5 -0
- package/package.json +2 -2
- package/src/components/MessageContent.parser.ts +39 -0
- package/src/components/MessageContent.test.ts +110 -0
- package/src/components/MessageContent.tsx +82 -0
- package/src/contexts/ElementsProvider.tsx +57 -7
- package/src/index.ts +2 -0
- package/src/lib/contextCompaction.test.ts +201 -0
- package/src/lib/contextCompaction.ts +211 -0
- package/src/lib/errorTracking.config.ts +2 -0
- package/src/lib/errorTracking.ts +1 -1
- package/src/lib/tools.byte-cap.test.ts +132 -0
- package/src/lib/tools.test.ts +259 -0
- package/src/lib/tools.ts +122 -0
- package/src/plugins/chart/index.ts +1 -0
- package/src/plugins/chart/ui/bar-chart.tsx +9 -1
- package/src/plugins/generative-ui/index.ts +1 -0
- package/src/plugins/index.test.ts +62 -0
- package/src/plugins/index.ts +14 -1
- package/src/types/index.ts +55 -0
- package/src/types/plugins.ts +6 -0
- package/dist/index-BmTGnEaV.cjs +0 -190
- package/dist/index-BmTGnEaV.cjs.map +0 -1
- package/dist/index-C4bFBGfl.cjs.map +0 -1
- package/dist/index-CtZz13Cf.js.map +0 -1
- package/dist/index-D93pV0_o.js.map +0 -1
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
capToolResultBytes,
|
|
4
|
+
truncateTextToByteCap,
|
|
5
|
+
wrapToolsWithByteCap,
|
|
6
|
+
} from "./tools";
|
|
7
|
+
import type { ToolSet } from "ai";
|
|
8
|
+
|
|
9
|
+
describe("truncateTextToByteCap", () => {
|
|
10
|
+
it("returns original when under cap", () => {
|
|
11
|
+
expect(truncateTextToByteCap("hello world", 100)).toBe("hello world");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("truncates with head + tail + notice when over cap", () => {
|
|
15
|
+
const text = "a".repeat(1000) + "-MIDDLE-" + "b".repeat(1000);
|
|
16
|
+
const out = truncateTextToByteCap(text, 200);
|
|
17
|
+
expect(out.length).toBeLessThan(text.length);
|
|
18
|
+
expect(out).toContain("tool output truncated");
|
|
19
|
+
expect(out.startsWith("a")).toBe(true);
|
|
20
|
+
expect(out.endsWith("b")).toBe(true);
|
|
21
|
+
expect(out).not.toContain("MIDDLE");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("output always stays at or under maxBytes (notice included in budget)", () => {
|
|
25
|
+
// Regression test — earlier version appended the notice *without*
|
|
26
|
+
// reserving budget for it, so the output overshot maxBytes by ~100.
|
|
27
|
+
for (const maxBytes of [256, 512, 1024, 4096]) {
|
|
28
|
+
const text = "x".repeat(50_000);
|
|
29
|
+
const out = truncateTextToByteCap(text, maxBytes);
|
|
30
|
+
const outBytes = new TextEncoder().encode(out).byteLength;
|
|
31
|
+
expect(outBytes).toBeLessThanOrEqual(maxBytes);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("passes through when maxBytes <= 0 (disabled)", () => {
|
|
36
|
+
const text = "x".repeat(10_000);
|
|
37
|
+
expect(truncateTextToByteCap(text, 0)).toBe(text);
|
|
38
|
+
expect(truncateTextToByteCap(text, -1)).toBe(text);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("handles multibyte UTF-8 without crashing", () => {
|
|
42
|
+
const text = "🎉".repeat(500);
|
|
43
|
+
const out = truncateTextToByteCap(text, 200);
|
|
44
|
+
expect(out).toContain("tool output truncated");
|
|
45
|
+
expect(new TextEncoder().encode(out).byteLength).toBeGreaterThan(0);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("capToolResultBytes", () => {
|
|
50
|
+
it("truncates plain string results", () => {
|
|
51
|
+
const out = capToolResultBytes("x".repeat(5_000), 100);
|
|
52
|
+
expect(typeof out).toBe("string");
|
|
53
|
+
expect(out).not.toBe("x".repeat(5_000));
|
|
54
|
+
expect(out).toContain("tool output truncated");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("truncates text chunks inside MCP-shaped results", () => {
|
|
58
|
+
const result = {
|
|
59
|
+
content: [
|
|
60
|
+
{ type: "text", text: "short" },
|
|
61
|
+
{ type: "text", text: "big".repeat(5_000) },
|
|
62
|
+
],
|
|
63
|
+
isError: false,
|
|
64
|
+
};
|
|
65
|
+
const out = capToolResultBytes(result, 100) as typeof result;
|
|
66
|
+
expect(out.content[0]).toEqual({ type: "text", text: "short" });
|
|
67
|
+
expect((out.content[1] as { text: string }).text).toContain(
|
|
68
|
+
"tool output truncated",
|
|
69
|
+
);
|
|
70
|
+
expect(out.isError).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("leaves non-text chunks alone", () => {
|
|
74
|
+
const result = {
|
|
75
|
+
content: [
|
|
76
|
+
{ type: "image", data: "x".repeat(5_000), mimeType: "image/png" },
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
const out = capToolResultBytes(result, 100) as typeof result;
|
|
80
|
+
expect(out.content[0]).toEqual(result.content[0]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("preserves isError flag", () => {
|
|
84
|
+
const result = {
|
|
85
|
+
content: [{ type: "text", text: "tool blew up: " + "x".repeat(5_000) }],
|
|
86
|
+
isError: true,
|
|
87
|
+
};
|
|
88
|
+
const out = capToolResultBytes(result, 100) as typeof result;
|
|
89
|
+
expect(out.isError).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("passes unknown shapes through", () => {
|
|
93
|
+
expect(capToolResultBytes(42, 100)).toBe(42);
|
|
94
|
+
expect(capToolResultBytes(null, 100)).toBe(null);
|
|
95
|
+
expect(capToolResultBytes({ foo: "bar" }, 100)).toEqual({ foo: "bar" });
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("wrapToolsWithByteCap", () => {
|
|
100
|
+
it("is a no-op when maxBytes is undefined/0", () => {
|
|
101
|
+
const execute = vi.fn().mockResolvedValue("anything");
|
|
102
|
+
const tools: ToolSet = {
|
|
103
|
+
t: { description: "", inputSchema: { type: "object" }, execute } as never,
|
|
104
|
+
};
|
|
105
|
+
expect(wrapToolsWithByteCap(tools, undefined)).toBe(tools);
|
|
106
|
+
expect(wrapToolsWithByteCap(tools, 0)).toBe(tools);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("wraps execute and truncates oversized result", async () => {
|
|
110
|
+
const execute = vi.fn().mockResolvedValue({
|
|
111
|
+
content: [{ type: "text", text: "z".repeat(10_000) }],
|
|
112
|
+
});
|
|
113
|
+
const tools: ToolSet = {
|
|
114
|
+
t: { description: "", inputSchema: { type: "object" }, execute } as never,
|
|
115
|
+
};
|
|
116
|
+
const wrapped = wrapToolsWithByteCap(tools, 256);
|
|
117
|
+
const wrappedExecute = wrapped.t.execute!;
|
|
118
|
+
const out = (await wrappedExecute({}, { toolCallId: "id" } as never)) as {
|
|
119
|
+
content: Array<{ text: string }>;
|
|
120
|
+
};
|
|
121
|
+
expect(out.content[0]!.text).toContain("tool output truncated");
|
|
122
|
+
expect(execute).toHaveBeenCalledOnce();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("leaves tools without execute alone", () => {
|
|
126
|
+
const tools: ToolSet = {
|
|
127
|
+
t: { description: "", inputSchema: { type: "object" } } as never,
|
|
128
|
+
};
|
|
129
|
+
const wrapped = wrapToolsWithByteCap(tools, 256);
|
|
130
|
+
expect(wrapped.t).toBe(tools.t);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
convertToModelMessages,
|
|
4
|
+
isToolUIPart,
|
|
5
|
+
jsonSchema,
|
|
6
|
+
lastAssistantMessageIsCompleteWithToolCalls,
|
|
7
|
+
readUIMessageStream,
|
|
8
|
+
stepCountIs,
|
|
9
|
+
streamText,
|
|
10
|
+
type ToolSet,
|
|
11
|
+
type UIMessage,
|
|
12
|
+
type UIMessagePart,
|
|
13
|
+
} from "ai";
|
|
14
|
+
import { MockLanguageModelV2 } from "ai/test";
|
|
15
|
+
|
|
16
|
+
type MockStream = Extract<
|
|
17
|
+
NonNullable<
|
|
18
|
+
NonNullable<
|
|
19
|
+
ConstructorParameters<typeof MockLanguageModelV2>[0]
|
|
20
|
+
>["doStream"]
|
|
21
|
+
>,
|
|
22
|
+
(...a: never[]) => PromiseLike<{ stream: ReadableStream<unknown> }>
|
|
23
|
+
>;
|
|
24
|
+
type StreamPart =
|
|
25
|
+
Awaited<ReturnType<MockStream>>["stream"] extends ReadableStream<infer T>
|
|
26
|
+
? T
|
|
27
|
+
: never;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Repro for the assistants-onboarding "Skip bugged state":
|
|
31
|
+
*
|
|
32
|
+
* 1. Assistant calls a frontend tool (e.g. `request_environment_secrets`) that
|
|
33
|
+
* renders a form with a Skip button.
|
|
34
|
+
* 2. User clicks Skip. The form calls `draft.resolvePending(toolCallId, { cancelled: true })`.
|
|
35
|
+
* 3. Expected: tool-result is patched onto the message, the agent continues,
|
|
36
|
+
* chat returns to a ready state.
|
|
37
|
+
* 4. Observed (pre-fix): the chat stayed stuck — the next user message landed
|
|
38
|
+
* with an invalid tool sequence and the provider rejected it with a
|
|
39
|
+
* "message needing to be sent with role: assistant"-shaped error.
|
|
40
|
+
*
|
|
41
|
+
* `streamText` runs without an `execute` for frontend tools: AI-SDK's
|
|
42
|
+
* `frontendTools()` helper strips execute so client-side logic can take over.
|
|
43
|
+
* The missing link on main was that the runtime patched in the tool result
|
|
44
|
+
* but nothing resumed the turn. The fix wires `sendAutomaticallyWhen:
|
|
45
|
+
* lastAssistantMessageIsCompleteWithToolCalls` into `useChatRuntime`, which
|
|
46
|
+
* flips that resume on.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
function toolCallChunks(opts: {
|
|
50
|
+
toolCallId: string;
|
|
51
|
+
toolName: string;
|
|
52
|
+
input: string;
|
|
53
|
+
}): StreamPart[] {
|
|
54
|
+
return [
|
|
55
|
+
{ type: "stream-start", warnings: [] },
|
|
56
|
+
{
|
|
57
|
+
type: "response-metadata",
|
|
58
|
+
id: "resp-1",
|
|
59
|
+
modelId: "m",
|
|
60
|
+
timestamp: new Date(0),
|
|
61
|
+
},
|
|
62
|
+
{ type: "tool-input-start", id: opts.toolCallId, toolName: opts.toolName },
|
|
63
|
+
{ type: "tool-input-delta", id: opts.toolCallId, delta: opts.input },
|
|
64
|
+
{ type: "tool-input-end", id: opts.toolCallId },
|
|
65
|
+
{
|
|
66
|
+
type: "tool-call",
|
|
67
|
+
toolCallId: opts.toolCallId,
|
|
68
|
+
toolName: opts.toolName,
|
|
69
|
+
input: opts.input,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
type: "finish",
|
|
73
|
+
finishReason: "tool-calls",
|
|
74
|
+
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function makeStream<T>(chunks: T[]): ReadableStream<T> {
|
|
80
|
+
return new ReadableStream({
|
|
81
|
+
start(controller) {
|
|
82
|
+
for (const c of chunks) controller.enqueue(c);
|
|
83
|
+
controller.close();
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function collectUIMessages(
|
|
89
|
+
stream: AsyncIterable<UIMessage>,
|
|
90
|
+
): Promise<UIMessage[]> {
|
|
91
|
+
const out: UIMessage[] = [];
|
|
92
|
+
for await (const msg of stream) {
|
|
93
|
+
const idx = out.findIndex((m) => m.id === msg.id);
|
|
94
|
+
if (idx >= 0) out[idx] = msg;
|
|
95
|
+
else out.push(msg);
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function streamToolCallOnly(toolCallId: string): Promise<UIMessage[]> {
|
|
101
|
+
const toolsNoExecute = {
|
|
102
|
+
request_environment_secrets: {
|
|
103
|
+
description: "Ask the user to enter secrets for an env.",
|
|
104
|
+
inputSchema: jsonSchema({
|
|
105
|
+
type: "object",
|
|
106
|
+
properties: {
|
|
107
|
+
keys: {
|
|
108
|
+
type: "array",
|
|
109
|
+
items: {
|
|
110
|
+
type: "object",
|
|
111
|
+
properties: { name: { type: "string" } },
|
|
112
|
+
required: ["name"],
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
required: ["keys"],
|
|
117
|
+
}),
|
|
118
|
+
},
|
|
119
|
+
} as unknown as ToolSet;
|
|
120
|
+
|
|
121
|
+
const model = new MockLanguageModelV2({
|
|
122
|
+
doStream: async () => ({
|
|
123
|
+
stream: makeStream([
|
|
124
|
+
...toolCallChunks({
|
|
125
|
+
toolCallId,
|
|
126
|
+
toolName: "request_environment_secrets",
|
|
127
|
+
input: JSON.stringify({ keys: [{ name: "SLACK_BOT_TOKEN" }] }),
|
|
128
|
+
}),
|
|
129
|
+
]),
|
|
130
|
+
}),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const result = streamText({
|
|
134
|
+
model,
|
|
135
|
+
messages: [{ role: "user", content: "Set up Slack" }],
|
|
136
|
+
tools: toolsNoExecute,
|
|
137
|
+
stopWhen: stepCountIs(5),
|
|
138
|
+
});
|
|
139
|
+
return collectUIMessages(
|
|
140
|
+
readUIMessageStream({ stream: result.toUIMessageStream() }),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
describe("frontend tool Skip flow (sendAutomaticallyWhen fix)", () => {
|
|
145
|
+
it("without a tool-result, the message sequence is invalid — this is the bug we are fixing", async () => {
|
|
146
|
+
// Mirrors the elements flow on main: frontend tool has no execute inside
|
|
147
|
+
// streamText (it's run client-side by useToolInvocations). If nothing
|
|
148
|
+
// patches a tool-result onto the message, sending a follow-up user
|
|
149
|
+
// message produces an invalid sequence.
|
|
150
|
+
const toolCallId = "call_unresolved";
|
|
151
|
+
const messages = await streamToolCallOnly(toolCallId);
|
|
152
|
+
|
|
153
|
+
const assistant = messages.find((m) => m.role === "assistant")!;
|
|
154
|
+
const toolParts = (assistant.parts as UIMessagePart<never, never>[]).filter(
|
|
155
|
+
(p) => isToolUIPart(p),
|
|
156
|
+
);
|
|
157
|
+
expect(toolParts).toHaveLength(1);
|
|
158
|
+
expect((toolParts[0] as unknown as { state: string }).state).toBe(
|
|
159
|
+
"input-available",
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const follow: UIMessage[] = [
|
|
163
|
+
...messages,
|
|
164
|
+
{
|
|
165
|
+
id: "u2",
|
|
166
|
+
role: "user",
|
|
167
|
+
parts: [{ type: "text", text: "skip" }],
|
|
168
|
+
} as unknown as UIMessage,
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
// Also: `lastAssistantMessageIsCompleteWithToolCalls` must return `false`
|
|
172
|
+
// here — there is no tool result, so the runtime should NOT auto-resume.
|
|
173
|
+
expect(lastAssistantMessageIsCompleteWithToolCalls({ messages })).toBe(
|
|
174
|
+
false,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// And the resulting model-message sequence contains a bogus `role: "tool"`
|
|
178
|
+
// with empty content — the provider will reject this as an invalid tool
|
|
179
|
+
// message, surfacing to the user as the "needs role: assistant" error.
|
|
180
|
+
const modelMsgs = convertToModelMessages(follow);
|
|
181
|
+
const assistantIdx = modelMsgs.findIndex(
|
|
182
|
+
(m) =>
|
|
183
|
+
m.role === "assistant" &&
|
|
184
|
+
Array.isArray(m.content) &&
|
|
185
|
+
(m.content as Array<{ type: string }>).some(
|
|
186
|
+
(c) => c.type === "tool-call",
|
|
187
|
+
),
|
|
188
|
+
);
|
|
189
|
+
expect(assistantIdx).toBeGreaterThanOrEqual(0);
|
|
190
|
+
expect(modelMsgs[assistantIdx + 1]?.role).toBe("tool");
|
|
191
|
+
expect(modelMsgs[assistantIdx + 1]?.content).toEqual([]);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("once the tool-result is patched onto the message, sendAutomaticallyWhen fires and the sequence is valid", async () => {
|
|
195
|
+
// Simulates the full post-fix behaviour: `useToolInvocations` ran execute
|
|
196
|
+
// client-side and called `addToolResult`, which flips the tool part to
|
|
197
|
+
// `output-available`. With the result in place:
|
|
198
|
+
// - `lastAssistantMessageIsCompleteWithToolCalls` returns true, so the
|
|
199
|
+
// runtime re-issues the model turn (this is the 1-line fix).
|
|
200
|
+
// - `convertToModelMessages` produces a real `role: "tool"` message
|
|
201
|
+
// with the result, which the provider accepts.
|
|
202
|
+
const toolCallId = "call_resolved";
|
|
203
|
+
const rawMessages = await streamToolCallOnly(toolCallId);
|
|
204
|
+
|
|
205
|
+
// Patch in the tool-output-available state — this is the shape
|
|
206
|
+
// `chatHelpers.addToolResult` produces under the hood.
|
|
207
|
+
const patched: UIMessage[] = rawMessages.map((m) => {
|
|
208
|
+
if (m.role !== "assistant") return m;
|
|
209
|
+
return {
|
|
210
|
+
...m,
|
|
211
|
+
parts: (m.parts as Array<Record<string, unknown>>).map((p) =>
|
|
212
|
+
isToolUIPart(p as UIMessagePart<never, never>)
|
|
213
|
+
? {
|
|
214
|
+
...p,
|
|
215
|
+
state: "output-available",
|
|
216
|
+
output: { ok: true, cancelled: true },
|
|
217
|
+
}
|
|
218
|
+
: p,
|
|
219
|
+
),
|
|
220
|
+
} as UIMessage;
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const toolPart = (
|
|
224
|
+
(
|
|
225
|
+
patched.find((m) => m.role === "assistant")!.parts as UIMessagePart<
|
|
226
|
+
never,
|
|
227
|
+
never
|
|
228
|
+
>[]
|
|
229
|
+
).filter((p) => isToolUIPart(p))[0] as unknown as { state: string }
|
|
230
|
+
).state;
|
|
231
|
+
expect(toolPart).toBe("output-available");
|
|
232
|
+
|
|
233
|
+
// Pre-condition for the fix: the runtime auto-resumes the turn.
|
|
234
|
+
expect(
|
|
235
|
+
lastAssistantMessageIsCompleteWithToolCalls({ messages: patched }),
|
|
236
|
+
).toBe(true);
|
|
237
|
+
|
|
238
|
+
// And the sequence handed to the model is well-formed (assistant
|
|
239
|
+
// tool-call is followed by a real role:"tool" message with a result).
|
|
240
|
+
const modelMsgs = convertToModelMessages(patched);
|
|
241
|
+
const assistantIdx = modelMsgs.findIndex(
|
|
242
|
+
(m) =>
|
|
243
|
+
m.role === "assistant" &&
|
|
244
|
+
Array.isArray(m.content) &&
|
|
245
|
+
(m.content as Array<{ type: string }>).some(
|
|
246
|
+
(c) => c.type === "tool-call",
|
|
247
|
+
),
|
|
248
|
+
);
|
|
249
|
+
const next = modelMsgs[assistantIdx + 1];
|
|
250
|
+
expect(next?.role).toBe("tool");
|
|
251
|
+
expect(Array.isArray(next?.content) && next.content.length).toBeGreaterThan(
|
|
252
|
+
0,
|
|
253
|
+
);
|
|
254
|
+
const toolResult = (
|
|
255
|
+
next?.content as Array<{ type: string; output?: { type?: string } }>
|
|
256
|
+
)[0];
|
|
257
|
+
expect(toolResult?.type).toBe("tool-result");
|
|
258
|
+
});
|
|
259
|
+
});
|
package/src/lib/tools.ts
CHANGED
|
@@ -156,6 +156,128 @@ export interface ApprovalHelpers {
|
|
|
156
156
|
whitelistTool: (toolName: string) => void;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Default head/tail split (bytes) when a tool result exceeds the cap. Head keeps
|
|
161
|
+
* early context (e.g. the preamble of a log query); tail keeps the most recent
|
|
162
|
+
* lines, which are usually the most relevant.
|
|
163
|
+
*/
|
|
164
|
+
const BYTE_CAP_HEAD_FRACTION = 0.9;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Truncates a single string to maxBytes using a head + tail preserving strategy
|
|
168
|
+
* when it exceeds the cap. Returns the original string when under the cap.
|
|
169
|
+
*/
|
|
170
|
+
export function truncateTextToByteCap(text: string, maxBytes: number): string {
|
|
171
|
+
if (maxBytes <= 0) return text;
|
|
172
|
+
const original = text;
|
|
173
|
+
// Work in UTF-8 bytes to match what OpenRouter counts.
|
|
174
|
+
const encoded = new TextEncoder().encode(original);
|
|
175
|
+
if (encoded.byteLength <= maxBytes) return original;
|
|
176
|
+
|
|
177
|
+
// Reserve room for the notice up-front so final output stays under maxBytes.
|
|
178
|
+
// Without this deduction, output would be head + notice + tail ≈ maxBytes
|
|
179
|
+
// + ~100 bytes, which silently overshoots the cap.
|
|
180
|
+
const notice = `\n\n[…tool output truncated from ${encoded.byteLength} bytes to ${maxBytes}; ask a narrower question to see more…]\n\n`;
|
|
181
|
+
const noticeBytes = new TextEncoder().encode(notice).byteLength;
|
|
182
|
+
const availableBytes = Math.max(0, maxBytes - noticeBytes);
|
|
183
|
+
|
|
184
|
+
const headBytes = Math.max(
|
|
185
|
+
0,
|
|
186
|
+
Math.floor(availableBytes * BYTE_CAP_HEAD_FRACTION),
|
|
187
|
+
);
|
|
188
|
+
const tailBytes = Math.max(0, availableBytes - headBytes);
|
|
189
|
+
const decoder = new TextDecoder("utf-8", { fatal: false });
|
|
190
|
+
const head = decoder.decode(encoded.slice(0, headBytes));
|
|
191
|
+
const tail =
|
|
192
|
+
tailBytes > 0
|
|
193
|
+
? decoder.decode(encoded.slice(encoded.byteLength - tailBytes))
|
|
194
|
+
: "";
|
|
195
|
+
|
|
196
|
+
return tail ? `${head}${notice}${tail}` : `${head}${notice}`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Walks the shape returned by MCP/AI-SDK tool executors and truncates any
|
|
201
|
+
* over-sized text payload in place. Handles:
|
|
202
|
+
* - plain strings
|
|
203
|
+
* - { content: Array<{ type, text?, ... }>, isError? }
|
|
204
|
+
* Other shapes pass through untouched.
|
|
205
|
+
*/
|
|
206
|
+
export function capToolResultBytes(result: unknown, maxBytes: number): unknown {
|
|
207
|
+
if (maxBytes <= 0) return result;
|
|
208
|
+
|
|
209
|
+
if (typeof result === "string") {
|
|
210
|
+
return truncateTextToByteCap(result, maxBytes);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (result && typeof result === "object" && "content" in result) {
|
|
214
|
+
const r = result as {
|
|
215
|
+
content?: unknown;
|
|
216
|
+
isError?: boolean;
|
|
217
|
+
[k: string]: unknown;
|
|
218
|
+
};
|
|
219
|
+
if (Array.isArray(r.content)) {
|
|
220
|
+
const cappedContent = r.content.map((chunk) => {
|
|
221
|
+
if (
|
|
222
|
+
chunk &&
|
|
223
|
+
typeof chunk === "object" &&
|
|
224
|
+
(chunk as { type?: unknown }).type === "text" &&
|
|
225
|
+
typeof (chunk as { text?: unknown }).text === "string"
|
|
226
|
+
) {
|
|
227
|
+
return {
|
|
228
|
+
...(chunk as Record<string, unknown>),
|
|
229
|
+
text: truncateTextToByteCap(
|
|
230
|
+
(chunk as { text: string }).text,
|
|
231
|
+
maxBytes,
|
|
232
|
+
),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
return chunk;
|
|
236
|
+
});
|
|
237
|
+
return { ...r, content: cappedContent };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return result;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Wraps tools so that oversized results are truncated before they reach the
|
|
246
|
+
* conversation history. Tools whose result fits under the cap pass through
|
|
247
|
+
* untouched. Composes cleanly before or after wrapToolsWithApproval.
|
|
248
|
+
*/
|
|
249
|
+
export function wrapToolsWithByteCap(
|
|
250
|
+
tools: ToolSet,
|
|
251
|
+
maxBytes: number | undefined,
|
|
252
|
+
): ToolSet {
|
|
253
|
+
if (!maxBytes || maxBytes <= 0) {
|
|
254
|
+
return tools;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return Object.fromEntries(
|
|
258
|
+
Object.entries(tools).map(([name, tool]) => {
|
|
259
|
+
const originalExecute = tool.execute;
|
|
260
|
+
if (!originalExecute) {
|
|
261
|
+
return [name, tool];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return [
|
|
265
|
+
name,
|
|
266
|
+
{
|
|
267
|
+
...tool,
|
|
268
|
+
execute: async (args: unknown, options?: ToolCallOptions) => {
|
|
269
|
+
const result = await originalExecute(
|
|
270
|
+
args,
|
|
271
|
+
options as Parameters<typeof originalExecute>[1],
|
|
272
|
+
);
|
|
273
|
+
return capToolResultBytes(result, maxBytes);
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
];
|
|
277
|
+
}),
|
|
278
|
+
) as ToolSet;
|
|
279
|
+
}
|
|
280
|
+
|
|
159
281
|
/**
|
|
160
282
|
* Wraps tools with approval logic based on the approval config.
|
|
161
283
|
*/
|
|
@@ -5,6 +5,7 @@ import { ChartRenderer } from "./component";
|
|
|
5
5
|
* This plugin renders charts using json-render format.
|
|
6
6
|
*/
|
|
7
7
|
export const chart: Plugin = {
|
|
8
|
+
id: "chart",
|
|
8
9
|
language: "chart",
|
|
9
10
|
prompt: `WHEN TO USE CHARTS:
|
|
10
11
|
Create charts to visualize numerical data when it helps users understand patterns, trends, or comparisons. Use the 'chart' code block format.
|
|
@@ -58,6 +58,14 @@ export const BarChart: FC<BarChartProps> = ({
|
|
|
58
58
|
}) => {
|
|
59
59
|
const isHorizontal = layout === "horizontal";
|
|
60
60
|
|
|
61
|
+
// Estimate YAxis width from longest label to prevent clipping
|
|
62
|
+
const yAxisWidth = isHorizontal
|
|
63
|
+
? Math.max(
|
|
64
|
+
80,
|
|
65
|
+
Math.min(200, Math.max(...data.map((d) => d.label.length)) * 7 + 16),
|
|
66
|
+
)
|
|
67
|
+
: undefined;
|
|
68
|
+
|
|
61
69
|
return (
|
|
62
70
|
<div className={cn("flex flex-col gap-2", className)}>
|
|
63
71
|
{title && (
|
|
@@ -87,7 +95,7 @@ export const BarChart: FC<BarChartProps> = ({
|
|
|
87
95
|
<YAxis
|
|
88
96
|
dataKey="label"
|
|
89
97
|
type="category"
|
|
90
|
-
width={
|
|
98
|
+
width={yAxisWidth}
|
|
91
99
|
tick={{ fill: "var(--foreground)", fontSize: 12 }}
|
|
92
100
|
axisLine={{ stroke: "var(--border)" }}
|
|
93
101
|
tickLine={{ stroke: "var(--border)" }}
|
|
@@ -6,6 +6,7 @@ import { GenerativeUIRenderer } from "./component";
|
|
|
6
6
|
* Use the language identifier 'ui' or 'json-render' in code blocks.
|
|
7
7
|
*/
|
|
8
8
|
export const generativeUI: Plugin = {
|
|
9
|
+
id: "generative-ui",
|
|
9
10
|
language: "ui",
|
|
10
11
|
prompt: `Render structured data as visual UI using \`\`\`ui code blocks with valid JSON.
|
|
11
12
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { recommended, type Plugin } from "./index";
|
|
3
|
+
import { chart } from "./chart";
|
|
4
|
+
import { generativeUI } from "./generative-ui";
|
|
5
|
+
|
|
6
|
+
describe("recommended", () => {
|
|
7
|
+
it("contains chart and generative-ui plugins", () => {
|
|
8
|
+
expect(recommended).toHaveLength(2);
|
|
9
|
+
expect(recommended).toContain(chart);
|
|
10
|
+
expect(recommended).toContain(generativeUI);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("is usable as a plain array", () => {
|
|
14
|
+
const ids = recommended.map((p) => p.id);
|
|
15
|
+
expect(ids).toEqual(["chart", "generative-ui"]);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("recommended.except", () => {
|
|
20
|
+
it("excludes a plugin by id", () => {
|
|
21
|
+
const result = recommended.except("generative-ui");
|
|
22
|
+
expect(result).toHaveLength(1);
|
|
23
|
+
expect(result[0]).toBe(chart);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("excludes multiple plugins", () => {
|
|
27
|
+
const result = recommended.except("chart", "generative-ui");
|
|
28
|
+
expect(result).toHaveLength(0);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("returns all plugins when no ids match", () => {
|
|
32
|
+
const result = recommended.except("nonexistent");
|
|
33
|
+
expect(result).toHaveLength(2);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("returns all plugins when called with no arguments", () => {
|
|
37
|
+
const result = recommended.except();
|
|
38
|
+
expect(result).toHaveLength(2);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("is assignable to Plugin[] for backwards compatibility", () => {
|
|
42
|
+
// Consumers may have typed variables as Plugin[]
|
|
43
|
+
const plugins: Plugin[] = recommended.except("chart");
|
|
44
|
+
expect(plugins).toHaveLength(1);
|
|
45
|
+
|
|
46
|
+
// Spread into a plain array
|
|
47
|
+
const spread: Plugin[] = [...recommended];
|
|
48
|
+
expect(spread).toHaveLength(2);
|
|
49
|
+
|
|
50
|
+
// Nullish coalescing fallback (common pattern in ElementsProvider)
|
|
51
|
+
const config: { plugins?: Plugin[] } = {};
|
|
52
|
+
const resolved = config.plugins ?? recommended;
|
|
53
|
+
expect(resolved).toHaveLength(2);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("does not match on language when id is set", () => {
|
|
57
|
+
// generative-ui has id="generative-ui" and language="ui"
|
|
58
|
+
// Excluding by language value "ui" should not match because id takes precedence
|
|
59
|
+
const result = recommended.except("ui");
|
|
60
|
+
expect(result).toHaveLength(2);
|
|
61
|
+
});
|
|
62
|
+
});
|
package/src/plugins/index.ts
CHANGED
|
@@ -2,7 +2,20 @@ import type { Plugin } from "@/types/plugins";
|
|
|
2
2
|
import { chart } from "./chart";
|
|
3
3
|
import { generativeUI } from "./generative-ui";
|
|
4
4
|
|
|
5
|
-
export
|
|
5
|
+
export type PluginList = Plugin[] & {
|
|
6
|
+
except(...ids: string[]): Plugin[];
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
function createPluginList(plugins: Plugin[]): PluginList {
|
|
10
|
+
const arr = [...plugins] as PluginList;
|
|
11
|
+
arr.except = (...ids: string[]) => {
|
|
12
|
+
const excluded = new Set(ids);
|
|
13
|
+
return arr.filter((p) => !excluded.has(p.id ?? p.language));
|
|
14
|
+
};
|
|
15
|
+
return arr;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const recommended: PluginList = createPluginList([chart, generativeUI]);
|
|
6
19
|
export { chart } from "./chart";
|
|
7
20
|
export { generativeUI } from "./generative-ui";
|
|
8
21
|
|