mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-01 21:16:24 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
497a5ba022 | ||
|
|
3e1452f975 | ||
|
|
bebc3640f4 | ||
|
|
aa6a4f93bd | ||
|
|
8433dd732f | ||
|
|
c34d09fe97 | ||
|
|
2fb7985bb7 | ||
|
|
a931e8a9fd |
No files matched your search
@@ -37,7 +37,7 @@ jobs:
|
||||
echo "=== Flake structure ==="
|
||||
nix flake show --all-systems
|
||||
|
||||
SYSTEMS="x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin"
|
||||
SYSTEMS="x86_64-linux aarch64-linux aarch64-darwin"
|
||||
PACKAGES="opencode"
|
||||
# TODO: move 'desktop' to PACKAGES when #11755 is fixed
|
||||
OPTIONAL_PACKAGES="desktop"
|
||||
|
||||
@@ -34,8 +34,6 @@ jobs:
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
- system: aarch64-linux
|
||||
runner: blacksmith-4vcpu-ubuntu-2404-arm
|
||||
- system: x86_64-darwin
|
||||
runner: macos-15-intel
|
||||
- system: aarch64-darwin
|
||||
runner: macos-latest
|
||||
runs-on: ${{ matrix.runner }}
|
||||
@@ -126,7 +124,7 @@ jobs:
|
||||
|
||||
[ -f "$HASH_FILE" ] || echo '{"nodeModules":{}}' > "$HASH_FILE"
|
||||
|
||||
for SYSTEM in x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin; do
|
||||
for SYSTEM in x86_64-linux aarch64-linux aarch64-darwin; do
|
||||
FILE="hashes/hash-${SYSTEM}/hash.txt"
|
||||
if [ -f "$FILE" ]; then
|
||||
HASH="$(tr -d '[:space:]' < "$FILE")"
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
"aarch64-linux"
|
||||
"x86_64-linux"
|
||||
"aarch64-darwin"
|
||||
"x86_64-darwin"
|
||||
];
|
||||
forEachSystem = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
|
||||
rev = self.shortRev or self.dirtyShortRev or "dirty";
|
||||
@@ -22,7 +21,7 @@
|
||||
default = pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
bun
|
||||
nodejs_20
|
||||
nodejs
|
||||
pkg-config
|
||||
openssl
|
||||
git
|
||||
|
||||
@@ -7,7 +7,6 @@ in
|
||||
aarch64-linux = "20e68d6c4e47f3ebf59de7c6b1f8b8bec6a6ebda6a451132f9b465f3f13ce467";
|
||||
x86_64-linux = "2452b27112d92387471fa2488aafac85d79ea3f2ee1216c0abd5150d6c12362b";
|
||||
aarch64-darwin = "ac7194a3dfd81930ba35355c01620262c1254752859b42dcb8f4b9e4d174a871";
|
||||
x86_64-darwin = "4489aba55477a0082266cb690db1c829503ba3338048599d8fd243953df37dab";
|
||||
# fetchzip hashes the unpacked headers, not the release tarball.
|
||||
headers = "sha256-4eUy3BZVvxTl7KUOsxio7769lL6ag/ecbeK+qLURWMI=";
|
||||
}
|
||||
+3
-4
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-7DgxTpKv6ITKTom0mhlJNPQfdEjtEHK5P8uyCr26HZw=",
|
||||
"aarch64-linux": "sha256-PJxW1Ibfx6oS1neWPSHqzP1Pm1HT9my1TrrHmOb6/no=",
|
||||
"aarch64-darwin": "sha256-VIme5VHfM8JxNiDSOykkr5FytghDLI0FxkhiOXUSyQw=",
|
||||
"x86_64-darwin": "sha256-rQ/j0QkR1vxAq4jgUbr0nY4RDyiLTJqN8q1AfoiEqVQ="
|
||||
"x86_64-linux": "sha256-yCdtDQsXERjfL9bJg7YXNloMBOPwFkPMFVNAzXlarBM=",
|
||||
"aarch64-linux": "sha256-iQ1bLIszETcFoD4CENpoaDZJZF/KBKZFeTp9r7ogJ9Y=",
|
||||
"aarch64-darwin": "sha256-G7oIrTXEFQ5iEF8KSpA4xJPw9xwqRDpbIR472iIoqC4="
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,5 @@ stdenvNoCC.mkDerivation {
|
||||
"aarch64-linux"
|
||||
"x86_64-linux"
|
||||
"aarch64-darwin"
|
||||
"x86_64-darwin"
|
||||
];
|
||||
}
|
||||
@@ -40,7 +40,8 @@ const patterns = [
|
||||
/context[_ ]length[_ ]exceeded/i,
|
||||
/context length is only \d+ tokens/i,
|
||||
/input length.*exceeds.*context length/i,
|
||||
/prompt too long; exceeded (?:max )?context length/i,
|
||||
// Z.ai code 1261 arrives as `Prompt too long` or `Prompt 超长`.
|
||||
/prompt (?:too long|超长)/i,
|
||||
/too large for model with \d+ maximum context length/i,
|
||||
/prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i,
|
||||
/model_context_window_exceeded/i,
|
||||
@@ -156,13 +157,15 @@ const GATEWAY_CODE_LABEL = /^[^:\n]+: \[([A-Za-z0-9_.-]+)\]/
|
||||
const AUTH_TEXT = /incorrect api key provided/i
|
||||
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
|
||||
// Only consulted on 429, where throttles and account caps share a status.
|
||||
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded|budget exceeded|usage limit/i
|
||||
// Z.ai reports balance, plan expiry, plan limits, and plan model access on 429.
|
||||
const QUOTA_TEXT =
|
||||
/insufficient[-_\s]?(?:quota|balance)|quota[-_\s]?exceeded|budget exceeded|usage limit|limit exhausted|package has expired|plan does not yet include/i
|
||||
// Policy rejections without a dedicated code, matched against the provider's own
|
||||
// explanation only. OpenAI reuses `invalid_prompt` for usage-policy rejections while
|
||||
// Bedrock Mantle reuses it for schema validation; Anthropic reports blocked output
|
||||
// under `invalid_request_error`.
|
||||
const CONTENT_POLICY_TEXT =
|
||||
/violating our usage policy|blocked by content filtering policy|content[-_\s]?policy|rejected as a result of our safety system/i
|
||||
/violating our usage policy|blocked by content filtering policy|content[-_\s]?policy|rejected as a result of our safety system|detected potentially unsafe or sensitive content/i
|
||||
const SERVER_ERROR_TEXT =
|
||||
/\b(?:try again|(?:please |you can )?retry (?:the |this |your )?request|try (?:the |this |your )?request again|(?:currently |temporarily )?at capacity|overloaded|temporarily unavailable|service[-_\s]?unavailable|(?:server|internal)[-_\s]?error|server (?:is )?busy|provider returned (?:an )?error|resource[-_\s]?exhausted|upstream (?:connect|connection|request)|request buffer limit while retrying upstream)\b/i
|
||||
|
||||
|
||||
@@ -6,10 +6,17 @@ const MISSING_TOOL_RESULT = "Tool result missing"
|
||||
export function normalizeToolHistory(messages: ReadonlyArray<Message>) {
|
||||
const normalized: Message[] = []
|
||||
const pending = new Map<string, ToolCallPart>()
|
||||
// System updates cannot sit between a tool call and its results, so they wait until every pending call is answered.
|
||||
const held: Message[] = []
|
||||
const releaseHeld = () => {
|
||||
if (pending.size > 0) return
|
||||
normalized.push(...held)
|
||||
held.length = 0
|
||||
}
|
||||
const appendMissingResults = () => {
|
||||
if (pending.size === 0) return
|
||||
normalized.push(missingToolResults(pending.values()))
|
||||
if (pending.size > 0) normalized.push(missingToolResults(pending.values()))
|
||||
pending.clear()
|
||||
releaseHeld()
|
||||
}
|
||||
|
||||
for (const message of messages) {
|
||||
@@ -18,6 +25,12 @@ export function normalizeToolHistory(messages: ReadonlyArray<Message>) {
|
||||
if (message.role === "tool") {
|
||||
const tool = normalizeToolMessage(message, pending)
|
||||
if (tool) normalized.push(tool)
|
||||
releaseHeld()
|
||||
continue
|
||||
}
|
||||
|
||||
if (message.role === "system" && pending.size > 0) {
|
||||
held.push(message)
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ describe("Anthropic Messages effort updates", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("accepts a marker between a tool call and its result", () =>
|
||||
it.effect("moves a marker between a tool call and its result after the result", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
@@ -166,8 +166,8 @@ describe("Anthropic Messages effort updates", () => {
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "user", content: [{ type: "text", text: "Weather?" }] },
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }] },
|
||||
{ role: "system", content: [], output_config: { effort: "low" } },
|
||||
{ role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '{"temp":72}' }] },
|
||||
{ role: "system", content: [], output_config: { effort: "low" } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -284,6 +284,82 @@ describe("provider error classification", () => {
|
||||
).toEqual(Array(6).fill("QuotaExceeded"))
|
||||
})
|
||||
|
||||
test("classifies Z.ai plan and balance limits as quota rather than throttling", () => {
|
||||
const zai = (code: string, message: string) => ({ error: { code, message } })
|
||||
const cases = [
|
||||
zai("1113", "Insufficient balance or no resource package. Please recharge."),
|
||||
zai("1308", "Usage limit reached for 5 hours. Your limit will reset at 2026-10-01 00:00:00"),
|
||||
zai(
|
||||
"1309",
|
||||
"Your GLM Coding Plan package has expired and is temporarily unavailable. You can resume using it after renewing the subscription on the official website.",
|
||||
),
|
||||
zai("1310", "Weekly/Monthly Limit Exhausted. Your limit will reset at 2026-10-01 00:00:00"),
|
||||
zai("1311", "Your current subscription plan does not yet include access to glm-5"),
|
||||
zai("1314", "Your enterprise package has expired. Please contact your enterprise administrator."),
|
||||
// Z.ai's Anthropic-compatible endpoint wraps the code and request ID into the message.
|
||||
{
|
||||
type: "error",
|
||||
error: {
|
||||
type: "rate_limit_error",
|
||||
code: "1309",
|
||||
message:
|
||||
"[1309][Your GLM Coding Plan package has expired and is temporarily unavailable. You can resume using it after renewing the subscription on the official website.][20260929132151e73af01340d54b58]",
|
||||
},
|
||||
},
|
||||
]
|
||||
expect(
|
||||
cases.map(
|
||||
(body) =>
|
||||
classifyProviderFailure({ message: body.error.message, status: 429, rawBody: JSON.stringify(body) })._tag,
|
||||
),
|
||||
).toEqual(Array(cases.length).fill("QuotaExceeded"))
|
||||
})
|
||||
|
||||
test("classifies Z.ai prompt length rejections as context overflow", () => {
|
||||
const cases = [
|
||||
{ error: { code: "1261", message: "Prompt 超长" } },
|
||||
{ error: { code: "1261", message: "Prompt too long" } },
|
||||
{
|
||||
type: "error",
|
||||
error: { type: "invalid_request_error", code: "1261", message: "[1261][Prompt too long][2026092913]" },
|
||||
},
|
||||
]
|
||||
expect(
|
||||
cases.map((body) => {
|
||||
const reason = classifyProviderFailure({
|
||||
message: body.error.message,
|
||||
status: 400,
|
||||
rawBody: JSON.stringify(body),
|
||||
})
|
||||
return reason._tag === "InvalidRequest" ? reason.classification : reason._tag
|
||||
}),
|
||||
).toEqual(["context-overflow", "context-overflow", "context-overflow"])
|
||||
})
|
||||
|
||||
test("classifies Z.ai sensitive content rejections as content policy", () => {
|
||||
const message =
|
||||
"System detected potentially unsafe or sensitive content in input or generation. Please avoid using prompts that may generate sensitive content. Thank you for your cooperation."
|
||||
expect(
|
||||
classifyProviderFailure({
|
||||
message,
|
||||
status: 400,
|
||||
rawBody: JSON.stringify({ error: { code: "1301", message } }),
|
||||
})._tag,
|
||||
).toBe("ContentPolicy")
|
||||
})
|
||||
|
||||
test("keeps Z.ai throttling and overload retryable", () => {
|
||||
expect(
|
||||
[
|
||||
{ error: { code: "1302", message: "Rate limit reached for requests" } },
|
||||
{ error: { code: "1305", message: "The service may be temporarily overloaded, please try again later" } },
|
||||
].map(
|
||||
(body) =>
|
||||
classifyProviderFailure({ message: body.error.message, status: 429, rawBody: JSON.stringify(body) })._tag,
|
||||
),
|
||||
).toEqual(["RateLimit", "RateLimit"])
|
||||
})
|
||||
|
||||
test("does not let substituted server codes make a 4xx retryable", () => {
|
||||
const openai = { error: { type: "server_error", message: "Upstream request failed: Model is unavailable." } }
|
||||
const anthropic = {
|
||||
|
||||
@@ -588,9 +588,9 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects a system update between a local tool call and its result", () =>
|
||||
it.effect("moves a system update between a local tool call and its result after the result", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: opus48,
|
||||
messages: [
|
||||
@@ -601,9 +601,13 @@ describe("Anthropic Messages route", () => {
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
)
|
||||
|
||||
expect(error.message).toContain("system updates cannot split a local tool call from its tool result")
|
||||
expect(prepared.body.messages.slice(1)).toEqual([
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }] },
|
||||
{ role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '"Done."' }] },
|
||||
{ role: "system", content: [{ type: "text", text: "Too early.", cache_control: undefined }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -112,4 +112,51 @@ describe("tool history normalization", () => {
|
||||
|
||||
expect(normalized[1]?.content[0]).toMatchObject({ name: "lookup", namespace: undefined })
|
||||
})
|
||||
|
||||
test("moves system updates after the results of pending calls", () => {
|
||||
const calls = Message.assistant([toolCall("first"), toolCall("second")])
|
||||
const first = toolResult("first", "one", "first", "text")
|
||||
const second = toolResult("second", "two", "second", "text")
|
||||
const update = Message.system("First update.")
|
||||
const later = Message.system("Second update.")
|
||||
const user = Message.user("Continue.")
|
||||
const missing = (id: string) =>
|
||||
Message.tool(ToolResultPart.make({ id, name: id, result: "Tool result missing", resultType: "error" }))
|
||||
|
||||
expect(normalizeToolHistory([calls, first, update, later, second, user])).toEqual([
|
||||
calls,
|
||||
first,
|
||||
second,
|
||||
update,
|
||||
later,
|
||||
user,
|
||||
])
|
||||
expect(normalizeToolHistory([Message.assistant(toolCall("first")), update, user])).toEqual([
|
||||
Message.assistant(toolCall("first")),
|
||||
missing("first"),
|
||||
update,
|
||||
user,
|
||||
])
|
||||
expect(normalizeToolHistory([Message.assistant(toolCall("first")), update])).toEqual([
|
||||
Message.assistant(toolCall("first")),
|
||||
missing("first"),
|
||||
update,
|
||||
])
|
||||
})
|
||||
|
||||
test("moves effort updates after the results of pending calls", () => {
|
||||
const call = Message.assistant(toolCall("first"))
|
||||
const result = toolResult("first", "one", "first", "text")
|
||||
const effort = Message.effort({ effort: "low", previous: "high" })
|
||||
|
||||
expect(normalizeToolHistory([call, effort, result])).toEqual([call, result, effort])
|
||||
})
|
||||
|
||||
test("keeps system updates in place when no call is pending", () => {
|
||||
const history = [Message.assistant(toolCall("first")), toolResult("first", "one", "first", "text")]
|
||||
const update = Message.system("Update.")
|
||||
const input = [Message.user("Start."), update, ...history, update, Message.user("Continue.")]
|
||||
|
||||
expect(normalizeToolHistory(input)).toBe(input)
|
||||
})
|
||||
})
|
||||
+101
-40
@@ -1,52 +1,113 @@
|
||||
import { agent, RequestError, type Stream } from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeClient } from "@opencode/client/promise"
|
||||
import {
|
||||
agent,
|
||||
RequestError,
|
||||
type AgentHandlerContext,
|
||||
type AgentNotificationHandlersByMethod,
|
||||
type AgentNotificationMethod,
|
||||
type AgentRequestHandlersByMethod,
|
||||
type AgentRequestMethod,
|
||||
type Stream,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { ClientError, type OpenCodeClient } from "@opencode/client/promise"
|
||||
import { Cause, Effect } from "effect"
|
||||
import { ACPConnection } from "./connection"
|
||||
import { ACPError } from "./error"
|
||||
import { ACPService } from "./service"
|
||||
|
||||
export function connect(client: OpenCodeClient, stream: Stream) {
|
||||
const connection = agent({ name: "opencode" })
|
||||
.onRequest("initialize", (ctx) => run(service.initialize(ctx.params)))
|
||||
.onRequest("authenticate", (ctx) => run(service.authenticate(ctx.params)))
|
||||
.onRequest("session/new", (ctx) => run(service.newSession(ctx.params)))
|
||||
.onRequest("session/load", (ctx) => run(service.loadSession(ctx.params)))
|
||||
.onRequest("session/list", (ctx) => run(service.listSessions(ctx.params)))
|
||||
.onRequest("session/delete", (ctx) => run(service.deleteSession(ctx.params)))
|
||||
.onRequest("session/resume", (ctx) => run(service.resumeSession(ctx.params)))
|
||||
.onRequest("session/close", (ctx) => run(service.closeSession(ctx.params)))
|
||||
.onRequest("session/fork", (ctx) => run(service.forkSession(ctx.params)))
|
||||
.onRequest("session/set_config_option", (ctx) => run(service.setSessionConfigOption(ctx.params)))
|
||||
.onRequest("session/set_mode", (ctx) => run(service.setSessionMode(ctx.params)))
|
||||
.onRequest("session/prompt", (ctx) => run(service.prompt(ctx.params, ctx.signal)))
|
||||
.onNotification("session/cancel", (ctx) => run(service.cancel(ctx.params)))
|
||||
.connect(stream)
|
||||
// Untraced so request spans parent to the caller's span instead of a setup span that has already ended.
|
||||
export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stream: Stream) {
|
||||
const run = Effect.runPromiseWith(yield* Effect.context<never>())
|
||||
const handle =
|
||||
<Params, A>(call: (ctx: AgentHandlerContext<Params>) => Effect.Effect<A, ACPError.Error | RequestError>) =>
|
||||
(name: string) => {
|
||||
const handler = Effect.fn(name)(
|
||||
call,
|
||||
Effect.mapError((error) => (error instanceof RequestError ? error : ACPError.toRequestError(error))),
|
||||
Effect.tapCauseIf(Cause.hasDies, (cause) => Effect.logError("ACP request failed", cause)),
|
||||
Effect.catchDefect((defect) => Effect.fail(ACPError.toRequestError(ACPError.fromUnknown(defect)))),
|
||||
)
|
||||
return (ctx: AgentHandlerContext<Params>) => run(handler(ctx))
|
||||
}
|
||||
const app = agent({ name: "opencode" })
|
||||
const request = <Method extends AgentRequestMethod>(
|
||||
method: Method,
|
||||
make: (name: string) => AgentRequestHandlersByMethod[Method],
|
||||
) => app.onRequest(method, make(spanName(method)))
|
||||
const notification = <Method extends AgentNotificationMethod>(
|
||||
method: Method,
|
||||
make: (name: string) => AgentNotificationHandlersByMethod[Method],
|
||||
) => app.onNotification(method, make(spanName(method)))
|
||||
|
||||
request(
|
||||
"initialize",
|
||||
handle((ctx) => promise(() => service.initialize(ctx.params))),
|
||||
)
|
||||
request(
|
||||
"authenticate",
|
||||
handle((ctx) => promise(() => service.authenticate(ctx.params))),
|
||||
)
|
||||
request(
|
||||
"session/new",
|
||||
handle((ctx) => promise(() => service.newSession(ctx.params))),
|
||||
)
|
||||
request(
|
||||
"session/load",
|
||||
handle((ctx) => promise(() => service.loadSession(ctx.params))),
|
||||
)
|
||||
request(
|
||||
"session/list",
|
||||
handle((ctx) => promise(() => service.listSessions(ctx.params))),
|
||||
)
|
||||
request(
|
||||
"session/delete",
|
||||
handle((ctx) => promise(() => service.deleteSession(ctx.params))),
|
||||
)
|
||||
request(
|
||||
"session/resume",
|
||||
handle((ctx) => promise(() => service.resumeSession(ctx.params))),
|
||||
)
|
||||
request(
|
||||
"session/close",
|
||||
handle((ctx) => promise(() => service.closeSession(ctx.params))),
|
||||
)
|
||||
request(
|
||||
"session/fork",
|
||||
handle((ctx) => promise(() => service.forkSession(ctx.params))),
|
||||
)
|
||||
request(
|
||||
"session/set_config_option",
|
||||
handle((ctx) => promise(() => service.setSessionConfigOption(ctx.params))),
|
||||
)
|
||||
request(
|
||||
"session/set_mode",
|
||||
handle((ctx) => promise(() => service.setSessionMode(ctx.params))),
|
||||
)
|
||||
// The SDK signal is passed through rather than interrupting the fiber: a cancelled turn still resolves with
|
||||
// `stopReason: "cancelled"`.
|
||||
request(
|
||||
"session/prompt",
|
||||
handle((ctx) => promise(() => service.prompt(ctx.params, ctx.signal))),
|
||||
)
|
||||
notification(
|
||||
"session/cancel",
|
||||
handle((ctx) => promise(() => service.cancel(ctx.params))),
|
||||
)
|
||||
const connection = app.connect(stream)
|
||||
// Inbound dispatch starts after the stream's async read loop yields, so handlers never observe this before assignment.
|
||||
const service = ACPService.make({ client, connection: ACPConnection.make(connection) })
|
||||
return connection
|
||||
}
|
||||
})
|
||||
|
||||
async function run<A>(promise: Promise<A>) {
|
||||
try {
|
||||
return await promise
|
||||
} catch (error) {
|
||||
if (error instanceof RequestError) throw error
|
||||
if (isACPError(error)) throw ACPError.toRequestError(error)
|
||||
throw ACPError.toRequestError(ACPError.fromUnknown(error))
|
||||
}
|
||||
}
|
||||
const spanName = (method: string) => `cli.acp.${method.replaceAll("/", ".")}`
|
||||
|
||||
function isACPError(error: unknown): error is ACPError.Error {
|
||||
return (
|
||||
error instanceof ACPError.SessionNotFoundError ||
|
||||
error instanceof ACPError.SessionDirectoryMismatchError ||
|
||||
error instanceof ACPError.InvalidConfigOptionError ||
|
||||
error instanceof ACPError.InvalidModelError ||
|
||||
error instanceof ACPError.InvalidEffortError ||
|
||||
error instanceof ACPError.InvalidModeError ||
|
||||
error instanceof ACPError.AuthRequiredError ||
|
||||
error instanceof ACPError.UnknownAuthMethodError ||
|
||||
error instanceof ACPError.ServiceFailureError
|
||||
const promise = <A>(evaluate: () => Promise<A>) =>
|
||||
Effect.tryPromise({ try: evaluate, catch: (cause) => cause }).pipe(
|
||||
Effect.catch((cause) => {
|
||||
if (cause instanceof RequestError || ACPError.is(cause)) return Effect.fail(cause)
|
||||
if (cause instanceof ClientError && cause.reason === "Transport")
|
||||
return Effect.fail(new ACPError.ServerUnavailableError())
|
||||
return Effect.die(cause)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export * as ACP from "./agent"
|
||||
@@ -40,16 +40,27 @@ export class ServiceFailureError extends Schema.TaggedError<ServiceFailureError>
|
||||
errorName: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
|
||||
export type Error =
|
||||
| SessionNotFoundError
|
||||
| SessionDirectoryMismatchError
|
||||
| InvalidConfigOptionError
|
||||
| InvalidModelError
|
||||
| InvalidEffortError
|
||||
| InvalidModeError
|
||||
| AuthRequiredError
|
||||
| UnknownAuthMethodError
|
||||
| ServiceFailureError
|
||||
export class ServerUnavailableError extends Schema.TaggedError<ServerUnavailableError>()(
|
||||
"ACPServerUnavailableError",
|
||||
{},
|
||||
) {}
|
||||
|
||||
const Errors = Schema.Union([
|
||||
SessionNotFoundError,
|
||||
SessionDirectoryMismatchError,
|
||||
InvalidConfigOptionError,
|
||||
InvalidModelError,
|
||||
InvalidEffortError,
|
||||
InvalidModeError,
|
||||
AuthRequiredError,
|
||||
UnknownAuthMethodError,
|
||||
ServiceFailureError,
|
||||
ServerUnavailableError,
|
||||
])
|
||||
|
||||
export type Error = typeof Errors.Type
|
||||
|
||||
export const is = Schema.is(Errors)
|
||||
|
||||
export function toRequestError(error: Error): RequestError {
|
||||
switch (error._tag) {
|
||||
@@ -83,6 +94,8 @@ export function toRequestError(error: Error): RequestError {
|
||||
},
|
||||
error.safeMessage,
|
||||
)
|
||||
case "ACPServerUnavailableError":
|
||||
return RequestError.internalError({ errorName: "ServerUnavailable" }, "OpenCode server is unavailable")
|
||||
}
|
||||
const exhaustive: never = error
|
||||
return exhaustive
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ndJsonStream } from "@agentclientprotocol/sdk"
|
||||
import { OpenCode } from "@opencode/client/promise"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { CrossSpawnSpawner } from "@opencode/util/cross-spawn-spawner"
|
||||
import { Effect } from "effect"
|
||||
import { Writable } from "node:stream"
|
||||
import { ACP } from "../../acp/agent"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
@@ -13,23 +15,26 @@ export default Runtime.handler(
|
||||
process.env.OPENCODE_CLIENT = "acp"
|
||||
const endpoint = yield* Standalone.start()
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const input = new WritableStream<Uint8Array>({
|
||||
write: (chunk) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
process.stdout.write(chunk, (error) => (error ? reject(error) : resolve()))
|
||||
const connection = yield* ACP.connect(client, ndJsonStream(Writable.toWeb(process.stdout), Bun.stdin.stream()))
|
||||
const code = yield* Effect.raceFirst(
|
||||
Effect.promise(() => connection.closed).pipe(Effect.as(0)),
|
||||
endpoint.exited.pipe(
|
||||
Effect.match({
|
||||
onSuccess: (code) => `code ${code}`,
|
||||
onFailure: (error) =>
|
||||
error.cause instanceof CrossSpawnSpawner.KilledBySignal ? `signal ${error.cause.signal}` : error.message,
|
||||
}),
|
||||
})
|
||||
const output = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
process.stdin.on("data", (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk)))
|
||||
process.stdin.on("end", () => controller.close())
|
||||
process.stdin.on("error", (error) => controller.error(error))
|
||||
},
|
||||
})
|
||||
const connection = ACP.connect(client, ndJsonStream(input, output))
|
||||
process.stdin.resume()
|
||||
yield* Effect.promise(() => connection.closed)
|
||||
// EOF owns this stdio process; exiting also closes the private server's lease pipe.
|
||||
yield* Effect.sync(() => process.exit(0))
|
||||
// stdout carries ACP, so the diagnostic goes to stderr.
|
||||
Effect.flatMap((reason) =>
|
||||
Effect.sync(() => {
|
||||
process.stderr.write(`opencode acp: server exited unexpectedly (${reason})\n`)
|
||||
return 1
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
// Closing the handler scope would wait for the private server's graceful shutdown; its lease pipe already
|
||||
// ends the server once this process exits.
|
||||
yield* Effect.sync(() => process.exit(code))
|
||||
}),
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Service, type Endpoint } from "@opencode/client/effect/service"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { CrossSpawnSpawner } from "@opencode/util/cross-spawn-spawner"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Deferred, Effect, Schema, Stream } from "effect"
|
||||
@@ -53,7 +53,8 @@ const makeEndpoint = Effect.fn("cli.standalone.endpoint")(
|
||||
url: ready.url,
|
||||
auth: { type: "basic" as const, username: "opencode", password },
|
||||
pid: proc.pid,
|
||||
} satisfies Endpoint & { readonly pid: number }
|
||||
exited: proc.exitCode,
|
||||
}
|
||||
},
|
||||
Effect.provide(LayerNode.compile(CrossSpawnSpawner.node)),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { RequestError } from "@agentclientprotocol/sdk"
|
||||
import { Cause } from "effect"
|
||||
import { ACPError } from "../../src/acp/error"
|
||||
import { rpcError, startSession, startWire } from "./wire-fixture"
|
||||
|
||||
describe("acp errors", () => {
|
||||
test("maps validation failures to invalid params", () => {
|
||||
@@ -58,3 +60,34 @@ describe("acp errors", () => {
|
||||
expect(serialized).not.toContain("stack")
|
||||
})
|
||||
})
|
||||
|
||||
describe("acp error boundary over the wire", () => {
|
||||
test("maps unexpected server failures to the generic internal error", async () => {
|
||||
await using acp = await startWire({
|
||||
fetch: (request) =>
|
||||
request.method === "POST" && request.path === "/api/session" ? new Response(null, { status: 500 }) : undefined,
|
||||
})
|
||||
await acp.initialize()
|
||||
|
||||
expect(await rpcError(acp.newSession())).toEqual({
|
||||
code: -32603,
|
||||
message: "Internal error: Internal service failure",
|
||||
data: { errorName: "ClientError" },
|
||||
})
|
||||
expect(acp.logs.map((log) => ({ message: log.message, cause: Cause.squash(log.cause) }))).toMatchObject([
|
||||
{ message: ["ACP request failed"], cause: { name: "ClientError", reason: "UnexpectedStatus" } },
|
||||
])
|
||||
})
|
||||
|
||||
test("reports an unavailable server once the server stops", async () => {
|
||||
await using acp = await startSession()
|
||||
await acp.server.stop()
|
||||
|
||||
expect(await rpcError(acp.request("session/list", {}))).toEqual({
|
||||
code: -32603,
|
||||
message: "Internal error: OpenCode server is unavailable",
|
||||
data: { errorName: "ServerUnavailable" },
|
||||
})
|
||||
expect(acp.logs).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -101,8 +101,8 @@ describe("acp lifecycle subprocess", () => {
|
||||
}, 60_000)
|
||||
|
||||
// The private server is found with `pgrep`, which Windows lacks.
|
||||
const todoOutsideWindows = process.platform === "win32" ? test.skip : test.todo
|
||||
todoOutsideWindows(
|
||||
const testOutsideWindows = process.platform === "win32" ? test.skip : test
|
||||
testOutsideWindows(
|
||||
"exits when the private server process dies (https://github.com/anomalyco/opencode/issues/51716)",
|
||||
async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
@@ -121,7 +121,9 @@ describe("acp lifecycle subprocess", () => {
|
||||
const timeout = Promise.withResolvers<"running">()
|
||||
const timer = setTimeout(() => timeout.resolve("running"), 10_000)
|
||||
const exited = await Promise.race([acp.exited, timeout.promise]).finally(() => clearTimeout(timer))
|
||||
expect(exited).not.toBe("running")
|
||||
expect(exited).toBe(1)
|
||||
await acp[Symbol.asyncDispose]()
|
||||
expect(acp.stderr()).toContain("opencode acp: server exited unexpectedly (signal SIGKILL)")
|
||||
},
|
||||
60_000,
|
||||
)
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
type TokenUsageInfo,
|
||||
} from "@opencode/client/promise"
|
||||
import type { BunRequest } from "bun"
|
||||
import { Option, Schema } from "effect"
|
||||
import { Effect, Logger, Option, Schema } from "effect"
|
||||
import { ACP } from "../../src/acp/agent"
|
||||
|
||||
type DurableEvent = Extract<OpenCodeEvent, { durable: unknown }>
|
||||
@@ -377,9 +377,12 @@ export async function startWire(options: WireOptions = {}) {
|
||||
|
||||
const clientToAgent = new TransformStream<Uint8Array, Uint8Array>()
|
||||
const agentToClient = new TransformStream<Uint8Array, Uint8Array>()
|
||||
const agentConnection = ACP.connect(
|
||||
OpenCode.make({ baseUrl: server.url }),
|
||||
ndJsonStream(agentToClient.writable, clientToAgent.readable),
|
||||
const logs: Array<Pick<Logger.Options<unknown>, "message" | "cause">> = []
|
||||
const agentConnection = await Effect.runPromise(
|
||||
ACP.connect(
|
||||
OpenCode.make({ baseUrl: server.url }),
|
||||
ndJsonStream(agentToClient.writable, clientToAgent.readable),
|
||||
).pipe(Effect.provide(Logger.layer([Logger.make((log) => logs.push({ message: log.message, cause: log.cause }))]))),
|
||||
)
|
||||
const clientStream = ndJsonStream(clientToAgent.writable, agentToClient.readable)
|
||||
const connection = client({ name: "test" })
|
||||
@@ -456,6 +459,7 @@ export async function startWire(options: WireOptions = {}) {
|
||||
|
||||
return {
|
||||
server,
|
||||
logs,
|
||||
received,
|
||||
updates,
|
||||
permissions,
|
||||
|
||||
@@ -76,6 +76,10 @@ export type AgentListInput = { readonly location?: { readonly directory?: string
|
||||
export type AgentListOutput = { readonly location: Location.PublicRef; readonly data: ReadonlyArray<Agent.Info> }
|
||||
export type AgentListOperation<E = never> = (input?: AgentListInput) => Effect.Effect<AgentListOutput, E>
|
||||
|
||||
export type AgentDefaultInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
export type AgentDefaultOutput = { readonly location: Location.PublicRef; readonly data: Agent.Info | undefined }
|
||||
export type AgentDefaultOperation<E = never> = (input?: AgentDefaultInput) => Effect.Effect<AgentDefaultOutput, E>
|
||||
|
||||
export type AgentGetInput = {
|
||||
readonly agentID: Agent.ID
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
@@ -85,6 +89,7 @@ export type AgentGetOperation<E = never> = (input: AgentGetInput) => Effect.Effe
|
||||
|
||||
export interface AgentApi<E = never> {
|
||||
readonly list: AgentListOperation<E>
|
||||
readonly default: AgentDefaultOperation<E>
|
||||
readonly get: AgentGetOperation<E>
|
||||
}
|
||||
|
||||
@@ -412,11 +417,7 @@ export type SessionInstructionsEntryRemoveOperation<E = never> = (
|
||||
input: SessionInstructionsEntryRemoveInput,
|
||||
) => Effect.Effect<SessionInstructionsEntryRemoveOutput, E>
|
||||
|
||||
export type SessionGenerateInput = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly prompt: string
|
||||
readonly model?: Model.Ref | undefined
|
||||
}
|
||||
export type SessionGenerateInput = { readonly sessionID: Session.ID; readonly prompt: string }
|
||||
export type SessionGenerateOutput = { readonly text: string }
|
||||
export type SessionGenerateOperation<E = never> = (
|
||||
input: SessionGenerateInput,
|
||||
|
||||
@@ -14,6 +14,8 @@ import type {
|
||||
LocationReloadOutput,
|
||||
AgentListInput,
|
||||
AgentListOutput,
|
||||
AgentDefaultInput,
|
||||
AgentDefaultOutput,
|
||||
AgentGetInput,
|
||||
AgentGetOutput,
|
||||
PluginListInput,
|
||||
@@ -319,6 +321,11 @@ const EndpointAgentList = (raw: RawClient["server.agent"]) => (input?: AgentList
|
||||
raw["agent.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointAgentDefault = (raw: RawClient["server.agent"]) => (input?: AgentDefaultInput) =>
|
||||
preserveEffect<AgentDefaultOutput>()(
|
||||
raw["agent.default"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointAgentGet = (raw: RawClient["server.agent"]) => (input: AgentGetInput) =>
|
||||
preserveEffect<AgentGetOutput>()(
|
||||
raw["agent.get"]({ params: { agentID: input["agentID"] }, query: { location: input["location"] } }).pipe(
|
||||
@@ -328,6 +335,7 @@ const EndpointAgentGet = (raw: RawClient["server.agent"]) => (input: AgentGetInp
|
||||
|
||||
const adaptGroupAgent = (raw: RawClient["server.agent"]) => ({
|
||||
list: EndpointAgentList(raw),
|
||||
default: EndpointAgentDefault(raw),
|
||||
get: EndpointAgentGet(raw),
|
||||
})
|
||||
|
||||
@@ -660,10 +668,7 @@ const EndpointSessionInstructionsEntryRemove =
|
||||
|
||||
const EndpointSessionGenerate = (raw: RawClient["server.session"]) => (input: SessionGenerateInput) =>
|
||||
preserveEffect<SessionGenerateOutput>()(
|
||||
raw["session.generate"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { prompt: input["prompt"], model: input["model"] },
|
||||
}).pipe(
|
||||
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
|
||||
@@ -8,6 +8,8 @@ import type {
|
||||
LocationReloadOutput,
|
||||
AgentListInput,
|
||||
AgentListOutput,
|
||||
AgentDefaultInput,
|
||||
AgentDefaultOutput,
|
||||
AgentGetInput,
|
||||
AgentGetOutput,
|
||||
PluginListInput,
|
||||
@@ -477,6 +479,18 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
default: (input?: AgentDefaultInput, requestOptions?: RequestOptions) =>
|
||||
request<AgentDefaultOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/agent/default`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
get: (input: AgentGetInput, requestOptions?: RequestOptions) =>
|
||||
request<AgentGetOutput>(
|
||||
{
|
||||
@@ -952,7 +966,7 @@ export function make(options: ClientOptions) {
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/generate`,
|
||||
body: { prompt: input["prompt"], model: input["model"] },
|
||||
body: { prompt: input["prompt"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401, 404, 503],
|
||||
empty: false,
|
||||
|
||||
@@ -2759,6 +2759,12 @@ export type AgentListInput = {
|
||||
|
||||
export type AgentListOutput = { location: LocationPublicRef; data: Array<AgentInfo> }
|
||||
|
||||
export type AgentDefaultInput = {
|
||||
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||
}
|
||||
|
||||
export type AgentDefaultOutput = { location: LocationPublicRef; data: AgentInfo | null }
|
||||
|
||||
export type AgentGetInput = {
|
||||
readonly agentID: { readonly agentID: string }["agentID"]
|
||||
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||
@@ -4579,14 +4585,7 @@ export type SessionInstructionsEntryRemoveOutput = void
|
||||
|
||||
export type SessionGenerateInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly prompt: {
|
||||
readonly prompt: string
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
}["prompt"]
|
||||
readonly model?: {
|
||||
readonly prompt: string
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
}["model"]
|
||||
readonly prompt: { readonly prompt: string }["prompt"]
|
||||
}
|
||||
|
||||
export type SessionGenerateOutput = SessionGenerateResponse["data"]
|
||||
|
||||
@@ -955,11 +955,7 @@ test("session methods use the public HTTP contract", async () => {
|
||||
text: "Hello",
|
||||
resume: false,
|
||||
})
|
||||
const generated = await client.session.generate({
|
||||
sessionID: "ses_test",
|
||||
prompt: "Summarize this session",
|
||||
model: { providerID: "openai", id: "gpt-5-mini", variant: "low" },
|
||||
})
|
||||
const generated = await client.session.generate({ sessionID: "ses_test", prompt: "Summarize this session" })
|
||||
const synthetic = await client.session.synthetic({
|
||||
sessionID: "ses_test",
|
||||
text: "Completed",
|
||||
@@ -971,7 +967,7 @@ test("session methods use the public HTTP contract", async () => {
|
||||
const context = await client.session.context({ sessionID: "ses_test" })
|
||||
const log = []
|
||||
for await (const item of client.session.log({ sessionID: "ses_test", after: 0 })) log.push(item)
|
||||
const interrupted = await client.session.interrupt({ sessionID: "ses_test", resume: true })
|
||||
const interrupted = await client.session.interrupt({ sessionID: "ses_test", continue: true })
|
||||
const message = await client.session.message.get({ sessionID: "ses_test", messageID: "msg_model" })
|
||||
|
||||
expect(page.cursor.next).toBe("next")
|
||||
@@ -980,10 +976,6 @@ test("session methods use the public HTTP contract", async () => {
|
||||
expect(created.id).toBe("ses_test")
|
||||
expect(admitted.id).toBe("msg_test")
|
||||
expect(generated.text).toBe("A transient answer")
|
||||
expect(JSON.parse(String(requests.find((request) => request.url.endsWith("/generate"))?.init?.body))).toEqual({
|
||||
prompt: "Summarize this session",
|
||||
model: { providerID: "openai", id: "gpt-5-mini", variant: "low" },
|
||||
})
|
||||
expect(interrupted).toEqual({ interrupted: true })
|
||||
expect(synthetic).toMatchObject({ type: "synthetic", data: { text: "Completed" }, delivery: "queue" })
|
||||
expect(context).toEqual([])
|
||||
@@ -1003,7 +995,7 @@ test("session methods use the public HTTP contract", async () => {
|
||||
["POST", "http://localhost:3000/api/experimental/session/ses_test/wait"],
|
||||
["GET", "http://localhost:3000/api/session/ses_test/context"],
|
||||
["GET", "http://localhost:3000/api/experimental/session/ses_test/log?after=0"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/interrupt?resume=true"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/interrupt?continue=true"],
|
||||
["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
|
||||
])
|
||||
const viewBody = requests.find((request) => request.url.endsWith("/api/session/ses_test/view"))?.init?.body
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface Interface extends State.Transformable<Editor> {
|
||||
readonly get: (id: ID) => Effect.Effect<Info | undefined>
|
||||
readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined>
|
||||
readonly select: (id?: ID | string) => Effect.Effect<Selection>
|
||||
readonly default: () => Effect.Effect<Info | undefined>
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
}
|
||||
|
||||
@@ -121,6 +122,9 @@ const layer = Layer.effect(
|
||||
const info = selectedDefault()
|
||||
return { id: info?.id ?? defaultID, info }
|
||||
}),
|
||||
default: Effect.fn("Agent.default")(function* () {
|
||||
return selectedDefault()
|
||||
}),
|
||||
list: Effect.fn("Agent.list")(function* () {
|
||||
const agents = Array.fromIterable(state.get().agents.values())
|
||||
const selected = selectedDefault()
|
||||
|
||||
@@ -4,6 +4,8 @@ import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import {
|
||||
Client,
|
||||
SdkError,
|
||||
SdkErrorCode,
|
||||
SdkHttpError,
|
||||
StreamableHTTPClientTransport,
|
||||
UnauthorizedError,
|
||||
@@ -35,6 +37,18 @@ const DEFAULT_EXECUTION_TIMEOUT = 12 * 60 * 60 * 1_000 // 12 hours
|
||||
const TERMINATE_TIMEOUT = 1_000
|
||||
const toError = (error: unknown) => (error instanceof Error ? error : new Error(String(error)))
|
||||
|
||||
// HTTP statuses and network error codes live on error properties and are lost once flattened to a message.
|
||||
const describe = (error: unknown) => {
|
||||
if (!(error instanceof Error)) return String(error)
|
||||
const detail =
|
||||
error instanceof SdkHttpError
|
||||
? `HTTP ${error.status}`
|
||||
: !(error instanceof SdkError) && "code" in error && typeof error.code === "string"
|
||||
? error.code
|
||||
: undefined
|
||||
return detail && !error.message.includes(detail) ? `${error.message} (${detail})` : error.message
|
||||
}
|
||||
|
||||
export type { GetPromptResult, Prompt, ReadResourceResult, Resource, Tool }
|
||||
export type ResourceTemplate = ResourceTemplateType
|
||||
|
||||
@@ -108,7 +122,7 @@ export interface Connection {
|
||||
readonly args?: Record<string, unknown>
|
||||
readonly sessionID?: Session.ID
|
||||
}) => Effect.Effect<CallToolResult, Error>
|
||||
readonly onClose: (callback: () => void) => void
|
||||
readonly onClose: (callback: (reason: string) => void) => void
|
||||
readonly onSessionExpired: (callback: () => void) => void
|
||||
readonly onToolsChanged: (callback: () => void) => void
|
||||
readonly onPromptsChanged: (callback: () => void) => void
|
||||
@@ -138,7 +152,15 @@ export const connect = Effect.fnUntraced(function* (
|
||||
onChanged: () => changed[key](),
|
||||
})
|
||||
|
||||
// The SDK fails pending requests with a bare "Connection closed"; the transport error before it says why.
|
||||
let lastError: string | undefined
|
||||
const explain = (error: unknown) =>
|
||||
error instanceof SdkError && error.code === SdkErrorCode.ConnectionClosed && lastError
|
||||
? `${describe(error)}: ${lastError}`
|
||||
: describe(error)
|
||||
|
||||
const initialize = Effect.fnUntraced(function* (transport: Transport) {
|
||||
const runFork = Effect.runForkWith(yield* Effect.context())
|
||||
const client = new Client(clientInfo, {
|
||||
capabilities: {
|
||||
...(elicitation ? { elicitation: { form: { applyDefaults: true }, url: {} } } : {}),
|
||||
@@ -153,6 +175,12 @@ export const connect = Effect.fnUntraced(function* (
|
||||
resources: listChanged("resources"),
|
||||
},
|
||||
})
|
||||
// Background work such as the standalone SSE stream reports failures only here; aborts come from close.
|
||||
client.onerror = (error) => {
|
||||
if (error.name === "AbortError") return
|
||||
lastError = describe(error)
|
||||
runFork(Effect.logWarning("mcp transport error", { server, error: lastError }))
|
||||
}
|
||||
client.setRequestHandler("roots/list", () => ({ roots: [{ uri: pathToFileURL(directory).href }] }))
|
||||
if (elicitation) {
|
||||
client.setRequestHandler("elicitation/create", (request, ctx) =>
|
||||
@@ -179,13 +207,13 @@ export const connect = Effect.fnUntraced(function* (
|
||||
reported: false,
|
||||
}
|
||||
const failure = (error: unknown) => {
|
||||
if (!(error instanceof SdkHttpError) || session.transport?.sessionId === undefined) return toError(error)
|
||||
if (!(error instanceof SdkHttpError) || session.transport?.sessionId === undefined) return new Error(explain(error))
|
||||
const expired =
|
||||
error.status === 404 ||
|
||||
(error.status === 400 &&
|
||||
typeof error.data.text === "string" &&
|
||||
error.data.text.includes("Bad Request: Server not initialized"))
|
||||
if (!expired) return toError(error)
|
||||
if (!expired) return new Error(explain(error))
|
||||
if (!session.reported) {
|
||||
session.reported = true
|
||||
session.expired?.()
|
||||
@@ -242,6 +270,8 @@ export const connect = Effect.fnUntraced(function* (
|
||||
// Close only aborts streams; the legacy session lives on until the server expires it unless
|
||||
// terminated explicitly. Terminate first: close aborts the signal the DELETE shares.
|
||||
const transport = session.transport
|
||||
// Termination failures are logged below; onerror would report them a second time.
|
||||
client.onerror = undefined
|
||||
if (transport?.sessionId !== undefined && !session.reported)
|
||||
yield* Effect.tryPromise({ try: () => transport.terminateSession(), catch: toError }).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
@@ -301,7 +331,7 @@ export const connect = Effect.fnUntraced(function* (
|
||||
),
|
||||
).pipe(Effect.map(toCallToolResult)),
|
||||
onClose: (callback) => {
|
||||
client.onclose = callback
|
||||
client.onclose = () => callback(lastError ? `Connection closed: ${lastError}` : "Connection closed")
|
||||
},
|
||||
onSessionExpired: (callback) => {
|
||||
session.expired = callback
|
||||
@@ -325,7 +355,7 @@ export const connect = Effect.fnUntraced(function* (
|
||||
server,
|
||||
message: `${error.message}; the server supports ${error.supported.join(", ")}. Set "protocol" for this server to one of those or to "legacy".`,
|
||||
})
|
||||
return yield* new ConnectError({ server, message: error instanceof Error ? error.message : String(error) })
|
||||
return yield* new ConnectError({ server, message: explain(error) })
|
||||
})
|
||||
|
||||
// Absent config is legacy: the SDK sends the plain initialize handshake with no discover probe.
|
||||
|
||||
@@ -62,6 +62,19 @@ export class ToolCallError extends Schema.TaggedError<ToolCallError>()("MCP.Tool
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
const unavailable = (server: ServerName, status: Status) => {
|
||||
switch (status.status) {
|
||||
case "failed":
|
||||
return `MCP server "${server}" is not connected: ${status.error}. Reconnect it from /mcps.`
|
||||
case "needs_auth":
|
||||
return `MCP server "${server}" needs authentication: ${status.error}. Sign in from /mcps.`
|
||||
case "disabled":
|
||||
return `MCP server "${server}" is disabled.`
|
||||
default:
|
||||
return `MCP server "${server}" is not connected.`
|
||||
}
|
||||
}
|
||||
|
||||
type ServerEntry = {
|
||||
readonly config: Mcp.ServerConfig
|
||||
status: Status
|
||||
@@ -388,10 +401,10 @@ export const layer = (options?: Options) =>
|
||||
|
||||
const watch = (name: ServerName, entry: ServerEntry, connection: McpClient.Connection) => {
|
||||
const live = whenLive(name, entry, connection)
|
||||
connection.onClose(() =>
|
||||
connection.onClose((reason) =>
|
||||
live(
|
||||
Effect.gen(function* () {
|
||||
entry.status = { status: "failed", error: "Connection closed" }
|
||||
entry.status = { status: "failed", error: reason }
|
||||
yield* stopServer(name, entry)
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name })
|
||||
}),
|
||||
@@ -660,13 +673,18 @@ export const layer = (options?: Options) =>
|
||||
return yield* new ToolCallError({
|
||||
server: target.name,
|
||||
tool: input.name,
|
||||
message: "MCP server is not connected",
|
||||
message: unavailable(target.name, target.entry.status),
|
||||
})
|
||||
const result = yield* recovering(target.name, target.entry, target.entry.client, (connection) =>
|
||||
connection.callTool({ name: input.name, args: input.args, sessionID: input.sessionID }),
|
||||
).pipe(
|
||||
Effect.mapError(
|
||||
(error) => new ToolCallError({ server: target.name, tool: input.name, message: error.message }),
|
||||
(error) =>
|
||||
new ToolCallError({
|
||||
server: target.name,
|
||||
tool: input.name,
|
||||
message: `MCP tool "${input.name}" on server "${target.name}" failed: ${error.message}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return { ...result, server: target.name, tool: input.name }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as McpStdio from "./stdio.js"
|
||||
|
||||
import { ReadBuffer, serializeMessage, type JSONRPCMessage, type Transport } from "@modelcontextprotocol/client"
|
||||
import { Cause, Duration, Effect, Queue, Scope, Stream } from "effect"
|
||||
import { Cause, Duration, Effect, Fiber, Option, Queue, Scope, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import type { ChildProcessHandle } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import { Environment } from "../environment/index.js"
|
||||
@@ -13,6 +13,9 @@ const CLOSE_GRACE = Duration.seconds(2)
|
||||
const FORCE_KILL_AFTER = Duration.seconds(2)
|
||||
const OUTGOING_CAPACITY = 64
|
||||
const MAX_FRAME_BYTES = 16 * 1024 * 1024
|
||||
// An unexpected exit is reported with its exit code and the end of stderr, which usually names the cause.
|
||||
const EXIT_WAIT = Duration.seconds(1)
|
||||
const STDERR_TAIL = 1_000
|
||||
|
||||
export interface Options {
|
||||
/** Server name; only used to attribute logs. */
|
||||
@@ -50,6 +53,7 @@ export const make = Effect.fnUntraced(function* (options: Options) {
|
||||
let startup: Promise<void> | undefined
|
||||
let closing: Promise<void> | undefined
|
||||
let trailingBytes = 0
|
||||
let stderr = ""
|
||||
|
||||
const stop = Effect.fnUntraced(function* (handle: ChildProcessHandle) {
|
||||
// Exit completion can precede descendant cleanup after the capture deadline.
|
||||
@@ -139,6 +143,20 @@ export const make = Effect.fnUntraced(function* (options: Options) {
|
||||
|
||||
const startOutput = (handle: ChildProcessHandle) =>
|
||||
Effect.gen(function* () {
|
||||
// Drain stderr into the debug log so chatty servers cannot stall on a full pipe.
|
||||
const drain = yield* Effect.forkScoped(
|
||||
handle.stderr.pipe(
|
||||
Stream.decodeText(),
|
||||
Stream.tap((output) => Effect.sync(() => (stderr = (stderr + output).slice(-STDERR_TAIL)))),
|
||||
Stream.runForEach((output) =>
|
||||
output.trim() === ""
|
||||
? Effect.void
|
||||
: Effect.logDebug("mcp server stderr", { server: options.server, output }),
|
||||
),
|
||||
Effect.ignore,
|
||||
),
|
||||
)
|
||||
|
||||
yield* Effect.forkScoped(
|
||||
Stream.runForEach(handle.stdout, deliver).pipe(
|
||||
Effect.tapCause((cause) =>
|
||||
@@ -152,25 +170,26 @@ export const make = Effect.fnUntraced(function* (options: Options) {
|
||||
Effect.ensuring(
|
||||
Effect.gen(function* () {
|
||||
const unexpected = state.phase !== "closed"
|
||||
if (unexpected) yield* Effect.promise(close)
|
||||
if (unexpected) {
|
||||
// stdout can end before the final stderr output has been read.
|
||||
const code = yield* Fiber.await(drain).pipe(
|
||||
Effect.andThen(handle.exitCode),
|
||||
Effect.timeoutOption(EXIT_WAIT),
|
||||
Effect.orElseSucceed(() => Option.none()),
|
||||
)
|
||||
const exited = Option.match(code, {
|
||||
onNone: () => "MCP server process exited",
|
||||
onSome: (code) => `MCP server process exited with code ${code}`,
|
||||
})
|
||||
const output = stderr.trim()
|
||||
transport.onerror?.(new Error(output ? `${exited}: ${output}` : exited))
|
||||
yield* Effect.promise(close)
|
||||
}
|
||||
transport.onclose?.()
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// Drain stderr into the debug log so chatty servers cannot stall on a full pipe.
|
||||
yield* Effect.forkScoped(
|
||||
handle.stderr.pipe(
|
||||
Stream.decodeText(),
|
||||
Stream.runForEach((output) =>
|
||||
output.trim() === ""
|
||||
? Effect.void
|
||||
: Effect.logDebug("mcp server stderr", { server: options.server, output }),
|
||||
),
|
||||
Effect.ignore,
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
return transport
|
||||
|
||||
@@ -96,20 +96,25 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
const decodeWorktree = Schema.decodeUnknownEffect(Worktree.Info)
|
||||
const decodeWorktrees = Schema.decodeUnknownEffect(Schema.Array(Worktree.ListEntry))
|
||||
|
||||
const listAgents = Effect.fn("PluginHost.listAgents")((ref: Location.Ref) =>
|
||||
Effect.gen(function* () {
|
||||
const atAgentLocation = <A>(
|
||||
input: Parameters<typeof locationRef>[0],
|
||||
use: (agents: Agent.Interface) => Effect.Effect<A>,
|
||||
) => {
|
||||
const ref = locationRef(input)
|
||||
if (!ref || isCurrentLocation(ref)) return response(use(agents))
|
||||
return Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const agents = yield* Agent.Service
|
||||
const remote = yield* Agent.Service
|
||||
return {
|
||||
location: new Location.Info({
|
||||
directory: location.directory,
|
||||
workspaceID: location.workspaceID,
|
||||
project: location.project,
|
||||
}),
|
||||
data: yield* agents.list(),
|
||||
data: yield* use(remote),
|
||||
}
|
||||
}).pipe(Effect.provide(locations.get(ref)), Effect.orDie),
|
||||
)
|
||||
}).pipe(Effect.provide(locations.get(ref)), Effect.orDie, Effect.withSpan("PluginHost.atAgentLocation"))
|
||||
}
|
||||
|
||||
// Keep the instance graph's inferred types independent of Session handles.
|
||||
const context: Plugin.Context = {
|
||||
@@ -118,30 +123,16 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
options: {},
|
||||
rpc: Object.assign(rpc.client, { register: rpc.register }),
|
||||
agent: {
|
||||
get: (input) => {
|
||||
const ref = locationRef(input)
|
||||
const output =
|
||||
ref && !isCurrentLocation(ref)
|
||||
? listAgents(ref).pipe(
|
||||
Effect.map((result) => ({
|
||||
...result,
|
||||
data: result.data.find((agent) => agent.id === input.agentID),
|
||||
})),
|
||||
)
|
||||
: response(agents.get(input.agentID))
|
||||
return output.pipe(
|
||||
get: (input) =>
|
||||
atAgentLocation(input, (agents) => agents.get(input.agentID)).pipe(
|
||||
Effect.flatMap((result) =>
|
||||
result.data
|
||||
? Effect.succeed({ ...result, data: result.data })
|
||||
: Effect.fail(new Error(`Agent not found: ${input.agentID}`)),
|
||||
),
|
||||
)
|
||||
},
|
||||
list: (input) => {
|
||||
const ref = locationRef(input)
|
||||
if (ref && !isCurrentLocation(ref)) return listAgents(ref)
|
||||
return response(agents.list())
|
||||
},
|
||||
),
|
||||
list: (input) => atAgentLocation(input, (agents) => agents.list()),
|
||||
default: (input) => atAgentLocation(input, (agents) => agents.default()),
|
||||
reload: agents.reload,
|
||||
transform: (callback) =>
|
||||
agents.transform((editor) => {
|
||||
|
||||
@@ -186,7 +186,6 @@ export interface Interface {
|
||||
readonly generate: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
prompt: string
|
||||
model?: Model.Ref
|
||||
}) => Effect.Effect<string, NotFoundError | SessionGenerate.Error>
|
||||
readonly command: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
@@ -405,7 +404,7 @@ const layer = Layer.effect(
|
||||
prompt: (input) => sessions.forSession(input.sessionID).prompt(input),
|
||||
generate: Effect.fn("Session.generate")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
return yield* SessionGenerate.generate({ session, prompt: input.prompt, model: input.model }).pipe(
|
||||
return yield* SessionGenerate.generate({ session, prompt: input.prompt }).pipe(
|
||||
Effect.provideService(Instance.Service, instances),
|
||||
Effect.provideService(Database.Service, database),
|
||||
Effect.provideService(LLMClient.Service, llm),
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Effect } from "effect"
|
||||
import { Database } from "../database/database.js"
|
||||
import { Instance } from "../instance/service.js"
|
||||
import { Plugin } from "../plugin/service.js"
|
||||
import type { Model } from "../model.js"
|
||||
import type { Instructions } from "../instructions/index.js"
|
||||
import { SessionContext } from "./context.js"
|
||||
import type { AgentNotFoundError } from "./error.js"
|
||||
@@ -21,7 +20,6 @@ export type Error = AgentNotFoundError | Instructions.InitializationBlocked | Se
|
||||
export const generate = Effect.fn("SessionGenerate.generate")(function* (input: {
|
||||
session: SessionSchema.Info
|
||||
prompt: string
|
||||
model?: Model.Ref
|
||||
}) {
|
||||
const instances = yield* Instance.Service
|
||||
const database = yield* Database.Service
|
||||
@@ -31,9 +29,7 @@ export const generate = Effect.fn("SessionGenerate.generate")(function* (input:
|
||||
yield* Plugin.awaitActivation
|
||||
const context = yield* SessionContext.Service
|
||||
const selection = yield* context.select(input.session.id)
|
||||
const model = yield* context.resolveModel(
|
||||
input.model === undefined ? selection.session : { ...selection.session, model: input.model },
|
||||
)
|
||||
const model = yield* context.resolveModel(selection.session)
|
||||
const history = yield* SessionHistory.preview(
|
||||
database.db,
|
||||
selection.session.id,
|
||||
|
||||
@@ -87,6 +87,73 @@ describe("Agent", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resolves the default agent the same way sessions select one", () =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* Agent.Service
|
||||
yield* agent.transform((editor) => {
|
||||
editor.update(Agent.ID.make("plan"), (info) => {
|
||||
info.mode = "primary"
|
||||
})
|
||||
editor.update(Agent.ID.make("build"), (info) => {
|
||||
info.mode = "primary"
|
||||
})
|
||||
editor.update(Agent.ID.make("reviewer"), (info) => {
|
||||
info.mode = "primary"
|
||||
})
|
||||
editor.update(Agent.ID.make("secret"), (info) => {
|
||||
info.hidden = true
|
||||
})
|
||||
editor.update(Agent.ID.make("explore"), (info) => {
|
||||
info.mode = "subagent"
|
||||
})
|
||||
})
|
||||
const resolved = Effect.gen(function* () {
|
||||
const info = yield* agent.default()
|
||||
expect((yield* agent.select()).info).toEqual(info)
|
||||
return info?.id
|
||||
})
|
||||
|
||||
expect(yield* resolved).toBe(Agent.ID.make("build"))
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
["reviewer", "reviewer"],
|
||||
["missing", "build"],
|
||||
["secret", "build"],
|
||||
["explore", "build"],
|
||||
] as const,
|
||||
([id, expected]) =>
|
||||
Effect.gen(function* () {
|
||||
yield* agent.transform((editor) => editor.default(Agent.ID.make(id)))
|
||||
expect(yield* resolved).toBe(Agent.ID.make(expected))
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to the first selectable agent without build", () =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* Agent.Service
|
||||
yield* agent.transform((editor) => {
|
||||
editor.update(Agent.ID.make("explore"), (info) => {
|
||||
info.mode = "subagent"
|
||||
})
|
||||
editor.update(Agent.ID.make("plan"), (info) => {
|
||||
info.mode = "primary"
|
||||
})
|
||||
})
|
||||
|
||||
expect((yield* agent.default())?.id).toBe(Agent.ID.make("plan"))
|
||||
|
||||
yield* agent.transform((editor) =>
|
||||
editor.update(Agent.ID.make("plan"), (info) => {
|
||||
info.mode = "subagent"
|
||||
}),
|
||||
)
|
||||
expect(yield* agent.default()).toBeUndefined()
|
||||
expect(yield* agent.select()).toEqual({ id: Agent.defaultID, info: undefined })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rebuilds state when a transform is replaced", () =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* Agent.Service
|
||||
|
||||
@@ -39,6 +39,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
agent: overrides.agent ?? {
|
||||
get: () => Effect.die("unused agent.get"),
|
||||
list: () => Effect.die("unused agent.list"),
|
||||
default: () => Effect.die("unused agent.default"),
|
||||
transform: () => Effect.die("unused agent.transform"),
|
||||
reload: () => Effect.die("unused agent.reload"),
|
||||
},
|
||||
@@ -204,6 +205,7 @@ export function agentHost(agent: Agent.Interface): Plugin.Context["agent"] {
|
||||
),
|
||||
),
|
||||
list: () => Effect.die("unused agent.list"),
|
||||
default: () => Effect.die("unused agent.default"),
|
||||
reload: agent.reload,
|
||||
transform: (callback) =>
|
||||
agent.transform((editor) =>
|
||||
|
||||
@@ -56,6 +56,7 @@ const run = Effect.fnUntraced(function* (events: ReadonlyArray<SessionEvent.Agen
|
||||
agent: {
|
||||
get: () => Effect.die("unused agent.get"),
|
||||
list: () => Effect.die("unused agent.list"),
|
||||
default: () => Effect.die("unused agent.default"),
|
||||
reload: () => Effect.die("unused agent.reload"),
|
||||
transform: (callback) => {
|
||||
callback({
|
||||
|
||||
@@ -24,7 +24,7 @@ import { Instructions } from "@opencode/core/instructions/index"
|
||||
import { InstructionBuiltIns } from "@opencode/core/instructions/builtins"
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { McpInstructions } from "@opencode/core/mcp/instructions"
|
||||
import { ID, Model } from "@opencode/core/model"
|
||||
import { ID } from "@opencode/core/model"
|
||||
import { Project } from "@opencode/core/project"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { ReferenceInstructions } from "@opencode/core/reference/instructions"
|
||||
@@ -60,7 +60,6 @@ let instruction: string | Instructions.Unavailable = "Initial context"
|
||||
const sessionID = SessionSchema.ID.make("ses_generate_test")
|
||||
|
||||
const model = LanguageModel.make({ id: "generate-model", provider: "test", route: OpenAIChat.route })
|
||||
const alternate = LanguageModel.make({ id: "cheap-model", provider: "other", route: OpenAIChat.route })
|
||||
const client = Layer.mock(LLMClient.Service)({
|
||||
stream: () => Stream.die(new Error("unused")),
|
||||
generate: (request, requestOptions) =>
|
||||
@@ -84,11 +83,10 @@ const client = Layer.mock(LLMClient.Service)({
|
||||
}),
|
||||
})
|
||||
const models = Layer.mock(SessionRunnerModel.Service)({
|
||||
resolve: (session) =>
|
||||
resolve: () =>
|
||||
Effect.succeed(
|
||||
SessionRunnerModel.resolved(session.model?.id === "cheap-model" ? alternate : model, {
|
||||
SessionRunnerModel.resolved(model, {
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
variant: session.model?.variant,
|
||||
cost: [],
|
||||
limit: { context: 200_000, output: 32_000 },
|
||||
}),
|
||||
@@ -355,39 +353,6 @@ it.effect(
|
||||
expect(requests[0]?.toolChoice).toBeUndefined()
|
||||
expect(options[0]?.webSocket).toBeUndefined()
|
||||
expect(yield* durableState(db, sessionID)).toEqual(before)
|
||||
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const override = Model.Ref.make({
|
||||
providerID: Provider.ID.make("other"),
|
||||
id: ID.make("cheap-model"),
|
||||
variant: Model.VariantID.make("low"),
|
||||
})
|
||||
let generates = 0
|
||||
yield* hooks.register(
|
||||
"session",
|
||||
"generate",
|
||||
(event) =>
|
||||
Effect.sync(() => {
|
||||
generates++
|
||||
expect(event.model).toEqual(override)
|
||||
}),
|
||||
{ providerID: override.providerID },
|
||||
)
|
||||
yield* SessionGenerate.generate({ session, prompt: "Summarize cheaply", model: override }).pipe(
|
||||
Effect.provideService(Instance.Service, instances),
|
||||
)
|
||||
expect(generates).toBe(1)
|
||||
expect(requests[1]?.model).toBe(alternate)
|
||||
expect(requests[1]?.promptCacheKey).toBe(sessionID)
|
||||
expect(userTexts(requests[1])).toEqual(["Existing durable context", "Summarize cheaply"])
|
||||
expect(yield* durableState(db, sessionID)).toEqual(before)
|
||||
|
||||
yield* SessionGenerate.generate({ session, prompt: "Use the session model again" }).pipe(
|
||||
Effect.provideService(Instance.Service, instances),
|
||||
)
|
||||
expect(requests[2]?.model).toBe(model)
|
||||
expect(generates).toBe(1)
|
||||
expect(yield* durableState(db, sessionID)).toEqual(before)
|
||||
}),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
@@ -295,6 +295,7 @@ export function fromPromise(plugin: Plugin) {
|
||||
agent: {
|
||||
get: adaptApiMethod(AgentEndpoints["agent.get"], host.agent.get),
|
||||
list: adaptApiMethod(AgentEndpoints["agent.list"], host.agent.list),
|
||||
default: adaptApiMethod(AgentEndpoints["agent.default"], host.agent.default),
|
||||
transform: transform(host.agent),
|
||||
reload: () => run(host.agent.reload()),
|
||||
},
|
||||
|
||||
+17
-437
@@ -48,106 +48,6 @@
|
||||
"summary": "Get server info"
|
||||
}
|
||||
},
|
||||
"/api/pair": {
|
||||
"post": {
|
||||
"tags": ["server"],
|
||||
"operationId": "server.pair",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "PairingCode",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PairingCode"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Create a short-lived, single-use code for a /auth/connect/:code pairing link.",
|
||||
"summary": "Create pairing code"
|
||||
}
|
||||
},
|
||||
"/auth/connect/{code}": {
|
||||
"get": {
|
||||
"tags": ["server"],
|
||||
"operationId": "server.connect",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "code",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "PairingSession",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PairingSession"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Redeem a pairing code. Browsers receive a session cookie and a redirect to the web app; requests that accept JSON receive a session token to use as the password.",
|
||||
"summary": "Redeem pairing code"
|
||||
}
|
||||
},
|
||||
"/api/location": {
|
||||
"get": {
|
||||
"tags": ["location"],
|
||||
@@ -986,19 +886,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "SessionNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Create a session at the requested location. A parentID creates a linked child session at its parent's location.",
|
||||
"description": "Create a session at the requested location.",
|
||||
"summary": "Create session",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -1017,17 +907,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"parentID": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -3918,7 +3797,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Generate transient text from the current session context without mutating session history. An optional model overrides the session model for this request only; omitting it preserves the session model and its prompt-cache reuse opportunity.",
|
||||
"description": "Generate transient text from the current session context without mutating session history.",
|
||||
"summary": "Generate text from session context",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -3928,16 +3807,6 @@
|
||||
"properties": {
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["prompt"],
|
||||
@@ -4639,21 +4508,6 @@
|
||||
"pattern": "^frm_"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "message",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -4737,6 +4591,21 @@
|
||||
"pattern": "^frm_"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "message",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -7328,125 +7197,6 @@
|
||||
"summary": "List MCP resources"
|
||||
}
|
||||
},
|
||||
"/api/credential": {
|
||||
"get": {
|
||||
"tags": ["credential"],
|
||||
"operationId": "credential.list",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Credential.Entry"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "List every stored integration credential, including its secret value.",
|
||||
"summary": "List credentials"
|
||||
},
|
||||
"post": {
|
||||
"tags": ["credential"],
|
||||
"operationId": "credential.create",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Credential.Entry"
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "ConflictError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ConflictErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Store an integration credential. It becomes the integration's active credential unless activate is false and the integration already has one. Fails with a conflict when the requested ID already exists.",
|
||||
"summary": "Create credential",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Credential.CreateInput"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/credential/{credentialID}": {
|
||||
"patch": {
|
||||
"tags": ["credential"],
|
||||
@@ -13881,9 +13631,6 @@
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": ["key", "oauth"]
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/components/schemas/Connection.Status"
|
||||
}
|
||||
},
|
||||
"required": ["type", "id", "label", "method"],
|
||||
@@ -13898,9 +13645,6 @@
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/components/schemas/Connection.Status"
|
||||
}
|
||||
},
|
||||
"required": ["type", "name"],
|
||||
@@ -13916,128 +13660,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"Connection.Status": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": ["needs_auth"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["status", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.CreateInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"integrationID": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"$ref": "#/components/schemas/Credential.Value"
|
||||
},
|
||||
"activate": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["integrationID", "value"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.Entry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"integrationID": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"active": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"value": {
|
||||
"$ref": "#/components/schemas/Credential.Value"
|
||||
}
|
||||
},
|
||||
"required": ["id", "integrationID", "label", "active", "value"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.Key": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["key"]
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"configuration": {
|
||||
"$ref": "#/components/schemas/Form.Answer_1"
|
||||
}
|
||||
},
|
||||
"required": ["type", "key"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.OAuth": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["oauth"]
|
||||
},
|
||||
"methodID": {
|
||||
"type": "string"
|
||||
},
|
||||
"refresh": {
|
||||
"type": "string"
|
||||
},
|
||||
"access": {
|
||||
"type": "string"
|
||||
},
|
||||
"expires": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": ["type", "methodID", "refresh", "access", "expires"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.Value": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Credential.OAuth"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Credential.Key"
|
||||
}
|
||||
]
|
||||
},
|
||||
"FileDiff.Info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -14124,12 +13746,6 @@
|
||||
"$ref": "#/components/schemas/Form.Value"
|
||||
}
|
||||
},
|
||||
"Form.Answer_1": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/components/schemas/Form.Value"
|
||||
}
|
||||
},
|
||||
"Form.BooleanField": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -16093,29 +15709,6 @@
|
||||
"Money.USDPerMillionTokens": {
|
||||
"type": "number"
|
||||
},
|
||||
"PairingCode": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"expires_in": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": ["code", "expires_in"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PairingSession": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["token"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Permission.Effect": {
|
||||
"type": "string",
|
||||
"enum": ["allow", "deny", "ask"]
|
||||
@@ -18641,16 +18234,6 @@
|
||||
"type": "integer",
|
||||
"minimum": 100,
|
||||
"maximum": 599
|
||||
},
|
||||
"response": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"body": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["body"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["type", "message"],
|
||||
@@ -19070,9 +18653,6 @@
|
||||
"exit": {
|
||||
"type": "number"
|
||||
},
|
||||
"signal": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
|
||||
@@ -20,6 +20,21 @@ export const AgentGroup = HttpApiGroup.make("server.agent")
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("agent.default", "/api/agent/default", {
|
||||
query: LocationQuery,
|
||||
success: Location.response(Schema.UndefinedOr(Agent.Info)),
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "agent.default",
|
||||
summary: "Get default agent",
|
||||
description:
|
||||
"Retrieve the agent used when a session has no explicit agent selection. The result may precede initial plugin and config settlement.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("agent.get", "/api/agent/:agentID", {
|
||||
params: { agentID: Agent.ID },
|
||||
|
||||
@@ -708,7 +708,7 @@ export const makeSessionGroup = <
|
||||
.add(
|
||||
HttpApiEndpoint.post("session.generate", "/api/session/:sessionID/generate", {
|
||||
params: { sessionID: Session.ID },
|
||||
payload: Schema.Struct({ prompt: Schema.String, model: Model.Ref.pipe(Schema.optional) }),
|
||||
payload: Schema.Struct({ prompt: Schema.String }),
|
||||
success: Schema.Struct({
|
||||
data: Schema.Struct({ text: Schema.String }),
|
||||
}).annotate({ identifier: "SessionGenerateResponse" }),
|
||||
@@ -719,8 +719,7 @@ export const makeSessionGroup = <
|
||||
OpenApi.annotations({
|
||||
identifier: "session.generate",
|
||||
summary: "Generate text from session context",
|
||||
description:
|
||||
"Generate transient text from the current session context without mutating session history. An optional model overrides the session model for this request only; omitting it preserves the session model and its prompt-cache reuse opportunity.",
|
||||
description: "Generate transient text from the current session context without mutating session history.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -12,6 +12,13 @@ export const AgentHandler = HttpApiBuilder.group(Api, "server.agent", (handlers)
|
||||
return yield* response(Agent.Service.use((agent) => agent.list()))
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"agent.default",
|
||||
Effect.fn(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
return yield* response(agents.default())
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"agent.get",
|
||||
Effect.fn(function* (ctx) {
|
||||
|
||||
@@ -589,7 +589,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
"session.generate",
|
||||
Effect.fn(function* (ctx) {
|
||||
const text = yield* session
|
||||
.generate({ sessionID: ctx.params.sessionID, prompt: ctx.payload.prompt, model: ctx.payload.model })
|
||||
.generate({ sessionID: ctx.params.sessionID, prompt: ctx.payload.prompt })
|
||||
.pipe(
|
||||
Effect.mapError((error) =>
|
||||
error._tag === "Session.NotFoundError"
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { expect } from "bun:test"
|
||||
import { Agent } from "@opencode/schema/agent"
|
||||
import { Effect, Schedule, Schema } from "effect"
|
||||
import { tmpdir } from "../../core/test/fixture/tmpdir"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { startServer } from "./fixture/server"
|
||||
|
||||
const DefaultResponse = Schema.Struct({
|
||||
location: Schema.Struct({ directory: Schema.String }),
|
||||
data: Schema.NullOr(Agent.Info),
|
||||
})
|
||||
|
||||
it.live("returns the default agent for each location", () =>
|
||||
Effect.gen(function* () {
|
||||
const global = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-agent-default-global-")))
|
||||
const configured = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-agent-default-")))
|
||||
const unconfigured = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-agent-default-")))
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(
|
||||
path.join(configured.path, "opencode.json"),
|
||||
JSON.stringify({ default_agent: "reviewer", agents: { reviewer: { mode: "primary" } } }),
|
||||
),
|
||||
)
|
||||
const server = yield* startServer(global.path)
|
||||
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
{ directory: configured.path, expected: "reviewer" },
|
||||
{ directory: unconfigured.path, expected: "build" },
|
||||
],
|
||||
(project) =>
|
||||
Effect.gen(function* () {
|
||||
const url = new URL("/api/agent/default", server.base)
|
||||
url.searchParams.set("location[directory]", project.directory)
|
||||
const response = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
|
||||
expect(response.status).toBe(200)
|
||||
const body = Schema.decodeUnknownSync(DefaultResponse)(yield* Effect.promise(() => response.json()))
|
||||
expect(body.location.directory).toBe(project.directory)
|
||||
return body.data?.id
|
||||
}).pipe(
|
||||
// Agents register during plugin activation, after the location is served.
|
||||
Effect.filterOrFail((id) => id === project.expected),
|
||||
Effect.retry(Schedule.spaced("10 millis")),
|
||||
Effect.timeout("2 seconds"),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -51,14 +51,6 @@ it.live(
|
||||
limit: { context: 200_000, output: 8_192 },
|
||||
},
|
||||
)
|
||||
const alternate = SessionRunnerModel.resolved(
|
||||
LanguageModel.make({ id: "cheap-model", provider: "other", route: OpenAIChat.route }),
|
||||
{
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
cost: [],
|
||||
limit: { context: 200_000, output: 8_192 },
|
||||
},
|
||||
)
|
||||
// Host and private instances must reuse the same global layer identities.
|
||||
const replacements: LayerNode.Replacements = [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
@@ -69,9 +61,7 @@ it.live(
|
||||
Watcher.node.replace(Watcher.configured({ enabled: false })),
|
||||
llmClient.replace(Layer.succeed(LLMClient.Service, llm)),
|
||||
SessionRunnerModel.node.replace(
|
||||
Layer.succeed(SessionRunnerModel.Service, {
|
||||
resolve: (session) => Effect.succeed(session.model?.id === alternate.ref.id ? alternate : model),
|
||||
}),
|
||||
Layer.succeed(SessionRunnerModel.Service, { resolve: () => Effect.succeed(model) }),
|
||||
),
|
||||
Instance.node.replace(
|
||||
makeGlobalNode({
|
||||
@@ -245,17 +235,6 @@ it.live(
|
||||
})),
|
||||
)
|
||||
|
||||
const selected = yield* sessions.get(first.id)
|
||||
yield* llm.push(TestLLM.text("A cheaper answer", "cheap-generation"))
|
||||
const generated = yield* request(`/api/session/${first.id}/generate`, {
|
||||
prompt: "Summarize cheaply",
|
||||
model: alternate.ref,
|
||||
})
|
||||
expect(generated.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => generated.json())).toEqual({ data: { text: "A cheaper answer" } })
|
||||
expect((yield* llm.requests()).at(-1)?.model).toBe(alternate.model)
|
||||
expect((yield* sessions.get(first.id)).model).toEqual(selected.model)
|
||||
|
||||
// Seed through Core, then use HTTP to reach those exact private instances.
|
||||
const pending = yield* Effect.forEach(configs, (config) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
+17
-437
@@ -48,106 +48,6 @@
|
||||
"summary": "Get server info"
|
||||
}
|
||||
},
|
||||
"/api/pair": {
|
||||
"post": {
|
||||
"tags": ["server"],
|
||||
"operationId": "server.pair",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "PairingCode",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PairingCode"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Create a short-lived, single-use code for a /auth/connect/:code pairing link.",
|
||||
"summary": "Create pairing code"
|
||||
}
|
||||
},
|
||||
"/auth/connect/{code}": {
|
||||
"get": {
|
||||
"tags": ["server"],
|
||||
"operationId": "server.connect",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "code",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "PairingSession",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PairingSession"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Redeem a pairing code. Browsers receive a session cookie and a redirect to the web app; requests that accept JSON receive a session token to use as the password.",
|
||||
"summary": "Redeem pairing code"
|
||||
}
|
||||
},
|
||||
"/api/location": {
|
||||
"get": {
|
||||
"tags": ["location"],
|
||||
@@ -986,19 +886,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "SessionNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Create a session at the requested location. A parentID creates a linked child session at its parent's location.",
|
||||
"description": "Create a session at the requested location.",
|
||||
"summary": "Create session",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -1017,17 +907,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"parentID": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -3918,7 +3797,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Generate transient text from the current session context without mutating session history. An optional model overrides the session model for this request only; omitting it preserves the session model and its prompt-cache reuse opportunity.",
|
||||
"description": "Generate transient text from the current session context without mutating session history.",
|
||||
"summary": "Generate text from session context",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -3928,16 +3807,6 @@
|
||||
"properties": {
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["prompt"],
|
||||
@@ -4639,21 +4508,6 @@
|
||||
"pattern": "^frm_"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "message",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -4737,6 +4591,21 @@
|
||||
"pattern": "^frm_"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "message",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -7328,125 +7197,6 @@
|
||||
"summary": "List MCP resources"
|
||||
}
|
||||
},
|
||||
"/api/credential": {
|
||||
"get": {
|
||||
"tags": ["credential"],
|
||||
"operationId": "credential.list",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Credential.Entry"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "List every stored integration credential, including its secret value.",
|
||||
"summary": "List credentials"
|
||||
},
|
||||
"post": {
|
||||
"tags": ["credential"],
|
||||
"operationId": "credential.create",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Credential.Entry"
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "ConflictError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ConflictErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Store an integration credential. It becomes the integration's active credential unless activate is false and the integration already has one. Fails with a conflict when the requested ID already exists.",
|
||||
"summary": "Create credential",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Credential.CreateInput"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/credential/{credentialID}": {
|
||||
"patch": {
|
||||
"tags": ["credential"],
|
||||
@@ -13881,9 +13631,6 @@
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": ["key", "oauth"]
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/components/schemas/Connection.Status"
|
||||
}
|
||||
},
|
||||
"required": ["type", "id", "label", "method"],
|
||||
@@ -13898,9 +13645,6 @@
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/components/schemas/Connection.Status"
|
||||
}
|
||||
},
|
||||
"required": ["type", "name"],
|
||||
@@ -13916,128 +13660,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"Connection.Status": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": ["needs_auth"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["status", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.CreateInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"integrationID": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"$ref": "#/components/schemas/Credential.Value"
|
||||
},
|
||||
"activate": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["integrationID", "value"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.Entry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"integrationID": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"active": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"value": {
|
||||
"$ref": "#/components/schemas/Credential.Value"
|
||||
}
|
||||
},
|
||||
"required": ["id", "integrationID", "label", "active", "value"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.Key": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["key"]
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"configuration": {
|
||||
"$ref": "#/components/schemas/Form.Answer_1"
|
||||
}
|
||||
},
|
||||
"required": ["type", "key"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.OAuth": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["oauth"]
|
||||
},
|
||||
"methodID": {
|
||||
"type": "string"
|
||||
},
|
||||
"refresh": {
|
||||
"type": "string"
|
||||
},
|
||||
"access": {
|
||||
"type": "string"
|
||||
},
|
||||
"expires": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": ["type", "methodID", "refresh", "access", "expires"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.Value": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Credential.OAuth"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Credential.Key"
|
||||
}
|
||||
]
|
||||
},
|
||||
"FileDiff.Info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -14124,12 +13746,6 @@
|
||||
"$ref": "#/components/schemas/Form.Value"
|
||||
}
|
||||
},
|
||||
"Form.Answer_1": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/components/schemas/Form.Value"
|
||||
}
|
||||
},
|
||||
"Form.BooleanField": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -16093,29 +15709,6 @@
|
||||
"Money.USDPerMillionTokens": {
|
||||
"type": "number"
|
||||
},
|
||||
"PairingCode": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"expires_in": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": ["code", "expires_in"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PairingSession": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["token"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Permission.Effect": {
|
||||
"type": "string",
|
||||
"enum": ["allow", "deny", "ask"]
|
||||
@@ -18641,16 +18234,6 @@
|
||||
"type": "integer",
|
||||
"minimum": 100,
|
||||
"maximum": 599
|
||||
},
|
||||
"response": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"body": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["body"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["type", "message"],
|
||||
@@ -19070,9 +18653,6 @@
|
||||
"exit": {
|
||||
"type": "number"
|
||||
},
|
||||
"signal": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
|
||||
@@ -48,106 +48,6 @@
|
||||
"summary": "Get server info"
|
||||
}
|
||||
},
|
||||
"/api/pair": {
|
||||
"post": {
|
||||
"tags": ["server"],
|
||||
"operationId": "server.pair",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "PairingCode",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PairingCode"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Create a short-lived, single-use code for a /auth/connect/:code pairing link.",
|
||||
"summary": "Create pairing code"
|
||||
}
|
||||
},
|
||||
"/auth/connect/{code}": {
|
||||
"get": {
|
||||
"tags": ["server"],
|
||||
"operationId": "server.connect",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "code",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "PairingSession",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PairingSession"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Redeem a pairing code. Browsers receive a session cookie and a redirect to the web app; requests that accept JSON receive a session token to use as the password.",
|
||||
"summary": "Redeem pairing code"
|
||||
}
|
||||
},
|
||||
"/api/location": {
|
||||
"get": {
|
||||
"tags": ["location"],
|
||||
@@ -986,19 +886,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "SessionNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Create a session at the requested location. A parentID creates a linked child session at its parent's location.",
|
||||
"description": "Create a session at the requested location.",
|
||||
"summary": "Create session",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -1017,17 +907,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"parentID": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -3918,7 +3797,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Generate transient text from the current session context without mutating session history. An optional model overrides the session model for this request only; omitting it preserves the session model and its prompt-cache reuse opportunity.",
|
||||
"description": "Generate transient text from the current session context without mutating session history.",
|
||||
"summary": "Generate text from session context",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -3928,16 +3807,6 @@
|
||||
"properties": {
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["prompt"],
|
||||
@@ -4639,21 +4508,6 @@
|
||||
"pattern": "^frm_"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "message",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -4737,6 +4591,21 @@
|
||||
"pattern": "^frm_"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "message",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -7328,125 +7197,6 @@
|
||||
"summary": "List MCP resources"
|
||||
}
|
||||
},
|
||||
"/api/credential": {
|
||||
"get": {
|
||||
"tags": ["credential"],
|
||||
"operationId": "credential.list",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Credential.Entry"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "List every stored integration credential, including its secret value.",
|
||||
"summary": "List credentials"
|
||||
},
|
||||
"post": {
|
||||
"tags": ["credential"],
|
||||
"operationId": "credential.create",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Credential.Entry"
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "ConflictError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ConflictErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Store an integration credential. It becomes the integration's active credential unless activate is false and the integration already has one. Fails with a conflict when the requested ID already exists.",
|
||||
"summary": "Create credential",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Credential.CreateInput"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/credential/{credentialID}": {
|
||||
"patch": {
|
||||
"tags": ["credential"],
|
||||
@@ -13881,9 +13631,6 @@
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": ["key", "oauth"]
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/components/schemas/Connection.Status"
|
||||
}
|
||||
},
|
||||
"required": ["type", "id", "label", "method"],
|
||||
@@ -13898,9 +13645,6 @@
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/components/schemas/Connection.Status"
|
||||
}
|
||||
},
|
||||
"required": ["type", "name"],
|
||||
@@ -13916,128 +13660,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"Connection.Status": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": ["needs_auth"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["status", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.CreateInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"integrationID": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"$ref": "#/components/schemas/Credential.Value"
|
||||
},
|
||||
"activate": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["integrationID", "value"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.Entry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"integrationID": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"active": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"value": {
|
||||
"$ref": "#/components/schemas/Credential.Value"
|
||||
}
|
||||
},
|
||||
"required": ["id", "integrationID", "label", "active", "value"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.Key": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["key"]
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"configuration": {
|
||||
"$ref": "#/components/schemas/Form.Answer_1"
|
||||
}
|
||||
},
|
||||
"required": ["type", "key"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.OAuth": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["oauth"]
|
||||
},
|
||||
"methodID": {
|
||||
"type": "string"
|
||||
},
|
||||
"refresh": {
|
||||
"type": "string"
|
||||
},
|
||||
"access": {
|
||||
"type": "string"
|
||||
},
|
||||
"expires": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": ["type", "methodID", "refresh", "access", "expires"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.Value": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Credential.OAuth"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Credential.Key"
|
||||
}
|
||||
]
|
||||
},
|
||||
"FileDiff.Info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -14124,12 +13746,6 @@
|
||||
"$ref": "#/components/schemas/Form.Value"
|
||||
}
|
||||
},
|
||||
"Form.Answer_1": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/components/schemas/Form.Value"
|
||||
}
|
||||
},
|
||||
"Form.BooleanField": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -16093,29 +15709,6 @@
|
||||
"Money.USDPerMillionTokens": {
|
||||
"type": "number"
|
||||
},
|
||||
"PairingCode": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"expires_in": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": ["code", "expires_in"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PairingSession": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["token"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Permission.Effect": {
|
||||
"type": "string",
|
||||
"enum": ["allow", "deny", "ask"]
|
||||
@@ -18641,16 +18234,6 @@
|
||||
"type": "integer",
|
||||
"minimum": 100,
|
||||
"maximum": 599
|
||||
},
|
||||
"response": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"body": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["body"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["type", "message"],
|
||||
@@ -19070,9 +18653,6 @@
|
||||
"exit": {
|
||||
"type": "number"
|
||||
},
|
||||
"signal": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
|
||||
@@ -821,22 +821,6 @@ effect: (ctx) =>
|
||||
}),
|
||||
```
|
||||
|
||||
`session.generate` uses the session's model by default, retaining the opportunity to reuse its prompt cache.
|
||||
Supply `model` to override it for one request without changing the session; switching models generally cannot reuse
|
||||
the existing model's cache.
|
||||
|
||||
```ts
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
const created = yield* ctx.session.create({ title: "Review" }).pipe(Effect.orDie)
|
||||
const generated = yield* ctx.session.generate({
|
||||
sessionID: created.id,
|
||||
prompt: "Summarize this project",
|
||||
model: { providerID: Provider.ID.make("openai"), id: Model.ID.make("gpt-5-mini") },
|
||||
}).pipe(Effect.orDie)
|
||||
}),
|
||||
```
|
||||
|
||||
Remove a temporary session with `remove`. Removal stops active execution and recursively deletes child sessions.
|
||||
|
||||
```ts
|
||||
|
||||
@@ -766,18 +766,6 @@ const command = await ctx.session.command({ sessionID, command: "review", argume
|
||||
const synthetic = await ctx.session.synthetic({ sessionID, text: "Deployment completed" })
|
||||
```
|
||||
|
||||
`session.generate` uses the session's model by default, retaining the opportunity to reuse its prompt cache.
|
||||
Supply `model` to override it for one request without changing the session; switching models generally cannot reuse
|
||||
the existing model's cache.
|
||||
|
||||
```ts
|
||||
const generated = await ctx.session.generate({
|
||||
sessionID,
|
||||
prompt: "Summarize this project",
|
||||
model: { providerID: "openai", id: "gpt-5-mini" },
|
||||
})
|
||||
```
|
||||
|
||||
Rename, interrupt, or wait for a session.
|
||||
|
||||
```ts
|
||||
|
||||
Reference in new issue
Block a user