mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-05 16:41:01 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c4cba53f67 |
@@ -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 (
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
LanguageModel,
|
||||
LanguageModelLimits,
|
||||
LLMEvent,
|
||||
InvalidProviderOutputReason,
|
||||
ProviderID,
|
||||
mergeGenerationOptions,
|
||||
mergeHttpOptions,
|
||||
@@ -232,17 +231,6 @@ const streamError = (route: string, message: string, cause: Cause.Cause<unknown>
|
||||
return ProviderShared.eventError(route, message, Cause.pretty(cause))
|
||||
}
|
||||
|
||||
const incompleteStreamError = (route: string) =>
|
||||
new AIError({
|
||||
module: "LLMClient",
|
||||
method: "stream",
|
||||
reason: new InvalidProviderOutputReason({
|
||||
classification: "incomplete-stream",
|
||||
message: "The provider response ended unexpectedly.",
|
||||
route,
|
||||
}),
|
||||
})
|
||||
|
||||
const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent, AIError>) =>
|
||||
Stream.suspend(() => {
|
||||
let terminal = false
|
||||
@@ -259,7 +247,7 @@ const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent,
|
||||
Effect.suspend(() =>
|
||||
terminal
|
||||
? Effect.void
|
||||
: Effect.fail(incompleteStreamError(route)),
|
||||
: Effect.fail(ProviderShared.eventError(route, "Provider stream ended without a terminal finish event")),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -428,7 +416,10 @@ const generateWith = (stream: Interface["stream"]) =>
|
||||
const state = yield* stream(request, options).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
|
||||
const response = LLMResponse.complete(state)
|
||||
if (response) return response
|
||||
return yield* incompleteStreamError(`${request.model.provider}/${request.model.route.id}`)
|
||||
return yield* ProviderShared.eventError(
|
||||
`${request.model.provider}/${request.model.route.id}`,
|
||||
"Provider stream ended without a terminal finish event",
|
||||
)
|
||||
})
|
||||
|
||||
export function stream(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, AIError, Service> {
|
||||
|
||||
@@ -105,7 +105,6 @@ export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOut
|
||||
)({
|
||||
_tag: Schema.tag("InvalidProviderOutput"),
|
||||
message: Schema.String,
|
||||
classification: Schema.optional(Schema.Literals(["incomplete-stream"])),
|
||||
route: Schema.optional(Schema.String),
|
||||
raw: Schema.optional(Schema.String),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
|
||||
@@ -24,7 +24,7 @@ export type ToolExecute<Parameters extends ToolSchema<any>, Success extends Tool
|
||||
) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure>
|
||||
|
||||
export interface ToolModelOutputInput<Parameters, Output> {
|
||||
readonly id: ToolCallPart["id"]
|
||||
readonly callID: ToolCallPart["id"]
|
||||
readonly parameters: Parameters
|
||||
readonly output: Output
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export interface Definition<Parameters extends ToolSchema<any>, Success extends
|
||||
/** @internal */
|
||||
readonly _project: (
|
||||
parameters: Schema.Schema.Type<Parameters>,
|
||||
id: ToolCallPart["id"],
|
||||
callID: ToolCallPart["id"],
|
||||
output: unknown,
|
||||
) => ToolOutputType
|
||||
/** @internal */
|
||||
@@ -173,8 +173,8 @@ export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool {
|
||||
toStructuredOutput: config.toStructuredOutput,
|
||||
_decode: Effect.succeed,
|
||||
_encode: Effect.succeed,
|
||||
_project: (parameters, id, output) =>
|
||||
project(config.toModelOutput, config.toStructuredOutput, parameters, id, output),
|
||||
_project: (parameters, callID, output) =>
|
||||
project(config.toModelOutput, config.toStructuredOutput, parameters, callID, output),
|
||||
_legacyResult: config.toModelOutput === undefined && config.toStructuredOutput === undefined,
|
||||
_definition: new ToolDefinition({
|
||||
name: "",
|
||||
@@ -193,8 +193,8 @@ export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool {
|
||||
toStructuredOutput: config.toStructuredOutput,
|
||||
_decode: Schema.decodeUnknownEffect(config.parameters),
|
||||
_encode: Schema.encodeEffect(config.success),
|
||||
_project: (parameters, id, output) =>
|
||||
project(config.toModelOutput, config.toStructuredOutput, parameters, id, output),
|
||||
_project: (parameters, callID, output) =>
|
||||
project(config.toModelOutput, config.toStructuredOutput, parameters, callID, output),
|
||||
_legacyResult: false,
|
||||
_definition: new ToolDefinition({
|
||||
name: "",
|
||||
@@ -239,12 +239,12 @@ const project = (
|
||||
toModelOutput: ((input: ToolModelOutputInput<any, any>) => ReadonlyArray<Tool.Content>) | undefined,
|
||||
toStructuredOutput: ((output: unknown) => unknown) | undefined,
|
||||
parameters: unknown,
|
||||
id: ToolCallPart["id"],
|
||||
callID: ToolCallPart["id"],
|
||||
output: unknown,
|
||||
): ToolOutputType =>
|
||||
ToolOutput.make(
|
||||
toStructuredOutput?.(output) ?? output,
|
||||
toModelOutput?.({ id, parameters, output }) ??
|
||||
toModelOutput?.({ callID, parameters, output }) ??
|
||||
(typeof output === "string" ? [{ type: "text", text: output }] : []),
|
||||
)
|
||||
|
||||
|
||||
@@ -133,8 +133,8 @@ describe("llm route", () => {
|
||||
Effect.gen(function* () {
|
||||
const error = yield* (yield* LLMClient.Service).stream(request).pipe(Stream.runDrain, Effect.flip)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput", classification: "incomplete-stream" })
|
||||
expect(error.message).toContain("The provider response ended unexpectedly.")
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
|
||||
expect(error.message).toContain("Provider stream ended without a terminal finish event")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -538,8 +538,7 @@ describe("Anthropic Messages route", () => {
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidProviderOutput",
|
||||
classification: "incomplete-stream",
|
||||
message: "The provider response ended unexpectedly.",
|
||||
message: "Provider stream ended without a terminal finish event",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -1136,12 +1136,9 @@ describe("OpenAI Chat route", () => {
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
|
||||
])
|
||||
expect(events.filter(LLMEvent.is.toolCall)).toEqual([])
|
||||
expect(streamError.reason).toMatchObject({
|
||||
_tag: "InvalidProviderOutput",
|
||||
classification: "incomplete-stream",
|
||||
})
|
||||
expect(streamError.message).toContain("The provider response ended unexpectedly.")
|
||||
expect(error.message).toContain("The provider response ended unexpectedly.")
|
||||
expect(streamError.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
|
||||
expect(streamError.message).toContain("Provider stream ended without a terminal finish event")
|
||||
expect(error.message).toContain("Provider stream ended without a terminal finish event")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -169,7 +169,7 @@ describe("LLMClient tools", () => {
|
||||
LLMEvent.toolCall({ id: "call_projected", name: "projected", input: { prefix: "count" } }),
|
||||
)
|
||||
|
||||
expect(calls).toEqual([{ id: "call_projected", parameters: { prefix: "count" }, output: { count: "2" } }])
|
||||
expect(calls).toEqual([{ callID: "call_projected", parameters: { prefix: "count" }, output: { count: "2" } }])
|
||||
expect(dispatched.result).toEqual({ type: "text", value: "count:2" })
|
||||
expect(dispatched.output).toEqual({ structured: { count: "2" }, content: [{ type: "text", text: "count:2" }] })
|
||||
expect(dispatched.events).toEqual([
|
||||
|
||||
@@ -27,8 +27,8 @@ Tool.make({
|
||||
parameters: Schema.Struct({ city: Schema.String }),
|
||||
success: Schema.Struct({ forecast: Schema.NumberFromString }),
|
||||
execute: () => Effect.succeed({ forecast: 1 }),
|
||||
toModelOutput: ({ id, parameters, output }) => [
|
||||
{ type: "text", text: `${id}:${parameters.city}:${output.forecast}` },
|
||||
toModelOutput: ({ callID, parameters, output }) => [
|
||||
{ type: "text", text: `${callID}:${parameters.city}:${output.forecast}` },
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -4,13 +4,15 @@ import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
|
||||
import { SessionV1 } from "@opencode-ai/schema/session-v1"
|
||||
import type {
|
||||
AssistantMessage,
|
||||
GlobalEvent,
|
||||
Message,
|
||||
Part,
|
||||
Session,
|
||||
SessionStatus,
|
||||
ToolPart,
|
||||
ToolState,
|
||||
UserMessage,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { SessionV1Info, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import { expect, type Page } from "@playwright/test"
|
||||
import { Schema } from "effect"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
@@ -25,29 +27,18 @@ export const assistantID = "msg_1001_timeline_assistant"
|
||||
export const title = "Timeline visual stability"
|
||||
export const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||
|
||||
type Session = SessionV1Info
|
||||
type GlobalEvent = {
|
||||
directory: string
|
||||
project?: string
|
||||
workspace?: string
|
||||
payload: {
|
||||
id: string
|
||||
type: string
|
||||
properties: Record<string, unknown>
|
||||
type TimelinePayload = Extract<
|
||||
GlobalEvent["payload"],
|
||||
{
|
||||
type:
|
||||
| "message.updated"
|
||||
| "message.removed"
|
||||
| "message.part.updated"
|
||||
| "message.part.removed"
|
||||
| "message.part.delta"
|
||||
| "session.status"
|
||||
}
|
||||
}
|
||||
|
||||
type TimelineProperties = {
|
||||
"message.updated": { sessionID: string; info: Message }
|
||||
"message.removed": { sessionID: string; messageID: string }
|
||||
"message.part.updated": { sessionID: string; part: Part; time: number }
|
||||
"message.part.removed": { sessionID: string; messageID: string; partID: string }
|
||||
"message.part.delta": { sessionID: string; messageID: string; partID: string; field: string; delta: string }
|
||||
"session.status": { sessionID: string; status: SessionStatus }
|
||||
}
|
||||
type TimelinePayload = {
|
||||
[Type in keyof TimelineProperties]: { id: string; type: Type; properties: TimelineProperties[Type] }
|
||||
}[keyof TimelineProperties]
|
||||
>
|
||||
|
||||
type DeepReadonly<Value> = Value extends readonly unknown[]
|
||||
? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> }
|
||||
@@ -106,6 +97,7 @@ export async function setupTimeline(
|
||||
locale?: string
|
||||
deviceScaleFactor?: number
|
||||
seedHistory?: boolean
|
||||
protocol?: "v1" | "v2"
|
||||
} = {},
|
||||
) {
|
||||
const sessions = input.sessions ?? [session()]
|
||||
@@ -123,7 +115,7 @@ export async function setupTimeline(
|
||||
retry: input.eventRetry ?? 20,
|
||||
})
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
protocol: input.protocol,
|
||||
directory,
|
||||
project: project(),
|
||||
provider: provider(),
|
||||
@@ -243,7 +235,7 @@ export function event(type: TimelinePayload["type"], properties: TimelinePayload
|
||||
}
|
||||
|
||||
export function validateTimelineEvent(input: unknown): TimelineEvent {
|
||||
return decodeEvent(input, decodeOptions) as TimelineEvent
|
||||
return decodeEvent(input, decodeOptions)
|
||||
}
|
||||
|
||||
export function validateTimelineMessages(input: readonly TimelineMessage[]): TimelineMessage[] {
|
||||
@@ -468,7 +460,7 @@ export function toolPart(
|
||||
input: Record<string, unknown>,
|
||||
options: ToolOptions<ToolStatus> = {},
|
||||
): Omit<ToolPart, "sessionID" | "messageID"> {
|
||||
const base = { id, type: "tool" as const, callID: id, tool }
|
||||
const base = { id, type: "tool" as const, callID: `call_${id}`, tool }
|
||||
if (state === "pending") return { ...base, state: { status: state, input, raw: "" } }
|
||||
if (state === "running")
|
||||
return {
|
||||
|
||||
@@ -1,121 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import type { Page, Route } from "@playwright/test"
|
||||
import { currentMessage, mockOpenCodeServer } from "../../utils/mock-server"
|
||||
|
||||
test("preserves current messages", () => {
|
||||
const message = {
|
||||
id: "msg_current",
|
||||
type: "user",
|
||||
time: { created: 1 },
|
||||
text: "current",
|
||||
files: [{ data: "e30=", mime: "application/json", source: { type: "inline" } }],
|
||||
} satisfies SessionMessageInfo
|
||||
|
||||
expect(currentMessage(message)).toBe(message)
|
||||
})
|
||||
|
||||
test("converts rich legacy messages to current message types", () => {
|
||||
expect(
|
||||
currentMessage({
|
||||
info: { id: "msg_user", role: "user", time: { created: 1 } },
|
||||
parts: [
|
||||
{ type: "text", text: "Use @src/a.ts with @explore" },
|
||||
{
|
||||
type: "file",
|
||||
mime: "application/json",
|
||||
filename: "data.json",
|
||||
url: "data:application/json;base64,e30=",
|
||||
},
|
||||
{
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
filename: "a.ts",
|
||||
url: "src/a.ts",
|
||||
source: { type: "file", text: { value: "@src/a.ts", start: 4, end: 13 } },
|
||||
},
|
||||
{ type: "agent", name: "explore", source: { value: "@explore", start: 19, end: 27 } },
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
id: "msg_user",
|
||||
type: "user",
|
||||
time: { created: 1 },
|
||||
text: "Use @src/a.ts with @explore",
|
||||
files: [
|
||||
{ data: "e30=", mime: "application/json", name: "data.json", source: { type: "inline" } },
|
||||
{
|
||||
data: "",
|
||||
mime: "text/plain",
|
||||
name: "a.ts",
|
||||
source: { type: "uri", uri: "src/a.ts" },
|
||||
mention: { text: "@src/a.ts", start: 4, end: 13 },
|
||||
},
|
||||
],
|
||||
agents: [{ name: "explore", mention: { text: "@explore", start: 19, end: 27 } }],
|
||||
})
|
||||
|
||||
expect(
|
||||
currentMessage({
|
||||
info: {
|
||||
id: "msg_assistant",
|
||||
role: "assistant",
|
||||
time: { created: 2, completed: 5 },
|
||||
agent: "explore",
|
||||
modelID: "model",
|
||||
providerID: "provider",
|
||||
variant: "high",
|
||||
cost: 0.5,
|
||||
tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
|
||||
finish: "tool-calls",
|
||||
error: { name: "MessageAbortedError", data: { message: "Stopped" } },
|
||||
},
|
||||
parts: [
|
||||
{ type: "text", text: "Answer" },
|
||||
{ type: "reasoning", text: "Thinking", time: { start: 2, end: 3 } },
|
||||
{
|
||||
id: "prt_tool",
|
||||
callID: "call_tool",
|
||||
type: "tool",
|
||||
tool: "read",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { filePath: "src/a.ts" },
|
||||
output: "contents",
|
||||
metadata: { title: "a.ts" },
|
||||
time: { start: 3, end: 4 },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
time: { created: 2, completed: 5 },
|
||||
agent: "explore",
|
||||
model: { id: "model", providerID: "provider", variant: "high" },
|
||||
cost: 0.5,
|
||||
tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
|
||||
finish: "tool-calls",
|
||||
error: { type: "MessageAbortedError", message: "Stopped" },
|
||||
content: [
|
||||
{ type: "text", text: "Answer" },
|
||||
{ type: "reasoning", text: "Thinking", time: { created: 2, completed: 3 } },
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_tool",
|
||||
name: "read",
|
||||
time: { created: 3, ran: 3, completed: 4 },
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { filePath: "src/a.ts" },
|
||||
content: [{ type: "text", text: "contents" }],
|
||||
metadata: { title: "a.ts" },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
|
||||
test("applies message latency after a list response gate is released", async () => {
|
||||
const events: string[] = []
|
||||
@@ -145,7 +30,7 @@ test("applies message latency after a list response gate is released", async ()
|
||||
})
|
||||
|
||||
const response = handler!({
|
||||
request: () => ({ url: () => "http://127.0.0.1:4096/api/session/session/message" }),
|
||||
request: () => ({ url: () => "http://127.0.0.1:4096/session/session/message" }),
|
||||
fulfill: () => {
|
||||
events.push("fulfill")
|
||||
return Promise.resolve()
|
||||
|
||||
@@ -85,17 +85,21 @@ async function mockServers(page: Page, requests: string[]) {
|
||||
const current = url.origin === serverA ? sessionA : sessionB
|
||||
const directory = url.searchParams.get("directory")
|
||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||
if (url.pathname === "/api/event")
|
||||
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
||||
return sse(route)
|
||||
if (url.pathname === "/global/health") return json(route, {}, 404)
|
||||
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
||||
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
||||
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
|
||||
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||
if (url.pathname === "/provider")
|
||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
|
||||
const serverA = `http://127.0.0.1:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const serverA = "http://127.0.0.1:4096"
|
||||
const serverB = "http://127.0.0.1:4097"
|
||||
const directoryA = "C:/server-a"
|
||||
const directoryB = "/home/server-b"
|
||||
@@ -32,7 +32,7 @@ test("session settings use the remote server context", async ({ page }) => {
|
||||
.poll(() =>
|
||||
permissionRequests.some((request) => {
|
||||
const url = new URL(request)
|
||||
return url.origin === serverB && url.searchParams.get("location[directory]") === directoryB
|
||||
return url.origin === serverB && url.searchParams.get("directory") === directoryB
|
||||
}),
|
||||
)
|
||||
.toBe(true)
|
||||
@@ -67,7 +67,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
||||
.poll(() =>
|
||||
permissionRequests.some((request) => {
|
||||
const url = new URL(request)
|
||||
return url.origin === serverA && url.searchParams.get("location[directory]") === directoryA
|
||||
return url.origin === serverA && url.searchParams.get("directory") === directoryA
|
||||
}),
|
||||
)
|
||||
.toBe(true)
|
||||
@@ -99,10 +99,10 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
||||
.toEqual([
|
||||
{
|
||||
origin: serverA,
|
||||
directory: undefined,
|
||||
directory: directoryA,
|
||||
sessionID: sessionA.id,
|
||||
permissionID: "permission-background-a",
|
||||
body: { reply: "once" },
|
||||
body: { response: "once" },
|
||||
},
|
||||
])
|
||||
|
||||
@@ -127,17 +127,17 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
||||
.toEqual([
|
||||
{
|
||||
origin: serverA,
|
||||
directory: undefined,
|
||||
directory: directoryA,
|
||||
sessionID: sessionA.id,
|
||||
permissionID: "permission-background-a",
|
||||
body: { reply: "once" },
|
||||
body: { response: "once" },
|
||||
},
|
||||
{
|
||||
origin: serverA,
|
||||
directory: undefined,
|
||||
directory: directoryA,
|
||||
sessionID: childSessionA.id,
|
||||
permissionID: "permission-background-a-child",
|
||||
body: { reply: "once" },
|
||||
body: { response: "once" },
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -168,8 +168,8 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
||||
const remote = url.origin === serverB
|
||||
const directory = remote ? directoryB : directoryA
|
||||
const sessions = remote ? [sessionB] : [sessionA, childSessionA]
|
||||
const requestDirectory = url.searchParams.get("location[directory]")
|
||||
const response = url.pathname.match(/^\/api\/session\/([^/]+)\/permission\/([^/]+)\/reply$/)
|
||||
const requestDirectory = url.searchParams.get("directory")
|
||||
const response = url.pathname.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/)
|
||||
if (route.request().method() === "POST" && response) {
|
||||
permissionResponses.push({
|
||||
origin: url.origin,
|
||||
@@ -181,21 +181,13 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
||||
return json(route, true)
|
||||
}
|
||||
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||
if (url.pathname === "/api/event")
|
||||
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
||||
return sse(route)
|
||||
if (url.pathname === "/api/provider")
|
||||
return json(route, {
|
||||
location: { directory },
|
||||
data: [{ id: remote ? "server-b" : "server-a", name: remote ? "Server B Provider" : "Server A Provider", package: "test" }],
|
||||
})
|
||||
if (url.pathname === "/api/model") return json(route, { location: { directory }, data: [model(remote)] })
|
||||
if (url.pathname === "/api/model/default") return json(route, { location: { directory }, data: model(remote) })
|
||||
if (url.pathname === "/api/agent") return json(route, { location: { directory }, data: [] })
|
||||
if (url.pathname === "/api/permission/request") {
|
||||
permissionRequests.push(url.toString())
|
||||
return json(route, { location: { directory }, data: [] })
|
||||
}
|
||||
if (["/api/command", "/api/reference", "/api/question/request"].includes(url.pathname))
|
||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
||||
if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent")
|
||||
return json(route, { data: [] })
|
||||
if (url.pathname === "/api/model/default") return json(route, { data: null })
|
||||
if (["/api/command", "/api/reference", "/api/permission/request", "/api/question/request"].includes(url.pathname))
|
||||
return json(route, { location: { directory }, data: [] })
|
||||
if (url.pathname === "/api/mcp") return json(route, { location: { directory }, data: [] })
|
||||
if (url.pathname === "/api/mcp/resource")
|
||||
@@ -219,6 +211,8 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
||||
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
||||
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
|
||||
return json(route, { data: [], cursor: {} })
|
||||
const current = sessions.find((session) => url.pathname === `/session/${session.id}`)
|
||||
if (current) return json(route, current)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
@@ -228,6 +222,7 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
||||
}
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||
if (url.pathname === "/provider") return json(route, provider(remote ? "server-b" : "server-a"))
|
||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||
if (url.pathname === "/project" || url.pathname === "/project/current") {
|
||||
@@ -293,25 +288,6 @@ function provider(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function model(remote: boolean) {
|
||||
const id = remote ? "server-b" : "server-a"
|
||||
const name = remote ? "Server B" : "Server A"
|
||||
return {
|
||||
id,
|
||||
modelID: id,
|
||||
providerID: id,
|
||||
name: `${name} Model`,
|
||||
family: id,
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
variants: [],
|
||||
time: { released: Date.now() },
|
||||
cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: 200_000, output: 32_000 },
|
||||
}
|
||||
}
|
||||
|
||||
function json(route: Route, body: unknown, status = 200) {
|
||||
return route.fulfill({
|
||||
status,
|
||||
|
||||
@@ -58,18 +58,22 @@ async function mockServers(page: Page) {
|
||||
const current = url.origin === serverA ? sessionA : sessionB
|
||||
const directory = url.searchParams.get("directory")
|
||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||
if (url.pathname === "/api/event")
|
||||
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
||||
return sse(route, url.pathname === "/api/event")
|
||||
if (url.pathname === "/global/health") return json(route, {}, 404)
|
||||
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
||||
if (url.pathname === "/api/session/active")
|
||||
return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
|
||||
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
||||
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
||||
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
|
||||
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||
if (url.pathname === "/provider")
|
||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||
|
||||
@@ -14,7 +14,6 @@ test.use({ viewport: { width: 1440, height: 900 } })
|
||||
test("opens and searches project files inline", async ({ page }) => {
|
||||
const searches: { query: string; dirs?: string; limit?: number }[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
@@ -128,7 +127,7 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
|
||||
await expect(sidebarToggle).toBeEnabled()
|
||||
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
|
||||
expect(searches).toContainEqual({ query: "nested", dirs: "file", limit: 200 })
|
||||
expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 })
|
||||
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
|
||||
|
||||
@@ -19,25 +19,36 @@ test("restores review mode and selected file per session", async ({ page }) => {
|
||||
await expectSessionTitle(page, titleA)
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
|
||||
await selectFile(page, "alpha.ts")
|
||||
await selectMode(page, "Git changes", "Branch changes")
|
||||
await selectFile(page, "beta.ts")
|
||||
|
||||
await switchSession(page, titleB)
|
||||
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
|
||||
await selectFile(page, "gamma.ts")
|
||||
|
||||
await switchSession(page, titleA)
|
||||
await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
|
||||
await expectSelectedFile(page, "beta.ts")
|
||||
await selectMode(page, "Branch changes", "Git changes")
|
||||
await expectSelectedFile(page, "alpha.ts")
|
||||
await selectMode(page, "Git changes", "Branch changes")
|
||||
await expectSelectedFile(page, "beta.ts")
|
||||
|
||||
await page.reload()
|
||||
await expectSessionTitle(page, titleA)
|
||||
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
|
||||
await expectSelectedFile(page, "alpha.ts")
|
||||
await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
|
||||
await expectSelectedFile(page, "beta.ts")
|
||||
|
||||
await switchSession(page, titleB)
|
||||
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
|
||||
await expectSelectedFile(page, "gamma.ts")
|
||||
})
|
||||
|
||||
async function selectMode(page: Page, current: string, next: string) {
|
||||
await page.getByRole("button", { name: current }).click()
|
||||
await page.getByRole("option", { name: next }).dispatchEvent("click")
|
||||
}
|
||||
|
||||
async function selectFile(page: Page, file: string) {
|
||||
await page.getByRole("button", { name: file }).click()
|
||||
await expectSelectedFile(page, file)
|
||||
@@ -54,7 +65,7 @@ async function switchSession(page: Page, title: string) {
|
||||
|
||||
async function setup(page: Page) {
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
protocol: "v1",
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
@@ -78,27 +89,22 @@ async function setup(page: Page) {
|
||||
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route(/\/api\/vcs(?:\?.*)?$/, (route) =>
|
||||
await page.route(/\/vcs(?:\?.*)?$/, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
location: { directory, project: { id: projectID, directory, canonical: directory } },
|
||||
data: { branch: "feature", defaultBranch: "dev" },
|
||||
}),
|
||||
body: JSON.stringify({ branch: "feature", default_branch: "dev" }),
|
||||
}),
|
||||
)
|
||||
await page.route("**/api/vcs/diff**", (route) =>
|
||||
await page.route("**/vcs/diff**", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
location: { directory, project: { id: projectID, directory, canonical: directory } },
|
||||
data:
|
||||
new URL(route.request().url()).searchParams.get("mode") === "branch"
|
||||
? [diff("src/alpha.ts"), diff("src/beta.ts")]
|
||||
: [diff("src/alpha.ts"), diff("src/gamma.ts")],
|
||||
}),
|
||||
body: JSON.stringify(
|
||||
new URL(route.request().url()).searchParams.get("mode") === "branch"
|
||||
? [diff("src/alpha.ts"), diff("src/beta.ts")]
|
||||
: [diff("src/alpha.ts"), diff("src/gamma.ts")],
|
||||
),
|
||||
}),
|
||||
)
|
||||
await page.addInitScript(
|
||||
|
||||
@@ -25,7 +25,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
let detailFailures = 1
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
protocol: "v1",
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
@@ -62,32 +62,33 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
events: () => events.splice(0, 1),
|
||||
eventRetry: 16,
|
||||
})
|
||||
await page.route(/\/api\/vcs(?:\?.*)?$/, (route) =>
|
||||
await page.route(/\/vcs(?:\?.*)?$/, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
location: { directory, project: { id: projectID, directory, canonical: directory } },
|
||||
data: { branch: "review-pane-performance", defaultBranch: "dev" },
|
||||
branch: "review-pane-performance",
|
||||
default_branch: "dev",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await page.route("**/api/vcs/diff**", (route) => {
|
||||
await page.route("**/vcs/diff**", (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const scope = url.searchParams.get("location[directory]")?.replaceAll("\\", "/")
|
||||
const scope = url.searchParams.get("directory")?.replaceAll("\\", "/")
|
||||
const detail = scope?.endsWith("/src/branch/d00027")
|
||||
if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" })
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
location: { directory, project: { id: projectID, directory, canonical: directory } },
|
||||
data: detail
|
||||
? branchDiffs
|
||||
.filter((diff) => diff.file.startsWith("src/branch/d00027/"))
|
||||
.map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
|
||||
: branchDiffs,
|
||||
}),
|
||||
body: JSON.stringify(
|
||||
url.searchParams.get("mode") === "branch"
|
||||
? detail
|
||||
? branchDiffs
|
||||
.filter((diff) => diff.file.startsWith("src/branch/d00027/"))
|
||||
.map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
|
||||
: branchDiffs
|
||||
: Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)),
|
||||
),
|
||||
})
|
||||
})
|
||||
await page.route("**/pty*", (route) =>
|
||||
@@ -108,7 +109,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await page.route("**/api/pty/pty_review_terminal*", (route) =>
|
||||
await page.route("**/pty/pty_review_terminal*", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
@@ -126,7 +127,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await page.route("**/api/pty/pty_review_terminal/connect-token*", (route) =>
|
||||
await page.route("**/pty/pty_review_terminal/connect-token*", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
@@ -136,7 +137,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await page.routeWebSocket("**/api/pty/pty_review_terminal/connect", () => undefined)
|
||||
await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined)
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem(
|
||||
@@ -148,7 +149,9 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
await expect(page.locator("#review-panel")).toBeVisible()
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
await expectTree(page, 8, "git-0.ts")
|
||||
|
||||
await selectMode(page, "Git changes", "Branch changes")
|
||||
await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740")
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(page.locator("#terminal-panel")).toBeVisible()
|
||||
@@ -171,9 +174,9 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
expect(bottomGap).toBeLessThanOrEqual(16)
|
||||
const lazyDiff = page.waitForRequest((request) => {
|
||||
const url = new URL(request.url())
|
||||
return (
|
||||
url.pathname === "/api/vcs/diff" &&
|
||||
url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
||||
return (
|
||||
url.pathname === "/vcs/diff" &&
|
||||
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
||||
)
|
||||
})
|
||||
await lastFile.click()
|
||||
@@ -187,46 +190,59 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
const refreshedDiff = page.waitForRequest((request) => {
|
||||
const url = new URL(request.url())
|
||||
return (
|
||||
url.pathname === "/api/vcs/diff" &&
|
||||
url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
||||
url.pathname === "/vcs/diff" &&
|
||||
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
||||
)
|
||||
})
|
||||
sessionStatus[sessionID] = { type: "idle" }
|
||||
events.push(statusEvent("idle"))
|
||||
await refreshedDiff
|
||||
await expect(preview).toContainText("after-2")
|
||||
await selectMode(page, "Branch changes", "Git changes")
|
||||
await expectTree(page, 8, "git-0.ts")
|
||||
await page.getByRole("button", { name: "git-0.ts" }).click()
|
||||
await selectMode(page, "Git changes", "Branch changes")
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
|
||||
const filter = page.getByRole("searchbox", { name: "Filter files" })
|
||||
await filter.fill("generated-2738")
|
||||
await expectTree(page, 1, "generated-2738.ts")
|
||||
await filter.fill("")
|
||||
await expectTree(page, 2_773, "generated-2738.ts")
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
|
||||
await page.getByRole("button", { name: "Toggle file tree" }).click()
|
||||
await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveCount(0)
|
||||
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(0)
|
||||
await page.getByRole("button", { name: "Toggle file tree" }).click()
|
||||
await expectTree(page, 2_773, "generated-2738.ts")
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(page.locator("#terminal-panel")).toHaveCount(0)
|
||||
await expectTree(page, 2_773, "generated-2738.ts")
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(page.locator("#terminal-panel")).toBeVisible()
|
||||
await expectTree(page, 2_773, "generated-2738.ts")
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
await expect(page.locator("#review-panel")).toHaveCount(0)
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
await expectTree(page, 2_773, "generated-2738.ts")
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
await page.setViewportSize({ width: 1_000, height: 700 })
|
||||
await expectTree(page, 2_773, "generated-2738.ts")
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
await expectStackGeometry(page)
|
||||
await page.setViewportSize({ width: 1_000, height: 120 })
|
||||
await page.setViewportSize({ width: 1_400, height: 900 })
|
||||
await expectTree(page, 2_773, "generated-2738.ts")
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
await expectStackGeometry(page)
|
||||
})
|
||||
|
||||
async function selectMode(page: Page, current: string, next: string) {
|
||||
await page.getByRole("button", { name: current }).click()
|
||||
const option = page.getByRole("option", { name: next })
|
||||
await expect(option).toBeVisible()
|
||||
await option.click()
|
||||
}
|
||||
|
||||
async function expectTree(page: Page, total: number, file: string) {
|
||||
await expectMountedTree(page, total)
|
||||
await expect(page.getByRole("button", { name: file })).toBeVisible()
|
||||
|
||||
@@ -52,7 +52,7 @@ const editPart = {
|
||||
sessionID,
|
||||
messageID: assistantMessageID,
|
||||
type: "tool",
|
||||
callID: editPartID,
|
||||
callID: "call_edit_regression",
|
||||
tool: "edit",
|
||||
state: {
|
||||
status: "completed",
|
||||
|
||||
@@ -103,7 +103,7 @@ test("moves busy through retry and recovery to final idle content", async ({ pag
|
||||
await timeline.send(status("idle"), 350)
|
||||
await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-part-id="prt_recovered"]')).toContainText("Recovered response")
|
||||
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
|
||||
})
|
||||
|
||||
function lines(count: number) {
|
||||
|
||||
@@ -89,6 +89,7 @@ test.describe("session timeline projection", () => {
|
||||
const aborted = assistantMessage(
|
||||
[
|
||||
{ id: "prt_before_abort", type: "text", text: "Before interruption" },
|
||||
{ id: "prt_compaction", type: "compaction", auto: true },
|
||||
],
|
||||
{
|
||||
id: "msg_1001_assistant_aborted",
|
||||
@@ -121,13 +122,13 @@ test.describe("session timeline projection", () => {
|
||||
await scroller.evaluate((element) => (element.scrollTop = 0))
|
||||
|
||||
await expect(page.locator('[data-timeline-row="TurnDivider"]')).toHaveCount(1)
|
||||
await expect(page.getByText("Before interruption", { exact: true })).toBeVisible()
|
||||
await expect(page.getByText("Session compacted", { exact: true })).toBeVisible()
|
||||
await expect(page.getByText("Visible provider failure")).toBeVisible()
|
||||
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
||||
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test("renders legacy synthetic comments as ordinary V2 user text", async ({ page }) => {
|
||||
test("renders comment strips and historical diff summary overflow", async ({ page }) => {
|
||||
const user = userMessage(
|
||||
[
|
||||
userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", {
|
||||
@@ -158,14 +159,10 @@ test.describe("session timeline projection", () => {
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.evaluate((element) => (element.scrollTop = 0))
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
"The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable Continue after the comment",
|
||||
{ exact: true },
|
||||
),
|
||||
).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="CommentStrip"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-row="CommentStrip"]')).toBeVisible()
|
||||
await expect(page.getByText("Keep this stable", { exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
|
||||
await expect(page.getByText(/show all/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test("renders interruption independently when the turn is not compacted", async ({ page }) => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantID,
|
||||
assistantMessage,
|
||||
reasoningPart,
|
||||
setupTimeline,
|
||||
@@ -71,7 +70,7 @@ for (const profile of profiles) {
|
||||
await timeline.send(status("busy"), 150)
|
||||
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${assistantID}:reasoning:0"]`)).toHaveCount(profile.body ? 1 : 0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(profile.body ? 1 : 0)
|
||||
if (!profile.summaries && profile.reasoning.trim()) {
|
||||
await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
|
||||
}
|
||||
@@ -90,5 +89,5 @@ test("does not infer reasoning visibility from provider identity", async ({ page
|
||||
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-part-id*="reasoning"]')).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${assistantID}:text:0"]`)).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-part-id="prt_provider_text"]')).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -23,11 +23,10 @@ test("groups singleton and separated context operations at correct boundaries",
|
||||
]
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
await expect(
|
||||
page.locator('[data-timeline-part-ids="prt_boundary_01_read,prt_boundary_03_glob,prt_boundary_04_grep"]'),
|
||||
).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_boundary_01_read"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_boundary_06_list"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(4)
|
||||
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(5)
|
||||
})
|
||||
|
||||
test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {
|
||||
|
||||
@@ -145,6 +145,7 @@ test("allows paint rounding for every framed row but not fixed turn gaps", async
|
||||
}),
|
||||
],
|
||||
})
|
||||
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
|
||||
|
||||
const rows = await page.locator("[data-timeline-key]").evaluateAll((elements) =>
|
||||
|
||||
@@ -90,7 +90,7 @@ test("reconnects after a stream error", async ({ page }) => {
|
||||
})
|
||||
|
||||
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { eventRetry: 10 })
|
||||
const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" })
|
||||
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
|
||||
id: "timeline-event-7",
|
||||
})
|
||||
@@ -107,10 +107,10 @@ test("passes through non-event fetches", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page)
|
||||
|
||||
const health = await page.evaluate(async () => {
|
||||
const response = await fetch("/api/health")
|
||||
const response = await fetch("/global/health")
|
||||
return response.json()
|
||||
})
|
||||
|
||||
expect(health).toEqual({ healthy: true, version: "2.0.0", pid: 1 })
|
||||
expect(health).toEqual({ healthy: true })
|
||||
expect(await timeline.transport.connections()).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -89,19 +89,23 @@ async function mockServer(page: Page) {
|
||||
if (url.origin !== server) return route.fallback()
|
||||
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
|
||||
return new Promise(() => {})
|
||||
if (url.pathname === "/api/event")
|
||||
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
||||
return sse(route)
|
||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
||||
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||
const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
|
||||
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
||||
if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`))
|
||||
return json(route, { data: [], cursor: {} })
|
||||
const byId = sessions.find((item) => url.pathname === `/session/${item.id}`)
|
||||
if (byId) return json(route, byId)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||
if (url.pathname === "/provider")
|
||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||
|
||||
@@ -4,9 +4,12 @@ import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/NewProject"
|
||||
|
||||
test("creates a session in a new project and selects its model", async ({ page }) => {
|
||||
test("creates a session in a new project, connects OpenCode Go, and selects its model", async ({ page }) => {
|
||||
let connectedGo = false
|
||||
let pendingGo = false
|
||||
const connections: Array<{ integrationID: string; body: unknown }> = []
|
||||
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v1",
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_model_selection_flow",
|
||||
@@ -43,9 +46,17 @@ test("creates a session in a new project and selects its model", async ({ page }
|
||||
},
|
||||
},
|
||||
],
|
||||
connected: ["opencode", "opencode-go"],
|
||||
connected: connectedGo ? ["opencode", "opencode-go"] : ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "free-model" },
|
||||
}),
|
||||
integrationMethods: { "opencode-go": [{ type: "api", label: "API key" }] },
|
||||
onConnectKey: (input) => {
|
||||
connections.push(input)
|
||||
if (input.integrationID === "opencode-go") pendingGo = true
|
||||
},
|
||||
onInstanceDispose: () => {
|
||||
if (pendingGo) connectedGo = true
|
||||
},
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
fileList: (path) =>
|
||||
@@ -55,17 +66,6 @@ test("creates a session in a new project and selects its model", async ({ page }
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:model",
|
||||
JSON.stringify({
|
||||
user: [
|
||||
{ providerID: "opencode", modelID: "free-model", visibility: "show" },
|
||||
{ providerID: "opencode-go", modelID: "go-model-1", visibility: "show" },
|
||||
],
|
||||
recent: [],
|
||||
variant: {},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
await page.goto("/")
|
||||
@@ -79,7 +79,16 @@ test("creates a session in a new project and selects its model", async ({ page }
|
||||
|
||||
const modelControl = page.locator('[data-action="prompt-model"]')
|
||||
await modelControl.click()
|
||||
await expect(page.locator('[data-option-key="opencode:free-model"]')).toBeVisible()
|
||||
await expect(page.locator('[data-section="free-models"]')).toContainText("Free models provided by OpenCode")
|
||||
|
||||
await page.locator('[data-provider-id="opencode-go"]').click()
|
||||
await page.locator('[data-input="provider-api-key"]').fill("mock-go-api-key")
|
||||
await page.locator('[data-action="provider-connect-submit"]').click()
|
||||
await expect(page.locator('[data-component="dialog-v2"]')).toHaveCount(0)
|
||||
expect(connections).toEqual([{ integrationID: "opencode-go", body: { type: "api", key: "mock-go-api-key" } }])
|
||||
|
||||
await expect(modelControl).toHaveAttribute("data-control-type", "popover")
|
||||
await modelControl.click()
|
||||
const goModel = page.locator('[data-option-key="opencode-go:go-model-1"]')
|
||||
await expect(goModel).toBeVisible()
|
||||
await goModel.click()
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import type { Page, Route } from "@playwright/test"
|
||||
import type {
|
||||
JsonValue,
|
||||
PromptAgentAttachment,
|
||||
PromptFileAttachment,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageInfo,
|
||||
SessionStructuredError,
|
||||
} from "@opencode-ai/client/promise"
|
||||
|
||||
const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
|
||||
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
|
||||
@@ -55,6 +47,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
"/vcs": { branch: "main", default_branch: "main" },
|
||||
"/session": config.sessions,
|
||||
}
|
||||
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
@@ -84,7 +77,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
if (path === "/api/health" && config.protocol === "v2")
|
||||
return json(route, { healthy: true, version: "2.0.0", pid: 1 })
|
||||
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
|
||||
if (path === "/provider") return json(route, providerConfig(config))
|
||||
if (path === "/provider")
|
||||
return json(route, typeof config.provider === "function" ? config.provider() : config.provider)
|
||||
if (path === "/provider/auth") return json(route, config.integrationMethods ?? {})
|
||||
const legacyAuth = path.match(/^\/auth\/([^/]+)$/)?.[1]
|
||||
if (legacyAuth && route.request().method() === "PUT") {
|
||||
@@ -140,17 +134,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
},
|
||||
],
|
||||
})
|
||||
if (path === "/api/provider")
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
data: currentProviders(providerConfig(config)),
|
||||
})
|
||||
if (path === "/api/model") return json(route, { location: location(config), data: currentModels(providerConfig(config)) })
|
||||
if (path === "/api/model/default")
|
||||
return json(route, { location: location(config), data: currentDefaultModel(providerConfig(config)) })
|
||||
if (path === "/api/integration") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/command") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/plugin") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/mcp") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/mcp/resource")
|
||||
return json(route, { location: location(config), data: { resources: [], templates: [] } })
|
||||
@@ -158,31 +142,25 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
if (integration && route.request().method() === "GET")
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
data: {
|
||||
id: integration,
|
||||
name: integration,
|
||||
methods: config.integrationMethods?.[integration] ?? [{ type: "key", label: "API key" }],
|
||||
connections: [],
|
||||
},
|
||||
data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] },
|
||||
})
|
||||
const integrationConnect = path.match(/^\/api\/integration\/([^/]+)\/connect\/key$/)?.[1]
|
||||
if (integrationConnect && route.request().method() === "POST") {
|
||||
config.onConnectKey?.({ integrationID: integrationConnect, body: route.request().postDataJSON() })
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (/^\/api\/credential\/[^/]+$/.test(path) && route.request().method() === "DELETE")
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
if (path === "/api/project") return json(route, [config.project])
|
||||
if (path === "/api/project/current")
|
||||
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
|
||||
if (path === "/api/location") return json(route, location(config))
|
||||
const projectCopy = path.match(/^\/experimental\/project\/([^/]+)\/copy$/)?.[1]
|
||||
if (projectCopy && route.request().method() === "POST") {
|
||||
const input = route.request().postDataJSON() as { directory: string; name?: string }
|
||||
return json(route, { directory: `${input.directory}/${input.name ?? "copy"}` })
|
||||
}
|
||||
if (projectCopy && route.request().method() === "DELETE")
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
if (path.startsWith("/api/project/") && route.request().method() === "PATCH") return json(route, config.project)
|
||||
if (path === "/api/path")
|
||||
return json(route, {
|
||||
state: config.directory,
|
||||
config: config.directory,
|
||||
worktree: config.directory,
|
||||
directory: config.directory,
|
||||
home: "C:/OpenCode",
|
||||
})
|
||||
if (path === "/api/permission/request")
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
@@ -199,43 +177,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } })
|
||||
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] })
|
||||
if (path === "/api/fs/list" && config.fileList)
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
data: await config.fileList(url.searchParams.get("path") ?? ""),
|
||||
})
|
||||
const fileRead = path.match(/^\/api\/fs\/read\/(.+)$/)?.[1]
|
||||
if (fileRead && config.fileContent) {
|
||||
const value = await config.fileContent(decodeURIComponent(fileRead))
|
||||
const content = value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "")
|
||||
return route.fulfill({ status: 200, body: content, headers: { "content-type": "application/octet-stream" } })
|
||||
}
|
||||
if (path === "/api/fs/find" && config.findFiles) {
|
||||
const entries = await config.findFiles({
|
||||
query: url.searchParams.get("query") ?? "",
|
||||
dirs: url.searchParams.get("type") ?? undefined,
|
||||
limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
|
||||
})
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
data: Array.isArray(entries)
|
||||
? entries.map((entry) =>
|
||||
typeof entry === "string"
|
||||
? {
|
||||
name: entry.split(/[\\/]/).at(-1) ?? entry,
|
||||
path: entry,
|
||||
absolute: `${config.directory}/${entry}`,
|
||||
type: "directory",
|
||||
ignored: false,
|
||||
}
|
||||
: entry,
|
||||
)
|
||||
: entries,
|
||||
})
|
||||
}
|
||||
if (path === "/api/pty/shells") return json(route, { location: location(config), data: [] })
|
||||
if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path))
|
||||
return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } })
|
||||
if (emptyObject.has(path)) return json(route, {})
|
||||
if (emptyList.has(path)) return json(route, [])
|
||||
if (path === "/api/session") {
|
||||
const directory = url.searchParams.get("directory")
|
||||
const parentID = url.searchParams.get("parentID")
|
||||
@@ -262,9 +208,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
})
|
||||
}
|
||||
if (path === "/api/session/active") {
|
||||
const statuses = (
|
||||
typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {})
|
||||
) as Record<string, { type?: string }>
|
||||
const statuses = (config.sessionStatus ?? {}) as Record<string, { type?: string }>
|
||||
return json(route, {
|
||||
data: Object.fromEntries(
|
||||
Object.entries(statuses).flatMap(([id, status]) =>
|
||||
@@ -282,9 +226,12 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") return json(route, true)
|
||||
if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST")
|
||||
if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
|
||||
return json(route, true)
|
||||
}
|
||||
if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST") {
|
||||
return json(route, true)
|
||||
}
|
||||
if (
|
||||
/^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
|
||||
route.request().method() === "POST"
|
||||
@@ -294,8 +241,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (emptyObject.has(path)) return json(route, {})
|
||||
if (emptyList.has(path)) return json(route, [])
|
||||
if (path in staticRoutes) return json(route, staticRoutes[path])
|
||||
|
||||
const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/)
|
||||
@@ -307,17 +252,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
})
|
||||
}
|
||||
|
||||
const currentMessageMatch = path.match(/^\/api\/session\/([^/]+)\/message\/([^/]+)$/)
|
||||
if (currentMessageMatch) {
|
||||
config.onMessage?.({ sessionID: currentMessageMatch[1]!, messageID: currentMessageMatch[2]! })
|
||||
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||
const message = config.message?.(currentMessageMatch[1]!, currentMessageMatch[2]!)
|
||||
if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404)
|
||||
return json(route, { data: currentMessage(message) })
|
||||
}
|
||||
|
||||
const sessionMatch = path.match(/^\/session\/([^/]+)$/)
|
||||
if (sessionMatch) return json(route, config.sessions.find((session) => session.id === sessionMatch[1]) ?? {})
|
||||
if (sessionMatch) {
|
||||
const session = config.sessions.find((s) => s.id === sessionMatch[1])
|
||||
return json(route, session ?? {})
|
||||
}
|
||||
|
||||
const projectMatch = path.match(/^\/project\/([^/]+)$/)
|
||||
if (projectMatch) return json(route, config.project)
|
||||
@@ -361,7 +300,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
|
||||
await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before })
|
||||
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||
const pageData = config.pageMessages(messagesMatch[1], Number(url.searchParams.get("limit") ?? 80), before)
|
||||
const limit = Number(url.searchParams.get("limit") ?? 80)
|
||||
const pageData = config.pageMessages(messagesMatch[1], limit, before)
|
||||
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
|
||||
if (!pageData.cursor) return json(route, pageData.items)
|
||||
const cursor = `cursor_${++nextCursor}`
|
||||
@@ -377,75 +317,10 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
function location(config: MockServerConfig) {
|
||||
return {
|
||||
directory: config.directory,
|
||||
project: { id: (config.project as { id?: string }).id, directory: config.directory, canonical: config.directory },
|
||||
project: { id: (config.project as { id?: string }).id, directory: config.directory },
|
||||
}
|
||||
}
|
||||
|
||||
function providerConfig(config: MockServerConfig) {
|
||||
return typeof config.provider === "function" ? config.provider() : config.provider
|
||||
}
|
||||
|
||||
function currentProviders(value: unknown) {
|
||||
if (!record(value) || !Array.isArray(value.all)) return Array.isArray(value) ? value : []
|
||||
return value.all.filter(record).flatMap((provider) =>
|
||||
typeof provider.id === "string" && typeof provider.name === "string"
|
||||
? [{ id: provider.id, name: provider.name, package: provider.id }]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
function currentModels(value: unknown) {
|
||||
if (!record(value) || !Array.isArray(value.all)) return []
|
||||
return value.all.filter(record).flatMap((provider) => {
|
||||
if (typeof provider.id !== "string" || !record(provider.models)) return []
|
||||
return Object.values(provider.models)
|
||||
.filter(record)
|
||||
.flatMap((model) => {
|
||||
if (typeof model.id !== "string" || typeof model.name !== "string") return []
|
||||
const limit = record(model.limit) ? model.limit : {}
|
||||
const cost = record(model.cost) ? model.cost : {}
|
||||
return [
|
||||
{
|
||||
id: model.id,
|
||||
modelID: model.id,
|
||||
providerID: provider.id,
|
||||
name: model.name,
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
variants: record(model.variants)
|
||||
? Object.entries(model.variants).map(([id, settings]) => ({
|
||||
id,
|
||||
...(jsonRecord(settings) ? { settings: jsonRecord(settings) } : {}),
|
||||
}))
|
||||
: [],
|
||||
time: { released: Date.now() },
|
||||
cost: [
|
||||
{
|
||||
input: typeof cost.input === "number" ? cost.input : 0,
|
||||
output: typeof cost.output === "number" ? cost.output : 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: {
|
||||
context: typeof limit.context === "number" ? limit.context : 200_000,
|
||||
output: typeof limit.output === "number" ? limit.output : 32_000,
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function currentDefaultModel(value: unknown) {
|
||||
if (!record(value) || !record(value.default)) return null
|
||||
const selected = value.default
|
||||
const models = currentModels(value)
|
||||
return models.find(
|
||||
(model) => model.providerID === selected.providerID && model.id === selected.modelID,
|
||||
) ?? null
|
||||
}
|
||||
|
||||
function currentPermission(value: unknown) {
|
||||
const permission = value as Record<string, unknown>
|
||||
if (permission.action) return permission
|
||||
@@ -489,222 +364,63 @@ export function currentSession(session: { id: string } & Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export function currentMessage(value: unknown): SessionMessageInfo {
|
||||
if (isCurrentMessage(value)) return value
|
||||
if (!record(value) || !record(value.info) || !Array.isArray(value.parts)) throw new Error("Invalid message fixture")
|
||||
|
||||
const info = value.info
|
||||
const parts = value.parts.filter(record)
|
||||
if (typeof info.id !== "string" || !record(info.time) || typeof info.time.created !== "number")
|
||||
throw new Error("Invalid legacy message fixture")
|
||||
|
||||
const time = {
|
||||
created: info.time.created,
|
||||
...(typeof info.time.completed === "number" ? { completed: info.time.completed } : {}),
|
||||
function currentMessage(value: unknown) {
|
||||
const item = value as {
|
||||
info: Record<string, unknown> & { id: string; role: "user" | "assistant"; time: { created: number } }
|
||||
parts: Array<Record<string, unknown> & { type: string }>
|
||||
}
|
||||
if (info.role === "user") {
|
||||
if (item.info.role === "user") {
|
||||
return {
|
||||
id: info.id,
|
||||
id: item.info.id,
|
||||
type: "user",
|
||||
time: { created: time.created },
|
||||
text: parts
|
||||
time: item.info.time,
|
||||
text: item.parts
|
||||
.flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : []))
|
||||
.join("\n"),
|
||||
files: parts.flatMap((part) => (part.type === "file" ? legacyFile(part) : [])),
|
||||
agents: parts.flatMap((part) => (part.type === "agent" ? legacyAgent(part) : [])),
|
||||
}
|
||||
}
|
||||
if (info.role !== "assistant") throw new Error("Invalid legacy message role")
|
||||
|
||||
return {
|
||||
id: info.id,
|
||||
id: item.info.id,
|
||||
type: "assistant",
|
||||
time,
|
||||
agent: typeof info.agent === "string" ? info.agent : typeof info.mode === "string" ? info.mode : "build",
|
||||
model: {
|
||||
id: typeof info.modelID === "string" ? info.modelID : "model",
|
||||
providerID: typeof info.providerID === "string" ? info.providerID : "provider",
|
||||
...(typeof info.variant === "string" ? { variant: info.variant } : {}),
|
||||
},
|
||||
content: parts.flatMap((part) => legacyAssistantContent(part, time.created)),
|
||||
...(typeof info.cost === "number" ? { cost: info.cost } : {}),
|
||||
...(tokens(info.tokens) ? { tokens: tokens(info.tokens) } : {}),
|
||||
...(structuredError(info.error) ? { error: structuredError(info.error) } : {}),
|
||||
...(finish(info.finish) ? { finish: finish(info.finish) } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function isCurrentMessage(value: unknown): value is SessionMessageInfo {
|
||||
return record(value) && typeof value.id === "string" && typeof value.type === "string" && !record(value.info)
|
||||
}
|
||||
|
||||
function legacyFile(part: Record<string, unknown>): PromptFileAttachment[] {
|
||||
if (typeof part.mime !== "string" || typeof part.url !== "string") return []
|
||||
const data = part.url.match(/^data:[^,]*;base64,(.*)$/)?.[1] ?? ""
|
||||
const source = record(part.source) ? part.source : undefined
|
||||
const sourceText = source && record(source.text) ? source.text : undefined
|
||||
const mention = mentionFrom(sourceText)
|
||||
const uri = source?.type === "resource" && typeof source.uri === "string" ? source.uri : part.url
|
||||
return [
|
||||
{
|
||||
data,
|
||||
mime: part.mime,
|
||||
source: part.url.startsWith("data:") ? { type: "inline" } : { type: "uri", uri },
|
||||
...(typeof part.filename === "string" ? { name: part.filename } : {}),
|
||||
...(mention ? { mention } : {}),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function legacyAgent(part: Record<string, unknown>): PromptAgentAttachment[] {
|
||||
if (typeof part.name !== "string") return []
|
||||
const mention = mentionFrom(record(part.source) ? part.source : undefined)
|
||||
return [{ name: part.name, ...(mention ? { mention } : {}) }]
|
||||
}
|
||||
|
||||
function mentionFrom(value: Record<string, unknown> | undefined) {
|
||||
if (
|
||||
!value ||
|
||||
typeof value.value !== "string" ||
|
||||
typeof value.start !== "number" ||
|
||||
typeof value.end !== "number"
|
||||
)
|
||||
return
|
||||
return { text: value.value, start: value.start, end: value.end }
|
||||
}
|
||||
|
||||
function legacyAssistantContent(
|
||||
part: Record<string, unknown>,
|
||||
created: number,
|
||||
): SessionMessageAssistant["content"] {
|
||||
if (part.type === "text" && typeof part.text === "string")
|
||||
return [{ type: "text", text: part.text, ...(jsonRecord(part.metadata) ? { state: jsonRecord(part.metadata) } : {}) }]
|
||||
if (part.type === "reasoning" && typeof part.text === "string") {
|
||||
const time = record(part.time) ? part.time : undefined
|
||||
return [
|
||||
{
|
||||
type: "reasoning",
|
||||
text: part.text,
|
||||
...(jsonRecord(part.metadata) ? { state: jsonRecord(part.metadata) } : {}),
|
||||
...(time && typeof time.start === "number"
|
||||
? {
|
||||
time: {
|
||||
created: time.start,
|
||||
...(typeof time.end === "number" ? { completed: time.end } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
]
|
||||
}
|
||||
if (part.type !== "tool" || typeof part.id !== "string" || typeof part.tool !== "string" || !record(part.state))
|
||||
return []
|
||||
|
||||
const state = part.state
|
||||
const time = record(state.time) ? state.time : undefined
|
||||
const toolTime = {
|
||||
created: time && typeof time.start === "number" ? time.start : created,
|
||||
...(time && typeof time.start === "number" ? { ran: time.start } : {}),
|
||||
...(time && typeof time.end === "number" ? { completed: time.end } : {}),
|
||||
}
|
||||
const input = jsonRecord(state.input) ?? {}
|
||||
const metadata = jsonRecord(state.metadata)
|
||||
const base = {
|
||||
type: "tool" as const,
|
||||
id: typeof part.callID === "string" ? part.callID : part.id,
|
||||
name: part.tool,
|
||||
time: toolTime,
|
||||
...(typeof part.executed === "boolean" ? { executed: part.executed } : {}),
|
||||
...(jsonRecord(part.providerState) ? { providerState: jsonRecord(part.providerState) } : {}),
|
||||
...(jsonRecord(part.providerResultState) ? { providerResultState: jsonRecord(part.providerResultState) } : {}),
|
||||
}
|
||||
if (state.status === "pending")
|
||||
return [{ ...base, state: { status: "streaming", input: typeof state.raw === "string" ? state.raw : JSON.stringify(input) } }]
|
||||
if (state.status === "completed")
|
||||
return [
|
||||
{
|
||||
...base,
|
||||
state: {
|
||||
status: "completed",
|
||||
input,
|
||||
content: [{ type: "text", text: typeof state.output === "string" ? state.output : "" }],
|
||||
...(metadata ? { metadata } : {}),
|
||||
time: item.info.time,
|
||||
agent: item.info.agent ?? "build",
|
||||
model: { id: item.info.modelID ?? "model", providerID: item.info.providerID ?? "provider" },
|
||||
cost: item.info.cost,
|
||||
tokens: item.info.tokens,
|
||||
error: item.info.error,
|
||||
content: item.parts.flatMap<unknown>((part) => {
|
||||
if (part.type === "text" || part.type === "reasoning") return [{ type: part.type, text: part.text ?? "" }]
|
||||
if (part.type !== "tool") return []
|
||||
const state = part.state as Record<string, unknown>
|
||||
return [
|
||||
{
|
||||
type: "tool",
|
||||
id: part.id,
|
||||
name: part.tool,
|
||||
time: state.time ?? { created: item.info.time.created },
|
||||
state:
|
||||
state.status === "pending"
|
||||
? { status: "streaming", input: state.raw ?? JSON.stringify(state.input ?? {}) }
|
||||
: state.status === "completed"
|
||||
? {
|
||||
status: "completed",
|
||||
input: state.input ?? {},
|
||||
structured: state.metadata ?? {},
|
||||
content: [{ type: "text", text: state.output ?? "" }],
|
||||
}
|
||||
: state.status === "error"
|
||||
? {
|
||||
status: "error",
|
||||
input: state.input ?? {},
|
||||
structured: state.metadata ?? {},
|
||||
content: [],
|
||||
error: { type: "ToolError", message: state.error ?? "Tool failed" },
|
||||
}
|
||||
: { status: "running", input: state.input ?? {}, structured: state.metadata ?? {}, content: [] },
|
||||
},
|
||||
},
|
||||
]
|
||||
if (state.status === "error")
|
||||
return [
|
||||
{
|
||||
...base,
|
||||
state: {
|
||||
status: "error",
|
||||
input,
|
||||
error: structuredError(state.error) ?? { type: "ToolError", message: "Tool failed" },
|
||||
...(metadata ? { metadata } : {}),
|
||||
},
|
||||
},
|
||||
]
|
||||
return [{ ...base, state: { status: "running", input, metadata: metadata ?? {} } }]
|
||||
}
|
||||
|
||||
function structuredError(value: unknown): SessionStructuredError | undefined {
|
||||
if (typeof value === "string") return { type: "Error", message: value }
|
||||
if (!record(value)) return
|
||||
if (typeof value.type === "string" && typeof value.message === "string")
|
||||
return { type: value.type, message: value.message }
|
||||
if (typeof value.name !== "string" || !record(value.data) || typeof value.data.message !== "string") return
|
||||
return { type: value.name, message: value.data.message }
|
||||
}
|
||||
|
||||
function tokens(value: unknown): SessionMessageAssistant["tokens"] | undefined {
|
||||
if (!record(value) || !record(value.cache)) return
|
||||
if (
|
||||
typeof value.input !== "number" ||
|
||||
typeof value.output !== "number" ||
|
||||
typeof value.reasoning !== "number" ||
|
||||
typeof value.cache.read !== "number" ||
|
||||
typeof value.cache.write !== "number"
|
||||
)
|
||||
return
|
||||
return {
|
||||
input: value.input,
|
||||
output: value.output,
|
||||
reasoning: value.reasoning,
|
||||
cache: { read: value.cache.read, write: value.cache.write },
|
||||
}
|
||||
}
|
||||
|
||||
function finish(value: unknown): SessionMessageAssistant["finish"] | undefined {
|
||||
if (
|
||||
value === "stop" ||
|
||||
value === "length" ||
|
||||
value === "tool-calls" ||
|
||||
value === "content-filter" ||
|
||||
value === "error" ||
|
||||
value === "unknown"
|
||||
)
|
||||
return value
|
||||
}
|
||||
|
||||
function jsonRecord(value: unknown): Record<string, JsonValue> | undefined {
|
||||
if (!record(value)) return
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).flatMap(([key, item]) => {
|
||||
const next = jsonValue(item)
|
||||
return next === undefined ? [] : [[key, next]]
|
||||
]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function jsonValue(value: unknown): JsonValue | undefined {
|
||||
if (value === null || typeof value === "string" || typeof value === "boolean") return value
|
||||
if (typeof value === "number") return Number.isFinite(value) ? value : null
|
||||
if (Array.isArray(value)) return value.map((item) => jsonValue(item) ?? null)
|
||||
return jsonRecord(value)
|
||||
}
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
}
|
||||
|
||||
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
|
||||
|
||||
@@ -53,7 +53,7 @@ describe("normalizePermissionRequest", () => {
|
||||
resources: ["README.md"],
|
||||
save: ["*.md"],
|
||||
metadata: { path: "README.md" },
|
||||
source: { type: "tool", messageID: "message-1", id: "call-1" },
|
||||
source: { type: "tool", messageID: "message-1", callID: "call-1" },
|
||||
}),
|
||||
).toEqual({
|
||||
id: "permission-1",
|
||||
|
||||
@@ -48,7 +48,7 @@ export function normalizePermissionRequest(input: PermissionRequest | LegacyPerm
|
||||
always: input.save ?? [],
|
||||
metadata: input.metadata ?? {},
|
||||
tool:
|
||||
input.source?.type === "tool" ? { messageID: input.source.messageID, callID: input.source.id } : undefined,
|
||||
input.source?.type === "tool" ? { messageID: input.source.messageID, callID: input.source.callID } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,24 +21,12 @@ describe("adaptServerEvent", () => {
|
||||
id: "evt_1",
|
||||
created: 1,
|
||||
type: "permission.asked",
|
||||
data: {
|
||||
id: "perm_1",
|
||||
sessionID: "ses_1",
|
||||
action: "read",
|
||||
resources: ["src/**"],
|
||||
source: { type: "tool", messageID: "msg_1", id: "call_1" },
|
||||
},
|
||||
data: { id: "perm_1", sessionID: "ses_1", action: "read", resources: ["src/**"] },
|
||||
} as OpenCodeEvent
|
||||
|
||||
expect(adaptServerEvent(current)).toMatchObject({
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id: "perm_1",
|
||||
sessionID: "ses_1",
|
||||
permission: "read",
|
||||
patterns: ["src/**"],
|
||||
tool: { messageID: "msg_1", callID: "call_1" },
|
||||
},
|
||||
properties: { id: "perm_1", sessionID: "ses_1", permission: "read", patterns: ["src/**"] },
|
||||
current,
|
||||
})
|
||||
})
|
||||
@@ -82,26 +70,6 @@ describe("coalesceServerEvents", () => {
|
||||
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
|
||||
})
|
||||
|
||||
test("coalesces current tool input deltas by tool ID", () => {
|
||||
const current = (eventID: string, id: string, delta: string) =>
|
||||
adaptServerEvent({
|
||||
id: eventID,
|
||||
created: 1,
|
||||
type: "session.tool.input.delta",
|
||||
location: { directory: "/repo" },
|
||||
data: { sessionID: "ses", assistantMessageID: "msg", id, delta },
|
||||
} as OpenCodeEvent)
|
||||
const result = coalesceServerEvents([
|
||||
{ directory: "/repo", payload: current("evt_1", "call_1", "{") },
|
||||
{ directory: "/repo", payload: current("evt_2", "call_1", "}") },
|
||||
{ directory: "/repo", payload: current("evt_3", "call_2", "[]") },
|
||||
])
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { id: "call_1", delta: "{}" } })
|
||||
expect(result[1]?.payload.current).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } })
|
||||
})
|
||||
|
||||
test("preserves event boundaries and distinct fields", () => {
|
||||
const status = {
|
||||
directory: "/repo",
|
||||
|
||||
@@ -39,7 +39,7 @@ export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
|
||||
metadata: event.data.metadata ?? {},
|
||||
tool:
|
||||
event.data.source?.type === "tool"
|
||||
? { messageID: event.data.source.messageID, callID: event.data.source.id }
|
||||
? { messageID: event.data.source.messageID, callID: event.data.source.callID }
|
||||
: undefined,
|
||||
},
|
||||
current: event,
|
||||
@@ -142,7 +142,7 @@ function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefine
|
||||
|
||||
function currentDeltaKey(event: CurrentDelta) {
|
||||
if (event.type === "session.tool.input.delta")
|
||||
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.id}`
|
||||
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.callID}`
|
||||
if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}`
|
||||
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}`
|
||||
}
|
||||
|
||||
@@ -92,19 +92,19 @@ describe("v2 session reducer", () => {
|
||||
...base,
|
||||
id: "evt_tool_start",
|
||||
type: "session.tool.input.started",
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", id: "call_1", name: "bash" },
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", name: "bash" },
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_tool_delta",
|
||||
type: "session.tool.input.delta",
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", id: "call_1", delta: "{}" },
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", delta: "{}" },
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_tool_called",
|
||||
type: "session.tool.called",
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", id: "call_1", input: {}, executed: true },
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", input: {}, executed: true },
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
@@ -113,7 +113,7 @@ describe("v2 session reducer", () => {
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_assistant",
|
||||
id: "call_1",
|
||||
callID: "call_1",
|
||||
metadata: {},
|
||||
content: [{ type: "text", text: "done" }],
|
||||
executed: true,
|
||||
|
||||
@@ -241,13 +241,13 @@ export function createV2SessionReducer() {
|
||||
case "session.tool.input.started":
|
||||
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
|
||||
...item,
|
||||
content: item.content.some((content) => content.type === "tool" && content.id === event.data.id)
|
||||
content: item.content.some((content) => content.type === "tool" && content.id === event.data.callID)
|
||||
? item.content
|
||||
: [
|
||||
...item.content,
|
||||
{
|
||||
type: "tool",
|
||||
id: event.data.id,
|
||||
id: event.data.callID,
|
||||
name: event.data.name,
|
||||
state: { status: "streaming", input: "" },
|
||||
time: { created: event.created },
|
||||
@@ -255,17 +255,17 @@ export function createV2SessionReducer() {
|
||||
],
|
||||
}))
|
||||
case "session.tool.input.delta":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) =>
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
|
||||
tool.state.status === "streaming"
|
||||
? { ...tool, state: { ...tool.state, input: tool.state.input + event.data.delta } }
|
||||
: tool,
|
||||
)
|
||||
case "session.tool.input.ended":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) =>
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
|
||||
tool.state.status === "streaming" ? { ...tool, state: { ...tool.state, input: event.data.text } } : tool,
|
||||
)
|
||||
case "session.tool.called":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) => ({
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => ({
|
||||
...tool,
|
||||
executed: event.data.executed,
|
||||
providerState: event.data.state,
|
||||
@@ -274,7 +274,7 @@ export function createV2SessionReducer() {
|
||||
time: { ...tool.time, ran: event.created },
|
||||
}))
|
||||
case "session.tool.progress":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) =>
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
|
||||
tool.state.status === "running"
|
||||
? {
|
||||
...tool,
|
||||
@@ -284,7 +284,7 @@ export function createV2SessionReducer() {
|
||||
: tool,
|
||||
)
|
||||
case "session.tool.success":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) => {
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => {
|
||||
if (tool.state.status !== "running") return tool
|
||||
return {
|
||||
...tool,
|
||||
@@ -302,7 +302,7 @@ export function createV2SessionReducer() {
|
||||
}
|
||||
})
|
||||
case "session.tool.failed":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) => {
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => {
|
||||
if (tool.state.status !== "streaming" && tool.state.status !== "running") return tool
|
||||
return {
|
||||
...tool,
|
||||
|
||||
@@ -72,7 +72,7 @@ export async function streamTurn(input: {
|
||||
if (next.done) throw new Error("event stream disconnected during prompt execution")
|
||||
const event = next.value
|
||||
if (event.type === "permission.asked" && event.data.sessionID === input.sessionID) {
|
||||
const tool = event.data.source?.id ? tools.get(event.data.source.id) : undefined
|
||||
const tool = event.data.source?.callID ? tools.get(event.data.source.callID) : undefined
|
||||
await replyPermission({
|
||||
client: input.client,
|
||||
connection: input.connection,
|
||||
@@ -120,11 +120,11 @@ export async function streamTurn(input: {
|
||||
}
|
||||
if (event.type === "session.tool.input.started") {
|
||||
assistantMessageID = event.data.assistantMessageID
|
||||
tools.set(event.data.id, { name: event.data.name, input: {}, metadata: {}, content: [] })
|
||||
tools.set(event.data.callID, { name: event.data.name, input: {}, metadata: {}, content: [] })
|
||||
await update({
|
||||
sessionUpdate: "tool_call",
|
||||
...pendingToolCall({
|
||||
toolCallId: event.data.id,
|
||||
toolCallId: event.data.callID,
|
||||
toolName: event.data.name,
|
||||
state: { input: {} },
|
||||
cwd: input.cwd,
|
||||
@@ -134,13 +134,13 @@ export async function streamTurn(input: {
|
||||
}
|
||||
if (event.type === "session.tool.called") {
|
||||
assistantMessageID = event.data.assistantMessageID
|
||||
const current = tools.get(event.data.id) ?? emptyToolState()
|
||||
const current = tools.get(event.data.callID) ?? emptyToolState()
|
||||
current.input = event.data.input
|
||||
tools.set(event.data.id, current)
|
||||
tools.set(event.data.callID, current)
|
||||
await update({
|
||||
sessionUpdate: "tool_call_update",
|
||||
...runningToolUpdate({
|
||||
toolCallId: event.data.id,
|
||||
toolCallId: event.data.callID,
|
||||
toolName: current.name,
|
||||
state: { input: current.input },
|
||||
cwd: input.cwd,
|
||||
@@ -149,13 +149,13 @@ export async function streamTurn(input: {
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.progress") {
|
||||
const current = tools.get(event.data.id)
|
||||
const current = tools.get(event.data.callID)
|
||||
if (!current) continue
|
||||
current.metadata = event.data.metadata
|
||||
await update({
|
||||
sessionUpdate: "tool_call_update",
|
||||
...runningToolUpdate({
|
||||
toolCallId: event.data.id,
|
||||
toolCallId: event.data.callID,
|
||||
toolName: current.name,
|
||||
state: { input: current.input },
|
||||
cwd: input.cwd,
|
||||
@@ -164,8 +164,8 @@ export async function streamTurn(input: {
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.success") {
|
||||
const current = tools.get(event.data.id) ?? emptyToolState()
|
||||
tools.delete(event.data.id)
|
||||
const current = tools.get(event.data.callID) ?? emptyToolState()
|
||||
tools.delete(event.data.callID)
|
||||
await syncEditedFiles({
|
||||
connection: input.connection,
|
||||
writeTextFile: input.writeTextFile,
|
||||
@@ -178,7 +178,7 @@ export async function streamTurn(input: {
|
||||
await update({
|
||||
sessionUpdate: "tool_call_update",
|
||||
...completedToolUpdate({
|
||||
toolCallId: event.data.id,
|
||||
toolCallId: event.data.callID,
|
||||
toolName: current.name,
|
||||
input: current.input,
|
||||
metadata: event.data.metadata,
|
||||
@@ -188,12 +188,12 @@ export async function streamTurn(input: {
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.failed") {
|
||||
const current = tools.get(event.data.id) ?? emptyToolState()
|
||||
tools.delete(event.data.id)
|
||||
const current = tools.get(event.data.callID) ?? emptyToolState()
|
||||
tools.delete(event.data.callID)
|
||||
await update({
|
||||
sessionUpdate: "tool_call_update",
|
||||
...errorToolUpdate({
|
||||
toolCallId: event.data.id,
|
||||
toolCallId: event.data.callID,
|
||||
toolName: current.name,
|
||||
input: current.input,
|
||||
metadata: event.data.metadata ?? current.metadata,
|
||||
|
||||
@@ -31,7 +31,7 @@ export async function replyPermission(input: {
|
||||
sessionId: input.sessionID,
|
||||
toolCall: {
|
||||
...pendingToolCall({
|
||||
toolCallId: input.event.data.source?.id ?? input.event.data.id,
|
||||
toolCallId: input.event.data.source?.callID ?? input.event.data.id,
|
||||
toolName,
|
||||
state: { input: toolInput, title: permissionTitle(toolName, toolInput, previews) },
|
||||
cwd: input.cwd,
|
||||
|
||||
@@ -300,7 +300,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
|
||||
if (event.type === "session.tool.input.started") {
|
||||
flushStep()
|
||||
tools.set(toolKey(event.data.assistantMessageID, event.data.id), {
|
||||
tools.set(toolKey(event.data.assistantMessageID, event.data.callID), {
|
||||
id: partID(event.id),
|
||||
timestamp: time,
|
||||
assistantMessageID: event.data.assistantMessageID,
|
||||
@@ -312,18 +312,18 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.input.ended") {
|
||||
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.id))
|
||||
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID))
|
||||
if (current) current.raw = event.data.text
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.input.delta") {
|
||||
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.id))
|
||||
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID))
|
||||
if (current) current.raw = (current.raw ?? "") + event.data.delta
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.called") {
|
||||
flushStep()
|
||||
const key = toolKey(event.data.assistantMessageID, event.data.id)
|
||||
const key = toolKey(event.data.assistantMessageID, event.data.callID)
|
||||
const current = tools.get(key)
|
||||
tools.set(key, {
|
||||
id: current?.id ?? partID(event.id),
|
||||
@@ -340,18 +340,18 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.progress") {
|
||||
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.id))
|
||||
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID))
|
||||
if (current) {
|
||||
current.metadata = event.data.metadata
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.success") {
|
||||
const key = toolKey(event.data.assistantMessageID, event.data.id)
|
||||
const key = toolKey(event.data.assistantMessageID, event.data.callID)
|
||||
const current = tools.get(key) ?? fallbackTool(event)
|
||||
const tool: SessionMessageAssistantTool = {
|
||||
type: "tool",
|
||||
id: event.data.id,
|
||||
id: event.data.callID,
|
||||
name: current.tool,
|
||||
executed: event.data.executed,
|
||||
providerState: current.providerState,
|
||||
@@ -365,11 +365,11 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
time: { created: current.timestamp, ran: current.timestamp, completed: time },
|
||||
}
|
||||
const part: MiniToolPart = {
|
||||
partID: current.id,
|
||||
id: current.id,
|
||||
sessionID: input.sessionID,
|
||||
messageID: event.data.assistantMessageID,
|
||||
type: "tool",
|
||||
id: event.data.id,
|
||||
callID: event.data.callID,
|
||||
tool: current.tool,
|
||||
state: {
|
||||
status: "completed",
|
||||
@@ -392,14 +392,14 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.failed") {
|
||||
const key = toolKey(event.data.assistantMessageID, event.data.id)
|
||||
const key = toolKey(event.data.assistantMessageID, event.data.callID)
|
||||
const current = tools.get(key) ?? fallbackTool(event)
|
||||
const error = event.data.error.message
|
||||
const metadata = event.data.metadata ?? current.metadata
|
||||
const content = event.data.content ?? nonEmptyToolContent(current.content)
|
||||
const tool: SessionMessageAssistantTool = {
|
||||
type: "tool",
|
||||
id: event.data.id,
|
||||
id: event.data.callID,
|
||||
name: current.tool,
|
||||
executed: event.data.executed,
|
||||
providerState: current.providerState,
|
||||
@@ -414,11 +414,11 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
time: { created: current.timestamp, ran: current.timestamp, completed: time },
|
||||
}
|
||||
const part: MiniToolPart = {
|
||||
partID: current.id,
|
||||
id: current.id,
|
||||
sessionID: input.sessionID,
|
||||
messageID: event.data.assistantMessageID,
|
||||
type: "tool",
|
||||
id: event.data.id,
|
||||
callID: event.data.callID,
|
||||
tool: current.tool,
|
||||
state: {
|
||||
status: "error",
|
||||
@@ -470,7 +470,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
if (event.type === "session.step.failed") {
|
||||
if (
|
||||
input.compatibility === "v1" &&
|
||||
event.data.error.message === "The provider response ended unexpectedly."
|
||||
event.data.error.message === "Provider stream ended without a terminal finish event"
|
||||
) {
|
||||
pendingStep = undefined
|
||||
v1InvalidOutput = true
|
||||
@@ -578,11 +578,11 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
const key = toolKey(message.id, item.id)
|
||||
if (renderedTools.has(key) || item.state.status === "streaming" || item.state.status === "running") continue
|
||||
const part: MiniToolPart = {
|
||||
partID: projectedPartID(message.id, `tool-${item.id}`),
|
||||
id: projectedPartID(message.id, `tool-${item.id}`),
|
||||
sessionID: input.sessionID,
|
||||
messageID: message.id,
|
||||
type: "tool",
|
||||
id: item.id,
|
||||
callID: item.id,
|
||||
tool: item.name,
|
||||
state:
|
||||
item.state.status === "completed"
|
||||
@@ -771,8 +771,8 @@ function partID(eventID: string) {
|
||||
return `prt_${eventID.replace(/^evt_/, "")}`
|
||||
}
|
||||
|
||||
function toolKey(messageID: string, id: string) {
|
||||
return `${messageID}\u0000${id}`
|
||||
function toolKey(messageID: string, callID: string) {
|
||||
return `${messageID}\u0000${callID}`
|
||||
}
|
||||
|
||||
function contentKey(messageID: string, ordinal: number) {
|
||||
@@ -786,7 +786,7 @@ function projectedPartID(messageID: string, part: string) {
|
||||
function fallbackTool(event: {
|
||||
id: string
|
||||
created: number
|
||||
data: { assistantMessageID: string; id: string }
|
||||
data: { assistantMessageID: string; callID: string }
|
||||
}): ToolState {
|
||||
return {
|
||||
id: partID(event.id),
|
||||
|
||||
@@ -200,7 +200,7 @@ describe("acp event behavior", () => {
|
||||
durableEvent("session.tool.input.started", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_ok",
|
||||
callID: "call_ok",
|
||||
name: "shell",
|
||||
}),
|
||||
)
|
||||
@@ -208,7 +208,7 @@ describe("acp event behavior", () => {
|
||||
durableEvent("session.tool.called", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_ok",
|
||||
callID: "call_ok",
|
||||
input: { command: "printf done", workdir: "sub" },
|
||||
executed: false,
|
||||
}),
|
||||
@@ -217,7 +217,7 @@ describe("acp event behavior", () => {
|
||||
ephemeralEvent("session.tool.progress", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_ok",
|
||||
callID: "call_ok",
|
||||
metadata: { phase: 1 },
|
||||
}),
|
||||
)
|
||||
@@ -225,7 +225,7 @@ describe("acp event behavior", () => {
|
||||
durableEvent("session.tool.success", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_ok",
|
||||
callID: "call_ok",
|
||||
metadata: { exit: 0 },
|
||||
content: [{ type: "text", text: "done" }],
|
||||
executed: true,
|
||||
@@ -235,7 +235,7 @@ describe("acp event behavior", () => {
|
||||
durableEvent("session.tool.input.started", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_fail",
|
||||
callID: "call_fail",
|
||||
name: "read",
|
||||
}),
|
||||
)
|
||||
@@ -243,7 +243,7 @@ describe("acp event behavior", () => {
|
||||
durableEvent("session.tool.called", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_fail",
|
||||
callID: "call_fail",
|
||||
input: { path: "/workspace/missing.ts" },
|
||||
executed: false,
|
||||
}),
|
||||
@@ -252,7 +252,7 @@ describe("acp event behavior", () => {
|
||||
ephemeralEvent("session.tool.progress", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_fail",
|
||||
callID: "call_fail",
|
||||
metadata: { bytes: 0 },
|
||||
}),
|
||||
)
|
||||
@@ -260,7 +260,7 @@ describe("acp event behavior", () => {
|
||||
durableEvent("session.tool.failed", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_fail",
|
||||
callID: "call_fail",
|
||||
error: { type: "tool.error", message: "not found" },
|
||||
metadata: { bytes: 0 },
|
||||
content: [{ type: "text", text: "opening" }],
|
||||
|
||||
@@ -43,14 +43,14 @@ describe("acp permission behavior", () => {
|
||||
permissionAsked("ses_allow", "perm_once", {
|
||||
action: "shell",
|
||||
metadata: { command: "printf hello" },
|
||||
source: { type: "tool", messageID: "msg_allow", id: "call_once" },
|
||||
source: { type: "tool", messageID: "msg_allow", callID: "call_once" },
|
||||
}),
|
||||
)
|
||||
send(
|
||||
permissionAsked("ses_allow", "perm_always", {
|
||||
action: "read",
|
||||
metadata: { path: "/workspace/file.ts" },
|
||||
source: { type: "tool", messageID: "msg_allow", id: "call_always" },
|
||||
source: { type: "tool", messageID: "msg_allow", callID: "call_always" },
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_allow" }))
|
||||
@@ -166,7 +166,7 @@ describe("acp permission behavior", () => {
|
||||
durableEvent("session.tool.input.started", {
|
||||
sessionID: "ses_edit",
|
||||
assistantMessageID: "msg_edit",
|
||||
id: "call_edit",
|
||||
callID: "call_edit",
|
||||
name: "edit",
|
||||
}),
|
||||
)
|
||||
@@ -174,7 +174,7 @@ describe("acp permission behavior", () => {
|
||||
durableEvent("session.tool.called", {
|
||||
sessionID: "ses_edit",
|
||||
assistantMessageID: "msg_edit",
|
||||
id: "call_edit",
|
||||
callID: "call_edit",
|
||||
input: { path: "file.ts", oldString: "before", newString: "after" },
|
||||
executed: false,
|
||||
}),
|
||||
@@ -182,7 +182,7 @@ describe("acp permission behavior", () => {
|
||||
send(
|
||||
permissionAsked("ses_edit", "perm_edit", {
|
||||
action: "edit",
|
||||
source: { type: "tool", messageID: "msg_edit", id: "call_edit" },
|
||||
source: { type: "tool", messageID: "msg_edit", callID: "call_edit" },
|
||||
}),
|
||||
)
|
||||
},
|
||||
@@ -192,7 +192,7 @@ describe("acp permission behavior", () => {
|
||||
durableEvent("session.tool.success", {
|
||||
sessionID: "ses_edit",
|
||||
assistantMessageID: "msg_edit",
|
||||
id: "call_edit",
|
||||
callID: "call_edit",
|
||||
metadata: { files: [{ file: "file.ts" }], replacements: 1 },
|
||||
content: [{ type: "text", text: "edited" }],
|
||||
executed: true,
|
||||
@@ -256,7 +256,7 @@ describe("acp permission behavior", () => {
|
||||
durableEvent("session.tool.input.started", {
|
||||
sessionID: "ses_patch",
|
||||
assistantMessageID: "msg_patch",
|
||||
id: "call_patch",
|
||||
callID: "call_patch",
|
||||
name: "patch",
|
||||
}),
|
||||
)
|
||||
@@ -264,7 +264,7 @@ describe("acp permission behavior", () => {
|
||||
durableEvent("session.tool.called", {
|
||||
sessionID: "ses_patch",
|
||||
assistantMessageID: "msg_patch",
|
||||
id: "call_patch",
|
||||
callID: "call_patch",
|
||||
input: { patchText },
|
||||
executed: false,
|
||||
}),
|
||||
@@ -272,7 +272,7 @@ describe("acp permission behavior", () => {
|
||||
send(
|
||||
permissionAsked("ses_patch", "perm_patch", {
|
||||
action: "edit",
|
||||
source: { type: "tool", messageID: "msg_patch", id: "call_patch" },
|
||||
source: { type: "tool", messageID: "msg_patch", callID: "call_patch" },
|
||||
}),
|
||||
)
|
||||
},
|
||||
@@ -285,7 +285,7 @@ describe("acp permission behavior", () => {
|
||||
durableEvent("session.tool.success", {
|
||||
sessionID: "ses_patch",
|
||||
assistantMessageID: "msg_patch",
|
||||
id: "call_patch",
|
||||
callID: "call_patch",
|
||||
metadata: { files: [{ file: "first.ts" }, { file: "second.ts" }] },
|
||||
content: [{ type: "text", text: "patched" }],
|
||||
executed: true,
|
||||
@@ -499,7 +499,7 @@ function permissionAsked(
|
||||
input: {
|
||||
readonly action?: string
|
||||
readonly metadata?: Record<string, unknown>
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
|
||||
} = {},
|
||||
) {
|
||||
return ephemeralEvent("permission.asked", {
|
||||
|
||||
@@ -109,7 +109,7 @@ function failedTool(inputID: string): V2Event[] {
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_failed_tool",
|
||||
id: "call_failed_tool",
|
||||
callID: "call_failed_tool",
|
||||
name: "shell",
|
||||
},
|
||||
},
|
||||
@@ -121,7 +121,7 @@ function failedTool(inputID: string): V2Event[] {
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_failed_tool",
|
||||
id: "call_failed_tool",
|
||||
callID: "call_failed_tool",
|
||||
input: { command: "printf partial && false" },
|
||||
executed: true,
|
||||
},
|
||||
@@ -133,7 +133,7 @@ function failedTool(inputID: string): V2Event[] {
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_failed_tool",
|
||||
id: "call_failed_tool",
|
||||
callID: "call_failed_tool",
|
||||
metadata: { checkpoint: 1 },
|
||||
},
|
||||
},
|
||||
@@ -145,7 +145,7 @@ function failedTool(inputID: string): V2Event[] {
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_failed_tool",
|
||||
id: "call_failed_tool",
|
||||
callID: "call_failed_tool",
|
||||
error: { type: "unknown", message: "tool failed" },
|
||||
metadata: { checkpoint: 1 },
|
||||
content: [{ type: "text", text: "partial output" }],
|
||||
@@ -168,7 +168,7 @@ function successfulGrep(inputID: string): V2Event[] {
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_grep",
|
||||
id: "call_grep",
|
||||
callID: "call_grep",
|
||||
name: "grep",
|
||||
},
|
||||
},
|
||||
@@ -180,7 +180,7 @@ function successfulGrep(inputID: string): V2Event[] {
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_grep",
|
||||
id: "call_grep",
|
||||
callID: "call_grep",
|
||||
input: { pattern: "needle" },
|
||||
executed: true,
|
||||
},
|
||||
@@ -193,7 +193,7 @@ function successfulGrep(inputID: string): V2Event[] {
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_grep",
|
||||
id: "call_grep",
|
||||
callID: "call_grep",
|
||||
metadata: { matches: 2 },
|
||||
content: [{ type: "text", text }],
|
||||
executed: false,
|
||||
@@ -503,8 +503,8 @@ describe("runNonInteractivePrompt", () => {
|
||||
turn: (messageID) => [
|
||||
prompted(messageID),
|
||||
stepStarted(),
|
||||
stepFailed("The provider response ended unexpectedly."),
|
||||
executionFailed("The provider response ended unexpectedly."),
|
||||
stepFailed("Provider stream ended without a terminal finish event"),
|
||||
executionFailed("Provider stream ended without a terminal finish event"),
|
||||
],
|
||||
})
|
||||
|
||||
@@ -561,7 +561,7 @@ describe("runNonInteractivePrompt", () => {
|
||||
type: "tool_use",
|
||||
part: {
|
||||
type: "tool",
|
||||
id: "call_failed_tool",
|
||||
callID: "call_failed_tool",
|
||||
tool: "shell",
|
||||
state: {
|
||||
status: "error",
|
||||
|
||||
@@ -605,7 +605,7 @@ export type Endpoint5_26Output =
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly id: string
|
||||
readonly callID: string
|
||||
readonly name: string
|
||||
}
|
||||
}
|
||||
@@ -619,7 +619,7 @@ export type Endpoint5_26Output =
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly id: string
|
||||
readonly callID: string
|
||||
readonly text: string
|
||||
}
|
||||
}
|
||||
@@ -633,7 +633,7 @@ export type Endpoint5_26Output =
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly id: string
|
||||
readonly callID: string
|
||||
readonly input: { readonly [x: string]: unknown }
|
||||
readonly executed: boolean
|
||||
readonly state?: SessionMessage.ProviderState | undefined
|
||||
@@ -649,7 +649,7 @@ export type Endpoint5_26Output =
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly id: string
|
||||
readonly callID: string
|
||||
readonly content: readonly [
|
||||
(
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
@@ -685,7 +685,7 @@ export type Endpoint5_26Output =
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly id: string
|
||||
readonly callID: string
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly content?:
|
||||
| readonly [
|
||||
|
||||
@@ -56,6 +56,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
let announced = false
|
||||
let lastSpawn = 0
|
||||
let spawnDelay = 5_000
|
||||
let ownerHeld = false
|
||||
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) =>
|
||||
Effect.sync(() => {
|
||||
if (announced) return
|
||||
@@ -83,6 +84,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
const info = registration.info
|
||||
const service = registration.service
|
||||
if (service !== undefined) {
|
||||
ownerHeld = false
|
||||
spawnDelay = 5_000
|
||||
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
|
||||
if (compatible && service.state === "ready") return Option.some(service)
|
||||
@@ -95,13 +97,14 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
return Option.none<LocalService>()
|
||||
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
|
||||
|
||||
const failure = [...contenders].map(contenderFailure).find((error): error is Error => error !== undefined)
|
||||
if (failure !== undefined) return yield* Effect.fail(failure)
|
||||
const finished = [...contenders].filter(contenderFinished)
|
||||
const failure = finished.map(contenderFailure).find((error): error is Error => error !== undefined)
|
||||
if (finished.some((item) => item.child.exitCode === 0)) {
|
||||
ownerHeld = true
|
||||
spawnDelay = Math.min(spawnDelay * 2, 30_000)
|
||||
}
|
||||
finished.forEach((item) => contenders.delete(item))
|
||||
if (failure !== undefined && contenders.size === 0) return yield* Effect.fail(failure)
|
||||
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
|
||||
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
|
||||
yield* announce("missing")
|
||||
|
||||
@@ -297,7 +297,7 @@ export type FormExternalField = { key: string; type: "external"; url: string; ti
|
||||
|
||||
export type FormValue = string | number | boolean | Array<string>
|
||||
|
||||
export type PermissionSource = { type: "tool"; messageID: string; id: string }
|
||||
export type PermissionSource = { type: "tool"; messageID: string; callID: string }
|
||||
|
||||
export type PermissionSavedInfo = { id: string; projectID: string; action: string; resource: string }
|
||||
|
||||
@@ -486,7 +486,7 @@ export type Pty = {
|
||||
|
||||
export type QuestionOption = { label: string; description: string }
|
||||
|
||||
export type QuestionTool = { messageID: string; id: string }
|
||||
export type QuestionTool = { messageID: string; callID: string }
|
||||
|
||||
export type QuestionAnswer = Array<string>
|
||||
|
||||
@@ -744,7 +744,7 @@ export type SessionToolInputStarted = {
|
||||
type: "session.tool.input.started"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; assistantMessageID: string; id: string; name: string }
|
||||
data: { sessionID: string; assistantMessageID: string; callID: string; name: string }
|
||||
}
|
||||
|
||||
export type SessionToolInputEnded = {
|
||||
@@ -754,7 +754,7 @@ export type SessionToolInputEnded = {
|
||||
type: "session.tool.input.ended"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; assistantMessageID: string; id: string; text: string }
|
||||
data: { sessionID: string; assistantMessageID: string; callID: string; text: string }
|
||||
}
|
||||
|
||||
export type SessionCompactionAdmitted = {
|
||||
@@ -915,7 +915,7 @@ export type SessionToolInputDelta = {
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.tool.input.delta"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; assistantMessageID: string; id: string; delta: string }
|
||||
data: { sessionID: string; assistantMessageID: string; callID: string; delta: string }
|
||||
}
|
||||
|
||||
export type SessionToolProgress = {
|
||||
@@ -924,7 +924,7 @@ export type SessionToolProgress = {
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.tool.progress"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; assistantMessageID: string; id: string; metadata: { [x: string]: JsonValue } }
|
||||
data: { sessionID: string; assistantMessageID: string; callID: string; metadata: { [x: string]: JsonValue } }
|
||||
}
|
||||
|
||||
export type SessionCompactionDelta = {
|
||||
@@ -1380,7 +1380,7 @@ export type SessionToolCalled = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
id: string
|
||||
callID: string
|
||||
input: { [x: string]: any }
|
||||
executed: boolean
|
||||
state?: SessionMessageProviderState7
|
||||
@@ -1831,7 +1831,7 @@ export type SessionToolSuccess = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
id: string
|
||||
callID: string
|
||||
content: [ToolContent1, ...Array<ToolContent1>]
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
executed: boolean
|
||||
@@ -1849,7 +1849,7 @@ export type SessionToolFailed = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
id: string
|
||||
callID: string
|
||||
error: SessionStructuredError
|
||||
content?: [ToolContent1, ...Array<ToolContent1>]
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
@@ -4472,7 +4472,7 @@ export type PermissionCreateInput = {
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
|
||||
readonly agent?: string | null
|
||||
}["id"]
|
||||
readonly action: {
|
||||
@@ -4481,7 +4481,7 @@ export type PermissionCreateInput = {
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
|
||||
readonly agent?: string | null
|
||||
}["action"]
|
||||
readonly resources: {
|
||||
@@ -4490,7 +4490,7 @@ export type PermissionCreateInput = {
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
|
||||
readonly agent?: string | null
|
||||
}["resources"]
|
||||
readonly save?: {
|
||||
@@ -4499,7 +4499,7 @@ export type PermissionCreateInput = {
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
|
||||
readonly agent?: string | null
|
||||
}["save"]
|
||||
readonly metadata?: {
|
||||
@@ -4508,7 +4508,7 @@ export type PermissionCreateInput = {
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
|
||||
readonly agent?: string | null
|
||||
}["metadata"]
|
||||
readonly source?: {
|
||||
@@ -4517,7 +4517,7 @@ export type PermissionCreateInput = {
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
|
||||
readonly agent?: string | null
|
||||
}["source"]
|
||||
readonly agent?: {
|
||||
@@ -4526,7 +4526,7 @@ export type PermissionCreateInput = {
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
|
||||
readonly agent?: string | null
|
||||
}["agent"]
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
let announced = false
|
||||
let lastSpawn = 0
|
||||
let spawnDelay = 5_000
|
||||
let ownerHeld = false
|
||||
|
||||
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) => {
|
||||
if (announced) return
|
||||
@@ -64,6 +65,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
const registration = await registered(options.file, true)
|
||||
|
||||
if (registration.service !== undefined) {
|
||||
ownerHeld = false
|
||||
spawnDelay = 5_000
|
||||
const service = registration.service
|
||||
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
|
||||
@@ -76,13 +78,14 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
}
|
||||
} else {
|
||||
if (lastSpawn === 0 && registration.info !== undefined) lastSpawn = Date.now()
|
||||
const failure = [...contenders].map(contenderFailure).find((error) => error !== undefined)
|
||||
if (failure !== undefined) throw failure
|
||||
const finished = [...contenders].filter(contenderFinished)
|
||||
const failure = finished.map(contenderFailure).find((error) => error !== undefined)
|
||||
if (finished.some((item) => item.child.exitCode === 0)) {
|
||||
ownerHeld = true
|
||||
spawnDelay = Math.min(spawnDelay * 2, 30_000)
|
||||
}
|
||||
finished.forEach((item) => contenders.delete(item))
|
||||
if (failure !== undefined && contenders.size === 0) throw failure
|
||||
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
|
||||
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
|
||||
announce("missing")
|
||||
|
||||
@@ -9,20 +9,14 @@ if (mode === "record-start") {
|
||||
}
|
||||
if (mode === "signal") process.kill(process.pid, process.platform === "win32" ? "SIGTERM" : "SIGKILL")
|
||||
|
||||
if (
|
||||
mode === "delayed" ||
|
||||
mode === "delayed-failed" ||
|
||||
mode === "coordinated" ||
|
||||
mode === "coordinated-failed-loser"
|
||||
) {
|
||||
if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated") {
|
||||
await appendFile(registration + ".starts", process.pid + "\n")
|
||||
const owner = await writeFile(registration + ".owner", String(process.pid), { flag: "wx" })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (!owner) process.exit(mode === "coordinated-failed-loser" ? 1 : 0)
|
||||
if (mode === "coordinated" || mode === "coordinated-failed-loser") {
|
||||
if (!owner) process.exit()
|
||||
if (mode === "coordinated") {
|
||||
while ((await Bun.file(registration + ".starts").text()).trim().split("\n").length < 2) await Bun.sleep(10)
|
||||
if (mode === "coordinated-failed-loser") await Bun.sleep(1_500)
|
||||
} else await Bun.sleep(Number(delay))
|
||||
if (mode === "delayed-failed") process.exit(1)
|
||||
}
|
||||
|
||||
@@ -44,24 +44,6 @@ test("ensures a missing service with native promises", async () => {
|
||||
}
|
||||
}, 15_000)
|
||||
|
||||
test("waits for a live contender when another native contender fails", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
|
||||
const endpoint = await Service.ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "coordinated-failed-loser"],
|
||||
})
|
||||
const info = await Bun.file(registration).json()
|
||||
try {
|
||||
expect(endpoint.url).toBe(info.url)
|
||||
} finally {
|
||||
process.kill(info.pid, "SIGTERM")
|
||||
await waitForExit(info.pid)
|
||||
}
|
||||
}, 15_000)
|
||||
|
||||
test("reports a failed registered service", async () => {
|
||||
const registration = await setup("failed-owner")
|
||||
|
||||
|
||||
@@ -141,24 +141,6 @@ test("waits for a slow winner while bounding lock probes", async () => {
|
||||
}
|
||||
}, 15_000)
|
||||
|
||||
test("waits for a live contender when another contender fails", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const endpoint = await run(
|
||||
Service.ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "coordinated-failed-loser"],
|
||||
}),
|
||||
)
|
||||
const info = await Bun.file(registration).json()
|
||||
try {
|
||||
expect(endpoint.url).toBe(info.url)
|
||||
} finally {
|
||||
process.kill(info.pid, "SIGTERM")
|
||||
}
|
||||
}, 15_000)
|
||||
|
||||
test("reports a contender that fails to start", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
|
||||
@@ -89,14 +89,24 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const loading = Semaphore.makeUnsafe(1)
|
||||
let connected = false
|
||||
let savedConnection = false
|
||||
let providers: typeof ConfigV1.Info.Type.provider | undefined
|
||||
|
||||
const load = Effect.fn("OpencodePlugin.load")(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("opencode")
|
||||
const credential = connection
|
||||
const resolved = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
: undefined
|
||||
// Plugin activation batches transforms, so the first resolve can precede this plugin's OAuth registration.
|
||||
const credential =
|
||||
connection && resolved?.type === "oauth" && resolved.expires <= Date.now() + Duration.toMillis(Duration.minutes(5))
|
||||
? yield* ctx.integration.reload().pipe(
|
||||
Effect.andThen(ctx.integration.connection.resolve(connection)),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
)
|
||||
: resolved
|
||||
connected = connection !== undefined
|
||||
savedConnection = connection?.type === "credential"
|
||||
providers = credential
|
||||
? yield* fetchProviders(http, credential).pipe(
|
||||
Effect.catch((cause) =>
|
||||
@@ -116,6 +126,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
||||
|
||||
yield* load()
|
||||
yield* ctx.catalog.transform((catalog) => {
|
||||
if (savedConnection && providers === undefined) catalog.provider.remove(Provider.ID.opencode)
|
||||
for (const [providerID, item] of Object.entries(providers ?? {})) {
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.integrationID = Integration.ID.make("opencode")
|
||||
|
||||
@@ -111,9 +111,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
type DraftText = WritableDraft<SessionMessage.AssistantText>
|
||||
type DraftReasoning = WritableDraft<SessionMessage.AssistantReasoning>
|
||||
|
||||
const latestTool = (assistant: DraftAssistant | undefined, id?: string) =>
|
||||
const latestTool = (assistant: DraftAssistant | undefined, callID?: string) =>
|
||||
assistant?.content.findLast(
|
||||
(item): item is DraftTool => item.type === "tool" && (id === undefined || item.id === id),
|
||||
(item): item is DraftTool => item.type === "tool" && (callID === undefined || item.id === callID),
|
||||
)
|
||||
|
||||
const latestText = (assistant: DraftAssistant | undefined) =>
|
||||
@@ -331,7 +331,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
castDraft(
|
||||
SessionMessage.AssistantTool.make({
|
||||
type: "tool",
|
||||
id: event.data.id,
|
||||
id: event.data.callID,
|
||||
name: event.data.name,
|
||||
time: { created: event.created },
|
||||
state: SessionMessage.ToolStateStreaming.make({ status: "streaming", input: "" }),
|
||||
@@ -343,13 +343,13 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
"session.tool.input.delta": () => Effect.void,
|
||||
"session.tool.input.ended": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestTool(draft, event.data.id)
|
||||
const match = latestTool(draft, event.data.callID)
|
||||
if (match && match.state.status === "streaming") match.state.input = event.data.text
|
||||
})
|
||||
},
|
||||
"session.tool.called": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestTool(draft, event.data.id)
|
||||
const match = latestTool(draft, event.data.callID)
|
||||
if (match) {
|
||||
match.executed = event.data.executed
|
||||
match.providerState = event.data.state
|
||||
@@ -366,7 +366,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
},
|
||||
"session.tool.progress": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestTool(draft, event.data.id)
|
||||
const match = latestTool(draft, event.data.callID)
|
||||
if (match && match.state.status === "running") {
|
||||
match.state.metadata = event.data.metadata
|
||||
}
|
||||
@@ -376,7 +376,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
// never reaches into ephemeral progress history.
|
||||
"session.tool.success": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestTool(draft, event.data.id)
|
||||
const match = latestTool(draft, event.data.callID)
|
||||
if (match && match.state.status === "running") {
|
||||
match.executed = event.data.executed || match.executed === true
|
||||
match.providerResultState = event.data.resultState
|
||||
@@ -394,7 +394,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
},
|
||||
"session.tool.failed": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestTool(draft, event.data.id)
|
||||
const match = latestTool(draft, event.data.callID)
|
||||
if (match && (match.state.status === "streaming" || match.state.status === "running")) {
|
||||
match.executed = event.data.executed || match.executed === true
|
||||
match.providerResultState = event.data.resultState
|
||||
|
||||
@@ -37,7 +37,6 @@ import { SessionUsage } from "../usage"
|
||||
type CallOutcome = Data.TaggedEnum<{
|
||||
Completed: { readonly needsContinuation: boolean; readonly step: number }
|
||||
Retry: { readonly step: number }
|
||||
Continue: { readonly cause: AIError; readonly error: SessionRunnerRetry.RetryableFailure["error"]; readonly step: number }
|
||||
Restart: { readonly step: number; readonly recoveredOverflow: boolean }
|
||||
}>
|
||||
const CallOutcome = Data.taggedEnum<CallOutcome>()
|
||||
@@ -92,8 +91,6 @@ const classifyToolExits = (
|
||||
const TOOLS_INTERRUPTED = { type: "aborted", message: "Tool execution interrupted" } as const
|
||||
const STEP_INTERRUPTED = { type: "aborted", message: "Step interrupted" } as const
|
||||
const RESULT_MISSING = { type: "tool.result-missing", message: "Provider did not return a tool result" } as const
|
||||
const CONTINUE_AFTER_INCOMPLETE_STREAM =
|
||||
"The previous response was interrupted. Continue from where you left off without repeating completed content."
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
@@ -190,20 +187,6 @@ const layer = Layer.effect(
|
||||
assistantMessageID,
|
||||
).pipe(Effect.catchTag("SessionRunner.RetryableFailure", waitForRetry))
|
||||
if (outcome._tag === "Completed") return { needsContinuation: outcome.needsContinuation, step: outcome.step }
|
||||
if (outcome._tag === "Continue") {
|
||||
yield* retry(
|
||||
new SessionRunnerRetry.RetryableFailure({
|
||||
cause: outcome.cause,
|
||||
error: outcome.error,
|
||||
step: outcome.step,
|
||||
}),
|
||||
).pipe(Pull.catchDone(() => Effect.fail(outcome.cause)))
|
||||
yield* bus.publish(SessionEvent.Synthetic, {
|
||||
sessionID,
|
||||
text: CONTINUE_AFTER_INCOMPLETE_STREAM,
|
||||
})
|
||||
assistantMessageID = SessionMessage.ID.create()
|
||||
}
|
||||
if (outcome._tag === "Restart") {
|
||||
if (outcome.recoveredOverflow) recoverOverflow = false
|
||||
assistantMessageID = SessionMessage.ID.create()
|
||||
@@ -443,17 +426,6 @@ const layer = Layer.effect(
|
||||
})
|
||||
}
|
||||
|
||||
const incompleteStream =
|
||||
llmFailure?.reason._tag === "InvalidProviderOutput" &&
|
||||
llmFailure.reason.classification === "incomplete-stream"
|
||||
const toolsAllowContinuation = tools.declines.length === 0 && !tools.interrupted
|
||||
if (llmError && incompleteStream && record.outputStarted && toolsAllowContinuation)
|
||||
return CallOutcome.Continue({
|
||||
cause: llmFailure,
|
||||
error: llmError,
|
||||
step: currentStep,
|
||||
})
|
||||
|
||||
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
|
||||
if (tools.declines.length > 0) return yield* Effect.interrupt
|
||||
if (tools.interrupted && tools.failure) return yield* Effect.failCause(tools.failure)
|
||||
@@ -516,7 +488,7 @@ const layer = Layer.effect(
|
||||
yield* bus.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID,
|
||||
assistantMessageID: message.id,
|
||||
id: tool.id,
|
||||
callID: tool.id,
|
||||
error: { type: "aborted", message: `Tool execution interrupted: ${tool.name}` },
|
||||
executed: tool.executed === true,
|
||||
})
|
||||
|
||||
@@ -78,7 +78,7 @@ const hostedContent = (result: ToolResultValue): NonEmptyContent => {
|
||||
* between a check (`tool.settled`, `stepStarted`, ...) and its mark, so check-and-mark
|
||||
* stays atomic under cooperative scheduling. (2) Never require a cross-source event
|
||||
* order: each publishing fiber is sequential, so per-source order holds by construction,
|
||||
* and consumers fold by id/ordinal rather than global position.
|
||||
* and consumers fold by callID/ordinal rather than global position.
|
||||
*/
|
||||
export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, input: Input) => {
|
||||
const tools = new Map<
|
||||
@@ -188,14 +188,14 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
}),
|
||||
true,
|
||||
)
|
||||
const toolInput = fragments("tool input", (id, value) =>
|
||||
const toolInput = fragments("tool input", (callID, value) =>
|
||||
Effect.gen(function* () {
|
||||
const tool = tools.get(id)
|
||||
if (!tool) return yield* Effect.die(new Error(`Tool input end before start: ${id}`))
|
||||
const tool = tools.get(callID)
|
||||
if (!tool) return yield* Effect.die(new Error(`Tool input end before start: ${callID}`))
|
||||
yield* bus.publish(SessionEvent.Tool.Input.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
id,
|
||||
callID,
|
||||
text: value,
|
||||
})
|
||||
}),
|
||||
@@ -225,7 +225,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
yield* bus.publish(SessionEvent.Tool.Input.Started, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID,
|
||||
id: event.id,
|
||||
callID: event.id,
|
||||
name: event.name,
|
||||
})
|
||||
})
|
||||
@@ -258,7 +258,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
yield* bus.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
id: event.id,
|
||||
callID: event.id,
|
||||
error: {
|
||||
type: "tool.input-json",
|
||||
message: "Tool call arguments were malformed JSON and were not executed. Retry with valid JSON.",
|
||||
@@ -272,14 +272,14 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
yield* flushFragments()
|
||||
})
|
||||
|
||||
const failTool = Effect.fnUntraced(function* (id: string, error: SessionError.Error) {
|
||||
const tool = tools.get(id)
|
||||
const failTool = Effect.fnUntraced(function* (callID: string, error: SessionError.Error) {
|
||||
const tool = tools.get(callID)
|
||||
if (!tool || tool.settled) return false
|
||||
tool.settled = true
|
||||
yield* bus.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
id,
|
||||
callID,
|
||||
error,
|
||||
...failureSnapshot(tool),
|
||||
executed: tool.providerExecuted,
|
||||
@@ -289,10 +289,10 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
|
||||
const failTools = Effect.fnUntraced(function* (error: SessionError.Error, mode: "all" | "hosted" | "uncalled") {
|
||||
let failed = false
|
||||
for (const [id, tool] of tools) {
|
||||
for (const [callID, tool] of tools) {
|
||||
if (tool.settled || (mode === "hosted" && !tool.providerExecuted) || (mode === "uncalled" && tool.called))
|
||||
continue
|
||||
failed = (yield* failTool(id, error)) || failed
|
||||
failed = (yield* failTool(callID, error)) || failed
|
||||
}
|
||||
return failed
|
||||
})
|
||||
@@ -328,9 +328,9 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
return yield* failTools(error, scope)
|
||||
})
|
||||
|
||||
const assistantMessageIDForTool = (id: string) => {
|
||||
const tool = tools.get(id)
|
||||
return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(new Error(`Unknown tool call: ${id}`))
|
||||
const assistantMessageIDForTool = (callID: string) => {
|
||||
const tool = tools.get(callID)
|
||||
return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(new Error(`Unknown tool call: ${callID}`))
|
||||
}
|
||||
|
||||
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent) {
|
||||
@@ -399,7 +399,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
yield* bus.publish(SessionEvent.Tool.Input.Delta, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
id: event.id,
|
||||
callID: event.id,
|
||||
delta: event.text,
|
||||
})
|
||||
return
|
||||
@@ -424,7 +424,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
yield* bus.publish(SessionEvent.Tool.Called, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
id: event.id,
|
||||
callID: event.id,
|
||||
input: asRecord(event.input),
|
||||
executed: tool.providerExecuted,
|
||||
state: providerState(event.providerMetadata),
|
||||
@@ -450,7 +450,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
yield* bus.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
id: event.id,
|
||||
callID: event.id,
|
||||
error: { type: "tool.execution", message: stringify(event.result.value) },
|
||||
...failureSnapshot(tool),
|
||||
executed,
|
||||
@@ -461,7 +461,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
yield* bus.publish(SessionEvent.Tool.Success, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
id: event.id,
|
||||
callID: event.id,
|
||||
content: hostedContent(event.result),
|
||||
executed,
|
||||
resultState,
|
||||
@@ -478,7 +478,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
yield* bus.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
id: event.id,
|
||||
callID: event.id,
|
||||
error:
|
||||
event.message === `Unknown tool: ${event.name}`
|
||||
? { type: "tool.unknown", message: event.message }
|
||||
@@ -508,30 +508,30 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
}
|
||||
})
|
||||
|
||||
const progress = Effect.fnUntraced(function* (id: string, update: Tool.Metadata) {
|
||||
const tool = tools.get(id)
|
||||
const progress = Effect.fnUntraced(function* (callID: string, update: Tool.Metadata) {
|
||||
const tool = tools.get(callID)
|
||||
if (!tool?.called || tool.settled)
|
||||
return yield* Effect.die(new Error(`Tool progress outside running call: ${id}`))
|
||||
return yield* Effect.die(new Error(`Tool progress outside running call: ${callID}`))
|
||||
tool.progress = update
|
||||
yield* bus.publish(SessionEvent.Tool.Progress, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
id,
|
||||
callID,
|
||||
metadata: update,
|
||||
})
|
||||
})
|
||||
|
||||
/** Publishes one canonical terminal event for a locally executed tool call. */
|
||||
const toolExecution = Effect.fnUntraced(function* (
|
||||
id: string,
|
||||
callID: string,
|
||||
name: string,
|
||||
result: Tool.Result,
|
||||
) {
|
||||
const tool = tools.get(id)
|
||||
if (!tool?.called) return yield* Effect.die(new Error(`Tool execution before call: ${id}`))
|
||||
const tool = tools.get(callID)
|
||||
if (!tool?.called) return yield* Effect.die(new Error(`Tool execution before call: ${callID}`))
|
||||
if (tool.name !== name)
|
||||
return yield* Effect.die(new Error(`Tool execution name changed for ${id}: ${tool.name} -> ${name}`))
|
||||
if (tool.settled) return yield* Effect.die(new Error(`Duplicate tool execution: ${id}`))
|
||||
return yield* Effect.die(new Error(`Tool execution name changed for ${callID}: ${tool.name} -> ${name}`))
|
||||
if (tool.settled) return yield* Effect.die(new Error(`Duplicate tool execution: ${callID}`))
|
||||
tool.settled = true
|
||||
const content =
|
||||
typeof result.content === "string"
|
||||
@@ -539,11 +539,11 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
: result.content === undefined
|
||||
? []
|
||||
: [...result.content]
|
||||
if (content.length === 0) return yield* Effect.die(new Error(`Tool execution has no content: ${id}`))
|
||||
if (content.length === 0) return yield* Effect.die(new Error(`Tool execution has no content: ${callID}`))
|
||||
yield* bus.publish(SessionEvent.Tool.Success, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
id,
|
||||
callID,
|
||||
content: [content[0], ...content.slice(1)],
|
||||
...(result.metadata === undefined ? {} : { metadata: result.metadata }),
|
||||
executed: tool.providerExecuted,
|
||||
|
||||
@@ -20,11 +20,10 @@ export function isRetryable(error: AIError) {
|
||||
case "ProviderInternal":
|
||||
case "Transport":
|
||||
return true
|
||||
case "InvalidProviderOutput":
|
||||
return error.reason.classification === "incomplete-stream"
|
||||
case "Authentication":
|
||||
case "QuotaExceeded":
|
||||
case "ContentPolicy":
|
||||
case "InvalidProviderOutput":
|
||||
case "InvalidRequest":
|
||||
case "NoRoute":
|
||||
case "UnknownProvider":
|
||||
|
||||
@@ -93,7 +93,7 @@ const layer = Layer.effect(
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
callID: context.callID,
|
||||
input,
|
||||
}
|
||||
yield* hooks.trigger("tool", "execute.before", beforeEvent)
|
||||
@@ -106,7 +106,7 @@ const layer = Layer.effect(
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
callID: context.callID,
|
||||
input: beforeEvent.input,
|
||||
}
|
||||
if ("failure" in execution) {
|
||||
@@ -228,7 +228,7 @@ const layer = Layer.effect(
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
messageID: input.messageID,
|
||||
id: Tool.CallID.make(input.call.id),
|
||||
callID: Tool.CallID.make(input.call.id),
|
||||
progress: input.progress ?? (() => Effect.void),
|
||||
}
|
||||
if (input.call.name === "execute" && codemodeTool)
|
||||
|
||||
@@ -22,7 +22,7 @@ Location-scoped built-in layers acquire `Permission.Service` and every other req
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
callID: context.callID,
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ export const layer = Layer.effectDiscard(
|
||||
source: {
|
||||
type: "tool",
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
callID: context.callID,
|
||||
},
|
||||
})
|
||||
const result = yield* mcp
|
||||
|
||||
@@ -129,7 +129,7 @@ export const Plugin = {
|
||||
const permissionSource = {
|
||||
type: "tool" as const,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
callID: context.callID,
|
||||
}
|
||||
if (input.oldString === input.newString) {
|
||||
return yield* new ToolFailure({
|
||||
|
||||
@@ -61,7 +61,7 @@ export const Plugin = {
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const searchPath = input.path === "undefined" || input.path === "null" ? undefined : input.path
|
||||
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
|
||||
const source = { type: "tool" as const, messageID: context.messageID, callID: context.callID }
|
||||
const target = yield* mutation.resolve({ path: searchPath ?? ".", kind: "directory" })
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
|
||||
@@ -76,7 +76,7 @@ export const Plugin = {
|
||||
output: Output,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
|
||||
const source = { type: "tool" as const, messageID: context.messageID, callID: context.callID }
|
||||
const target = yield* mutation.resolve({ path: input.path ?? "." })
|
||||
if (target.externalDirectory)
|
||||
yield* permission.assert({
|
||||
|
||||
@@ -95,7 +95,7 @@ export const Plugin = {
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
callID: context.callID,
|
||||
}
|
||||
if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" })
|
||||
const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe(
|
||||
|
||||
@@ -70,7 +70,7 @@ export const Plugin = {
|
||||
resources: ["*"],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.messageID, id: context.id },
|
||||
source: { type: "tool", messageID: context.messageID, callID: context.callID },
|
||||
})
|
||||
.pipe(
|
||||
Effect.mapError((error) => new ToolFailure({ message: "Permission denied: question", error })),
|
||||
@@ -81,7 +81,7 @@ export const Plugin = {
|
||||
title: "Questions",
|
||||
metadata: {
|
||||
kind: "question",
|
||||
tool: { messageID: context.messageID, id: context.id },
|
||||
tool: { messageID: context.messageID, callID: context.callID },
|
||||
},
|
||||
fields: [
|
||||
toField(input.questions[0], 0),
|
||||
|
||||
@@ -56,7 +56,7 @@ export const Plugin = {
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
callID: context.callID,
|
||||
}
|
||||
const target = yield* mutation.resolve({ path: input.path, kind: "directory" })
|
||||
const external = target.externalDirectory
|
||||
|
||||
@@ -89,10 +89,10 @@ export const Plugin = {
|
||||
|
||||
const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
id: string,
|
||||
callID: string,
|
||||
command: string,
|
||||
) {
|
||||
yield* runtime.job.wait({ id: id }).pipe(
|
||||
yield* runtime.job.wait({ id: callID }).pipe(
|
||||
Effect.flatMap((result) => {
|
||||
const state =
|
||||
result.info?.status === "completed"
|
||||
@@ -111,7 +111,7 @@ export const Plugin = {
|
||||
: "Command cancelled"
|
||||
return runtime.session.synthetic({
|
||||
sessionID,
|
||||
text: `<shell id="${id}" state="${state}" command="${command}">\n${text}\n</shell>`,
|
||||
text: `<shell id="${callID}" state="${state}" command="${command}">\n${text}\n</shell>`,
|
||||
description: command,
|
||||
metadata: { source: "shell", state },
|
||||
})
|
||||
@@ -134,7 +134,7 @@ export const Plugin = {
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
callID: context.callID,
|
||||
}
|
||||
const timeout = input.background === true ? (input.timeout ?? 0) : (input.timeout ?? DEFAULT_TIMEOUT_MS)
|
||||
let finalTimeout = timeout
|
||||
@@ -176,12 +176,7 @@ export const Plugin = {
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const workdir = yield* fsUtil.stat(target.canonical).pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||
Effect.fail(new Error(`Working directory does not exist: ${target.canonical}`)),
|
||||
),
|
||||
)
|
||||
if (workdir.type !== "Directory")
|
||||
if ((yield* fsUtil.stat(target.canonical)).type !== "Directory")
|
||||
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
|
||||
}),
|
||||
)
|
||||
@@ -232,7 +227,7 @@ export const Plugin = {
|
||||
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
|
||||
)
|
||||
const job = yield* runtime.job.start({
|
||||
id: context.id,
|
||||
id: context.callID,
|
||||
type: name,
|
||||
title: info.command,
|
||||
metadata: { sessionID: context.sessionID, shellID: info.id },
|
||||
@@ -241,7 +236,7 @@ export const Plugin = {
|
||||
|
||||
if (input.background === true) {
|
||||
yield* runtime.job.background(job.id)
|
||||
yield* notifyWhenDone(context.sessionID, context.id, info.command)
|
||||
yield* notifyWhenDone(context.sessionID, context.callID, info.command)
|
||||
return {
|
||||
output: BACKGROUND_STARTED,
|
||||
shellID: info.id,
|
||||
@@ -255,7 +250,7 @@ export const Plugin = {
|
||||
)
|
||||
if (result?.type === "backgrounded") {
|
||||
yield* shell.timeout(info.id, 0)
|
||||
yield* notifyWhenDone(context.sessionID, context.id, info.command)
|
||||
yield* notifyWhenDone(context.sessionID, context.callID, info.command)
|
||||
return {
|
||||
output: BACKGROUND_STARTED,
|
||||
shellID: info.id,
|
||||
|
||||
@@ -75,7 +75,7 @@ export const Plugin = {
|
||||
save: [skill.id],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.messageID, id: context.id },
|
||||
source: { type: "tool", messageID: context.messageID, callID: context.callID },
|
||||
})
|
||||
const directory = path.dirname(skill.location)
|
||||
const files =
|
||||
|
||||
@@ -159,7 +159,7 @@ export const Plugin = {
|
||||
source: {
|
||||
type: "tool",
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
callID: context.callID,
|
||||
},
|
||||
})
|
||||
.pipe(Effect.mapError((error) => new ToolFailure({ message: `Subagent denied: ${agent.id}`, error })))
|
||||
|
||||
@@ -139,7 +139,7 @@ export const Plugin = {
|
||||
metadata: input,
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.messageID, id: context.id },
|
||||
source: { type: "tool", messageID: context.messageID, callID: context.callID },
|
||||
})
|
||||
|
||||
const { body, contentType } = yield* Effect.gen(function* () {
|
||||
|
||||
@@ -47,7 +47,7 @@ export const Plugin = {
|
||||
metadata: input,
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.messageID, id: context.id },
|
||||
source: { type: "tool", messageID: context.messageID, callID: context.callID },
|
||||
})
|
||||
const result = yield* ctx.websearch.query(input).pipe(
|
||||
Effect.catch((error) => {
|
||||
|
||||
@@ -67,7 +67,7 @@ export const Plugin = {
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
callID: context.callID,
|
||||
}
|
||||
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
|
||||
const external = target.externalDirectory
|
||||
|
||||
@@ -714,7 +714,7 @@ describe("DatabaseMigration", () => {
|
||||
sql`INSERT INTO event VALUES ('evt_success', 'session.tool.success.1', ${JSON.stringify({
|
||||
sessionID: "ses_test",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_hosted",
|
||||
callID: "call_hosted",
|
||||
structured: {},
|
||||
content: [],
|
||||
result: { type: "json", value: [{ url: "https://example.com" }] },
|
||||
@@ -725,7 +725,7 @@ describe("DatabaseMigration", () => {
|
||||
sql`INSERT INTO event VALUES ('evt_failed', 'session.tool.failed.1', ${JSON.stringify({
|
||||
sessionID: "ses_test",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_failed",
|
||||
callID: "call_failed",
|
||||
error: { type: "tool.execution", message: "timed out" },
|
||||
metadata: { truncated: false },
|
||||
executed: false,
|
||||
@@ -795,7 +795,7 @@ describe("DatabaseMigration", () => {
|
||||
expect(JSON.parse(event!.data)).toEqual({
|
||||
sessionID: "ses_test",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_hosted",
|
||||
callID: "call_hosted",
|
||||
structured: {},
|
||||
content: [],
|
||||
result: { type: "json", value: [{ url: "https://example.com" }] },
|
||||
@@ -806,7 +806,7 @@ describe("DatabaseMigration", () => {
|
||||
expect(JSON.parse(failedEvent!.data)).toEqual({
|
||||
sessionID: "ses_test",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_failed",
|
||||
callID: "call_failed",
|
||||
error: { type: "tool.execution", message: "timed out" },
|
||||
metadata: { truncated: false },
|
||||
executed: false,
|
||||
|
||||
@@ -928,7 +928,7 @@ it.effect("waits for permission before calling an MCP tool", () =>
|
||||
source: {
|
||||
type: "tool",
|
||||
messageID: toolIdentity.messageID,
|
||||
id: "call_mcp_permission",
|
||||
callID: "call_mcp_permission",
|
||||
},
|
||||
})
|
||||
expect(calls).toBe(0)
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
@@ -283,6 +284,96 @@ describe("OpencodePlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("refreshes saved OAuth before loading the Console catalog on cold startup", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const requests: string[] = []
|
||||
return {
|
||||
requests,
|
||||
server: Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) => {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/auth/device/token") {
|
||||
requests.push("refresh")
|
||||
return Response.json({ access_token: "fresh", refresh_token: "next", expires_in: 600 })
|
||||
}
|
||||
if (url.pathname === "/api/config") {
|
||||
requests.push(`config:${request.headers.get("authorization")}`)
|
||||
return Response.json({
|
||||
config: {
|
||||
provider: {
|
||||
console: {
|
||||
name: "Console",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
models: { current: { name: "Current" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
}),
|
||||
}
|
||||
}),
|
||||
({ requests, server }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* (yield* Credential.Service).create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("device"),
|
||||
access: "expired",
|
||||
refresh: "refresh",
|
||||
expires: 1,
|
||||
metadata: { server: server.url.origin },
|
||||
}),
|
||||
})
|
||||
|
||||
yield* State.batch(addPlugin())
|
||||
|
||||
expect(requests).toEqual(["refresh", "config:Bearer fresh"])
|
||||
expect(
|
||||
yield* (yield* Catalog.Service).model.get(Provider.ID.make("console"), Model.ID.make("current")),
|
||||
).toBeDefined()
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("hides legacy fallback models when Console configuration fails", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() =>
|
||||
Bun.serve({
|
||||
port: 0,
|
||||
fetch: () => new Response("Unauthorized", { status: 401 }),
|
||||
}),
|
||||
),
|
||||
(server) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((draft) => {
|
||||
draft.provider.update(Provider.ID.opencode, () => {})
|
||||
draft.model.update(Provider.ID.opencode, Model.ID.make("legacy"), () => {})
|
||||
})
|
||||
yield* (yield* Credential.Service).create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: Credential.Key.make({
|
||||
type: "key",
|
||||
key: "console-key",
|
||||
metadata: { server: server.url.origin },
|
||||
}),
|
||||
})
|
||||
|
||||
yield* State.batch(addPlugin())
|
||||
|
||||
expect(yield* catalog.model.get(Provider.ID.opencode, Model.ID.make("legacy"))).toBeUndefined()
|
||||
}),
|
||||
(server) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("uses a public key and disables paid models without credentials", () =>
|
||||
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -255,19 +255,19 @@ it.effect("generates from fresh settled Session context without durable mutation
|
||||
yield* bus.publish(SessionEvent.Tool.Input.Started, {
|
||||
sessionID,
|
||||
assistantMessageID: activeAssistant,
|
||||
id: "active-call",
|
||||
callID: "active-call",
|
||||
name: "echo",
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Tool.Input.Ended, {
|
||||
sessionID,
|
||||
assistantMessageID: activeAssistant,
|
||||
id: "active-call",
|
||||
callID: "active-call",
|
||||
text: "{}",
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Tool.Called, {
|
||||
sessionID,
|
||||
assistantMessageID: activeAssistant,
|
||||
id: "active-call",
|
||||
callID: "active-call",
|
||||
input: {},
|
||||
executed: false,
|
||||
})
|
||||
|
||||
@@ -237,7 +237,7 @@ test("success event data can carry provider-executed result state", () => {
|
||||
const decoded = Schema.decodeUnknownSync(SessionEvent.Tool.Success.data)({
|
||||
sessionID,
|
||||
assistantMessageID: SessionMessage.ID.create(),
|
||||
id: "call-old",
|
||||
callID: "call-old",
|
||||
content: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }],
|
||||
executed: true,
|
||||
resultState: {
|
||||
|
||||
@@ -339,7 +339,7 @@ describe("Tool", () => {
|
||||
call: { type: "tool-call", id: "call-context", name: "context", input: {} },
|
||||
})
|
||||
expect(contexts).toEqual([
|
||||
{ sessionID, ...identity, id: Tool.CallID.make("call-context"), progress: expect.any(Function) },
|
||||
{ sessionID, ...identity, callID: Tool.CallID.make("call-context"), progress: expect.any(Function) },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -513,19 +513,6 @@ const providerUnavailable = () =>
|
||||
reason: new TransportReason({ message: "Provider unavailable" }),
|
||||
})
|
||||
|
||||
const incompleteStream = () =>
|
||||
new AIError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new InvalidProviderOutputReason({
|
||||
classification: "incomplete-stream",
|
||||
message: "The provider response ended unexpectedly.",
|
||||
}),
|
||||
})
|
||||
|
||||
const INCOMPLETE_STREAM_CONTINUATION =
|
||||
"The previous response was interrupted. Continue from where you left off without repeating completed content."
|
||||
|
||||
const invalidRequest = () =>
|
||||
new AIError({
|
||||
module: "test",
|
||||
@@ -948,7 +935,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* TestLLM.push(TestLLM.tool("call-location", "location_context", { query: "hello" }), [])
|
||||
const bus = yield* Bus.Service
|
||||
const progressFiber = yield* bus.subscribe(SessionEvent.Tool.Progress).pipe(
|
||||
Stream.filter((event) => event.data.sessionID === sessionID && event.data.id === "call-location"),
|
||||
Stream.filter((event) => event.data.sessionID === sessionID && event.data.callID === "call-location"),
|
||||
Stream.take(1),
|
||||
Stream.runCollect,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
@@ -962,7 +949,7 @@ describe("SessionRunnerLLM", () => {
|
||||
sessionID,
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: expect.stringMatching(/^msg_/),
|
||||
id: Tool.CallID.make("call-location"),
|
||||
callID: Tool.CallID.make("call-location"),
|
||||
progress: expect.any(Function),
|
||||
},
|
||||
])
|
||||
@@ -2395,7 +2382,7 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(messageRoles(requests[1])).toEqual(["user", "assistant", "tool"])
|
||||
expect(authorizations).toMatchObject([{ sessionID, id: "call-echo" }])
|
||||
expect(authorizations).toMatchObject([{ sessionID, callID: "call-echo" }])
|
||||
expect(executions).toEqual(["hello"])
|
||||
const context = yield* session.context(sessionID)
|
||||
expect(context).toMatchObject([
|
||||
@@ -3007,19 +2994,19 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* bus.publish(SessionEvent.Tool.Input.Started, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id: "call-interrupted",
|
||||
callID: "call-interrupted",
|
||||
name: "echo",
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Tool.Input.Ended, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id: "call-interrupted",
|
||||
callID: "call-interrupted",
|
||||
text: '{"text":"stale"}',
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Tool.Called, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id: "call-interrupted",
|
||||
callID: "call-interrupted",
|
||||
input: { text: "stale" },
|
||||
executed: false,
|
||||
})
|
||||
@@ -3064,19 +3051,19 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* bus.publish(SessionEvent.Tool.Input.Started, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id: "call-hosted-interrupted",
|
||||
callID: "call-hosted-interrupted",
|
||||
name: "web_search",
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Tool.Input.Ended, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id: "call-hosted-interrupted",
|
||||
callID: "call-hosted-interrupted",
|
||||
text: '{"query":"stale"}',
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Tool.Called, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id: "call-hosted-interrupted",
|
||||
callID: "call-hosted-interrupted",
|
||||
input: { query: "stale" },
|
||||
executed: true,
|
||||
state: { itemId: "call-hosted-interrupted" },
|
||||
@@ -3115,7 +3102,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* bus.publish(SessionEvent.Tool.Input.Started, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id: "call-pending-interrupted",
|
||||
callID: "call-pending-interrupted",
|
||||
name: "echo",
|
||||
})
|
||||
requests.length = 0
|
||||
@@ -3962,26 +3949,6 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retries an incomplete stream before output", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Retry incomplete stream")
|
||||
yield* TestLLM.push(Stream.fail(incompleteStream()))
|
||||
yield* TestLLM.push(TestLLM.text("Recovered", "incomplete-stream-success"))
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* TestLLM.wait(1)
|
||||
yield* TestClock.adjust("2 seconds")
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user" },
|
||||
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses a larger provider retry-after delay", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
@@ -3999,11 +3966,10 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues an incomplete stream after observable text", () =>
|
||||
it.effect("does not retry eligible failures after observable output", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const failure = incompleteStream()
|
||||
yield* admit(session, "Continue partial output")
|
||||
const failure = rateLimited()
|
||||
yield* TestLLM.push(
|
||||
TestLLM.failAfter(
|
||||
failure,
|
||||
@@ -4012,194 +3978,19 @@ describe("SessionRunnerLLM", () => {
|
||||
LLMEvent.textDelta({ id: "partial-rate-limit", text: "Partial" }),
|
||||
),
|
||||
)
|
||||
yield* TestLLM.push(TestLLM.text(" continuation", "continued-text"))
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* TestLLM.wait(1)
|
||||
yield* TestClock.adjust("2 seconds")
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests[1]?.messages.at(-2)).toMatchObject({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Partial" }],
|
||||
})
|
||||
expect(requests[1]?.messages.at(-1)).toMatchObject({
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: INCOMPLETE_STREAM_CONTINUATION,
|
||||
},
|
||||
],
|
||||
})
|
||||
const context = yield* session.context(sessionID)
|
||||
expect(context).toMatchObject([
|
||||
{ type: "user", text: "Continue partial output" },
|
||||
expect(yield* runPrompt(session, "Do not replay partial output").pipe(Effect.flip)).toBe(failure)
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(yield* recordedEventTypes(sessionID)).not.toContain("session.retry.scheduled.1")
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user" },
|
||||
{
|
||||
type: "assistant",
|
||||
finish: "error",
|
||||
error: { type: "provider.invalid-output" },
|
||||
error: { type: "provider.rate-limit" },
|
||||
content: [{ type: "text", text: "Partial" }],
|
||||
},
|
||||
{
|
||||
type: "synthetic",
|
||||
text: INCOMPLETE_STREAM_CONTINUATION,
|
||||
},
|
||||
{ type: "assistant", finish: "stop", content: [{ type: "text", text: " continuation" }] },
|
||||
])
|
||||
const assistants = context.filter((message) => message.type === "assistant")
|
||||
expect(new Set(assistants.map((message) => message.id)).size).toBe(2)
|
||||
expect(context.find((message) => message.type === "synthetic")?.description).toBeUndefined()
|
||||
expect(yield* recordedEventTypes(sessionID)).toContain("session.retry.scheduled.1")
|
||||
yield* replaySessionProjection(sessionID)
|
||||
expect(yield* session.context(sessionID)).toMatchObject(context)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers interrupted reasoning before continuing an incomplete stream", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Continue interrupted reasoning")
|
||||
yield* TestLLM.push(
|
||||
TestLLM.failAfter(
|
||||
incompleteStream(),
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.reasoningStart({ id: "partial-reasoning" }),
|
||||
LLMEvent.reasoningDelta({ id: "partial-reasoning", text: "Partial thought" }),
|
||||
),
|
||||
)
|
||||
yield* TestLLM.push(TestLLM.text("Recovered", "reasoning-recovery"))
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* TestLLM.wait(1)
|
||||
yield* TestClock.adjust("2 seconds")
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(requests[1]?.messages.at(-2)).toMatchObject({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Partial thought" }],
|
||||
})
|
||||
expect(requests[1]?.messages.at(-1)).toMatchObject({
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: INCOMPLETE_STREAM_CONTINUATION,
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user" },
|
||||
{ type: "assistant", finish: "error", content: [{ type: "reasoning", text: "Partial thought" }] },
|
||||
{ type: "synthetic" },
|
||||
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues an incomplete stream after settling a local tool", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Continue after tool")
|
||||
yield* TestLLM.push(
|
||||
TestLLM.failAfter(
|
||||
incompleteStream(),
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-before-close", name: "echo", input: { text: "settled" } }),
|
||||
),
|
||||
)
|
||||
yield* TestLLM.push(TestLLM.text("Recovered", "tool-recovery"))
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* TestLLM.wait(1)
|
||||
while (!(yield* recordedEventTypes(sessionID)).includes("session.retry.scheduled.1")) yield* Effect.yieldNow
|
||||
yield* TestClock.adjust("2 seconds")
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(executions).toEqual(["settled"])
|
||||
expect(requests[1]?.messages.slice(-3)).toMatchObject([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "tool-call", id: "call-before-close", name: "echo", input: { text: "settled" } }],
|
||||
},
|
||||
{ role: "tool", content: [{ type: "tool-result", id: "call-before-close" }] },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: INCOMPLETE_STREAM_CONTINUATION,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues an incomplete stream after settling a local tool defect", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Continue after tool defect")
|
||||
yield* TestLLM.push(
|
||||
TestLLM.failAfter(
|
||||
incompleteStream(),
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-defect-before-close", name: "defect", input: {} }),
|
||||
),
|
||||
)
|
||||
yield* TestLLM.push(TestLLM.text("Recovered", "tool-defect-recovery"))
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* TestLLM.wait(1)
|
||||
while (!(yield* recordedEventTypes(sessionID)).includes("session.retry.scheduled.1")) yield* Effect.yieldNow
|
||||
yield* TestClock.adjust("2 seconds")
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(messageRoles(requests[1])).toEqual(["user", "assistant", "tool", "user"])
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user" },
|
||||
{
|
||||
type: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call-defect-before-close",
|
||||
state: { status: "error", error: { type: "unknown", message: "unexpected tool defect" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: "synthetic", text: INCOMPLETE_STREAM_CONTINUATION },
|
||||
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("stops incomplete stream continuations after five total attempts", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Exhaust partial continuations")
|
||||
const failure = incompleteStream()
|
||||
yield* TestLLM.always(
|
||||
TestLLM.failAfter(
|
||||
failure,
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.textStart({ id: "partial-exhaustion" }),
|
||||
LLMEvent.textDelta({ id: "partial-exhaustion", text: "Partial" }),
|
||||
),
|
||||
)
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* TestLLM.wait(1)
|
||||
for (const [index, delay] of [2_000, 4_000, 8_000, 16_000].entries()) {
|
||||
yield* TestClock.adjust(delay)
|
||||
yield* TestLLM.wait(index + 2)
|
||||
}
|
||||
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure)
|
||||
expect(requests).toHaveLength(5)
|
||||
const context = yield* session.context(sessionID)
|
||||
expect(context.filter((message) => message.type === "assistant")).toHaveLength(5)
|
||||
expect(context.filter((message) => message.type === "synthetic")).toHaveLength(4)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4329,7 +4120,7 @@ describe("SessionRunnerLLM", () => {
|
||||
{
|
||||
type: "session.tool.failed.2",
|
||||
data: {
|
||||
id: "call-malformed",
|
||||
callID: "call-malformed",
|
||||
error: { type: "provider.invalid-output", message: "Invalid JSON input for tool call echo" },
|
||||
},
|
||||
},
|
||||
@@ -4429,7 +4220,7 @@ describe("SessionRunnerLLM", () => {
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
expect(durable.find((event) => event.type === "session.tool.input.ended.1")?.data).toMatchObject({
|
||||
id: "call-malformed",
|
||||
callID: "call-malformed",
|
||||
text: raw,
|
||||
})
|
||||
}),
|
||||
@@ -4825,13 +4616,13 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
const assistant = requireAssistant(yield* session.context(sessionID))
|
||||
const bus = yield* recordedStepSettlementEvents(sessionID, assistant.id)
|
||||
expect(bus.map((event) => ({ type: event.type, id: event.data.id }))).toEqual([
|
||||
{ type: "session.step.started.1", id: undefined },
|
||||
{ type: "session.tool.called.1", id: "call-local-raw-failure" },
|
||||
{ type: "session.tool.called.1", id: "call-hosted-raw-failure-pair" },
|
||||
{ type: "session.tool.failed.2", id: "call-local-raw-failure" },
|
||||
{ type: "session.tool.failed.2", id: "call-hosted-raw-failure-pair" },
|
||||
{ type: "session.step.failed.1", id: undefined },
|
||||
expect(bus.map((event) => ({ type: event.type, callID: event.data.callID }))).toEqual([
|
||||
{ type: "session.step.started.1", callID: undefined },
|
||||
{ type: "session.tool.called.1", callID: "call-local-raw-failure" },
|
||||
{ type: "session.tool.called.1", callID: "call-hosted-raw-failure-pair" },
|
||||
{ type: "session.tool.failed.2", callID: "call-local-raw-failure" },
|
||||
{ type: "session.tool.failed.2", callID: "call-hosted-raw-failure-pair" },
|
||||
{ type: "session.step.failed.1", callID: undefined },
|
||||
])
|
||||
expect(
|
||||
bus.filter((event) => event.type.startsWith("session.step.") && event.type !== "session.step.started.1"),
|
||||
|
||||
@@ -65,18 +65,18 @@ describe("Tool.Metadata", () => {
|
||||
if (!row) return yield* Effect.die("Missing projected assistant")
|
||||
return Schema.decodeUnknownSync(SessionMessage.Assistant)({ ...row.data, id: row.id, type: row.type })
|
||||
})
|
||||
const start = (id: string) =>
|
||||
const start = (callID: string) =>
|
||||
Effect.gen(function* () {
|
||||
yield* service.publish(SessionEvent.Tool.Input.Started, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id,
|
||||
callID,
|
||||
name: "bash",
|
||||
})
|
||||
yield* service.publish(SessionEvent.Tool.Called, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id,
|
||||
callID,
|
||||
input: { command: "pwd" },
|
||||
executed: false,
|
||||
})
|
||||
@@ -90,7 +90,7 @@ describe("Tool.Metadata", () => {
|
||||
const progress = yield* service.publish(SessionEvent.Tool.Progress, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id: "call-success",
|
||||
callID: "call-success",
|
||||
metadata: { phase: "checkpoint" },
|
||||
})
|
||||
expect((yield* readAssistant).content[0]).toMatchObject({
|
||||
@@ -100,7 +100,7 @@ describe("Tool.Metadata", () => {
|
||||
const success = yield* service.publish(SessionEvent.Tool.Success, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id: "call-success",
|
||||
callID: "call-success",
|
||||
metadata: { phase: "done" },
|
||||
content: content("complete"),
|
||||
executed: false,
|
||||
@@ -113,13 +113,13 @@ describe("Tool.Metadata", () => {
|
||||
yield* service.publish(SessionEvent.Tool.Progress, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id: "call-failed",
|
||||
callID: "call-failed",
|
||||
metadata: { phase: "checkpoint" },
|
||||
})
|
||||
const failed = yield* service.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id: "call-failed",
|
||||
callID: "call-failed",
|
||||
error: { type: "unknown", message: "boom" },
|
||||
metadata: { phase: "checkpoint" },
|
||||
content: content("before failure"),
|
||||
|
||||
@@ -12,7 +12,7 @@ const context = {
|
||||
sessionID: Session.ID.make("ses_execute"),
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_execute"),
|
||||
id: Tool.CallID.make("call_execute"),
|
||||
callID: Tool.CallID.make("call_execute"),
|
||||
progress: () => Effect.void,
|
||||
}
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ describe("QuestionTool", () => {
|
||||
expect(capturedInput()).toEqual({
|
||||
sessionID,
|
||||
title: "Questions",
|
||||
metadata: { kind: "question", tool: { messageID: toolIdentity.messageID, id: "call-question" } },
|
||||
metadata: { kind: "question", tool: { messageID: toolIdentity.messageID, callID: "call-question" } },
|
||||
fields: [
|
||||
{
|
||||
key: "q0",
|
||||
@@ -212,7 +212,7 @@ describe("QuestionTool", () => {
|
||||
expect(capturedInput()).toEqual({
|
||||
sessionID,
|
||||
title: "Questions",
|
||||
metadata: { kind: "question", tool: { messageID: toolIdentity.messageID, id: "call-question" } },
|
||||
metadata: { kind: "question", tool: { messageID: toolIdentity.messageID, callID: "call-question" } },
|
||||
fields: [
|
||||
{
|
||||
key: "q0",
|
||||
|
||||
@@ -257,31 +257,6 @@ describe("ShellTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reports a missing workdir", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
executeTool(registry, call({ command: cwdCommand, workdir: "missing" })),
|
||||
).pipe(
|
||||
Effect.andThen((settled) =>
|
||||
Effect.sync(() =>
|
||||
expect(settled).toEqual({
|
||||
status: "error",
|
||||
error: {
|
||||
type: "unknown",
|
||||
message: `Working directory does not exist: ${path.join(tmp.path, "missing")}`,
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("permissions compound commands separately", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface ToolHooks {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly id: Tool.CallID
|
||||
readonly callID: Tool.CallID
|
||||
input: unknown
|
||||
}
|
||||
readonly "execute.after": {
|
||||
@@ -25,7 +25,7 @@ export interface ToolHooks {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly id: Tool.CallID
|
||||
readonly callID: Tool.CallID
|
||||
readonly input: unknown
|
||||
} & (
|
||||
| {
|
||||
|
||||
@@ -34,7 +34,7 @@ interface ToolHooks {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly id: Tool.CallID
|
||||
readonly callID: Tool.CallID
|
||||
input: unknown
|
||||
}
|
||||
readonly "execute.after": {
|
||||
@@ -42,7 +42,7 @@ interface ToolHooks {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly id: Tool.CallID
|
||||
readonly callID: Tool.CallID
|
||||
readonly input: unknown
|
||||
} & (
|
||||
| {
|
||||
|
||||
@@ -17,7 +17,7 @@ export const Source = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("tool"),
|
||||
messageID: Schema.String,
|
||||
id: Schema.String,
|
||||
callID: Schema.String,
|
||||
}),
|
||||
]).annotate({ identifier: "Permission.Source" })
|
||||
export type Source = typeof Source.Type
|
||||
|
||||
@@ -45,7 +45,7 @@ export interface Prompt extends Schema.Schema.Type<typeof Prompt> {}
|
||||
|
||||
export const Tool = Schema.Struct({
|
||||
messageID: Schema.String,
|
||||
id: Schema.String,
|
||||
callID: Schema.String,
|
||||
}).annotate({ identifier: "Question.Tool" })
|
||||
export interface Tool extends Schema.Schema.Type<typeof Tool> {}
|
||||
|
||||
|
||||
@@ -365,7 +365,7 @@ export namespace Tool {
|
||||
const ToolBase = {
|
||||
...Base,
|
||||
assistantMessageID: SessionMessage.ID,
|
||||
id: Schema.String,
|
||||
callID: Schema.String,
|
||||
}
|
||||
|
||||
export namespace Input {
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface Context {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly id: CallID
|
||||
readonly callID: CallID
|
||||
readonly progress: (update: Metadata) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
|
||||
@@ -177,7 +177,7 @@ describe("public event manifest", () => {
|
||||
const tool = SessionEvent.Tool.Called.data.make({
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id: "call_test",
|
||||
callID: "call_test",
|
||||
input: {},
|
||||
executed: true,
|
||||
state: { itemId: "item_test" },
|
||||
|
||||
@@ -477,7 +477,7 @@ const makeToolDriver = Effect.fn("SimulatedProvider.makeToolDriver")(function* (
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
callID: context.callID,
|
||||
},
|
||||
}
|
||||
const pending: PendingToolInvocation = {
|
||||
|
||||
@@ -503,7 +503,7 @@ export namespace Backend {
|
||||
sessionID: Schema.String,
|
||||
agent: Schema.String,
|
||||
messageID: Schema.String,
|
||||
id: Schema.String,
|
||||
callID: Schema.String,
|
||||
}),
|
||||
})
|
||||
export interface ToolInvocation extends Schema.Schema.Type<typeof ToolInvocation> {}
|
||||
|
||||
@@ -279,7 +279,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
|
||||
expect.objectContaining({ name: "lookup", description: "Look up a value" }),
|
||||
)
|
||||
const progress: Tool.Metadata[] = []
|
||||
const executeCall = (id: string, query: string) =>
|
||||
const executeCall = (callID: string, query: string) =>
|
||||
toolSet.execute({
|
||||
sessionID: Session.ID.make("ses_simulated_tools"),
|
||||
agent: Agent.ID.make("build"),
|
||||
@@ -287,7 +287,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
|
||||
progress: (update) => Effect.sync(() => progress.push(update)),
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: id,
|
||||
id: callID,
|
||||
name: "lookup",
|
||||
input: { query },
|
||||
},
|
||||
@@ -302,7 +302,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
|
||||
sessionID: "ses_simulated_tools",
|
||||
agent: "build",
|
||||
messageID: "msg_simulated_tools",
|
||||
id: "call_success",
|
||||
callID: "call_success",
|
||||
},
|
||||
})
|
||||
const successID = requireString(requireRecord(successInvocation.params).id)
|
||||
@@ -420,25 +420,25 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
|
||||
invocations.map((invocation) => {
|
||||
const params = requireRecord(invocation.params)
|
||||
const context = requireRecord(params.context)
|
||||
return [requireString(context.id), requireString(params.id)]
|
||||
return [requireString(context.callID), requireString(params.id)]
|
||||
}),
|
||||
)
|
||||
for (const [requestID, toolID, value] of [
|
||||
for (const [id, callID, value] of [
|
||||
[5, "call_second", "second result"],
|
||||
[6, "call_first", "first result"],
|
||||
] as const) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: requestID,
|
||||
id,
|
||||
method: "tool.finish",
|
||||
params: {
|
||||
id: byCall.get(toolID),
|
||||
id: byCall.get(callID),
|
||||
output: { structured: value, content: [{ type: "text", text: value }] },
|
||||
},
|
||||
}),
|
||||
)
|
||||
expect(yield* Queue.take(messages)).toMatchObject({ id: requestID, result: { ok: true } })
|
||||
expect(yield* Queue.take(messages)).toMatchObject({ id, result: { ok: true } })
|
||||
}
|
||||
expect(yield* Fiber.join(concurrent[0])).toMatchObject({
|
||||
output: "first result",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -208,10 +208,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
const item = messages.findLast((item) => item.type === "compaction" && item.status === "running")
|
||||
return item?.type === "compaction" ? item : undefined
|
||||
},
|
||||
latestTool(assistant: SessionMessageAssistant | undefined, id?: string) {
|
||||
latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) {
|
||||
return assistant?.content.findLast(
|
||||
(item): item is SessionMessageAssistantTool =>
|
||||
item.type === "tool" && (id === undefined || item.id === id),
|
||||
item.type === "tool" && (callID === undefined || item.id === callID),
|
||||
)
|
||||
},
|
||||
latestText(assistant: SessionMessageAssistant | undefined) {
|
||||
@@ -592,7 +592,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
||||
type: "tool",
|
||||
id: event.data.id,
|
||||
id: event.data.callID,
|
||||
name: event.data.name,
|
||||
time: { created: event.created },
|
||||
state: { status: "streaming", input: "" },
|
||||
@@ -603,7 +603,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.id,
|
||||
event.data.callID,
|
||||
)
|
||||
if (match?.state.status === "streaming") match.state.input += event.data.delta
|
||||
})
|
||||
@@ -612,7 +612,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.id,
|
||||
event.data.callID,
|
||||
)
|
||||
if (match?.state.status === "streaming") match.state.input = event.data.text
|
||||
})
|
||||
@@ -621,7 +621,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.id,
|
||||
event.data.callID,
|
||||
)
|
||||
if (!match) return
|
||||
match.time.ran = event.created
|
||||
@@ -634,7 +634,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.id,
|
||||
event.data.callID,
|
||||
)
|
||||
if (match?.state.status !== "running") return
|
||||
match.state.metadata = event.data.metadata
|
||||
@@ -644,7 +644,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.id,
|
||||
event.data.callID,
|
||||
)
|
||||
if (match?.state.status !== "running") return
|
||||
match.state = {
|
||||
@@ -662,7 +662,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.id,
|
||||
event.data.callID,
|
||||
)
|
||||
if (!match || (match.state.status !== "streaming" && match.state.status !== "running")) return
|
||||
match.state = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
|
||||
@@ -373,7 +373,7 @@ function askPermission(state: State, item: Permit): void {
|
||||
resources: item.patterns,
|
||||
metadata: item.metadata ?? {},
|
||||
save: item.always,
|
||||
source: { type: "tool", messageID: item.ref.msg, id: item.ref.call },
|
||||
source: { type: "tool", messageID: item.ref.msg, callID: item.ref.call },
|
||||
tool,
|
||||
},
|
||||
})
|
||||
@@ -805,7 +805,7 @@ function emitForm(state: State, kind: FormKind = "question"): void {
|
||||
title: form.title,
|
||||
metadata:
|
||||
kind === "question"
|
||||
? { kind: "question", tool: { messageID: ref.msg, id: ref.call } }
|
||||
? { kind: "question", tool: { messageID: ref.msg, callID: ref.call } }
|
||||
: { kind: "mcp", message: `Synthetic ${kind} MCP elicitation` },
|
||||
fields: form.fields,
|
||||
}
|
||||
|
||||
@@ -164,13 +164,13 @@ function text(value: unknown): string | undefined {
|
||||
return next || undefined
|
||||
}
|
||||
|
||||
function sourceKey(messageID: string, id: string) {
|
||||
return `${messageID}\u0000${id}`
|
||||
function sourceKey(messageID: string, callID: string) {
|
||||
return `${messageID}\u0000${callID}`
|
||||
}
|
||||
|
||||
function permissionTool(request: PermissionRequest, tools: Map<string, SessionMessageAssistantTool>) {
|
||||
if (request.source?.type !== "tool") return request
|
||||
const tool = tools.get(sourceKey(request.source.messageID, request.source.id))
|
||||
const tool = tools.get(sourceKey(request.source.messageID, request.source.callID))
|
||||
return tool ? { ...request, tool } : request
|
||||
}
|
||||
|
||||
@@ -444,7 +444,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
...new Set(
|
||||
permissions.flatMap((request) => {
|
||||
if (request.source?.type !== "tool") return []
|
||||
const key = sourceKey(request.source.messageID, request.source.id)
|
||||
const key = sourceKey(request.source.messageID, request.source.callID)
|
||||
return child.toolSources.has(key) ? [] : [request.source.messageID]
|
||||
}),
|
||||
),
|
||||
@@ -475,7 +475,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
permissions.some(
|
||||
(request) =>
|
||||
request.source?.type === "tool" &&
|
||||
!child.toolSources.has(sourceKey(request.source.messageID, request.source.id)),
|
||||
!child.toolSources.has(sourceKey(request.source.messageID, request.source.callID)),
|
||||
)
|
||||
)
|
||||
throw new Error("Permission source tool is unavailable")
|
||||
@@ -737,12 +737,12 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
return
|
||||
}
|
||||
if (event.type === "session.tool.input.started") {
|
||||
if (child.finishedTools.has(sourceKey(event.data.assistantMessageID, event.data.id))) return
|
||||
if (child.finishedTools.has(sourceKey(event.data.assistantMessageID, event.data.callID))) return
|
||||
childTool(
|
||||
child,
|
||||
{
|
||||
type: "tool",
|
||||
id: event.data.id,
|
||||
id: event.data.callID,
|
||||
name: event.data.name,
|
||||
state: { status: "streaming", input: "" },
|
||||
time: { created: event.created },
|
||||
@@ -752,7 +752,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
return
|
||||
}
|
||||
if (event.type === "session.tool.input.delta" || event.type === "session.tool.input.ended") {
|
||||
const current = child.tools.get(sourceKey(event.data.assistantMessageID, event.data.id))
|
||||
const current = child.tools.get(sourceKey(event.data.assistantMessageID, event.data.callID))
|
||||
if (!current || current.part.state.status !== "streaming") return
|
||||
childTool(
|
||||
child,
|
||||
@@ -769,14 +769,14 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
return
|
||||
}
|
||||
if (event.type === "session.tool.called") {
|
||||
const key = sourceKey(event.data.assistantMessageID, event.data.id)
|
||||
const key = sourceKey(event.data.assistantMessageID, event.data.callID)
|
||||
if (child.finishedTools.has(key)) return
|
||||
const current = child.tools.get(key)
|
||||
childTool(
|
||||
child,
|
||||
{
|
||||
type: "tool",
|
||||
id: event.data.id,
|
||||
id: event.data.callID,
|
||||
name: current?.part.name ?? "tool",
|
||||
executed: event.data.executed,
|
||||
providerState: event.data.state,
|
||||
@@ -790,7 +790,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
return
|
||||
}
|
||||
if (event.type === "session.tool.progress") {
|
||||
const key = sourceKey(event.data.assistantMessageID, event.data.id)
|
||||
const key = sourceKey(event.data.assistantMessageID, event.data.callID)
|
||||
if (child.finishedTools.has(key)) return
|
||||
const current = child.tools.get(key)
|
||||
const part = current?.part
|
||||
@@ -798,7 +798,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
child,
|
||||
{
|
||||
type: "tool",
|
||||
id: event.data.id,
|
||||
id: event.data.callID,
|
||||
name: part?.name ?? "tool",
|
||||
executed: part?.executed,
|
||||
providerState: part?.providerState,
|
||||
@@ -819,7 +819,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
return
|
||||
}
|
||||
if (event.type === "session.tool.success" || event.type === "session.tool.failed") {
|
||||
const key = sourceKey(event.data.assistantMessageID, event.data.id)
|
||||
const key = sourceKey(event.data.assistantMessageID, event.data.callID)
|
||||
if (child.finishedTools.has(key)) return
|
||||
const current = child.tools.get(key)
|
||||
const part = current?.part
|
||||
@@ -828,7 +828,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
child,
|
||||
{
|
||||
type: "tool",
|
||||
id: event.data.id,
|
||||
id: event.data.callID,
|
||||
name: part?.name ?? "tool",
|
||||
executed: event.data.executed,
|
||||
providerState: part?.providerState,
|
||||
@@ -947,11 +947,11 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
if (!active(signal)) return
|
||||
if (event.type === "session.tool.input.started") {
|
||||
if (canonicalToolName(event.data.name) === "subagent")
|
||||
pendingCalls.set(sourceKey(event.data.assistantMessageID, event.data.id), {})
|
||||
pendingCalls.set(sourceKey(event.data.assistantMessageID, event.data.callID), {})
|
||||
return
|
||||
}
|
||||
if (event.type === "session.tool.called") {
|
||||
const key = sourceKey(event.data.assistantMessageID, event.data.id)
|
||||
const key = sourceKey(event.data.assistantMessageID, event.data.callID)
|
||||
if (pendingCalls.has(key)) pendingCalls.set(key, event.data.input)
|
||||
return
|
||||
}
|
||||
@@ -961,7 +961,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
event.type !== "session.tool.failed"
|
||||
)
|
||||
return
|
||||
const key = sourceKey(event.data.assistantMessageID, event.data.id)
|
||||
const key = sourceKey(event.data.assistantMessageID, event.data.callID)
|
||||
const pending = pendingCalls.get(key)
|
||||
if (event.type !== "session.tool.progress") pendingCalls.delete(key)
|
||||
const found = childSessionID(record(event.data.metadata))
|
||||
|
||||
@@ -91,12 +91,12 @@ type Wait = {
|
||||
}
|
||||
|
||||
// One active session.shell call. The HTTP response is the completion signal;
|
||||
// id correlates the live shell events once shell.started is observed, and
|
||||
// callID correlates the live shell events once shell.started is observed, and
|
||||
// abort cancels the blocking request when the user interrupts the turn.
|
||||
type ShellWait = {
|
||||
eventID: string
|
||||
messageID: string
|
||||
id?: string
|
||||
callID?: string
|
||||
resolve: () => void
|
||||
abort: () => void
|
||||
}
|
||||
@@ -291,27 +291,27 @@ function streamPartKey(messageID: string, partID: string) {
|
||||
return `${messageID}\u0000${partID}`
|
||||
}
|
||||
|
||||
function permissionSourceKey(messageID: string, id: string) {
|
||||
return streamPartKey(messageID, id)
|
||||
function permissionSourceKey(messageID: string, callID: string) {
|
||||
return streamPartKey(messageID, callID)
|
||||
}
|
||||
|
||||
function permissionTool(request: PermissionRequest, tools: Map<string, SessionMessageAssistantTool>) {
|
||||
if (request.source?.type !== "tool") return request
|
||||
const tool = tools.get(permissionSourceKey(request.source.messageID, request.source.id))
|
||||
const tool = tools.get(permissionSourceKey(request.source.messageID, request.source.callID))
|
||||
return tool ? { ...request, tool } : request
|
||||
}
|
||||
|
||||
// Direct shell calls use one "start" commit rendering `$ command` and one "progress"
|
||||
// commit rendering the merged output (see toolEntryBody in tool.ts).
|
||||
function shellCommit(
|
||||
id: string,
|
||||
callID: string,
|
||||
command: string,
|
||||
next: Pick<StreamCommit, "text" | "phase" | "toolState" | "toolError">,
|
||||
): StreamCommit {
|
||||
return {
|
||||
kind: "tool",
|
||||
source: "tool",
|
||||
partID: `shell:${id}`,
|
||||
partID: `shell:${callID}`,
|
||||
tool: "shell",
|
||||
shell: { command },
|
||||
...next,
|
||||
@@ -319,7 +319,7 @@ function shellCommit(
|
||||
}
|
||||
|
||||
function shellTerminal(
|
||||
id: string,
|
||||
callID: string,
|
||||
command: string,
|
||||
shell: { status: string; exit?: number | string },
|
||||
output: { output: string; cursor: number; size: number; truncated: boolean },
|
||||
@@ -332,10 +332,10 @@ function shellTerminal(
|
||||
: shell.status === "exited"
|
||||
? `Shell exited with code ${shell.exit ?? "unknown"}`
|
||||
: `Shell ${shell.status}`
|
||||
if (!error) return [shellCommit(id, command, { text, phase: "progress", toolState: "completed" })]
|
||||
if (!error) return [shellCommit(callID, command, { text, phase: "progress", toolState: "completed" })]
|
||||
return [
|
||||
...(text ? [shellCommit(id, command, { text, phase: "progress", toolState: "running" })] : []),
|
||||
shellCommit(id, command, { text: error, phase: "final", toolState: "error", toolError: error }),
|
||||
...(text ? [shellCommit(callID, command, { text, phase: "progress", toolState: "running" })] : []),
|
||||
shellCommit(callID, command, { text: error, phase: "final", toolState: "error", toolError: error }),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -570,7 +570,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
const sourcePending = (key: string) =>
|
||||
state.permissions.some(
|
||||
(request) =>
|
||||
request.source?.type === "tool" && permissionSourceKey(request.source.messageID, request.source.id) === key,
|
||||
request.source?.type === "tool" && permissionSourceKey(request.source.messageID, request.source.callID) === key,
|
||||
)
|
||||
|
||||
const pruneToolSources = () => {
|
||||
@@ -647,7 +647,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
}
|
||||
if (message.type === "shell") {
|
||||
state.shellCommands.set(message.shellID, message.command)
|
||||
if (state.shellWait?.messageID === message.id) state.shellWait.id = message.shellID
|
||||
if (state.shellWait?.messageID === message.id) state.shellWait.callID = message.shellID
|
||||
const completed = message.time.completed !== undefined
|
||||
if (!render) {
|
||||
// Suppressed history: mark settled shells rendered so live redelivery
|
||||
@@ -673,7 +673,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
state.shellEnded.add(message.shellID)
|
||||
write(shellTerminal(message.shellID, message.command, message, message.output))
|
||||
}
|
||||
if (completed && state.shellWait?.id === message.shellID) state.shellWait.resolve()
|
||||
if (completed && state.shellWait?.callID === message.shellID) state.shellWait.resolve()
|
||||
return
|
||||
}
|
||||
if (message.type === "compaction") {
|
||||
@@ -776,14 +776,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
) => {
|
||||
const pending = new Set(
|
||||
permissions.flatMap((request) =>
|
||||
request.source?.type === "tool" ? [permissionSourceKey(request.source.messageID, request.source.id)] : [],
|
||||
request.source?.type === "tool" ? [permissionSourceKey(request.source.messageID, request.source.callID)] : [],
|
||||
),
|
||||
)
|
||||
const messageIDs = [
|
||||
...new Set(
|
||||
permissions.flatMap((request) => {
|
||||
if (request.source?.type !== "tool") return []
|
||||
const key = permissionSourceKey(request.source.messageID, request.source.id)
|
||||
const key = permissionSourceKey(request.source.messageID, request.source.callID)
|
||||
return state.toolSources.has(key) ? [] : [request.source.messageID]
|
||||
}),
|
||||
),
|
||||
@@ -992,7 +992,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
if (event.type === "session.shell.started") {
|
||||
state.shellCommands.set(event.data.shell.id, event.data.shell.command)
|
||||
const wait = state.shellWait
|
||||
if (wait?.eventID === event.id) wait.id = event.data.shell.id
|
||||
if (wait?.eventID === event.id) wait.callID = event.data.shell.id
|
||||
if (state.shellStarted.has(event.data.shell.id)) return
|
||||
state.shellStarted.add(event.data.shell.id)
|
||||
write(
|
||||
@@ -1025,7 +1025,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
commits.push(...shellTerminal(event.data.shell.id, command, event.data.shell, event.data.output))
|
||||
}
|
||||
const wait = state.shellWait
|
||||
const owned = wait?.id === event.data.shell.id
|
||||
const owned = wait?.callID === event.data.shell.id
|
||||
write(commits, owned || state.wait || state.shellWait ? undefined : { phase: "idle", status: "" })
|
||||
if (owned) wait.resolve()
|
||||
return
|
||||
@@ -1109,7 +1109,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
if (event.type === "session.tool.input.started") {
|
||||
renderTool(event.data.assistantMessageID, {
|
||||
type: "tool",
|
||||
id: event.data.id,
|
||||
id: event.data.callID,
|
||||
name: event.data.name,
|
||||
state: { status: "streaming", input: "" },
|
||||
time: { created: event.created },
|
||||
@@ -1117,7 +1117,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
return
|
||||
}
|
||||
if (event.type === "session.tool.input.delta" || event.type === "session.tool.input.ended") {
|
||||
const current = state.tools.get(streamPartKey(event.data.assistantMessageID, event.data.id))
|
||||
const current = state.tools.get(streamPartKey(event.data.assistantMessageID, event.data.callID))
|
||||
if (!current || current.part.state.status !== "streaming") return
|
||||
renderTool(event.data.assistantMessageID, {
|
||||
...current.part,
|
||||
@@ -1130,12 +1130,12 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
return
|
||||
}
|
||||
if (event.type === "session.tool.called") {
|
||||
const key = streamPartKey(event.data.assistantMessageID, event.data.id)
|
||||
const key = streamPartKey(event.data.assistantMessageID, event.data.callID)
|
||||
if (state.finishedTools.has(key)) return
|
||||
const current = state.tools.get(key)
|
||||
const item: SessionMessageAssistantTool = {
|
||||
type: "tool",
|
||||
id: event.data.id,
|
||||
id: event.data.callID,
|
||||
name: current?.part.name ?? "tool",
|
||||
executed: event.data.executed,
|
||||
providerState: event.data.state,
|
||||
@@ -1146,13 +1146,13 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
return
|
||||
}
|
||||
if (event.type === "session.tool.progress") {
|
||||
const key = streamPartKey(event.data.assistantMessageID, event.data.id)
|
||||
const key = streamPartKey(event.data.assistantMessageID, event.data.callID)
|
||||
if (state.finishedTools.has(key)) return
|
||||
const current = state.tools.get(key)
|
||||
const part = current?.part
|
||||
renderTool(event.data.assistantMessageID, {
|
||||
type: "tool",
|
||||
id: event.data.id,
|
||||
id: event.data.callID,
|
||||
name: part?.name ?? "tool",
|
||||
executed: part?.executed,
|
||||
providerState: part?.providerState,
|
||||
@@ -1166,12 +1166,12 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
return
|
||||
}
|
||||
if (event.type === "session.tool.success" || event.type === "session.tool.failed") {
|
||||
const current = state.tools.get(streamPartKey(event.data.assistantMessageID, event.data.id))
|
||||
const current = state.tools.get(streamPartKey(event.data.assistantMessageID, event.data.callID))
|
||||
const part = current?.part
|
||||
const failed = event.type === "session.tool.failed"
|
||||
const item: SessionMessageAssistantTool = {
|
||||
type: "tool",
|
||||
id: event.data.id,
|
||||
id: event.data.callID,
|
||||
name: part?.name ?? "tool",
|
||||
executed: event.data.executed,
|
||||
providerState: part?.providerState,
|
||||
|
||||
@@ -244,11 +244,11 @@ type MiniToolState =
|
||||
// Retained only for the noninteractive run JSON/V1 compatibility boundary.
|
||||
// Interactive Mini commits carry SessionMessageAssistantTool directly.
|
||||
export type MiniToolPart = {
|
||||
partID: string
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type?: "tool"
|
||||
id: string
|
||||
callID: string
|
||||
tool: string
|
||||
state: MiniToolState
|
||||
}
|
||||
|
||||
@@ -2227,7 +2227,7 @@ function useToolPermission(part: () => SessionMessageAssistantTool | undefined)
|
||||
return createMemo(() => {
|
||||
if (local.permission.mode === "auto") return false
|
||||
const request = data.session.permission.list(ctx.sessionID)?.[0]
|
||||
return request?.source?.type === "tool" && request.source.id === part()?.id
|
||||
return request?.source?.type === "tool" && request.source.callID === part()?.id
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
if (!tool) return { input: undefined, metadata: undefined }
|
||||
const message = data.session.message.get(props.request.sessionID, tool.messageID)
|
||||
if (message?.type !== "assistant") return { input: undefined, metadata: undefined }
|
||||
const part = message.content.find((part) => part.type === "tool" && part.id === tool.id)
|
||||
const part = message.content.find((part) => part.type === "tool" && part.id === tool.callID)
|
||||
if (part?.type === "tool" && part.state.status !== "streaming") {
|
||||
return { input: part.state.input, metadata: part.state.metadata }
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
function pendingPermissions() {
|
||||
return new Set(
|
||||
(data.session.permission.list(sessionID()) ?? []).flatMap((request) =>
|
||||
request.source?.type === "tool" ? [request.source.id] : [],
|
||||
request.source?.type === "tool" ? [request.source.callID] : [],
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -255,7 +255,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
data.on("session.tool.input.started", (event) => {
|
||||
if (event.data.sessionID === sessionID())
|
||||
appendPart(
|
||||
{ messageID: event.data.assistantMessageID, partID: event.data.id },
|
||||
{ messageID: event.data.assistantMessageID, partID: event.data.callID },
|
||||
{ type: "tool", name: event.data.name },
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -880,7 +880,7 @@ test("completes exploration when a queued prompt is promoted", async () => {
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: "message-assistant",
|
||||
id: "call-read",
|
||||
callID: "call-read",
|
||||
name: "read",
|
||||
},
|
||||
})
|
||||
@@ -951,7 +951,7 @@ test("classifies live tool rows independently of their call ID", async () => {
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: "message-assistant",
|
||||
id: "reasoning:0",
|
||||
callID: "reasoning:0",
|
||||
name: "bash",
|
||||
},
|
||||
})
|
||||
@@ -2485,7 +2485,7 @@ test("settles pending tools when a live failure arrives", async () => {
|
||||
data: {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_explicit_assistant_9",
|
||||
id: "call-1",
|
||||
callID: "call-1",
|
||||
name: "bash",
|
||||
},
|
||||
})
|
||||
@@ -2497,7 +2497,7 @@ test("settles pending tools when a live failure arrives", async () => {
|
||||
data: {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_explicit_assistant_9",
|
||||
id: "call-1",
|
||||
callID: "call-1",
|
||||
input: {},
|
||||
executed: false,
|
||||
state: { call: true },
|
||||
@@ -2510,7 +2510,7 @@ test("settles pending tools when a live failure arrives", async () => {
|
||||
data: {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_explicit_assistant_9",
|
||||
id: "call-1",
|
||||
callID: "call-1",
|
||||
metadata: { sessionID: "session-child", status: "running" },
|
||||
},
|
||||
})
|
||||
@@ -2533,7 +2533,7 @@ test("settles pending tools when a live failure arrives", async () => {
|
||||
data: {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_explicit_assistant_9",
|
||||
id: "call-1",
|
||||
callID: "call-1",
|
||||
error: { type: "unknown", message: "aborted" },
|
||||
executed: false,
|
||||
resultState: { result: true },
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user