Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/cli/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,10 @@ export namespace SessionProcessor {
start: Date.now(),
},
},
metadata: value.providerMetadata,
metadata: {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 providerExecuted magic string lacks shared constant.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #116, packages/cli/src/session/processor.ts:148-150):

Problem: providerExecuted magic string lacks shared constant
Detail: The `providerExecuted` key is written into tool-part metadata in processor.ts and read back with a magic string in prompt.ts (`part.metadata?.providerExecuted`, packages/cli/src/session/prompt.ts:66), creating an implicit cross-file contract with no shared constant or typed field. The new tests also hardcode the string. A typo or rename on either side would silently disable the headless-truth logic with no compiler error.
Suggested fix: Export a shared constant (e.g. `MessageV2.PROVIDER_EXECUTED_METADATA_KEY = "providerExecuted"`) or add a typed optional field on ToolPart, and use it in both processor.ts (write), prompt.ts (read), and the tests.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The providerExecuted key is written into tool-part metadata in processor.ts and read back with a magic string in prompt.ts (part.metadata?.providerExecuted, packages/cli/src/session/prompt.ts:66), creating an implicit cross-file contract with no shared constant or typed field. The new tests also hardcode the string. A typo or rename on either side would silently disable the headless-truth logic with no compiler error.

                           start: Date.now(),
                         },
                       },
                      metadata: {
                        ...value.providerMetadata,
                        ...(value.providerExecuted ? { providerExecuted: true } : {}),
                      },
                      })
                      toolcalls[value.toolCallId] = part as MessageV2.ToolPart

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 providerMetadata can forge providerExecuted flag.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #116, packages/cli/src/session/processor.ts:148-151):

Problem: providerMetadata can forge providerExecuted flag
Detail: When `value.providerExecuted` is falsy, the persisted metadata is just `{ ...value.providerMetadata }`; a provider-supplied top-level key `providerExecuted` inside providerMetadata survives verbatim. SessionPrompt.hasToolCalls (packages/cli/src/session/prompt.ts:66) then treats the tool call as provider-executed, so the prompt loop can exit without executing the tool or taking the follow-up model turn — silently dropping a local tool call and, in json_schema mode, raising StructuredOutputError instead of continuing. Repro: given a provider adapter that surfaces a top-level `providerExecuted: true` entry in a tool-call part's providerMetadata while the part's own providerExecuted flag is false, when the model streams that local tool call and finishes with reason "stop", then the CLI persists metadata.providerExecuted=true, hasToolCalls returns false, and the loop exits without executing the tool.
Suggested fix: Overwrite the key unconditionally instead of conditionally spreading, e.g. `metadata: { ...value.providerMetadata, providerExecuted: value.providerExecuted === true }` (or strip any incoming `providerExecuted` key from providerMetadata before merging), so the flag can only come from the stream part itself.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

When value.providerExecuted is falsy, the persisted metadata is just { ...value.providerMetadata }; a provider-supplied top-level key providerExecuted inside providerMetadata survives verbatim. SessionPrompt.hasToolCalls (packages/cli/src/session/prompt.ts:66) then treats the tool call as provider-executed, so the prompt loop can exit without executing the tool or taking the follow-up model turn — silently dropping a local tool call and, in json_schema mode, raising StructuredOutputError instead of continuing. Repro: given a provider adapter that surfaces a top-level providerExecuted: true entry in a tool-call part's providerMetadata while the part's own providerExecuted flag is false, when the model streams that local tool call and finishes with reason "stop", then the CLI persists metadata.providerExecuted=true, hasToolCalls returns false, and the loop exits without executing the tool.

                           start: Date.now(),
                         },
                       },
                      metadata: {
                        ...value.providerMetadata,
                        ...(value.providerExecuted ? { providerExecuted: true } : {}),
                      },
                      })
                      toolcalls[value.toolCallId] = part as MessageV2.ToolPart

...value.providerMetadata,
...(value.providerExecuted ? { providerExecuted: true } : {}),
},
})
toolcalls[value.toolCallId] = part as MessageV2.ToolPart

