mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b43f059aa8 | ||
|
|
bc0127b0f2 | ||
|
|
7838988867 |
No files matched your search
@@ -12,6 +12,7 @@
|
||||
"ink-text-input": "^6.0.0",
|
||||
"openai": "^6.39.1",
|
||||
"react": "^19.2.6",
|
||||
"react-devtools-core": "^7.0.1",
|
||||
"shiki": "^4.1.0",
|
||||
"zod": "^4.4.3",
|
||||
},
|
||||
@@ -181,6 +182,8 @@
|
||||
|
||||
"react": ["[email protected]", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
|
||||
|
||||
"react-devtools-core": ["[email protected]", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="],
|
||||
|
||||
"react-reconciler": ["[email protected]", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="],
|
||||
|
||||
"regex": ["[email protected]", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
|
||||
@@ -197,6 +200,8 @@
|
||||
|
||||
"shebang-regex": ["[email protected]", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"shell-quote": ["[email protected]", "", {}, "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ=="],
|
||||
|
||||
"shiki": ["[email protected]", "", { "dependencies": { "@shikijs/core": "4.1.0", "@shikijs/engine-javascript": "4.1.0", "@shikijs/engine-oniguruma": "4.1.0", "@shikijs/langs": "4.1.0", "@shikijs/themes": "4.1.0", "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q=="],
|
||||
|
||||
"signal-exit": ["[email protected]", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
@@ -265,6 +270,8 @@
|
||||
|
||||
"npm-run-path/path-key": ["[email protected]", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||
|
||||
"react-devtools-core/ws": ["[email protected]", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA=="],
|
||||
|
||||
"restore-cursor/signal-exit": ["[email protected]", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@
|
||||
"ink-text-input": "^6.0.0",
|
||||
"openai": "^6.39.1",
|
||||
"react": "^19.2.6",
|
||||
"react-devtools-core": "^7.0.1",
|
||||
"shiki": "^4.1.0",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
|
||||
+19
-2
@@ -2,7 +2,7 @@ import type { Provider } from '../providers/types';
|
||||
import type { ToolCall, ToolResult } from '../tools/types';
|
||||
import { toolRegistry } from '../tools';
|
||||
import { DEFAULT_SYSTEM_PROMPT, PLAN_MODE_SYSTEM_PROMPT } from './prompts';
|
||||
import { clearPlan } from '../tools/plan';
|
||||
import { clearPlan, getCurrentPlan, type PlanItem } from '../tools/plan';
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface AgentOptions {
|
||||
@@ -10,6 +10,8 @@ export interface AgentOptions {
|
||||
onText?: (text: string) => void;
|
||||
onToolCall?: (toolCall: ToolCall) => void;
|
||||
onToolResult?: (result: ToolResult) => void;
|
||||
onUsage?: (usage: { promptTokens: number; completionTokens: number }) => void;
|
||||
onPlanUpdate?: (plan: PlanItem[]) => void;
|
||||
toolsEnabled?: boolean; // allows temporarily disabling tool calling for debugging
|
||||
debug?: boolean; // when true, logs the exact payload sent to the provider
|
||||
planMode?: boolean; // when true, the agent plans without modifying the codebase
|
||||
@@ -31,6 +33,8 @@ export async function runAgent(
|
||||
onText,
|
||||
onToolCall,
|
||||
onToolResult,
|
||||
onUsage,
|
||||
onPlanUpdate,
|
||||
toolsEnabled = true,
|
||||
debug = false,
|
||||
planMode = false
|
||||
@@ -38,6 +42,7 @@ export async function runAgent(
|
||||
|
||||
// Clear any previous plan when starting a new task
|
||||
clearPlan();
|
||||
onPlanUpdate?.(getCurrentPlan());
|
||||
|
||||
const systemPrompt = planMode ? PLAN_MODE_SYSTEM_PROMPT : DEFAULT_SYSTEM_PROMPT;
|
||||
|
||||
@@ -95,7 +100,7 @@ export async function runAgent(
|
||||
|
||||
// Show a sample of the schema for the first tool (very useful for debugging)
|
||||
const firstTool = toolDefinitions[0];
|
||||
if (firstTool.parameters) {
|
||||
if (firstTool?.parameters) {
|
||||
const schemaPreview = JSON.stringify(firstTool.parameters, null, 2).slice(0, 300);
|
||||
console.log(`│ First tool schema sample:\n${schemaPreview}...`);
|
||||
}
|
||||
@@ -113,6 +118,13 @@ export async function runAgent(
|
||||
if (onText) onText(event.content);
|
||||
}
|
||||
|
||||
if (event.type === 'usage') {
|
||||
onUsage?.({
|
||||
promptTokens: event.promptTokens,
|
||||
completionTokens: event.completionTokens,
|
||||
});
|
||||
}
|
||||
|
||||
if (event.type === 'tool-call-start') {
|
||||
toolCallMap.set(event.id, {
|
||||
id: event.id,
|
||||
@@ -189,6 +201,11 @@ export async function runAgent(
|
||||
onToolResult({ toolCallId: tc.id, result });
|
||||
}
|
||||
|
||||
const effects = tool?.onAfterExecute?.(result);
|
||||
if (effects?.planUpdated) {
|
||||
onPlanUpdate?.(getCurrentPlan());
|
||||
}
|
||||
|
||||
return { toolCallId: tc.id, result };
|
||||
});
|
||||
|
||||
|
||||
+73
-30
@@ -7,6 +7,28 @@ interface OpenAIProviderOptions {
|
||||
model: string;
|
||||
}
|
||||
|
||||
function isUnsupportedStreamOptionsError(error: unknown): boolean {
|
||||
const candidate = error as {
|
||||
status?: number;
|
||||
code?: string;
|
||||
message?: string;
|
||||
error?: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const statusAllowsRetry = candidate.status === 400 || candidate.status === 422;
|
||||
const errorText = [
|
||||
candidate.code,
|
||||
candidate.message,
|
||||
candidate.error?.code,
|
||||
candidate.error?.message,
|
||||
].filter(Boolean).join(' ').toLowerCase();
|
||||
|
||||
return statusAllowsRetry && /stream[ _-]?options?/.test(errorText);
|
||||
}
|
||||
|
||||
export class OpenAIProvider implements Provider {
|
||||
private client: OpenAI;
|
||||
private model: string;
|
||||
@@ -48,23 +70,31 @@ export class OpenAIProvider implements Provider {
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
const createStreamRequest = (includeUsage: boolean) => ({
|
||||
model: this.model,
|
||||
messages: openaiMessages as any,
|
||||
tools: openaiTools,
|
||||
stream: true as const,
|
||||
...(includeUsage ? {
|
||||
stream_options: {
|
||||
include_usage: true,
|
||||
},
|
||||
} : {}),
|
||||
});
|
||||
|
||||
let stream;
|
||||
try {
|
||||
stream = await this.client.chat.completions.create({
|
||||
model: this.model,
|
||||
messages: openaiMessages as any,
|
||||
tools: openaiTools,
|
||||
stream: true,
|
||||
});
|
||||
} catch (err: any) {
|
||||
const message = getDetailedErrorMessage(err);
|
||||
if (message.includes('401') || message.toLowerCase().includes('invalid') || message.toLowerCase().includes('unauthorized')) {
|
||||
throw new Error(`Provider authentication error (check your API key): ${message}`);
|
||||
stream = await this.client.chat.completions.create(createStreamRequest(true));
|
||||
} catch (error) {
|
||||
if (isUnsupportedStreamOptionsError(error)) {
|
||||
try {
|
||||
stream = await this.client.chat.completions.create(createStreamRequest(false));
|
||||
} catch (retryError) {
|
||||
throw formatProviderCreateError(retryError);
|
||||
}
|
||||
} else {
|
||||
throw formatProviderCreateError(error);
|
||||
}
|
||||
if (message.toLowerCase().includes('rate') || message.toLowerCase().includes('quota')) {
|
||||
throw new Error(`Provider rate limit error: ${message}`);
|
||||
}
|
||||
throw new Error(`Provider returned error: ${message}`);
|
||||
}
|
||||
|
||||
const toolCallAccumulators = new Map<number, {
|
||||
@@ -155,36 +185,49 @@ export class OpenAIProvider implements Provider {
|
||||
}
|
||||
|
||||
yield { type: 'done' };
|
||||
} catch (err: any) {
|
||||
const message = getDetailedErrorMessage(err);
|
||||
throw new Error(`Provider returned error during streaming: ${message}`);
|
||||
} catch (error) {
|
||||
throw formatProviderCreateError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getDetailedErrorMessage(err: any): string {
|
||||
if (!err) return 'Unknown error';
|
||||
function formatProviderCreateError(error: unknown): Error {
|
||||
const message = getDetailedErrorMessage(error);
|
||||
const lower = message.toLowerCase();
|
||||
|
||||
if (message.includes('401') || lower.includes('invalid') || lower.includes('unauthorized') || lower.includes('api key')) {
|
||||
return new Error(`Provider authentication error (check your API key): ${message}`);
|
||||
}
|
||||
|
||||
if (lower.includes('rate') || lower.includes('quota')) {
|
||||
return new Error(`Provider rate limit error: ${message}`);
|
||||
}
|
||||
|
||||
return new Error(`Provider returned error: ${message}`);
|
||||
}
|
||||
|
||||
function getDetailedErrorMessage(error: any): string {
|
||||
if (!error) return 'Unknown error';
|
||||
|
||||
// Try common places where real error lives (especially for custom gateways)
|
||||
if (err.message && !err.message.includes('Provider returned error')) {
|
||||
return err.message;
|
||||
if (error.message && !error.message.includes('Provider returned error')) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (err.error) {
|
||||
if (typeof err.error === 'string') return err.error;
|
||||
if (err.error.message) return err.error.message;
|
||||
try { return JSON.stringify(err.error); } catch {}
|
||||
if (error.error) {
|
||||
if (typeof error.error === 'string') return error.error;
|
||||
if (error.error.message) return error.error.message;
|
||||
try { return JSON.stringify(error.error); } catch {}
|
||||
}
|
||||
|
||||
if (err.response?.data) {
|
||||
const data = err.response.data;
|
||||
if (error.response?.data) {
|
||||
const data = error.response.data;
|
||||
if (data.error?.message) return data.error.message;
|
||||
if (typeof data.error === 'string') return data.error;
|
||||
try { return JSON.stringify(data); } catch {}
|
||||
}
|
||||
|
||||
if (err.cause?.message) return err.cause.message;
|
||||
if (error.cause?.message) return error.cause.message;
|
||||
|
||||
return err.message || String(err);
|
||||
return error.message || String(error);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,9 @@ The plan should be clear, actionable, and ordered. Keep it up to date as you wor
|
||||
|
||||
return formatPlan(currentPlan);
|
||||
},
|
||||
onAfterExecute() {
|
||||
return { planUpdated: true };
|
||||
},
|
||||
};
|
||||
|
||||
// Helper to get the current plan (can be used by other parts of the system later)
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface ToolExecutionEffects {
|
||||
planUpdated?: boolean;
|
||||
}
|
||||
|
||||
export interface Tool {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: z.ZodObject<any>; // Zod schema for validation
|
||||
execute: (args: any) => Promise<string>; // Returns tool result as string
|
||||
onAfterExecute?: (result: string) => ToolExecutionEffects | void;
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
|
||||
@@ -136,9 +136,11 @@ export const AddProvider: React.FC<AddProviderProps> = ({ onDone, onCancel }) =>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text marginTop={1} color={selectedOption === 1 ? 'greenBright' : 'white'}>
|
||||
{selectedOption === 1 ? '› ' : ' '}2. Add custom OpenAI-compatible provider
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color={selectedOption === 1 ? 'greenBright' : 'white'}>
|
||||
{selectedOption === 1 ? '› ' : ' '}2. Add custom OpenAI-compatible provider
|
||||
</Text>
|
||||
</Box>
|
||||
{selectedOption === 1 && (
|
||||
<Text color="gray" dimColor>
|
||||
{' '}For Groq, OpenAI, Ollama, etc.
|
||||
|
||||
+102
-89
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Box, Text, useApp, useInput } from 'ink';
|
||||
import { ProviderPicker } from './ProviderPicker';
|
||||
import { AddProvider } from './AddProvider';
|
||||
@@ -6,13 +6,34 @@ import { Logo } from './Logo';
|
||||
import { ThinkingSpinner } from './Spinner';
|
||||
import { MessageRenderer } from './MessageRenderer';
|
||||
import { ToolCallRenderer } from './ToolCallRenderer';
|
||||
import { Header } from './Header';
|
||||
import { TodoRail } from './TodoRail';
|
||||
import { configManager } from '../config/manager';
|
||||
import { loadProviderConfig } from '../config/provider';
|
||||
import { OpenAIProvider } from '../providers/openai';
|
||||
import { runAgent } from '../agent/loop';
|
||||
import type { PlanItem } from '../tools/plan';
|
||||
|
||||
type Screen = 'chat' | 'provider-picker' | 'add-provider';
|
||||
|
||||
type Usage = {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
};
|
||||
|
||||
type ProviderHeaderInfo = {
|
||||
name: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
function getProviderHeaderInfo(): ProviderHeaderInfo {
|
||||
const activeProfile = configManager.getActiveProvider();
|
||||
return {
|
||||
name: activeProfile?.name || (process.env.ZERO_PROVIDER_COMMAND ? 'command' : 'env'),
|
||||
model: activeProfile?.model || process.env.OPENAI_MODEL || 'default',
|
||||
};
|
||||
}
|
||||
|
||||
// Map low-level errors back to actionable guidance for the user. The full
|
||||
// error object is still surfaced separately when debug mode is on.
|
||||
function toFriendlyError(err: any): string {
|
||||
@@ -75,6 +96,7 @@ export const App: React.FC = () => {
|
||||
}, []);
|
||||
const [isThinking, setIsThinking] = useState(false);
|
||||
const [streamingMessageIndex, setStreamingMessageIndex] = useState<number | null>(null);
|
||||
const streamingMessageIndexRef = useRef<number | null>(null);
|
||||
|
||||
// Plan Mode (inspired by OpenClaude / Claude Code)
|
||||
const [isPlanMode, setIsPlanMode] = useState(false);
|
||||
@@ -85,11 +107,18 @@ export const App: React.FC = () => {
|
||||
|
||||
// Tools enabled (useful for debugging provider errors)
|
||||
const [toolsEnabled, setToolsEnabled] = useState(true);
|
||||
const [usage, setUsage] = useState<Usage>({ promptTokens: 0, completionTokens: 0 });
|
||||
const [plan, setPlan] = useState<PlanItem[]>([]);
|
||||
const [todoRailOpen, setTodoRailOpen] = useState(false);
|
||||
const [todoRailHidden, setTodoRailHidden] = useState(false);
|
||||
const planLengthRef = useRef(0);
|
||||
const todoRailHiddenRef = useRef(false);
|
||||
const [providerHeaderInfo, setProviderHeaderInfo] = useState<ProviderHeaderInfo>(() => getProviderHeaderInfo());
|
||||
|
||||
// Command suggestions
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
|
||||
const knownCommands = ['/provider', '/plan', '/debug-mode', '/debug', '/tools', '/help', '/exit', '/quit'];
|
||||
const knownCommands = ['/provider', '/plan', '/todo', '/debug-mode', '/debug', '/tools', '/help', '/exit', '/quit'];
|
||||
|
||||
// Update suggestions when input changes
|
||||
React.useEffect(() => {
|
||||
@@ -102,34 +131,13 @@ export const App: React.FC = () => {
|
||||
}
|
||||
}, [input]);
|
||||
|
||||
// Scrolling state (Grok Build style internal scrolling)
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
const [terminalRows, setTerminalRows] = useState(24); // default fallback
|
||||
|
||||
// Current provider info for the input bar (Grok Build style)
|
||||
const activeProfile = configManager.getActiveProvider();
|
||||
const currentProviderName = activeProfile?.name || (process.env.ZERO_PROVIDER_COMMAND ? 'command' : 'env');
|
||||
const currentModel = activeProfile?.model || process.env.OPENAI_MODEL || 'default';
|
||||
|
||||
// Track terminal size for proper scrolling
|
||||
React.useEffect(() => {
|
||||
const updateSize = () => {
|
||||
setTerminalRows(process.stdout.rows || 24);
|
||||
};
|
||||
process.stdout.on('resize', updateSize);
|
||||
updateSize();
|
||||
return () => {
|
||||
process.stdout.off('resize', updateSize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive (unless user scrolled up)
|
||||
React.useEffect(() => {
|
||||
// Only auto-scroll if user is near the bottom
|
||||
if (scrollOffset <= 3) {
|
||||
setScrollOffset(0);
|
||||
}
|
||||
}, [messages.length]);
|
||||
const currentProviderName = providerHeaderInfo.name;
|
||||
const currentModel = providerHeaderInfo.model;
|
||||
const totalTokens = usage.promptTokens + usage.completionTokens;
|
||||
const hasPlanItems = plan.length > 0;
|
||||
const hasActivePlanItems = plan.some((item) => item.status !== 'completed');
|
||||
const showTodoRail = todoRailOpen || (hasPlanItems && hasActivePlanItems && !todoRailHidden);
|
||||
|
||||
// Only capture main chat input when we're actually in the chat screen
|
||||
const isInChat = screen === 'chat';
|
||||
@@ -143,32 +151,9 @@ export const App: React.FC = () => {
|
||||
// Don't process chat input while in provider picker or add flow
|
||||
if (!isInChat) return;
|
||||
|
||||
// Scrolling controls (when input is empty)
|
||||
if (!input) {
|
||||
if (key.upArrow) {
|
||||
setScrollOffset((prev) => Math.min(prev + 1, messages.length - 1));
|
||||
return;
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setScrollOffset((prev) => Math.max(prev - 1, 0));
|
||||
return;
|
||||
}
|
||||
if (key.pageUp) {
|
||||
setScrollOffset((prev) => Math.min(prev + 8, messages.length - 1));
|
||||
return;
|
||||
}
|
||||
if (key.pageDown) {
|
||||
setScrollOffset((prev) => Math.max(prev - 8, 0));
|
||||
return;
|
||||
}
|
||||
if (key.home) {
|
||||
setScrollOffset(messages.length - 1);
|
||||
return;
|
||||
}
|
||||
if (key.end) {
|
||||
setScrollOffset(0);
|
||||
return;
|
||||
}
|
||||
if (!input && key.ctrl && inputChar === 't') {
|
||||
toggleTodoRail();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.return) {
|
||||
@@ -224,7 +209,9 @@ export const App: React.FC = () => {
|
||||
// Add empty assistant message that we'll stream into
|
||||
setMessages((prev) => {
|
||||
const newMessages = [...prev, { type: 'assistant' as const, content: '' }];
|
||||
setStreamingMessageIndex(newMessages.length - 1);
|
||||
const nextIndex = newMessages.length - 1;
|
||||
streamingMessageIndexRef.current = nextIndex;
|
||||
setStreamingMessageIndex(nextIndex);
|
||||
return newMessages;
|
||||
});
|
||||
|
||||
@@ -236,7 +223,7 @@ export const App: React.FC = () => {
|
||||
setIsThinking(false);
|
||||
setMessages((prev) => {
|
||||
const newMessages = [...prev];
|
||||
const idx = streamingMessageIndex ?? newMessages.length - 1;
|
||||
const idx = streamingMessageIndexRef.current ?? newMessages.length - 1;
|
||||
|
||||
if (newMessages[idx]?.type === 'assistant') {
|
||||
const current = newMessages[idx] as { type: 'assistant'; content: string };
|
||||
@@ -254,8 +241,6 @@ export const App: React.FC = () => {
|
||||
...prev,
|
||||
{ type: 'tool-call', name: tc.name, args: tc.arguments },
|
||||
]);
|
||||
// Reset streaming index since we inserted a message
|
||||
setStreamingMessageIndex(null);
|
||||
},
|
||||
onToolResult: (result) => {
|
||||
// Attach result to the most recent tool call that doesn't have one yet
|
||||
@@ -274,6 +259,19 @@ export const App: React.FC = () => {
|
||||
return newMessages;
|
||||
});
|
||||
},
|
||||
onUsage: (nextUsage) => {
|
||||
setUsage((current) => ({
|
||||
promptTokens: current.promptTokens + nextUsage.promptTokens,
|
||||
completionTokens: current.completionTokens + nextUsage.completionTokens,
|
||||
}));
|
||||
},
|
||||
onPlanUpdate: (nextPlan) => {
|
||||
if (nextPlan.length > 0 && planLengthRef.current === 0 && !todoRailHiddenRef.current) {
|
||||
setTodoRailOpen(false);
|
||||
}
|
||||
planLengthRef.current = nextPlan.length;
|
||||
setPlan(nextPlan);
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
setIsThinking(false);
|
||||
@@ -310,6 +308,7 @@ export const App: React.FC = () => {
|
||||
setMessages((prev) => [...prev, { type: 'system', content: friendlyMessage }]);
|
||||
} finally {
|
||||
setIsThinking(false);
|
||||
streamingMessageIndexRef.current = null;
|
||||
setStreamingMessageIndex(null);
|
||||
}
|
||||
};
|
||||
@@ -319,7 +318,7 @@ export const App: React.FC = () => {
|
||||
|
||||
const handleSlashCommand = (command: string) => {
|
||||
const parts = command.trim().split(/\s+/);
|
||||
const cmd = parts[0].toLowerCase();
|
||||
const cmd = (parts[0] ?? '').toLowerCase();
|
||||
const arg = parts[1]?.toLowerCase();
|
||||
|
||||
if (cmd === '/provider') {
|
||||
@@ -377,12 +376,22 @@ export const App: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === '/todo') {
|
||||
const nextVisibility = toggleTodoRail();
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ type: 'system', content: `Todo rail ${nextVisibility}.` },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === '/help') {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ type: 'system', content: 'Available commands:' },
|
||||
{ type: 'system', content: ' /provider - Manage LLM providers (fix provider errors here)' },
|
||||
{ type: 'system', content: ' /plan - Toggle Plan Mode (agent plans first, makes no edits)' },
|
||||
{ type: 'system', content: ' /todo - Show or hide the current plan rail' },
|
||||
{ type: 'system', content: ' /debug-mode - Toggle debug mode (prints full errors to console)' },
|
||||
{ type: 'system', content: ' /tools - Toggle tool calling (useful for debugging provider errors)' },
|
||||
{ type: 'system', content: ' /help - Show this help' },
|
||||
@@ -399,9 +408,26 @@ export const App: React.FC = () => {
|
||||
setMessages((prev) => [...prev, { type: 'system', content: `Unknown command: ${command}` }]);
|
||||
};
|
||||
|
||||
const toggleTodoRail = (): 'shown' | 'hidden' => {
|
||||
const nextVisibility = showTodoRail ? 'hidden' : 'shown';
|
||||
|
||||
if (showTodoRail) {
|
||||
setTodoRailOpen(false);
|
||||
todoRailHiddenRef.current = true;
|
||||
setTodoRailHidden(true);
|
||||
} else {
|
||||
todoRailHiddenRef.current = false;
|
||||
setTodoRailHidden(false);
|
||||
setTodoRailOpen(true);
|
||||
}
|
||||
|
||||
return nextVisibility;
|
||||
};
|
||||
|
||||
const handleProviderSelected = (name: string) => {
|
||||
const success = configManager.setActiveProvider(name);
|
||||
if (success) {
|
||||
setProviderHeaderInfo(getProviderHeaderInfo());
|
||||
setMessages((prev) => [...prev, { type: 'system', content: `Switched to provider: ${name}` }]);
|
||||
}
|
||||
setScreen('chat');
|
||||
@@ -423,6 +449,7 @@ export const App: React.FC = () => {
|
||||
const switched = configManager.setActiveProvider(providerName);
|
||||
|
||||
if (switched) {
|
||||
setProviderHeaderInfo(getProviderHeaderInfo());
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ type: 'system', content: `Added and switched to provider: ${providerName}` },
|
||||
@@ -463,20 +490,20 @@ export const App: React.FC = () => {
|
||||
|
||||
const showLogo = messages.length <= 2;
|
||||
|
||||
// Calculate visible messages for scrolling (Grok Build style)
|
||||
const chatHeight = Math.max(8, terminalRows - 6); // leave room for input + status
|
||||
const visibleMessages = messages.slice(scrollOffset, scrollOffset + chatHeight);
|
||||
|
||||
const canScrollUp = scrollOffset < messages.length - 1;
|
||||
const canScrollDown = scrollOffset > 0;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" height="100%">
|
||||
{/* Scrollable messages area with right-side scroll indicator (Grok Build style) */}
|
||||
<Header
|
||||
provider={currentProviderName}
|
||||
model={currentModel}
|
||||
usage={usage}
|
||||
totalTokens={totalTokens}
|
||||
planMode={isPlanMode}
|
||||
/>
|
||||
|
||||
{/* Message area; render the transcript directly so terminal scrollback stays useful. */}
|
||||
<Box
|
||||
flexGrow={1}
|
||||
flexDirection="row"
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* Main chat content */}
|
||||
<Box
|
||||
@@ -487,17 +514,8 @@ export const App: React.FC = () => {
|
||||
>
|
||||
{showLogo && <Logo />}
|
||||
|
||||
{/* Scroll indicator */}
|
||||
{(canScrollUp || canScrollDown) && (
|
||||
<Text color="gray" dimColor>
|
||||
{canScrollUp ? '↑ ' : ' '}Scroll with ↑↓ / PgUp/PgDn / Home/End {canScrollDown ? '↓' : ''}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Box flexDirection="column">
|
||||
{visibleMessages.map((msg, index) => {
|
||||
const realIndex = scrollOffset + index;
|
||||
|
||||
{messages.map((msg, realIndex) => {
|
||||
if (msg.type === 'user') {
|
||||
return (
|
||||
<Box key={realIndex} marginBottom={1}>
|
||||
@@ -555,16 +573,11 @@ export const App: React.FC = () => {
|
||||
{isThinking && <ThinkingSpinner />}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Scroll position (Grok Build style) */}
|
||||
{(canScrollUp || canScrollDown) && (
|
||||
<Box paddingX={1} justifyContent="flex-end">
|
||||
<Text color="gray" dimColor>
|
||||
{scrollOffset + 1}/{messages.length}{canScrollUp ? ' ↑' : ''}{canScrollDown ? ' ↓' : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{showTodoRail && (
|
||||
<TodoRail plan={plan} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Command suggestions */}
|
||||
{suggestions.length > 0 && (
|
||||
@@ -629,7 +642,7 @@ export const App: React.FC = () => {
|
||||
{/* Very subtle status line */}
|
||||
<Box paddingX={1} flexDirection="row">
|
||||
<Text color="gray" dimColor>
|
||||
/help • ↑↓ scroll • Ctrl+C exit
|
||||
/help • /todo or Ctrl+T todo • Ctrl+C exit
|
||||
</Text>
|
||||
{isPlanMode && (
|
||||
<Text color="green"> • PLAN MODE</Text>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
|
||||
type Usage = {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
};
|
||||
|
||||
interface HeaderProps {
|
||||
provider: string;
|
||||
model: string;
|
||||
usage: Usage;
|
||||
totalTokens: number;
|
||||
planMode: boolean;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({ provider, model, usage, totalTokens, planMode }) => (
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor={planMode ? 'green' : 'gray'}
|
||||
paddingX={1}
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Box flexDirection="row">
|
||||
<Text color="cyanBright" bold>ZERO</Text>
|
||||
<Text color="gray" dimColor> local agent</Text>
|
||||
{planMode && <Text color="green"> - PLAN</Text>}
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
<Text color="cyan">{provider}</Text>
|
||||
<Text color="gray"> / </Text>
|
||||
<Text color="magenta">{model}</Text>
|
||||
<Text color="gray" dimColor>
|
||||
{' '}p:{usage.promptTokens} c:{usage.completionTokens} t:{totalTokens}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
@@ -22,18 +22,21 @@ export const MessageRenderer: React.FC<MessageRendererProps> = ({ content }) =>
|
||||
matches.push({
|
||||
index: match.index,
|
||||
lang: match[1] || 'text',
|
||||
code: match[2].trim(),
|
||||
code: (match[2] ?? '').trim(),
|
||||
fullMatch: match[0],
|
||||
});
|
||||
}
|
||||
|
||||
// Highlight code blocks asynchronously
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const highlightAll = async () => {
|
||||
const newHighlights = new Map<number, string>();
|
||||
|
||||
for (let i = 0; i < matches.length; i++) {
|
||||
const m = matches[i];
|
||||
if (!m) continue;
|
||||
try {
|
||||
const ansi = await highlightCode(m.code, m.lang);
|
||||
newHighlights.set(i, ansi);
|
||||
@@ -42,16 +45,25 @@ export const MessageRenderer: React.FC<MessageRendererProps> = ({ content }) =>
|
||||
}
|
||||
}
|
||||
|
||||
setHighlightedBlocks(newHighlights);
|
||||
if (!cancelled) {
|
||||
setHighlightedBlocks(newHighlights);
|
||||
}
|
||||
};
|
||||
|
||||
if (matches.length > 0) {
|
||||
highlightAll();
|
||||
} else {
|
||||
setHighlightedBlocks(new Map());
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [content]);
|
||||
|
||||
for (let i = 0; i < matches.length; i++) {
|
||||
const m = matches[i];
|
||||
if (!m) continue;
|
||||
|
||||
// Add text before this code block (with paragraph formatting)
|
||||
if (m.index > lastIndex) {
|
||||
|
||||
@@ -35,7 +35,7 @@ export const ProviderPicker: React.FC<ProviderPickerProps> = ({ onSelect, onCanc
|
||||
if (key.return) {
|
||||
if (selectedIndex < providers.length) {
|
||||
const selected = providers[selectedIndex];
|
||||
onSelect(selected.name);
|
||||
if (selected) onSelect(selected.name);
|
||||
} else {
|
||||
// Last item = Add new
|
||||
onAddNew();
|
||||
@@ -48,7 +48,7 @@ export const ProviderPicker: React.FC<ProviderPickerProps> = ({ onSelect, onCanc
|
||||
if (!isNaN(num) && num >= 1 && num <= totalItems) {
|
||||
if (num <= providers.length) {
|
||||
const selected = providers[num - 1];
|
||||
onSelect(selected.name);
|
||||
if (selected) onSelect(selected.name);
|
||||
} else {
|
||||
onAddNew();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { PlanItem } from '../tools/plan';
|
||||
|
||||
export const TodoRail: React.FC<{ plan: PlanItem[] }> = ({ plan }) => (
|
||||
<Box
|
||||
width={34}
|
||||
flexShrink={0}
|
||||
flexDirection="column"
|
||||
borderStyle="single"
|
||||
borderColor="gray"
|
||||
paddingX={1}
|
||||
>
|
||||
<Text color="green" bold>todo</Text>
|
||||
{plan.length === 0 ? (
|
||||
<Text color="gray" dimColor>No active plan yet.</Text>
|
||||
) : (
|
||||
plan.map((item, index) => (
|
||||
<Box key={item.id || index} flexDirection="column" marginTop={1}>
|
||||
<Text color={planStatusColor(item.status)}>
|
||||
{index + 1}. {planStatusMark(item.status)} {item.content}
|
||||
</Text>
|
||||
{item.notes && (
|
||||
<Text color="gray" dimColor>
|
||||
{' '}{item.notes}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
export function planStatusMark(status: PlanItem['status']): string {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'x';
|
||||
case 'in_progress':
|
||||
return '>';
|
||||
case 'failed':
|
||||
return '!';
|
||||
default:
|
||||
return 'o';
|
||||
}
|
||||
}
|
||||
|
||||
export function planStatusColor(status: PlanItem['status']): 'green' | 'yellow' | 'red' | 'gray' {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'green';
|
||||
case 'in_progress':
|
||||
return 'yellow';
|
||||
case 'failed':
|
||||
return 'red';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
}
|
||||
+44
-2
@@ -1,6 +1,7 @@
|
||||
import { createHighlighter } from 'shiki'
|
||||
|
||||
let highlighterPromise: ReturnType<typeof createHighlighter> | null = null
|
||||
const highlightCache = new Map<string, Promise<string>>()
|
||||
|
||||
export async function getHighlighter() {
|
||||
if (!highlighterPromise) {
|
||||
@@ -31,14 +32,55 @@ export async function getHighlighter() {
|
||||
}
|
||||
|
||||
export async function highlightCode(code: string, lang: string = 'text'): Promise<string> {
|
||||
const cacheKey = `${lang}:${code.length}:${hashString(code)}`
|
||||
const cached = highlightCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const highlighted = highlightCodeUncached(code, lang)
|
||||
highlightCache.set(cacheKey, highlighted)
|
||||
return highlighted
|
||||
}
|
||||
|
||||
async function highlightCodeUncached(code: string, lang: string): Promise<string> {
|
||||
try {
|
||||
const highlighter = await getHighlighter()
|
||||
return highlighter.codeToAnsi(code, {
|
||||
lang: lang || 'text',
|
||||
const lines = highlighter.codeToTokensBase(code, {
|
||||
lang: (lang || 'text') as any,
|
||||
theme: 'github-dark',
|
||||
})
|
||||
|
||||
return lines
|
||||
.map((line) =>
|
||||
line
|
||||
.map((token) => token.color ? `${hexToAnsi(token.color)}${token.content}\x1b[39m` : token.content)
|
||||
.join(''),
|
||||
)
|
||||
.join('\n')
|
||||
} catch (error) {
|
||||
// Fallback to plain text if highlighting fails
|
||||
return code
|
||||
}
|
||||
}
|
||||
|
||||
function hashString(value: string): string {
|
||||
let hash = 5381
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
hash = ((hash << 5) + hash) ^ value.charCodeAt(i)
|
||||
}
|
||||
return (hash >>> 0).toString(36)
|
||||
}
|
||||
|
||||
function hexToAnsi(hex: string): string {
|
||||
const normalized = hex.replace('#', '')
|
||||
const value = Number.parseInt(normalized, 16)
|
||||
|
||||
if (Number.isNaN(value)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const red = (value >> 16) & 255
|
||||
const green = (value >> 8) & 255
|
||||
const blue = value & 255
|
||||
|
||||
return `\x1b[38;2;${red};${green};${blue}m`
|
||||
}
|
||||
@@ -85,4 +85,73 @@ describe('runAgent tool-call flow', () => {
|
||||
expect(answer).toBe('just an answer');
|
||||
expect(provider.received).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports usage and plan updates through callbacks', async () => {
|
||||
const provider = new MockProvider([
|
||||
[
|
||||
{ type: 'usage', promptTokens: 10, completionTokens: 2 },
|
||||
{ type: 'tool-call-start', id: 'call_1', name: 'update_plan' },
|
||||
{
|
||||
type: 'tool-call-delta',
|
||||
id: 'call_1',
|
||||
argumentsFragment: JSON.stringify({
|
||||
plan: [{ id: '1', content: 'track this', status: 'in_progress' }],
|
||||
}),
|
||||
},
|
||||
{ type: 'tool-call-end', id: 'call_1' },
|
||||
],
|
||||
[{ type: 'text', content: 'done' }],
|
||||
]);
|
||||
|
||||
const usageEvents: Array<{ promptTokens: number; completionTokens: number }> = [];
|
||||
const planSnapshots: string[][] = [];
|
||||
|
||||
await runAgent('make a plan', provider, {
|
||||
onUsage: (usage) => usageEvents.push(usage),
|
||||
onPlanUpdate: (plan) => planSnapshots.push(plan.map((item) => item.content)),
|
||||
});
|
||||
|
||||
expect(usageEvents).toEqual([{ promptTokens: 10, completionTokens: 2 }]);
|
||||
expect(planSnapshots).toEqual([[], ['track this']]);
|
||||
});
|
||||
|
||||
it('does not leak plan update callbacks across separate runs', async () => {
|
||||
const firstProvider = new MockProvider([
|
||||
[
|
||||
{ type: 'tool-call-start', id: 'call_1', name: 'update_plan' },
|
||||
{
|
||||
type: 'tool-call-delta',
|
||||
id: 'call_1',
|
||||
argumentsFragment: JSON.stringify({
|
||||
plan: [{ id: '1', content: 'first plan', status: 'pending' }],
|
||||
}),
|
||||
},
|
||||
{ type: 'tool-call-end', id: 'call_1' },
|
||||
],
|
||||
[{ type: 'text', content: 'first done' }],
|
||||
]);
|
||||
const secondProvider = new MockProvider([
|
||||
[
|
||||
{ type: 'tool-call-start', id: 'call_2', name: 'update_plan' },
|
||||
{
|
||||
type: 'tool-call-delta',
|
||||
id: 'call_2',
|
||||
argumentsFragment: JSON.stringify({
|
||||
plan: [{ id: '2', content: 'second plan', status: 'pending' }],
|
||||
}),
|
||||
},
|
||||
{ type: 'tool-call-end', id: 'call_2' },
|
||||
],
|
||||
[{ type: 'text', content: 'second done' }],
|
||||
]);
|
||||
|
||||
const planSnapshots: string[][] = [];
|
||||
|
||||
await runAgent('first', firstProvider, {
|
||||
onPlanUpdate: (plan) => planSnapshots.push(plan.map((item) => item.content)),
|
||||
});
|
||||
await runAgent('second', secondProvider, {});
|
||||
|
||||
expect(planSnapshots).toEqual([[], ['first plan']]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { afterEach, describe, expect, it, mock } from 'bun:test';
|
||||
|
||||
const createMock: any = mock(async () => {
|
||||
throw new Error('create mock not configured');
|
||||
});
|
||||
|
||||
mock.module('openai', () => ({
|
||||
default: class MockOpenAI {
|
||||
chat = {
|
||||
completions: {
|
||||
create: createMock,
|
||||
},
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
async function* streamFrom(chunks: any[]) {
|
||||
for (const chunk of chunks) {
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
createMock.mockReset();
|
||||
});
|
||||
|
||||
describe('OpenAIProvider stream options compatibility', () => {
|
||||
it('requests usage and retries without stream_options when a compatible provider rejects it', async () => {
|
||||
const { OpenAIProvider } = await import('../src/providers/openai');
|
||||
const unsupportedStreamOptions = Object.assign(new Error('unknown parameter: stream_options.include_usage'), {
|
||||
status: 400,
|
||||
});
|
||||
|
||||
createMock
|
||||
.mockRejectedValueOnce(unsupportedStreamOptions)
|
||||
.mockResolvedValueOnce(streamFrom([
|
||||
{
|
||||
choices: [{ delta: { content: 'hi' }, finish_reason: 'stop' }],
|
||||
},
|
||||
]));
|
||||
|
||||
const provider = new OpenAIProvider({ apiKey: 'test', model: 'test-model' });
|
||||
const events = [];
|
||||
|
||||
for await (const event of provider.streamCompletion([{ role: 'user', content: 'hello' }], [])) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(createMock).toHaveBeenCalledTimes(2);
|
||||
expect(createMock.mock.calls[0]?.[0]?.stream_options).toEqual({ include_usage: true });
|
||||
expect(createMock.mock.calls[1]?.[0]?.stream_options).toBeUndefined();
|
||||
expect(events).toContainEqual({ type: 'text', content: 'hi' });
|
||||
});
|
||||
|
||||
it('does not retry unrelated unknown-parameter errors', async () => {
|
||||
const { OpenAIProvider } = await import('../src/providers/openai');
|
||||
const unsupportedToolChoice = Object.assign(new Error('unknown parameter: tool_choice'), {
|
||||
status: 400,
|
||||
});
|
||||
|
||||
createMock.mockRejectedValueOnce(unsupportedToolChoice);
|
||||
|
||||
const provider = new OpenAIProvider({ apiKey: 'test', model: 'test-model' });
|
||||
|
||||
await expect((async () => {
|
||||
for await (const _event of provider.streamCompletion([{ role: 'user', content: 'hello' }], [])) {
|
||||
// Drain the stream.
|
||||
}
|
||||
})()).rejects.toThrow('unknown parameter: tool_choice');
|
||||
|
||||
expect(createMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('categorizes mid-stream authentication errors', async () => {
|
||||
const { OpenAIProvider } = await import('../src/providers/openai');
|
||||
|
||||
createMock.mockResolvedValueOnce(streamFrom([
|
||||
{
|
||||
error: {
|
||||
message: 'Incorrect API key provided',
|
||||
},
|
||||
},
|
||||
]));
|
||||
|
||||
const provider = new OpenAIProvider({ apiKey: 'test', model: 'test-model' });
|
||||
|
||||
await expect((async () => {
|
||||
for await (const _event of provider.streamCompletion([{ role: 'user', content: 'hello' }], [])) {
|
||||
// Drain the stream.
|
||||
}
|
||||
})()).rejects.toThrow('Provider authentication error');
|
||||
});
|
||||
|
||||
it('emits usage events from compatible OpenAI streams', async () => {
|
||||
const { OpenAIProvider } = await import('../src/providers/openai');
|
||||
|
||||
createMock.mockResolvedValueOnce(streamFrom([
|
||||
{
|
||||
choices: [{ delta: {}, finish_reason: 'stop' }],
|
||||
usage: {
|
||||
prompt_tokens: 8,
|
||||
completion_tokens: 3,
|
||||
},
|
||||
},
|
||||
]));
|
||||
|
||||
const provider = new OpenAIProvider({ apiKey: 'test', model: 'test-model' });
|
||||
const events = [];
|
||||
|
||||
for await (const event of provider.streamCompletion([{ role: 'user', content: 'hello' }], [])) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(createMock.mock.calls[0]?.[0]?.stream_options).toEqual({ include_usage: true });
|
||||
expect(events).toContainEqual({ type: 'usage', promptTokens: 8, completionTokens: 3 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user