Compare commits

..

1 Commits

Author SHA1 Message Date
Aiden Cline 3e3910bc84 fix(ai): derive Anthropic tool finish reason 2026-08-05 01:06:17 +00:00
11 changed files with 127 additions and 197 deletions
@@ -303,6 +303,7 @@ type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
interface ParserState {
readonly tools: ToolStream.State<number>
readonly hasLocalToolCalls: boolean
readonly reasoningSignatures: Readonly<Record<number, string>>
readonly usage?: Usage
readonly pendingFinish?: {
@@ -668,7 +669,12 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
// =============================================================================
// Stream Parsing
// =============================================================================
const mapFinishReason = (reason: string | null | undefined): FinishReason => {
const mapFinishReason = (reason: string | null | undefined, hasLocalToolCalls: boolean): FinishReason => {
if (
hasLocalToolCalls &&
(reason === undefined || reason === null || reason === "end_turn" || reason === "stop_sequence")
)
return "tool-calls"
if (reason === "end_turn" || reason === "stop_sequence" || reason === "pause_turn") return "stop"
if (reason === "max_tokens" || reason === "model_context_window_exceeded") return "length"
if (reason === "tool_use") return "tool-calls"
@@ -931,7 +937,17 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun
events.push(...resultEvents)
const reasoningSignatures = { ...state.reasoningSignatures }
delete reasoningSignatures[event.index]
return [{ ...state, lifecycle, tools: result.tools, reasoningSignatures }, events] satisfies StepResult
return [
{
...state,
hasLocalToolCalls:
state.hasLocalToolCalls || resultEvents.some((item) => item.type === "tool-call" && !item.providerExecuted),
lifecycle,
tools: result.tools,
reasoningSignatures,
},
events,
] satisfies StepResult
})
const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult => {
@@ -942,7 +958,7 @@ const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult =
usage,
pendingFinish: {
reason: {
normalized: mapFinishReason(event.delta?.stop_reason),
normalized: mapFinishReason(event.delta?.stop_reason, state.hasLocalToolCalls),
raw: event.delta?.stop_reason ?? undefined,
},
providerMetadata:
@@ -1013,6 +1029,7 @@ export const protocol = Protocol.make({
event: Protocol.jsonEvent(AnthropicEvent),
initial: () => ({
tools: ToolStream.empty<number>(),
hasLocalToolCalls: false,
reasoningSignatures: {},
lifecycle: Lifecycle.initial(),
}),
-1
View File
@@ -444,7 +444,6 @@ const mapUsage = (usage: GeminiUsage | undefined) => {
}
const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean): FinishReason => {
if (finishReason === undefined) return hasToolCalls ? "tool-calls" : "unknown"
if (finishReason === "STOP") return hasToolCalls ? "tool-calls" : "stop"
if (finishReason === "MAX_TOKENS") return "length"
if (
@@ -915,6 +915,30 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("maps a local tool call with end_turn as tool-calls", () =>
Effect.gen(function* () {
const body = sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{ type: "content_block_start", index: 0, content_block: { type: "tool_use", id: "call_1", name: "lookup" } },
{
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: '{"query":"weather"}' },
},
{ type: "content_block_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
)
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "end_turn" })
}),
)
it.effect("keeps malformed server tool input terminal", () =>
Effect.gen(function* () {
const body = sseEvents(
-28
View File
@@ -601,34 +601,6 @@ describe("Gemini route", () => {
}),
)
it.effect("maps tool calls without a finish reason", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
).pipe(
Effect.provide(
fixedResponse(
sseEvents({
candidates: [
{
content: {
role: "model",
parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }],
},
},
],
usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 1 },
}),
),
),
)
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: undefined })
}),
)
it.effect("assigns unique ids to multiple streamed tool calls", () =>
Effect.gen(function* () {
const body = sseEvents({
+55 -45
View File
@@ -10,7 +10,7 @@ import path from "path"
import { Bom } from "@opencode-ai/util/bom"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Formatter } from "../../formatter"
import { LocationMutation } from "../../location-mutation"
import { Location } from "../../location"
import { Patch } from "@opencode-ai/util/patch"
import { Permission } from "../../permission"
import DESCRIPTION from "../patch.txt"
@@ -54,11 +54,16 @@ type Prepared =
readonly content: string
readonly before: string
readonly after: string
readonly moveTarget?: LocationMutation.Target
readonly moveTarget?: Target
})
interface Target extends LocationMutation.Target {
readonly entry: string
interface Target {
readonly canonical: string
readonly resource: string
readonly externalDirectory?: {
readonly directory: string
readonly resource: string
}
}
export const Plugin = {
@@ -66,7 +71,7 @@ export const Plugin = {
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
const fs = yield* FSUtil.Service
const formatter = yield* Formatter.Service
const mutation = yield* LocationMutation.Service
const location = yield* Location.Service
const permission = yield* Permission.Service
yield* ctx.tool
@@ -102,46 +107,26 @@ export const Plugin = {
return yield* new ToolFailure({ message: "patch rejected: empty patch" })
}
const prepared: Prepared[] = []
const resources: string[] = []
const targets: Target[] = []
const updates = new Map<string, string>()
for (const hunk of hunks) {
yield* Effect.gen(function* () {
const resolved = yield* mutation.resolve({ path: hunk.path, kind: "file" })
const removesSource =
hunk.type === "delete" || (hunk.type === "update" && hunk.movePath !== undefined)
const entryDirectory = removesSource
? yield* mutation.resolve({ path: path.dirname(hunk.path), kind: "directory" })
: undefined
const target = {
...resolved,
entry: entryDirectory
? path.join(entryDirectory.canonical, path.basename(hunk.path))
: resolved.canonical,
} satisfies Target
resources.push(target.resource)
const target = resolveTarget(location, hunk.path)
targets.push(target)
if (target.externalDirectory) {
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
action: "external_directory",
resources: [target.externalDirectory.resource],
save: [target.externalDirectory.resource],
metadata: {
filepath: target.canonical,
parentDir: target.externalDirectory.directory,
},
sessionID: context.sessionID,
agent: context.agent,
source,
})
}
if (
entryDirectory?.externalDirectory &&
entryDirectory.externalDirectory.resource !== target.externalDirectory?.resource
) {
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(entryDirectory.externalDirectory),
sessionID: context.sessionID,
agent: context.agent,
source,
})
}
if (entryDirectory?.externalDirectory) {
const entryResource = target.entry.replaceAll("\\", "/")
if (entryResource !== target.resource) resources.push(entryResource)
}
if (hunk.type === "add") {
prepared.push({
...hunk,
@@ -200,13 +185,17 @@ export const Plugin = {
catch: (error) =>
new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }),
})
const moveTarget = hunk.movePath
? yield* mutation.resolve({ path: hunk.movePath, kind: "file" })
: undefined
if (moveTarget) resources.push(moveTarget.resource)
const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined
if (moveTarget) targets.push(moveTarget)
if (moveTarget?.externalDirectory) {
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(moveTarget.externalDirectory),
action: "external_directory",
resources: [moveTarget.externalDirectory.resource],
save: [moveTarget.externalDirectory.resource],
metadata: {
filepath: moveTarget.canonical,
parentDir: moveTarget.externalDirectory.directory,
},
sessionID: context.sessionID,
agent: context.agent,
source,
@@ -233,10 +222,10 @@ export const Plugin = {
const patchFiles = prepared.map((change) => patchFile(change))
yield* permission.assert({
action: "edit",
resources: [...new Set(resources)],
resources: [...new Set(targets.map((target) => target.resource))],
save: ["*"],
metadata: {
filepath: resources.join(", "),
filepath: targets.map((target) => target.resource).join(", "),
diff: patchFiles.map((file) => `${file.patch}\n`).join(""),
files: patchFiles,
},
@@ -269,14 +258,14 @@ export const Plugin = {
}
if (change.type === "delete") {
yield* fs
.remove(change.target.entry)
.remove(change.target.canonical)
.pipe(
Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)),
)
applied.push({
type: change.type,
resource: change.target.resource,
target: change.target.entry,
target: change.target.canonical,
})
return
}
@@ -285,7 +274,7 @@ export const Plugin = {
yield* fs
.writeWithDirs(moveTarget.canonical, change.content)
.pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
yield* fs.remove(change.target.entry).pipe(
yield* fs.remove(change.target.canonical).pipe(
Effect.mapError((error) =>
fail(`Wrote ${moveTarget.resource} but failed to remove ${change.target.resource}`, error),
),
@@ -425,3 +414,24 @@ function trimDiff(diff: string) {
})
.join("\n")
}
function resolveTarget(location: Location.Interface, value: string): Target {
const canonical =
process.platform === "win32"
? FSUtil.normalizePath(path.resolve(location.directory, value))
: path.resolve(location.directory, value)
const projectRoot = path.parse(location.project.directory).root
const external =
!FSUtil.contains(location.directory, canonical) &&
(location.project.directory === projectRoot || !FSUtil.contains(location.project.directory, canonical))
const directory = path.dirname(canonical)
const resource =
process.platform === "win32"
? FSUtil.normalizePathPattern(path.join(directory, "*"))
: path.join(directory, "*").replaceAll("\\", "/")
return {
canonical,
resource: path.relative(location.project.directory, canonical).replaceAll("\\", "/") || ".",
externalDirectory: external ? { directory, resource } : undefined,
}
}
+3 -80
View File
@@ -8,7 +8,6 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Formatter } from "@opencode-ai/core/formatter"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
@@ -23,7 +22,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
const patchToolNode = makeLocationNode({
name: "test/patch-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
deps: [Tool.node, Formatter.node, FSUtil.node, LocationMutation.node, Permission.node],
deps: [Tool.node, Formatter.node, FSUtil.node, Location.node, Permission.node],
})
const sessionID = Session.ID.make("ses_patch_tool_test")
@@ -404,72 +403,6 @@ describe("PatchTool", () => {
),
)
it.live("authorizes external symlink entries when deleting and moving", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
if (process.platform === "win32") return Effect.void
const active = path.join(tmp.path, "active")
const outside = path.join(tmp.path, "outside")
const other = path.join(tmp.path, "other")
const target = path.join(other, "target.txt")
const link = path.join(outside, "link.txt")
const moved = path.join(active, "moved.txt")
return Effect.promise(async () => {
await Promise.all([fs.mkdir(active), fs.mkdir(outside), fs.mkdir(other)])
await fs.writeFile(target, "before\n")
await fs.symlink(target, link)
}).pipe(
Effect.andThen(
withTool(active, (registry) =>
Effect.gen(function* () {
expect(
yield* executeTool(registry, call(`*** Begin Patch\n*** Delete File: ${link}\n*** End Patch`)),
).toMatchObject({ status: "completed" })
const targetRoot = yield* Effect.promise(() => fs.realpath(other))
const entryRoot = yield* Effect.promise(() => fs.realpath(outside))
expect(assertions).toMatchObject([
{ action: "external_directory", resources: [path.join(targetRoot, "*")] },
{ action: "external_directory", resources: [path.join(entryRoot, "*")] },
{
action: "edit",
resources: [path.join(targetRoot, "target.txt"), path.join(entryRoot, "link.txt")],
},
])
expect(yield* exists(link)).toBe(false)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("before\n")
reset()
yield* Effect.promise(() => fs.symlink(target, link))
expect(
yield* executeTool(
registry,
call(
`*** Begin Patch\n*** Update File: ${link}\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch`,
),
),
).toMatchObject({ status: "completed" })
expect(assertions).toMatchObject([
{ action: "external_directory", resources: [path.join(targetRoot, "*")] },
{ action: "external_directory", resources: [path.join(entryRoot, "*")] },
{
action: "edit",
resources: [path.join(targetRoot, "target.txt"), path.join(entryRoot, "link.txt"), "moved.txt"],
},
])
expect(yield* exists(link)).toBe(false)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("before\n")
expect(yield* Effect.promise(() => fs.readFile(moved, "utf8"))).toBe("after\n")
}),
),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("includes move file info in output and metadata", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
@@ -895,7 +828,7 @@ describe("PatchTool", () => {
),
)
it.live("requires external permission for a sibling path inside the project worktree", () =>
it.live("treats a sibling path inside the project worktree as internal", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
@@ -914,17 +847,7 @@ describe("PatchTool", () => {
call("*** Begin Patch\n*** Update File: ../sibling.txt\n@@\n-before\n+after\n*** End Patch"),
),
).toMatchObject({ status: "completed" })
const root = yield* Effect.promise(() => fs.realpath(tmp.path))
expect(assertions).toMatchObject([
{
action: "external_directory",
resources: [path.join(root, "*").replaceAll("\\", "/")],
},
{
action: "edit",
resources: [path.join(root, "sibling.txt").replaceAll("\\", "/")],
},
])
expect(assertions.map((input) => input.action)).toEqual(["edit"])
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
}),
tmp.path,
+8 -8
View File
@@ -88,10 +88,10 @@ export const Definitions = {
session_new: keybind("<leader>n", "Create a new session"),
session_list: keybind("<leader>l", "List all sessions"),
open_menu: keybind("ctrl+o", "Open recent sessions and projects"),
session_tab_next: keybind("ctrl+tab,alt+down", "Switch to next open tab"),
session_tab_previous: keybind("ctrl+shift+tab,alt+up", "Switch to previous open tab"),
session_tab_next_unread: keybind("alt+shift+down", "Switch to next unread tab"),
session_tab_previous_unread: keybind("alt+shift+up", "Switch to previous unread tab"),
session_tab_next: keybind("ctrl+tab,<leader>right,alt+shift+]", "Switch to next open tab"),
session_tab_previous: keybind("ctrl+shift+tab,<leader>left,alt+shift+[", "Switch to previous open tab"),
session_tab_next_unread: keybind("<leader>down", "Switch to next unread tab"),
session_tab_previous_unread: keybind("<leader>up", "Switch to previous unread tab"),
session_tab_close: keybind("<leader>w", "Close current tab"),
session_tab_reopen: keybind("ctrl+shift+t", "Reopen last closed tab"),
session_timeline: keybind("<leader>g", "Show session timeline"),
@@ -150,10 +150,10 @@ export const Definitions = {
messages_half_page_down: keybind("ctrl+alt+d", "Scroll messages down by half page"),
messages_first: keybind("ctrl+g,home,alt+home", "Navigate to first message"),
messages_last: keybind("ctrl+alt+g,end", "Navigate to last message"),
messages_next: keybind("none", "Navigate to next message"),
messages_previous: keybind("none", "Navigate to previous message"),
messages_next_user: keybind("none", "Navigate to next user message"),
messages_previous_user: keybind("none", "Navigate to previous user message"),
messages_next: keybind("alt+down", "Navigate to next message"),
messages_previous: keybind("alt+up", "Navigate to previous message"),
messages_next_user: keybind("alt+shift+down", "Navigate to next user message"),
messages_previous_user: keybind("alt+shift+up", "Navigate to previous user message"),
messages_last_user: keybind("alt+end", "Navigate to last user message"),
messages_copy: keybind("<leader>y", "Copy message"),
messages_undo: keybind("<leader>u", "Undo message"),
+2 -10
View File
@@ -82,19 +82,11 @@ export function moveSessionTab(tabs: SessionTab[], sessionID: string, index: num
return next
}
export function cycleSessionTab(
tabs: readonly SessionTab[],
active: string | undefined,
direction: 1 | -1,
matches: (tab: SessionTab) => boolean = () => true,
) {
export function cycleSessionTab(tabs: readonly SessionTab[], active: string | undefined, direction: 1 | -1) {
if (tabs.length === 0) return
const index = tabs.findIndex((tab) => tab.sessionID === active)
const start = index === -1 ? (direction === 1 ? -1 : 0) : index
return Array.from(
{ length: tabs.length },
(_, offset) => tabs[(start + direction * (offset + 1) + tabs.length * 2) % tabs.length],
).find(matches)
return tabs[(start + direction + tabs.length) % tabs.length]
}
// In-memory navigation history is bounded so a long-lived TUI does not accumulate one entry per
+4 -2
View File
@@ -298,8 +298,10 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
},
cycleUnread(direction: 1 | -1) {
if (!enabled()) return
const tab = cycleSessionTab(state().tabs, current(), direction, (tab) =>
Boolean(state().unread[tab.sessionID] || status(tab.sessionID).attention),
const tab = cycleSessionTab(
state().tabs.filter((tab) => state().unread[tab.sessionID] || status(tab.sessionID).attention),
current(),
direction,
)
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
},
+11 -9
View File
@@ -90,10 +90,10 @@ test("resolves message navigation defaults", () => {
const config = resolve({}, { terminalSuspend: true })
expect(config.keybinds.get("session.first")).toMatchObject([{ key: "ctrl+g,home,alt+home" }])
expect(config.keybinds.get("session.message.previous")).toEqual([])
expect(config.keybinds.get("session.message.next")).toEqual([])
expect(config.keybinds.get("session.message.user.previous")).toEqual([])
expect(config.keybinds.get("session.message.user.next")).toEqual([])
expect(config.keybinds.get("session.message.previous")).toMatchObject([{ key: "alt+up" }])
expect(config.keybinds.get("session.message.next")).toMatchObject([{ key: "alt+down" }])
expect(config.keybinds.get("session.message.user.previous")).toMatchObject([{ key: "alt+shift+up" }])
expect(config.keybinds.get("session.message.user.next")).toMatchObject([{ key: "alt+shift+down" }])
expect(config.keybinds.get("session.messages_last_user")).toMatchObject([{ key: "alt+end" }])
})
@@ -116,13 +116,15 @@ test("opens the subagent picker with down", () => {
expect(config.keybinds.get("session.child.first")).toMatchObject([{ key: "down" }])
})
test("navigates session tabs with option arrows", () => {
test("navigates session tabs with leader arrows", () => {
const config = resolve({}, { terminalSuspend: true })
expect(config.keybinds.get("session.tab.next")).toMatchObject([{ key: "ctrl+tab,alt+down" }])
expect(config.keybinds.get("session.tab.previous")).toMatchObject([{ key: "ctrl+shift+tab,alt+up" }])
expect(config.keybinds.get("session.tab.next_unread")).toMatchObject([{ key: "alt+shift+down" }])
expect(config.keybinds.get("session.tab.previous_unread")).toMatchObject([{ key: "alt+shift+up" }])
expect(config.keybinds.get("session.tab.next")).toMatchObject([{ key: "ctrl+tab,<leader>right,alt+shift+]" }])
expect(config.keybinds.get("session.tab.previous")).toMatchObject([
{ key: "ctrl+shift+tab,<leader>left,alt+shift+[" },
])
expect(config.keybinds.get("session.tab.next_unread")).toMatchObject([{ key: "<leader>down" }])
expect(config.keybinds.get("session.tab.previous_unread")).toMatchObject([{ key: "<leader>up" }])
})
test("preserves pinned session bindings alongside tab bindings", () => {
@@ -93,17 +93,6 @@ describe("session tabs", () => {
expect(cycleSessionTab(tabs, "b", 1)?.sessionID).toBe("a")
})
test("cycles to the nearest matching tab from an unmatched active tab", () => {
const tabs = ["a", "b", "c", "d", "e"].map((sessionID) => ({ sessionID }))
const unread = new Set(["a", "d"])
const matches = (tab: { sessionID: string }) => unread.has(tab.sessionID)
expect(cycleSessionTab(tabs, "c", 1, matches)?.sessionID).toBe("d")
expect(cycleSessionTab(tabs, "c", -1, matches)?.sessionID).toBe("a")
expect(cycleSessionTab(tabs, "e", 1, matches)?.sessionID).toBe("a")
expect(cycleSessionTab(tabs, "a", -1, matches)?.sessionID).toBe("d")
})
test("moves backward and forward through selection history", () => {
const tabs = ["a", "b", "c", "d"].map((sessionID) => ({ sessionID }))
const history = ["a", "b", "c", "d"].reduce(recordSessionTabHistory, { entries: [], index: -1 })