Mid conversation system messages (#9548)

This change makes system prompt text and tool changes part of the transcript rather than silently rewriting its starting conditions. This lets Pi record when instructions changed or tools became available, restore that state after resuming or navigating branches, and preserve cached prompt prefixes where the upstream supports it.
This commit is contained in:
Armin Ronacher 2026-09-16 15:54:07 +02:00 • committed by GitHub
parent 6671c60476
commit 9e05370b29
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
165 changed files with 3512 additions and 2552 deletions

View file

@ -181,7 +181,8 @@ The last message in context must be `user` or `toolResult` (not `assistant`).
```typescript
const agent = new Agent({
// Initial state
// Initial state. systemPrompt and tools become the leading system message
// unless messages already starts with one.
initialState: {
systemPrompt: string,
model: Model<any>,
@ -202,7 +203,8 @@ const agent = new Agent({
// Follow-up mode: "one-at-a-time" (default) or "all"
followUpMode: "one-at-a-time",
// Required stream function
// Required stream function. Receives a TranscriptContext: the prompt and tools
// are in the transcript's system messages, not on the context.
streamFn: models.streamSimple.bind(models),
// Session ID for provider caching
@ -250,7 +252,6 @@ const agent = new Agent({
```typescript
interface AgentState {
systemPrompt: string;
model: Model<any>;
thinkingLevel: ThinkingLevel;
tools: AgentTool<any>[];
@ -266,6 +267,17 @@ Access state via `agent.state`.
Assigning `agent.state.tools = [...]` or `agent.state.messages = [...]` copies the top-level array before storing it. Mutating the returned array mutates the current agent state.
The transcript owns the system prompt and tool declarations: the leading system message is the prompt, later system messages patch it (see `SystemMessage` in pi-ai). `agent.state.systemPrompt` is read-only and replays the transcript. `agent.state.tools` is the executable loadout; before every request the loop diffs it against the tools the transcript declares and, if they differ, announces the change in a system message (merged into a pending system message when one exists). pi-ai's `getCurrentSystemMessage(messages)` returns the replayed head, including declared tools, for any message array, including agent transcripts with custom message roles.
To change the prompt mid-conversation, append a system message with `content` (added instructions) or `sections` (named replacements):
```typescript
await agent.prompt([
{ role: "system", content: "", sections: { skills: "<skills>...</skills>" }, timestamp: Date.now() },
{ role: "user", content: "Continue", timestamp: Date.now() },
]);
```
During streaming, `agent.state.streamingMessage` contains the current partial assistant message.
`agent.state.isStreaming` remains `true` until the run fully settles, including awaited `agent_end` subscribers.
@ -293,7 +305,6 @@ await agent.continue();
### State Management
```typescript
agent.state.systemPrompt = "New prompt";
agent.state.model = getModel("openai", "gpt-4o");
agent.state.thinkingLevel = "medium";
agent.state.tools = [myTool];
@ -484,8 +495,7 @@ For direct control without the Agent class:
import { agentLoop, agentLoopContinue } from "@earendil-works/pi-agent-core";
const context: AgentContext = {
systemPrompt: "You are helpful.",
messages: [],
messages: [{ role: "system", content: "You are helpful.", timestamp: Date.now() }],
tools: [],
};

View file

@ -252,7 +252,7 @@ append.
The harness still assembles `AgentToolResult` in process for the model, and
`createToolResultMessage` still writes the settled `ToolResultMessage` to the
transcript with `content`, `details`, `usage`, `addedToolNames`, `isError`.
transcript with `content`, `details`, `usage`, `isError`.
Neither is a wire event.
## 6. Lane reduction

View file

@ -293,7 +293,7 @@ The staged message contains the final:
- added tool names;
- timestamp.
`terminate` remains orchestration state because it controls the batch continuation and is copied to the immutable entry's `terminate` field at placement. Staged `addedToolNames` do not affect the active tool set until that result materializes in the transcript.
`terminate` remains orchestration state because it controls the batch continuation and is copied to the immutable entry's `terminate` field at placement.
## Source-ordered materialization
@ -384,7 +384,7 @@ Example final text suffix:
Rules:
- do not run `after_tool` for this synthetic result;
- preserve checkpoint `usage` when present, but ignore checkpoint `addedToolNames` and `terminate` because progress never has final-result authority;
- preserve checkpoint `usage` when present, but ignore checkpoint `terminate` because progress never has final-result authority;
- set `terminate: false` and add no tools;
- an absent checkpoint value is also valid and yields only the interruption result;
- never infer completion from an apparent success line in partial output;

View file

@ -229,7 +229,7 @@ Skip. Its `toolResult` entry must already be in the captured transcript.
- Reconstruct the canonical `AgentToolResult` from the staged `ToolResultMessage` and the durable call termination flag as needed.
- Project `status: "settled"`, `result`, and `isError`.
The event and capture representations must normalize the final result identically so folding through `tool_end` equals a later authoritative snapshot. Pay attention to optional `details`, `usage`, `addedToolNames`, and `terminate`; do not rely on incidental object-property presence differences.
The event and capture representations must normalize the final result identically so folding through `tool_end` equals a later authoritative snapshot. Pay attention to optional `details`, `usage`, and `terminate`; do not rely on incidental object-property presence differences.
A missing or mismatched staged result for `outcome_ready` is presentation corruption and must fault snapshot capture.

View file

@ -5,9 +5,14 @@
import {
type AssistantMessage,
type Context,
EventStream,
getCurrentTools,
getToolStateChanges,
normalizeContext,
type SystemMessage,
type ToolResultMessage,
type ToolStateChanges,
toToolDeclaration,
validateToolArguments,
} from "@earendil-works/pi-ai";
import { getDefaultStreamFn } from "./stream-fn.ts";
@ -101,17 +106,18 @@ export async function runAgentLoop(
signal: AbortSignal | undefined,
streamFn: StreamFn,
): Promise<AgentMessage[]> {
const newMessages: AgentMessage[] = [...prompts];
const initialMessages = declareToolChanges(context, prompts);
const newMessages: AgentMessage[] = [...initialMessages];
const currentContext: AgentContext = {
...context,
messages: [...context.messages, ...prompts],
messages: [...context.messages, ...initialMessages],
};
await emit({ type: "agent_start" });
await emit({ type: "turn_start" });
for (const prompt of prompts) {
await emit({ type: "message_start", message: prompt });
await emit({ type: "message_end", message: prompt });
for (const message of initialMessages) {
await emit({ type: "message_start", message });
await emit({ type: "message_end", message });
}
await runLoop(currentContext, newMessages, config, signal, emit, streamFn ?? getDefaultStreamFn());
@ -173,10 +179,12 @@ async function runLoop(
// Inner loop: process tool calls and steering messages
while (hasMoreToolCalls || pendingMessages.length > 0) {
let preparedMessages: AgentMessage[] = [];
if (lastCompletedTurn) {
const nextTurnSnapshot = await config.prepareNextTurn?.(lastCompletedTurn);
if (nextTurnSnapshot) {
currentContext = nextTurnSnapshot.context ?? currentContext;
preparedMessages = nextTurnSnapshot.messages ?? [];
config = {
...config,
model: nextTurnSnapshot.model ?? config.model,
@ -197,16 +205,14 @@ async function runLoop(
await emit({ type: "turn_start" });
}
// Process pending messages (inject before next assistant response)
if (pendingMessages.length > 0) {
for (const message of pendingMessages) {
await emit({ type: "message_start", message });
await emit({ type: "message_end", message });
currentContext.messages.push(message);
newMessages.push(message);
}
pendingMessages = [];
// Process prepared and queued messages before the next assistant response.
for (const message of declareToolChanges(currentContext, [...preparedMessages, ...pendingMessages])) {
await emit({ type: "message_start", message });
await emit({ type: "message_end", message });
currentContext.messages.push(message);
newMessages.push(message);
}
pendingMessages = [];
// Stream assistant response
const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFunction);
@ -272,6 +278,60 @@ async function runLoop(
await emit({ type: "agent_end", messages: newMessages });
}
/**
* Declare tool loadout changes to the model.
*
* `context.tools` is what the runtime can execute; the transcript's system messages declare
* what the model may call. Before each request the difference becomes `toolsAdded` and
* `toolsRemoved` on a system message. When a pending system message exists, its tool fields
* are treated as intent and replaced with the delta between the committed transcript and
* the executable set, so replay always yields exactly `context.tools`. Otherwise a new
* system message is inserted before the first non-system pending message.
*/
function declareToolChanges(context: AgentContext, pendingMessages: AgentMessage[]): AgentMessage[] {
let systemIndex = -1;
for (let i = pendingMessages.length - 1; i >= 0; i--) {
if (pendingMessages[i].role === "system") {
systemIndex = i;
break;
}
}
const pending = pendingMessages[systemIndex] as SystemMessage | undefined;
const baseline = pending
? pendingMessages.map((message, index) =>
index === systemIndex ? withToolChanges(pending, NO_CHANGES) : message,
)
: pendingMessages;
const changes = getToolStateChanges(
getCurrentTools([...context.messages, ...baseline]),
(context.tools ?? []).map(toToolDeclaration),
);
const unchanged = changes.toolsAdded.length === 0 && changes.toolsRemoved.length === 0;
if (pending) {
// Keep the caller's message object when it already declares no tool changes.
if (unchanged && !pending.toolsAdded?.length && !pending.toolsRemoved?.length) return pendingMessages;
return baseline.map((message, index) => (index === systemIndex ? withToolChanges(pending, changes) : message));
}
if (unchanged) return pendingMessages;
const update = withToolChanges({ role: "system", content: "", timestamp: Date.now() }, changes);
const insertIndex = pendingMessages.findIndex((message) => message.role !== "system");
const index = insertIndex === -1 ? pendingMessages.length : insertIndex;
return [...pendingMessages.slice(0, index), update, ...pendingMessages.slice(index)];
}
const NO_CHANGES: ToolStateChanges = { toolsAdded: [], toolsRemoved: [] };
/** Copy a system message with its tool fields replaced by `changes`; empty lists omit the field. */
function withToolChanges(message: SystemMessage, { toolsAdded, toolsRemoved }: ToolStateChanges): SystemMessage {
const { toolsAdded: _added, toolsRemoved: _removed, ...rest } = message;
return {
...rest,
...(toolsAdded.length > 0 ? { toolsAdded } : {}),
...(toolsRemoved.length > 0 ? { toolsRemoved } : {}),
};
}
/**
* Stream an assistant response from the LLM.
* This is where AgentMessage[] gets transformed to Message[] for the LLM.
@ -292,12 +352,7 @@ async function streamAssistantResponse(
// Convert to LLM-compatible messages (AgentMessage[] → Message[])
const llmMessages = await config.convertToLlm(messages);
// Build LLM context
const llmContext: Context = {
systemPrompt: context.systemPrompt,
messages: llmMessages,
tools: context.tools,
};
const llmContext = normalizeContext({ messages: llmMessages });
// Resolve API key (important for expiring tokens)
const resolvedApiKey =
@ -791,7 +846,6 @@ function createToolResultMessage(finalized: FinalizedToolCallOutcome): ToolResul
content: finalized.result.content ?? [],
details: finalized.result.details,
usage: finalized.result.usage,
...(finalized.result.addedToolNames?.length ? { addedToolNames: finalized.result.addedToolNames } : {}),
isError: finalized.isError,
timestamp: Date.now(),
};

View file

@ -1,11 +1,15 @@
import type {
ImageContent,
Message,
Model,
SimpleStreamOptions,
TextContent,
ThinkingBudgets,
Transport,
import {
createInitialSystemMessage,
getCurrentSystemMessage,
getCurrentSystemPrompt,
type ImageContent,
type Message,
type Model,
type SimpleStreamOptions,
type TextContent,
type ThinkingBudgets,
type Transport,
toToolDeclaration,
} from "@earendil-works/pi-ai";
import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.ts";
import { getDefaultStreamFn } from "./stream-fn.ts";
@ -32,7 +36,11 @@ export type { QueueMode } from "./types.ts";
function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
return messages.filter(
(message) => message.role === "user" || message.role === "assistant" || message.role === "toolResult",
(message) =>
message.role === "system" ||
message.role === "user" ||
message.role === "assistant" ||
message.role === "toolResult",
);
}
@ -65,14 +73,21 @@ type MutableAgentState = Omit<AgentState, "isStreaming" | "streamingMessage" | "
errorMessage?: string;
};
function createMutableAgentState(
initialState?: Partial<Omit<AgentState, "pendingToolCalls" | "isStreaming" | "streamingMessage" | "errorMessage">>,
): MutableAgentState {
/** Initial state for {@link Agent}. `systemPrompt` and `tools` become the leading system message unless `messages` already starts with one. */
export type AgentInitialState = Partial<
Omit<AgentState, "pendingToolCalls" | "isStreaming" | "streamingMessage" | "errorMessage">
>;
function createMutableAgentState(initialState?: AgentInitialState): MutableAgentState {
let tools = initialState?.tools?.slice() ?? [];
let messages = initialState?.messages?.slice() ?? [];
const initialMessage = createInitialSystemMessage(initialState?.systemPrompt, tools.map(toToolDeclaration));
if (messages[0]?.role !== "system" && initialMessage) messages.unshift(initialMessage);
return {
systemPrompt: initialState?.systemPrompt ?? "",
get systemPrompt() {
return getCurrentSystemPrompt(messages);
},
model: initialState?.model ?? DEFAULT_MODEL,
thinkingLevel: initialState?.thinkingLevel ?? "off",
get tools() {
@ -96,7 +111,7 @@ function createMutableAgentState(
/** Options for constructing an {@link Agent}. */
export interface AgentOptions {
initialState?: Partial<Omit<AgentState, "pendingToolCalls" | "isStreaming" | "streamingMessage" | "errorMessage">>;
initialState?: AgentInitialState;
convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
streamFn: StreamFn;
@ -329,13 +344,14 @@ export class Agent {
return this.activeRun?.promise ?? Promise.resolve();
}
/** Clear transcript state, runtime state, and queued messages. */
/** Clear conversation state and queues while retaining the replayed prompt/tool baseline. */
reset(): void {
if (this.activeRun) {
throw new Error("Agent is already processing. Wait for completion before resetting.");
}
this._state.messages = [];
const baseline = getCurrentSystemMessage(this._state.messages);
this._state.messages = baseline ? [baseline] : [];
this._state.isStreaming = false;
this._state.streamingMessage = undefined;
this._state.pendingToolCalls = new Set<string>();
@ -364,7 +380,7 @@ export class Agent {
}
const lastMessage = this._state.messages[this._state.messages.length - 1];
if (!lastMessage) {
if (!lastMessage || this._state.messages.every((message) => message.role === "system")) {
throw new Error("No messages to continue from");
}
@ -436,7 +452,6 @@ export class Agent {
private createContextSnapshot(): AgentContext {
return {
systemPrompt: this._state.systemPrompt,
messages: this._state.messages.slice(),
tools: this._state.tools.slice(),
};

View file

@ -189,7 +189,6 @@ export function toolResultFromMessage(
content: message.content,
details: message.details,
...(message.usage === undefined ? {} : { usage: message.usage }),
...(message.addedToolNames === undefined ? {} : { addedToolNames: message.addedToolNames }),
...(terminate ? { terminate: true } : {}),
};
}
@ -203,7 +202,6 @@ export function createToolResultMessage(call: FinalizedToolCall): ToolResultMess
content: call.result.content ?? [],
...(call.result.details === undefined ? {} : { details: call.result.details }),
...(call.result.usage === undefined ? {} : { usage: call.result.usage }),
...(call.result.addedToolNames?.length ? { addedToolNames: call.result.addedToolNames } : {}),
isError: call.isError,
timestamp: Date.now(),
};

View file

@ -156,6 +156,7 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
],
timestamp: m.timestamp,
};
case "system":
case "user":
case "assistant":
case "toolResult":

View file

@ -15,14 +15,7 @@ import {
type UsageRow,
type Write,
} from "../../session/types.ts";
import {
branchTip,
deleteValue,
laneConfig,
operationToolArgsPrefix,
pendingEntry,
setValue,
} from "../../session/values.ts";
import { branchTip, deleteValue, operationToolArgsPrefix, pendingEntry, setValue } from "../../session/values.ts";
import type { Lane } from "../lane.ts";
import type { Drive } from "../types.ts";
@ -198,22 +191,6 @@ async function commitPlacement<TContext extends object | undefined>(
};
});
const complete = completedCalls.every((call) => call.status === "completed");
let nextConfiguration = state.configuration;
const addedNames: string[] = [];
for (const item of read.items) {
for (const name of item.message.addedToolNames ?? []) {
if (!nextConfiguration.activeToolNames.includes(name) && !addedNames.includes(name)) {
addedNames.push(name);
}
}
}
if (addedNames.length !== 0) {
nextConfiguration = {
...nextConfiguration,
activeToolNames: [...nextConfiguration.activeToolNames, ...addedNames],
};
writes.push(setValue(laneConfig(lane.name), nextConfiguration));
}
writes.push(setValue(branchTip(lane.name), parentId));
let nextRun: OperationState;
@ -240,7 +217,7 @@ async function commitPlacement<TContext extends object | undefined>(
kind: "commit",
writes,
operationState: nextRun,
lane: { tipId: parentId, configuration: nextConfiguration },
lane: { tipId: parentId, configuration: state.configuration },
materialize: () => complete,
events: (commit) => {
const events: HarnessEvent[] = [];
@ -260,15 +237,6 @@ async function commitPlacement<TContext extends object | undefined>(
});
}
}
if (addedNames.length !== 0) {
events.push({
type: "config_update",
lane: lane.name,
property: "activeTools",
previous: state.configuration.activeToolNames,
value: nextConfiguration.activeToolNames,
});
}
return events;
},
};

View file

@ -7,13 +7,13 @@
import {
type AssistantMessage,
type AssistantMessageEvent,
type Context,
EventStream,
type Model,
parseStreamingJson,
type SimpleStreamOptions,
type StopReason,
type ToolCall,
type TranscriptContext,
} from "@earendil-works/pi-ai";
// Create stream class matching ProxyMessageEventStream
@ -117,7 +117,11 @@ function buildProxyRequestOptions(options: ProxyStreamOptions): ProxySerializabl
};
}
export function streamProxy(model: Model<any>, context: Context, options: ProxyStreamOptions): ProxyMessageEventStream {
export function streamProxy(
model: Model<any>,
context: TranscriptContext,
options: ProxyStreamOptions,
): ProxyMessageEventStream {
const stream = new ProxyMessageEventStream();
(async () => {

View file

@ -3,7 +3,6 @@ import type {
AssistantMessage,
AssistantMessageEvent,
AssistantMessageEventStream,
Context,
ImageContent,
Message,
Model,
@ -11,6 +10,7 @@ import type {
TextContent,
Tool,
ToolResultMessage,
TranscriptContext,
Usage,
} from "@earendil-works/pi-ai";
import type { Static, TSchema } from "typebox";
@ -19,6 +19,10 @@ import type { Static, TSchema } from "typebox";
* Stream function used by the agent loop. `Models.streamSimple` satisfies
* this shape.
*
* The loop passes a normalized transcript: the system prompt and tool
* declarations are carried by the transcript's system messages, never by
* `context.systemPrompt` or `context.tools`.
*
* Contract:
* - Must not throw or return a rejected promise for request/model/runtime failures.
* - Must return an AssistantMessageEventStream.
@ -27,7 +31,7 @@ import type { Static, TSchema } from "typebox";
*/
export type StreamFn = (
model: Model<Api>,
context: Context,
context: TranscriptContext,
options?: SimpleStreamOptions,
) => AssistantMessageEventStream | Promise<AssistantMessageEventStream>;
@ -138,6 +142,8 @@ export interface ShouldStopAfterTurnContext {
export interface AgentLoopTurnUpdate {
/** Context for the next provider request. */
context?: AgentContext;
/** Messages to append before the next provider request, with normal lifecycle events. */
messages?: AgentMessage[];
/** Model for the next provider request. */
model?: Model<any>;
/** Thinking level for the next provider request. */
@ -152,7 +158,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
/**
* Converts AgentMessage[] to LLM-compatible Message[] before each LLM call.
*
* Each AgentMessage must be converted to a UserMessage, AssistantMessage, or ToolResultMessage
* Each AgentMessage must be converted to a SystemMessage, UserMessage, AssistantMessage, or ToolResultMessage
* that the LLM can understand. AgentMessages that cannot be converted (e.g., UI-only notifications,
* status messages) should be filtered out.
*
@ -224,7 +230,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
/**
* Called after `turn_end` when the loop will continue, immediately before the next turn starts.
* Return replacement context/model/thinking state to affect that turn.
* Return replacement context/model/thinking state or messages to append to affect that turn.
* Return undefined to keep using the current context/config.
*/
prepareNextTurn?: (
@ -332,16 +338,30 @@ export type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessag
* assigned arrays before storing them.
*/
export interface AgentState {
/** System prompt sent with each model request. */
systemPrompt: string;
/**
* Current system prompt, replayed from the transcript's system messages.
*
* Read-only: to change the prompt, append a system message with `content` or `sections`.
* In `initialState`, this seeds the leading system message.
*/
readonly systemPrompt: string;
/** Active model used for future turns. */
model: Model<any>;
/** Requested reasoning level for future turns. */
thinkingLevel: ThinkingLevel;
/** Available tools. Assigning a new array copies the top-level array. */
/**
* Executable tools. Assigning a new array copies the top-level array.
*
* Differences from the tools declared in the transcript are announced to the model
* with a system message before the next request.
*/
set tools(tools: AgentTool<any>[]);
get tools(): AgentTool<any>[];
/** Conversation transcript. Assigning a new array copies the top-level array. */
/**
* Conversation transcript. Assigning a new array copies the top-level array.
*
* System messages in the transcript carry the prompt and tool declarations.
*/
set messages(messages: AgentMessage[]);
get messages(): AgentMessage[];
/**
@ -366,8 +386,6 @@ export interface AgentToolResult<T> {
details: T;
/** Usage from the final tool execution itself, if available. Not used for main LLM context accounting. */
usage?: Usage;
/** Names of tools introduced by this result and available from this transcript point onward. */
addedToolNames?: string[];
/**
* Hint that the agent should stop after the current tool batch.
* Early termination only happens when every finalized tool result in the batch sets this to true.
@ -413,11 +431,9 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
/** Context snapshot passed into the low-level agent loop. */
export interface AgentContext {
/** System prompt included with the request. */
systemPrompt: string;
/** Transcript visible to the model. */
messages: AgentMessage[];
/** Tools available for this run. */
/** Tools available for execution in this run. */
tools?: AgentTool<any>[];
}
@ -435,7 +451,7 @@ export type AgentEvent =
// Turn lifecycle - a turn is one assistant response + any tool calls/results
| { type: "turn_start" }
| { type: "turn_end"; message: AgentMessage; toolResults: ToolResultMessage[] }
// Message lifecycle - emitted for user, assistant, and toolResult messages
// Message lifecycle - emitted for system, user, assistant, and toolResult messages
| { type: "message_start"; message: AgentMessage }
// Only emitted for assistant messages during streaming
| { type: "message_update"; message: AgentMessage; assistantMessageEvent: AssistantMessageEvent }

View file

@ -78,7 +78,9 @@ function createUserMessage(text: string): UserMessage {
// Simple identity converter for tests - just passes through standard messages
function identityConverter(messages: AgentMessage[]): Message[] {
return messages.filter((m) => m.role === "user" || m.role === "assistant" || m.role === "toolResult") as Message[];
return messages.filter(
(m) => m.role === "system" || m.role === "user" || m.role === "assistant" || m.role === "toolResult",
) as Message[];
}
describe("default stream function compatibility", () => {
@ -98,7 +100,7 @@ describe("default stream function compatibility", () => {
});
try {
const context: AgentContext = { systemPrompt: "", messages: [], tools: [] };
const context: AgentContext = { messages: [], tools: [] };
const config: AgentLoopConfig = { model: createModel(), convertToLlm: identityConverter };
const stream = Reflect.apply(agentLoop, undefined, [
[createUserMessage("Hello")],
@ -118,7 +120,6 @@ describe("default stream function compatibility", () => {
describe("agentLoop with AgentMessage", () => {
it("should emit events with AgentMessage types", async () => {
const context: AgentContext = {
systemPrompt: "You are helpful.",
messages: [],
tools: [],
};
@ -163,6 +164,45 @@ describe("agentLoop with AgentMessage", () => {
expect(eventTypes).toContain("agent_end");
});
it("should build provider context exclusively from transcript messages", async () => {
const initialSystem: AgentMessage = {
role: "system",
content: "Transcript prompt",
toolsAdded: [],
timestamp: 1,
};
const context: AgentContext = {
messages: [],
tools: [],
};
const config: AgentLoopConfig = {
model: createModel(),
convertToLlm: identityConverter,
};
const stream = agentLoop(
[initialSystem, createUserMessage("Hello")],
context,
config,
undefined,
(_model, providerContext) => {
// The provider receives a transcript: no top-level prompt or tool fields.
expect(Object.keys(providerContext)).toEqual(["messages"]);
expect(providerContext.messages[0]).toBe(initialSystem);
const response = new MockAssistantStream();
queueMicrotask(() => {
response.push({
type: "done",
reason: "stop",
message: createAssistantMessage([{ type: "text", text: "done" }]),
});
});
return response;
},
);
await stream.result();
});
it("should handle custom message types via convertToLlm", async () => {
// Create a custom message type
interface CustomNotification {
@ -178,7 +218,6 @@ describe("agentLoop with AgentMessage", () => {
};
const context: AgentContext = {
systemPrompt: "You are helpful.",
messages: [notification as unknown as AgentMessage], // Custom message in context
tools: [],
};
@ -220,7 +259,6 @@ describe("agentLoop with AgentMessage", () => {
it("should apply transformContext before convertToLlm", async () => {
const context: AgentContext = {
systemPrompt: "You are helpful.",
messages: [
createUserMessage("old message 1"),
createAssistantMessage([{ type: "text", text: "old response 1" }]),
@ -307,7 +345,6 @@ describe("agentLoop with AgentMessage", () => {
};
const context: AgentContext = {
systemPrompt: "",
messages: [],
tools: [tool],
};
@ -386,7 +423,6 @@ describe("agentLoop with AgentMessage", () => {
};
const context: AgentContext = {
systemPrompt: "",
messages: [],
tools: [tool],
};
@ -459,7 +495,6 @@ describe("agentLoop with AgentMessage", () => {
};
const context: AgentContext = {
systemPrompt: "",
messages: [],
tools: [tool],
};
@ -538,7 +573,6 @@ describe("agentLoop with AgentMessage", () => {
};
const context: AgentContext = {
systemPrompt: "",
messages: [],
tools: [tool],
};
@ -613,7 +647,6 @@ describe("agentLoop with AgentMessage", () => {
};
const context: AgentContext = {
systemPrompt: "",
messages: [],
tools: [tool],
};
@ -696,7 +729,6 @@ describe("agentLoop with AgentMessage", () => {
};
const context: AgentContext = {
systemPrompt: "",
messages: [],
tools: [tool],
};
@ -815,7 +847,6 @@ describe("agentLoop with AgentMessage", () => {
};
const context: AgentContext = {
systemPrompt: "",
messages: [],
tools: [slowTool],
};
@ -909,7 +940,6 @@ describe("agentLoop with AgentMessage", () => {
};
const context: AgentContext = {
systemPrompt: "",
messages: [],
tools: [slowTool, fastTool],
};
@ -985,7 +1015,6 @@ describe("agentLoop with AgentMessage", () => {
};
const context: AgentContext = {
systemPrompt: "",
messages: [],
tools: [tool],
};
@ -1043,11 +1072,10 @@ describe("agentLoop with AgentMessage", () => {
},
};
const context: AgentContext = {
systemPrompt: "first prompt",
messages: [],
tools: [tool],
};
let convertedSecondTurnSystemPrompt = "";
let convertedSecondTurnHasUpdate = false;
let prepareCalls = 0;
let prepared = false;
const config: AgentLoopConfig = {
@ -1059,10 +1087,10 @@ describe("agentLoop with AgentMessage", () => {
prepared = true;
return {
context: {
systemPrompt: "second prompt",
messages: currentContext.messages.slice(),
tools: currentContext.tools,
},
messages: [{ role: "system", content: "updated guidance", timestamp: 1 }],
};
},
};
@ -1071,7 +1099,9 @@ describe("agentLoop with AgentMessage", () => {
const stream = agentLoop([createUserMessage("echo something")], context, config, undefined, (_model, ctx) => {
llmCalls++;
if (llmCalls === 2) {
convertedSecondTurnSystemPrompt = ctx.systemPrompt ?? "";
convertedSecondTurnHasUpdate = ctx.messages.some(
(message) => message.role === "system" && message.content === "updated guidance",
);
}
const mockStream = new MockAssistantStream();
queueMicrotask(() => {
@ -1101,7 +1131,7 @@ describe("agentLoop with AgentMessage", () => {
expect(llmCalls).toBe(2);
expect(prepareCalls).toBe(1);
expect(convertedSecondTurnSystemPrompt).toBe("second prompt");
expect(convertedSecondTurnHasUpdate).toBe(true);
});
it("should stop after the current turn when shouldStopAfterTurn returns true", async () => {
@ -1122,7 +1152,6 @@ describe("agentLoop with AgentMessage", () => {
};
const context: AgentContext = {
systemPrompt: "",
messages: [],
tools: [tool],
};
@ -1183,8 +1212,9 @@ describe("agentLoop with AgentMessage", () => {
expect(steeringPolls).toBe(1);
expect(followUpPolls).toBe(0);
expect(callbackToolResultIds).toEqual(["tool-1"]);
expect(callbackContextRoles).toEqual(["user", "assistant", "toolResult"]);
expect(messages.map((message) => message.role)).toEqual(["user", "assistant", "toolResult"]);
expect(callbackContextRoles).toEqual(["system", "user", "assistant", "toolResult"]);
// The context declares no tools, so the loop announces the loadout with a system message.
expect(messages.map((message) => message.role)).toEqual(["system", "user", "assistant", "toolResult"]);
expect(events.map((event) => event.type)).toEqual([
"agent_start",
"turn_start",
@ -1192,6 +1222,8 @@ describe("agentLoop with AgentMessage", () => {
"message_end",
"message_start",
"message_end",
"message_start",
"message_end",
"tool_execution_start",
"tool_execution_end",
"message_start",
@ -1218,7 +1250,6 @@ describe("agentLoop with AgentMessage", () => {
};
const context: AgentContext = {
systemPrompt: "",
messages: [],
tools: [tool],
};
@ -1249,7 +1280,7 @@ describe("agentLoop with AgentMessage", () => {
const messages = await stream.result();
expect(llmCalls).toBe(1);
expect(messages.map((message) => message.role)).toEqual(["user", "assistant", "toolResult"]);
expect(messages.map((message) => message.role)).toEqual(["system", "user", "assistant", "toolResult"]);
expect(events.filter((event) => event.type === "turn_end")).toHaveLength(1);
});
@ -1270,7 +1301,6 @@ describe("agentLoop with AgentMessage", () => {
},
};
const context: AgentContext = {
systemPrompt: "",
messages: [],
tools: [tool],
};
@ -1329,7 +1359,6 @@ describe("agentLoop with AgentMessage", () => {
},
};
const context: AgentContext = {
systemPrompt: "",
messages: [],
tools: [tool],
};
@ -1388,7 +1417,6 @@ describe("agentLoop with AgentMessage", () => {
};
const context: AgentContext = {
systemPrompt: "",
messages: [],
tools: [tool],
};
@ -1428,6 +1456,7 @@ describe("agentLoop with AgentMessage", () => {
const messages = await stream.result();
expect(callIndex).toBe(2);
expect(messages.map((message) => message.role)).toEqual([
"system",
"user",
"assistant",
"toolResult",
@ -1452,7 +1481,6 @@ describe("agentLoop with AgentMessage", () => {
};
const context: AgentContext = {
systemPrompt: "",
messages: [],
tools: [tool],
};
@ -1488,7 +1516,6 @@ describe("agentLoop with AgentMessage", () => {
describe("agentLoopContinue with AgentMessage", () => {
it("should throw when context has no messages", () => {
const context: AgentContext = {
systemPrompt: "You are helpful.",
messages: [],
tools: [],
};
@ -1509,7 +1536,6 @@ describe("agentLoopContinue with AgentMessage", () => {
const userMessage: AgentMessage = createUserMessage("Hello");
const context: AgentContext = {
systemPrompt: "You are helpful.",
messages: [userMessage],
tools: [],
};
@ -1562,7 +1588,6 @@ describe("agentLoopContinue with AgentMessage", () => {
};
const context: AgentContext = {
systemPrompt: "You are helpful.",
messages: [customMessage as unknown as AgentMessage],
tools: [],
};

View file

@ -1,4 +1,11 @@
import { type AssistantMessage, type AssistantMessageEvent, EventStream, getModel } from "@earendil-works/pi-ai/compat";
import {
type AssistantMessage,
type AssistantMessageEvent,
EventStream,
getCurrentSystemMessage,
getModel,
toToolDeclaration,
} from "@earendil-works/pi-ai/compat";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import {
@ -46,6 +53,16 @@ function createAssistantMessage(text: string): AssistantMessage {
type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
function createTool(name: string): AgentTool {
return {
name,
label: name,
description: `${name} tool`,
parameters: Type.Object({}),
execute: async () => ({ content: [{ type: "text", text: name }], details: {} }),
};
}
function createAssistantToolUseMessage(content: ToolCallContent[]): AssistantMessage {
return {
role: "assistant",
@ -107,7 +124,6 @@ describe("Agent", () => {
const agent = new Agent({ streamFn: unusedStreamFunction });
expect(agent.state).toBeDefined();
expect(agent.state.systemPrompt).toBe("");
expect(agent.state.model).toBeDefined();
expect(agent.state.thinkingLevel).toBe("off");
expect(agent.state.tools).toEqual([]);
@ -129,11 +145,176 @@ describe("Agent", () => {
},
});
expect(agent.state.systemPrompt).toBe("You are a helpful assistant.");
expect(agent.state.messages).toEqual([{ role: "system", content: "You are a helpful assistant.", timestamp: 0 }]);
expect(agent.state.model).toBe(customModel);
expect(agent.state.thinkingLevel).toBe("low");
});
it("converts initial prompt and tools into transcript state", () => {
const tool: AgentTool = {
name: "echo",
label: "Echo",
description: "Echo input",
parameters: Type.Object({}),
execute: async () => ({ content: [{ type: "text", text: "echo" }], details: {} }),
};
const agent = new Agent({
initialState: { systemPrompt: "You are helpful.", tools: [tool] },
streamFn: unusedStreamFunction,
});
const initial = agent.state.messages[0];
expect(initial?.role).toBe("system");
if (initial?.role !== "system") throw new Error("expected initial system message");
expect(initial.content).toBe("You are helpful.");
expect(initial.toolsAdded?.map((value) => value.name)).toEqual(["echo"]);
});
it("declares tool loadout changes to the model before the next request", async () => {
const first = createTool("first");
const second = createTool("second");
const requests: string[][] = [];
const agent = new Agent({
initialState: { systemPrompt: "You are helpful.", tools: [first] },
streamFn: (_model, context) => {
requests.push(
context.messages.flatMap((message) =>
message.role === "system"
? [
`+${(message.toolsAdded ?? []).map((tool) => tool.name).join(",")}`,
`-${(message.toolsRemoved ?? []).map((tool) => tool.name).join(",")}`,
]
: [],
),
);
const stream = new MockAssistantStream();
queueMicrotask(() => {
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("done") });
});
return stream;
},
});
await agent.prompt("one");
agent.state.tools = [second];
await agent.prompt("two");
await agent.prompt("three");
expect(requests).toEqual([
["+first", "-"],
["+first", "-", "+second", "-first"],
["+first", "-", "+second", "-first"],
]);
const update = agent.state.messages.find((message) => message.role === "system" && message.toolsRemoved);
expect(update).toEqual({
role: "system",
content: "",
toolsAdded: [{ name: "second", description: "second tool", parameters: Type.Object({}) }],
toolsRemoved: [{ name: "first" }],
timestamp: expect.any(Number),
});
const initial = agent.state.messages[0];
if (initial?.role !== "system") throw new Error("expected initial system message");
expect(initial.toolsAdded?.[0]).not.toHaveProperty("execute");
});
it("merges tool changes into a pending system message", async () => {
const tool: AgentTool = {
name: "echo",
label: "Echo",
description: "Echo input",
parameters: Type.Object({}),
execute: async () => ({ content: [{ type: "text", text: "echo" }], details: {} }),
};
const agent = new Agent({
initialState: { systemPrompt: "You are helpful." },
streamFn: (_model, context) => {
expect(context.messages.filter((message) => message.role === "system")).toHaveLength(2);
const stream = new MockAssistantStream();
queueMicrotask(() => {
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("done") });
});
return stream;
},
});
agent.state.tools = [tool];
await agent.prompt([
{ role: "system", content: "", sections: { skills: "<skills>x</skills>" }, timestamp: 1 },
{ role: "user", content: "hi", timestamp: 2 },
]);
expect(agent.state.messages[1]).toEqual({
role: "system",
content: "",
sections: { skills: "<skills>x</skills>" },
toolsAdded: [{ name: "echo", description: "Echo input", parameters: Type.Object({}) }],
timestamp: 1,
});
});
it("rewrites pending tool declarations to match the executable set", async () => {
const agent = new Agent({
initialState: { systemPrompt: "You are helpful.", tools: [createTool("first")] },
streamFn: () => {
const stream = new MockAssistantStream();
queueMicrotask(() => {
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("done") });
});
return stream;
},
});
// The pending message claims to add `second` and remove `first`, but the executable
// set still has `first` and lacks `second`: the executable set wins.
await agent.prompt([
{
role: "system",
content: "",
sections: { note: "<note>x</note>" },
toolsAdded: [toToolDeclaration(createTool("second"))],
toolsRemoved: [{ name: "first" }],
timestamp: 1,
},
{ role: "user", content: "hi", timestamp: 2 },
]);
expect(agent.state.messages[1]).toEqual({
role: "system",
content: "",
sections: { note: "<note>x</note>" },
timestamp: 1,
});
expect(getCurrentSystemMessage(agent.state.messages)?.toolsAdded?.map((tool) => tool.name)).toEqual(["first"]);
});
it("restores the transcript baseline when reset", () => {
const tool: AgentTool = {
name: "echo",
label: "Echo",
description: "Echo input",
parameters: Type.Object({}),
execute: async () => ({ content: [{ type: "text", text: "echo" }], details: {} }),
};
const agent = new Agent({
initialState: {
systemPrompt: "You are helpful.",
tools: [tool],
messages: [{ role: "user", content: "old", timestamp: 1 }],
},
streamFn: unusedStreamFunction,
});
agent.reset();
expect(agent.state.messages).toHaveLength(1);
const initial = agent.state.messages[0];
expect(initial?.role).toBe("system");
if (initial?.role !== "system") throw new Error("expected initial system message");
expect(initial.content).toBe("You are helpful.");
expect(initial.toolsAdded?.map((value) => value.name)).toEqual(["echo"]);
});
it("should subscribe to events", () => {
const agent = new Agent({ streamFn: unusedStreamFunction });
@ -146,13 +327,13 @@ describe("Agent", () => {
expect(eventCount).toBe(0);
// State mutators don't emit events
agent.state.systemPrompt = "Test prompt";
agent.state.thinkingLevel = "low";
expect(eventCount).toBe(0);
expect(agent.state.systemPrompt).toBe("Test prompt");
expect(agent.state.thinkingLevel).toBe("low");
// Unsubscribe should work
unsubscribe();
agent.state.systemPrompt = "Another prompt";
agent.state.thinkingLevel = "high";
expect(eventCount).toBe(0); // Should not increase
});
@ -442,10 +623,6 @@ describe("Agent", () => {
it("should update state with mutators", () => {
const agent = new Agent({ streamFn: unusedStreamFunction });
// Test setSystemPrompt
agent.state.systemPrompt = "Custom prompt";
expect(agent.state.systemPrompt).toBe("Custom prompt");
// Test setModel
const newModel = getModel("google", "gemini-2.5-flash");
agent.state.model = newModel;
@ -779,7 +956,7 @@ describe("Agent", () => {
expect(requestCount).toBe(1);
expect(sawAbortSignal).toBe(true);
expect(callbackContextRoles).toEqual(["user", "assistant", "toolResult"]);
expect(callbackContextRoles).toEqual(["system", "user", "assistant", "toolResult"]);
});
it("forwards sessionId to streamFunction options", async () => {

View file

@ -50,11 +50,12 @@ async function basicPrompt(model: Model<string>) {
await agent.prompt("What is 2+2? Answer with just the number.");
expect(agent.state.isStreaming).toBe(false);
expect(agent.state.messages.length).toBe(2);
expect(agent.state.messages[0].role).toBe("user");
expect(agent.state.messages[1].role).toBe("assistant");
expect(agent.state.messages.length).toBe(3);
expect(agent.state.messages[0].role).toBe("system");
expect(agent.state.messages[1].role).toBe("user");
expect(agent.state.messages[2].role).toBe("assistant");
const assistantMessage = agent.state.messages[1];
const assistantMessage = agent.state.messages[2];
if (assistantMessage.role !== "assistant") throw new Error("Expected assistant message");
expect(getTextContent(assistantMessage)).toContain("4");
}
@ -157,7 +158,7 @@ async function stateUpdates(model: Model<string>) {
expect(events.indexOf("message_end")).toBeLessThan(events.lastIndexOf("agent_end"));
expect(agent.state.isStreaming).toBe(false);
expect(agent.state.messages.length).toBe(2);
expect(agent.state.messages.length).toBe(3);
}
async function multiTurnConversation(model: Model<string>) {
@ -172,12 +173,12 @@ async function multiTurnConversation(model: Model<string>) {
});
await agent.prompt("My name is Alice.");
expect(agent.state.messages.length).toBe(2);
expect(agent.state.messages.length).toBe(3);
await agent.prompt("What is my name?");
expect(agent.state.messages.length).toBe(4);
expect(agent.state.messages.length).toBe(5);
const lastMessage = agent.state.messages[3];
const lastMessage = agent.state.messages[4];
if (lastMessage.role !== "assistant") throw new Error("Expected assistant message");
expect(getTextContent(lastMessage).toLowerCase()).toContain("alice");
}
@ -255,7 +256,7 @@ describe("Agent integration with faux provider", () => {
await agent.prompt("What is 2+2?");
const assistantMessage = agent.state.messages[1];
const assistantMessage = agent.state.messages[2];
if (assistantMessage?.role !== "assistant") throw new Error("Expected assistant message");
expect(assistantMessage.content).toEqual([
{ type: "thinking", thinking: "step by step" },

View file

@ -516,7 +516,8 @@ describe("harness compaction", () => {
const { faux, model } = createFauxModel(false);
faux.setResponses([
(context) => {
const message = context.messages[0];
// The transcript leads with the summarization system prompt; the request is the first user message.
const message = context.messages.find((entry) => entry.role === "user");
const content = message?.role === "user" ? message.content : [];
promptText = Array.isArray(content) && content[0]?.type === "text" ? content[0].text : "";
return fauxAssistantMessage("## Goal\nTest summary");

View file

@ -6,6 +6,7 @@ import {
fauxProvider,
type Message,
type Model,
normalizeContext,
type SimpleStreamOptions,
type UserMessage,
} from "@earendil-works/pi-ai";
@ -219,7 +220,7 @@ describe("streamHarnessAssistant", () => {
toProviderMessages,
request: (context, options) => {
seenContext = context.messages;
return faux.provider.streamSimple(faux.getModel(), context, options);
return faux.provider.streamSimple(faux.getModel(), normalizeContext(context), options);
},
observer: {
start() {

View file

@ -196,7 +196,6 @@ describe("tool execution primitives", () => {
content: [{ type: "text", text: "original" }],
details: { original: true },
usage: originalUsage,
addedToolNames: ["new-tool"],
};
const finalized = finalizeToolCall(
@ -218,7 +217,6 @@ describe("tool execution primitives", () => {
content: [{ type: "text", text: "patched" }],
details: { patched: true },
usage: replacementUsage,
addedToolNames: ["new-tool"],
terminate: true,
});
expect(message).toMatchObject({
@ -228,7 +226,6 @@ describe("tool execution primitives", () => {
content: [{ type: "text", text: "patched" }],
details: { patched: true },
usage: replacementUsage,
addedToolNames: ["new-tool"],
isError: false,
});
expect(message.timestamp).toBeGreaterThanOrEqual(before);

View file

@ -122,7 +122,6 @@ function tool(
content: Array<{ type: "text"; text: string }>;
details: { value?: string };
usage?: ToolResultMessage["usage"];
addedToolNames?: string[];
terminate?: boolean;
}>,
replay: "never" | "safe" = "never",
@ -301,7 +300,6 @@ describe("durable tool batch", () => {
const second = tool("second", async (value) => ({
content: [{ type: "text", text: value }],
details: { value },
addedToolNames: ["introduced"],
}));
const fixture = await createFixture({
calls: [{ name: "first" }, { name: "second" }],
@ -340,7 +338,7 @@ describe("durable tool batch", () => {
at: "checkpoint",
continuation: { kind: "need_assistant" },
});
expect(fixture.lane.state.configuration.activeToolNames).toEqual(["first", "second", "introduced"]);
expect(fixture.lane.state.configuration.activeToolNames).toEqual(["first", "second"]);
expect(
await fixture.session.scanValues(
storedValues.operationToolArgsPrefix(fixture.operationId),
@ -539,7 +537,6 @@ describe("durable tool batch", () => {
toolCallId: "call-0",
toolName: "ready",
content: [{ type: "text", text: "already done" }],
addedToolNames: ["later"],
isError: false,
timestamp: 30,
};
@ -563,7 +560,7 @@ describe("durable tool batch", () => {
at: "checkpoint",
continuation: { kind: "may_finish", includeFinalAssistant: false },
});
expect(fixture.lane.state.configuration.activeToolNames).toEqual(["ready", "later"]);
expect(fixture.lane.state.configuration.activeToolNames).toEqual(["ready"]);
expect(fixture.events[0]).toMatchObject({ type: "turn_start", turnId: "turn-1", recovery: true });
expect(fixture.events.at(-1)).toMatchObject({ type: "turn_end", turnId: "turn-1", recovery: true });
});

View file

@ -389,7 +389,6 @@ describe("runtime lane watch", () => {
content: [{ type: "text", text: "settled" }],
details: { kind: "real" },
usage: toolUsage,
addedToolNames: ["later"],
isError: false,
timestamp: 3,
},
@ -438,7 +437,6 @@ describe("runtime lane watch", () => {
content: [{ type: "text", text: "settled" }],
details: { kind: "real" },
usage: toolUsage,
addedToolNames: ["later"],
terminate: true,
},
isError: false,

View file

@ -1,4 +1,5 @@
import type { AssistantMessage, AssistantMessageEvent, Model } from "@earendil-works/pi-ai";
import { normalizeContext } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it, vi } from "vitest";
import { type ProxyAssistantMessageEvent, streamProxy } from "../src/proxy.ts";
@ -53,14 +54,10 @@ describe("streamProxy", () => {
vi.fn(async () => new Response(body, { status: 200 })),
);
const stream = streamProxy(
model,
{ systemPrompt: "", messages: [] },
{
authToken: "test-token",
proxyUrl: "https://proxy.example.com",
},
);
const stream = streamProxy(model, normalizeContext({ systemPrompt: "", messages: [] }), {
authToken: "test-token",
proxyUrl: "https://proxy.example.com",
});
const events: AssistantMessageEvent[] = [];
for await (const event of stream) events.push(event);
const result = await stream.result();
@ -86,14 +83,10 @@ describe("streamProxy", () => {
vi.fn(async () => new Response(start + done, { status: 200 })),
);
const stream = streamProxy(
model,
{ systemPrompt: "", messages: [] },
{
authToken: "test-token",
proxyUrl: "https://proxy.example.com",
},
);
const stream = streamProxy(model, normalizeContext({ systemPrompt: "", messages: [] }), {
authToken: "test-token",
proxyUrl: "https://proxy.example.com",
});
const events: AssistantMessageEvent[] = [];
for await (const event of stream) events.push(event);
const result = await stream.result();
@ -110,14 +103,10 @@ describe("streamProxy", () => {
vi.fn(async () => new Response(body, { status: 200 })),
);
const stream = streamProxy(
model,
{ systemPrompt: "", messages: [] },
{
authToken: "test-token",
proxyUrl: "https://proxy.example.com",
},
);
const stream = streamProxy(model, normalizeContext({ systemPrompt: "", messages: [] }), {
authToken: "test-token",
proxyUrl: "https://proxy.example.com",
});
const events: AssistantMessageEvent[] = [];
for await (const event of stream) events.push(event);
const result = await stream.result();