Expand Down
9 changes: 8 additions & 1 deletion packages/cli/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested struc
export namespace SessionPrompt {
const log = Log.create({ service: "session.prompt" })

export function hasToolCalls(parts: MessageV2.Part[]) {
return parts.some((part) => part.type === "tool" && !part.metadata?.providerExecuted)
}

const state = Instance.state(
() => {
const data: Record<
Expand Down Expand Up @@ -332,9 +336,11 @@ export namespace SessionPrompt {
}

if (!lastUser) throw new Error("No user message found in stream. This should never happen.")
const lastAssistantMsg = msgs.findLast((msg) => msg.info.id === lastAssistant?.id)
if (
lastAssistant?.finish &&
!["tool-calls", "unknown"].includes(lastAssistant.finish) &&
!hasToolCalls(lastAssistantMsg?.parts ?? []) &&
lastUser.id < lastAssistant.id
) {
log.info("exiting loop", { sessionID })
Expand Down Expand Up @@ -709,8 +715,9 @@ export namespace SessionPrompt {

// Check if model finished (finish reason is not "tool-calls" or "unknown")
const modelFinished = processor.message.finish && !["tool-calls", "unknown"].includes(processor.message.finish)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finish-reason filter duplicated; extract helper.

Suggested change
const modelFinished = processor.message.finish && !["tool-calls", "unknown"].includes(processor.message.finish)
Add `export function isModelFinished(finish?: string) { return !!finish && !["tool-calls", "unknown"].includes(finish) }` next to hasToolCalls and use it in both the loop-exit condition and the modelFinished const.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #116, packages/cli/src/session/prompt.ts:717):

Problem: Finish-reason filter duplicated; extract helper
Detail: This PR extracts hasToolCalls as a shared helper, but the adjacent finish-reason filter `!["tool-calls", "unknown"].includes(finish)` still appears twice in prompt.ts (the loop-exit condition around line 342 and the modelFinished computation at line 717). Extracting both predicates keeps the two exit paths symmetric and single-sourced; the two sites must stay in agreement for the loop logic to be correct.
Suggested fix: Add `export function isModelFinished(finish?: string) { return !!finish && !["tool-calls", "unknown"].includes(finish) }` next to hasToolCalls and use it in both the loop-exit condition and the modelFinished const.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

This PR extracts hasToolCalls as a shared helper, but the adjacent finish-reason filter !["tool-calls", "unknown"].includes(finish) still appears twice in prompt.ts (the loop-exit condition around line 342 and the modelFinished computation at line 717). Extracting both predicates keeps the two exit paths symmetric and single-sourced; the two sites must stay in agreement for the loop logic to be correct.

      // Check if model finished (finish reason is not "tool-calls" or "unknown")
      const modelFinished = processor.message.finish && !["tool-calls", "unknown"].includes(processor.message.finish)
      const hasCurrentToolCalls = hasToolCalls(await MessageV2.parts(processor.message.id))

      if (modelFinished && !hasCurrentToolCalls && !processor.message.error) {

const hasCurrentToolCalls = hasToolCalls(await MessageV2.parts(processor.message.id))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Parts query runs even when model not finished.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #116, packages/cli/src/session/prompt.ts:718-720):

Problem: Parts query runs even when model not finished
Detail: hasCurrentToolCalls awaits `MessageV2.parts(processor.message.id)` unconditionally on every iteration of the structured-output retry loop, even when `modelFinished` is false or `processor.message.error` is set and the value is never used. This is inconsistent with the short-circuit `&&` chain it feeds and adds a redundant async persistence read per retry turn.
Suggested fix: Short-circuit inside the condition so the await only runs when the other guards pass: `if (modelFinished && !processor.message.error && !hasToolCalls(await MessageV2.parts(processor.message.id))) { ... }`, or compute hasCurrentToolCalls inside an `if (modelFinished)` guard.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

hasCurrentToolCalls awaits MessageV2.parts(processor.message.id) unconditionally on every iteration of the structured-output retry loop, even when modelFinished is false or processor.message.error is set and the value is never used. This is inconsistent with the short-circuit && chain it feeds and adds a redundant async persistence read per retry turn.

      // Check if model finished (finish reason is not "tool-calls" or "unknown")
      const modelFinished = processor.message.finish && !["tool-calls", "unknown"].includes(processor.message.finish)
      const hasCurrentToolCalls = hasToolCalls(await MessageV2.parts(processor.message.id))

      if (modelFinished && !hasCurrentToolCalls && !processor.message.error) {


if (modelFinished && !processor.message.error) {
if (modelFinished && !hasCurrentToolCalls && !processor.message.error) {
if (format.type === "json_schema") {
// Model stopped without calling StructuredOutput tool
processor.message.error = new MessageV2.StructuredOutputError({
Expand Down
15 changes: 14 additions & 1 deletion packages/cli/src/tool/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,19 @@ const parameters = z.object({
command: z.string().describe("The command that triggered this task").optional(),
})

export function taskResultText(result: MessageV2.WithParts, sessionID: string) {
if (result.info.role === "assistant" && result.info.error) {
const data = result.info.error.data

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 taskResultText crashes when error.data is undefined.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #116, packages/cli/src/tool/task.ts:30-31):

Problem: taskResultText crashes when error.data is undefined
Detail: taskResultText reads `const data = result.info.error.data` and then applies `"message" in data` without guarding against `data` being undefined or a non-object. Persisted child errors whose serialized shape lacks a `data` field (or carries a non-object data) make the `in` operator throw `TypeError: Cannot use 'in' operator`, replacing the intended "Subagent failed (task_id: ...): <reason>" message with a confusing TypeError — undermining this PR's goal of surfacing the real child failure cause. The added test only covers `MessageV2.APIError.toObject()`, which happens to include `data`, so the gap is untested. Repro: given a subagent whose final assistant message persists an error without a `data` payload, when the parent runs the Task tool, then taskResultText throws the raw TypeError instead of the child's actual error name/message.
Suggested fix: Guard the operand before using `in`: `const data = result.info.error.data as { message?: string } | undefined` then `const message = data && typeof data.message === "string" ? data.message : result.info.error.name` (or `"message" in (data ?? {})`). Add a test for an error serialized without `data`.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

taskResultText reads const data = result.info.error.data and then applies "message" in data without guarding against data being undefined or a non-object. Persisted child errors whose serialized shape lacks a data field (or carries a non-object data) make the in operator throw TypeError: Cannot use 'in' operator, replacing the intended "Subagent failed (task_id: ...): " message with a confusing TypeError — undermining this PR's goal of surfacing the real child failure cause. The added test only covers MessageV2.APIError.toObject(), which happens to include data, so the gap is untested. Repro: given a subagent whose final assistant message persists an error without a data payload, when the parent runs the Task tool, then taskResultText throws the raw TypeError instead of the child's actual error name/message.

export function taskResultText(result: MessageV2.WithParts, sessionID: string) {
  if (result.info.role === "assistant" && result.info.error) {
    const data = result.info.error.data
    const message = "message" in data && typeof data.message === "string" ? data.message : result.info.error.name
    throw new Error(`Subagent failed (task_id: ${sessionID}): ${message}`)
  }
  const failed = result.parts.findLast((part) => part.type === "tool" && part.state.status === "error")

const message = "message" in data && typeof data.message === "string" ? data.message : result.info.error.name
throw new Error(`Subagent failed (task_id: ${sessionID}): ${message}`)
}
const failed = result.parts.findLast((part) => part.type === "tool" && part.state.status === "error")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicated tool error check; use type predicate.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #116, packages/cli/src/tool/task.ts:34-35):

Problem: Duplicated tool error check; use type predicate
Detail: The findLast predicate and the immediately following if condition repeat the same compound check (`part.type === "tool" && part.state.status === "error"`) purely for TypeScript narrowing. A type predicate on findLast removes the duplicated condition.
Suggested fix: Use a type-guard predicate: `const failed = result.parts.findLast((part): part is MessageV2.ToolPart => part.type === "tool" && part.state.status === "error")` then `if (failed) { ... }`.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The findLast predicate and the immediately following if condition repeat the same compound check (part.type === "tool" && part.state.status === "error") purely for TypeScript narrowing. A type predicate on findLast removes the duplicated condition.

    throw new Error(`Subagent failed (task_id: ${sessionID}): ${message}`)
  }
  const failed = result.parts.findLast((part) => part.type === "tool" && part.state.status === "error")
  if (failed?.type === "tool" && failed.state.status === "error") {
    throw new Error(`Subagent failed (task_id: ${sessionID}): ${failed.state.error}`)
  }
  return result.parts.findLast((part) => part.type === "text")?.text ?? ""

if (failed?.type === "tool" && failed.state.status === "error") {
throw new Error(`Subagent failed (task_id: ${sessionID}): ${failed.state.error}`)
}
return result.parts.findLast((part) => part.type === "text")?.text ?? ""
}

export const TaskTool = Tool.define("task", async (ctx) => {
const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))

Expand Down Expand Up @@ -150,7 +163,7 @@ export const TaskTool = Tool.define("task", async (ctx) => {
parts: promptParts,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 agent.subtask.complete skipped on child failure.

Suggested change
parts: promptParts,
Trigger `agent.subtask.complete` (with a success/error flag) before throwing, or wrap the taskResultText call so the plugin event fires in a finally block on the failure path.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #116, packages/cli/src/tool/task.ts:163-165):

Problem: agent.subtask.complete skipped on child failure
Detail: taskResultText now throws on child failure before `await Plugin.trigger("agent.subtask.complete", ...)` executes, so plugins subscribed to subtask completion never observe failed subtasks, and any post-trigger accounting on this path is skipped. Previously the event fired for every completed subtask regardless of outcome; now failed subtasks become invisible to plugin-based stats/notifications. Repro: given a plugin subscribed to "agent.subtask.complete", when a child subagent fails (message-level error or error tool part), then taskResultText throws before Plugin.trigger runs and the plugin never receives the event.
Suggested fix: Trigger `agent.subtask.complete` (with a success/error flag) before throwing, or wrap the taskResultText call so the plugin event fires in a finally block on the failure path.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

taskResultText now throws on child failure before await Plugin.trigger("agent.subtask.complete", ...) executes, so plugins subscribed to subtask completion never observe failed subtasks, and any post-trigger accounting on this path is skipped. Previously the event fired for every completed subtask regardless of outcome; now failed subtasks become invisible to plugin-based stats/notifications. Repro: given a plugin subscribed to "agent.subtask.complete", when a child subagent fails (message-level error or error tool part), then taskResultText throws before Plugin.trigger runs and the plugin never receives the event.

        parts: promptParts,
      })

      const text = taskResultText(result, session.id)

      await Plugin.trigger(
        "agent.subtask.complete",

})

const text = result.parts.findLast((x) => x.type === "text")?.text ?? ""
const text = taskResultText(result, session.id)

await Plugin.trigger(
"agent.subtask.complete",
Expand Down
96 changes: 96 additions & 0 deletions packages/cli/test/session/processor-tool-metadata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, expect, spyOn, test } from "bun:test"
import { Agent } from "../../src/agent/agent"
import { Identifier } from "../../src/id/id"
import { Instance } from "../../src/project/instance"
import { Provider } from "../../src/provider/provider"
import { Session } from "../../src/session"
import { LLM } from "../../src/session/llm"
import { SessionProcessor } from "../../src/session/processor"
import type { MessageV2 } from "../../src/session/message-v2"
import { tmpdir } from "../fixture/fixture"

describe("session processor tool metadata", () => {
test("persists provider-executed attribution from the stream", async () => {
await using tmp = await tmpdir({
config: {
enabled_providers: ["alibaba"],
provider: { alibaba: { options: { apiKey: "test-key" } } },
},
})

await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({ title: "Provider tool metadata fixture" })
const agent = await Agent.get("build")
const model = await Provider.getModel("alibaba", "qwen-plus")
const user = (await Session.updateMessage({
id: Identifier.ascending("message"),
sessionID: session.id,
role: "user",
time: { created: Date.now() },
agent: agent.name,
model: { providerID: model.providerID, modelID: model.id },
})) as MessageV2.User
const assistant = (await Session.updateMessage({
id: Identifier.ascending("message"),
sessionID: session.id,
role: "assistant",
parentID: user.id,
modelID: model.id,
providerID: model.providerID,
mode: agent.name,
agent: agent.name,
path: { cwd: tmp.path, root: tmp.path },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: Date.now() },
})) as MessageV2.Assistant

const stream = spyOn(LLM, "stream").mockResolvedValue({
fullStream: (async function* () {
yield { type: "tool-input-start", id: "call_1", toolName: "server_tool" }
yield {
type: "tool-call",
toolCallId: "call_1",
toolName: "server_tool",
input: {},
providerExecuted: true,
}
yield {
type: "finish-step",
finishReason: "stop",
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
}
})(),
} as unknown as Awaited<ReturnType<typeof LLM.stream>>)

try {
const processor = SessionProcessor.create({
assistantMessage: assistant,
sessionID: session.id,
model,
abort: new AbortController().signal,
})
await processor.process({
user,
sessionID: session.id,
model,
agent,
abort: new AbortController().signal,
system: [],
messages: [],
tools: {},
})

const part = (await Session.messages({ sessionID: session.id }))
.flatMap((message) => message.parts)
.find((item) => item.type === "tool" && item.callID === "call_1")
expect(part?.type === "tool" ? part.metadata?.providerExecuted : undefined).toBe(true)
} finally {
stream.mockRestore()
}
},
})
})
})
170 changes: 170 additions & 0 deletions packages/cli/test/session/prompt-tool-loop.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Instance } from "../../src/project/instance"
import { Session } from "../../src/session"
import { SessionPrompt } from "../../src/session/prompt"
import type { MessageV2 } from "../../src/session/message-v2"
import { tmpdir } from "../fixture/fixture"

function tool(input: { status?: "pending" | "running" | "completed" | "error"; providerExecuted?: boolean }) {
const status = input.status ?? "completed"
return {
id: "part_1",
sessionID: "session_1",
messageID: "message_1",
type: "tool",
callID: "call_1",
tool: "read",
metadata: input.providerExecuted ? { providerExecuted: true } : undefined,
state:
status === "pending"
? { status, input: {}, raw: "{}" }
: status === "running"
? { status, input: {}, time: { start: 1 } }
: status === "completed"
? {
status,
input: {},
output: "ok",
title: "read",
metadata: {},
time: { start: 1, end: 2 },
}
: {
status,
input: {},
error: "failed",
time: { start: 1, end: 2 },
},
} as MessageV2.ToolPart
}

function stream(chunks: unknown[]) {
const body =
chunks
.map((chunk) => `data: ${JSON.stringify(chunk)}`)
.concat("data: [DONE]")
.join("\n\n") + "\n\n"
return new Response(body, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
})
}

describe("session prompt tool-call continuation", () => {
test("detects non-provider-executed tool calls that require another model turn", () => {
expect(SessionPrompt.hasToolCalls([tool({ status: "pending" })])).toBe(true)
expect(SessionPrompt.hasToolCalls([tool({ status: "completed" })])).toBe(true)
expect(SessionPrompt.hasToolCalls([tool({ status: "error" })])).toBe(true)
})

test("ignores provider-executed tool calls", () => {
expect(SessionPrompt.hasToolCalls([tool({ providerExecuted: true })])).toBe(false)
})

test("continues structured output after a provider reports stop with a local tool call", async () => {
const requests: Record<string, unknown>[] = []
const server = Bun.serve({
port: 0,
async fetch(request) {
requests.push((await request.json()) as Record<string, unknown>)
const call = requests.length === 1 ? "invalid" : "StructuredOutput"
const args =
requests.length === 1 ? { tool: "missing", error: "fixture tool call" } : { result: "follow-up reached" }
return stream([
{
id: `chatcmpl-${requests.length}`,
object: "chat.completion.chunk",
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
},
{
id: `chatcmpl-${requests.length}`,
object: "chat.completion.chunk",
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
id: `call_${requests.length}`,
type: "function",
function: { name: call, arguments: JSON.stringify(args) },
},
],
},
finish_reason: null,
},
],
},
{
id: `chatcmpl-${requests.length}`,
object: "chat.completion.chunk",
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
},
])
},
})

try {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "aictrl.json"),
JSON.stringify({
$schema: "https://aictrl.ai/config.json",
enabled_providers: ["alibaba"],
provider: {
alibaba: {
options: {
apiKey: "test-key",
baseURL: `${server.url.origin}/v1`,
},
},
},
}),
)
},
})

await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({ title: "Tool continuation fixture" })
const result = await SessionPrompt.prompt({
sessionID: session.id,
model: { providerID: "alibaba", modelID: "qwen-plus" },
parts: [{ type: "text", text: "Return structured output after using a tool." }],
format: {
type: "json_schema",
schema: {
type: "object",
properties: { result: { type: "string" } },
required: ["result"],
},
retryCount: 0,
},
})

expect(requests).toHaveLength(2)
expect(result.info.role).toBe("assistant")
if (result.info.role !== "assistant") throw new Error("Expected assistant result")
expect(result.info.structured).toEqual({ result: "follow-up reached" })
expect(result.info.error).toBeUndefined()

const messages = await Session.messages({ sessionID: session.id })
const first = messages.find(
(message) =>
message.info.role === "assistant" &&
message.parts.some((part) => part.type === "tool" && part.tool === "invalid"),
)
expect(first?.info.role === "assistant" ? first.info.finish : undefined).toBe("stop")
expect(first?.info.role === "assistant" ? first.info.error : undefined).toBeUndefined()
},
})
} finally {
server.stop()
}
}, 15_000)
})
Loading