mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-28 12:36:15 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afb8358e82 | ||
|
|
8381153418 | ||
|
|
96d84626f8 | ||
|
|
f607ca4c72 | ||
|
|
39416a0d95 | ||
|
|
84a012a0e9 | ||
|
|
16a0996bd4 | ||
|
|
60d5f83ffd | ||
|
|
1455995ac7 | ||
|
|
52c04508a2 | ||
|
|
5a67fcc17e | ||
|
|
73b575468e |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/theme": patch
|
||||
---
|
||||
|
||||
Use the unread accent color by default for question and permission status indicators, while preserving explicit theme overrides.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": major
|
||||
---
|
||||
|
||||
Treat MCP as a word in current Core namespace exports: rename `MCP` to `Mcp`, `MCPClient` to `McpClient`, `MCPStdio` to `McpStdio`, `MCPOAuth` to `McpOAuth`, `ConfigMCPPlugin` to `ConfigMcpPlugin`, and `MCPCodeModeExclusionPlugin` to `McpCodeModeExclusionPlugin`. Direct consumers must update their imports. Module paths, Schema contracts, runtime service keys, error tags, and behavior are unchanged.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
"@opencode-ai/app": patch
|
||||
---
|
||||
|
||||
Keep project labels stable when opening multiple clones of the same repository, while still refreshing the canonical path when its directory is renamed or removed.
|
||||
|
||||
Worktree setup scripts receive the selected source directory as `OPENCODE_WORKTREE_BASE` rather than another clone's shared project path.
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { LLM, LLMRequest, LLMResponse } from "../src/index.js"
|
||||
import { Route, Endpoint, LLMClient, Protocol, type FramingDef } from "../src/route.js"
|
||||
@@ -148,15 +148,13 @@ describe("llm route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("builds models from configured routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const configured = fake.with({ provider: "fake-provider", endpoint: { baseURL: "https://fake.local" } })
|
||||
test("builds models from configured routes", () => {
|
||||
const configured = fake.with({ provider: "fake-provider", endpoint: { baseURL: "https://fake.local" } })
|
||||
|
||||
expect(configured.model({ id: "fake-model" })).toMatchObject({
|
||||
provider: "fake-provider",
|
||||
})
|
||||
}),
|
||||
)
|
||||
expect(configured.model({ id: "fake-model" })).toMatchObject({
|
||||
provider: "fake-provider",
|
||||
})
|
||||
})
|
||||
|
||||
it.effect("does not register duplicate route ids globally", () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
+114
-167
@@ -1,10 +1,10 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer, Ref, Stream } from "effect"
|
||||
import { Headers, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Deferred, Effect, Fiber, Ref, Stream } from "effect"
|
||||
import { Headers, HttpClientError, HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, AIError, HttpContext, InvalidProviderOutputError, TransportError } from "../src/index.js"
|
||||
import { LLMClient, RequestExecutor, WebSocketTransport, type WebSocketChannelExecutor } from "../src/route.js"
|
||||
import * as OpenAIChat from "../src/protocols/openai-chat.js"
|
||||
import * as OpenAI from "../src/providers/openai.js"
|
||||
import { route } from "../src/protocols/openai-chat.js"
|
||||
import { configure } from "../src/providers/openai.js"
|
||||
import { dynamicResponse, fixedResponse, systemError } from "./lib/http.js"
|
||||
import { deltaChunk } from "./lib/openai-chunks.js"
|
||||
import { sseEvents, sseRaw } from "./lib/sse.js"
|
||||
@@ -18,47 +18,6 @@ const secretRequest = HttpClientRequest.post("https://provider.test/v1/chat?api_
|
||||
HttpClientRequest.setHeaders(Headers.fromInput({ authorization: "Bearer header-secret-456" })),
|
||||
)
|
||||
|
||||
const responsesLayer = (responses: ReadonlyArray<Response>) =>
|
||||
RequestExecutor.layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const cursor = yield* Ref.make(0)
|
||||
return Layer.succeed(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.make((request) =>
|
||||
Effect.gen(function* () {
|
||||
const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
|
||||
return HttpClientResponse.fromWeb(request, responses[index] ?? responses[responses.length - 1])
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const countedResponsesLayer = (attempts: Ref.Ref<number>, responses: ReadonlyArray<Response>) =>
|
||||
RequestExecutor.layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const cursor = yield* Ref.make(0)
|
||||
return Layer.succeed(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.make((request) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Ref.update(attempts, (value) => value + 1)
|
||||
const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
|
||||
return HttpClientResponse.fromWeb(request, responses[index] ?? responses[responses.length - 1])
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const expectAIError = (error: unknown) => {
|
||||
expect(error).toBeInstanceOf(AIError)
|
||||
if (!(error instanceof AIError)) throw new Error("expected AIError")
|
||||
@@ -107,19 +66,17 @@ describe("RequestExecutor", () => {
|
||||
return yield* executor.execute(request).pipe(Effect.flip)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.error(cause)
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 503,
|
||||
headers: { "x-request-id": "req_failed_body" },
|
||||
fixedResponse(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.error(cause)
|
||||
},
|
||||
),
|
||||
]),
|
||||
}),
|
||||
{
|
||||
status: 503,
|
||||
headers: { "x-request-id": "req_failed_body" },
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -148,15 +105,14 @@ describe("RequestExecutor", () => {
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.error(systemError("ECONNRESET", "disconnected query-secret-123 header-secret-456"))
|
||||
},
|
||||
}),
|
||||
),
|
||||
]),
|
||||
fixedResponse(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.error(systemError("ECONNRESET", "disconnected query-secret-123 header-secret-456"))
|
||||
},
|
||||
}),
|
||||
{},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -176,15 +132,14 @@ describe("RequestExecutor", () => {
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
pull(controller) {
|
||||
controller.error(new TypeError("fetch failed", { cause: systemError("ECONNRESET", "socket closed") }))
|
||||
},
|
||||
}),
|
||||
),
|
||||
]),
|
||||
fixedResponse(
|
||||
new ReadableStream({
|
||||
pull(controller) {
|
||||
controller.error(new TypeError("fetch failed", { cause: systemError("ECONNRESET", "socket closed") }))
|
||||
},
|
||||
}),
|
||||
{},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -200,7 +155,7 @@ describe("RequestExecutor", () => {
|
||||
expect(error.message).toBe("plugin rejected request")
|
||||
expect(error.reason.cause).toBeInstanceOf(Error)
|
||||
expect(error.reason.http).toBeUndefined()
|
||||
}).pipe(Effect.provide(responsesLayer([]))),
|
||||
}).pipe(Effect.provide(dynamicResponse(() => Effect.die(new Error("unexpected HTTP request"))))),
|
||||
)
|
||||
|
||||
it.effect("reports the request sent by middleware", () =>
|
||||
@@ -249,11 +204,9 @@ describe("RequestExecutor", () => {
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" })
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
new Response('{"error":{"code":"context_length_exceeded","message":"prompt too long"}}', {
|
||||
status: 400,
|
||||
}),
|
||||
]),
|
||||
fixedResponse('{"error":{"code":"context_length_exceeded","message":"prompt too long"}}', {
|
||||
status: 400,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -269,7 +222,7 @@ describe("RequestExecutor", () => {
|
||||
classification: "payload-too-large",
|
||||
})
|
||||
expect(error.reason.http?.status).toBe(413)
|
||||
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
|
||||
}).pipe(Effect.provide(fixedResponse("request too large", { status: 413 }))),
|
||||
)
|
||||
|
||||
it.effect("classifies Anthropic request_too_large as context overflow", () =>
|
||||
@@ -285,11 +238,9 @@ describe("RequestExecutor", () => {
|
||||
expect(error.reason.http?.status).toBe(413)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
new Response('{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}', {
|
||||
status: 413,
|
||||
}),
|
||||
]),
|
||||
fixedResponse('{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}', {
|
||||
status: 413,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -303,7 +254,7 @@ describe("RequestExecutor", () => {
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
|
||||
expect(error.message).toBe("Provider request failed with HTTP 400")
|
||||
}).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))),
|
||||
}).pipe(Effect.provide(fixedResponse("invalid parameter", { status: 400 }))),
|
||||
)
|
||||
|
||||
it.effect("preserves structured provider messages from large error bodies", () =>
|
||||
@@ -317,15 +268,13 @@ describe("RequestExecutor", () => {
|
||||
expect(error.reason.body).toContain(largeProviderMessage)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
model: "gpt-5.6-sol",
|
||||
error: { type: "invalid_request", message: largeProviderMessage },
|
||||
}),
|
||||
{ status: 400 },
|
||||
),
|
||||
]),
|
||||
fixedResponse(
|
||||
JSON.stringify({
|
||||
model: "test-model",
|
||||
error: { type: "invalid_request", message: largeProviderMessage },
|
||||
}),
|
||||
{ status: 400 },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -340,7 +289,7 @@ describe("RequestExecutor", () => {
|
||||
_tag: "InvalidRequest",
|
||||
})
|
||||
expect(error.message).toBe("Provider request failed with HTTP 400")
|
||||
}).pipe(Effect.provide(responsesLayer([new Response('{"error":{"message":" "}}', { status: 400 })]))),
|
||||
}).pipe(Effect.provide(fixedResponse('{"error":{"message":" "}}', { status: 400 }))),
|
||||
)
|
||||
|
||||
it.effect("classifies provider rate limits hidden behind HTTP 400", () =>
|
||||
@@ -352,7 +301,7 @@ describe("RequestExecutor", () => {
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "RateLimit" })
|
||||
}).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })])))
|
||||
}).pipe(Effect.provide(fixedResponse(body, { status: 400 })))
|
||||
|
||||
yield* classify("Request rate increased too quickly")
|
||||
yield* classify('{"type":"error","error":{"type":"too_many_requests"}}')
|
||||
@@ -369,7 +318,7 @@ describe("RequestExecutor", () => {
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
|
||||
}).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })])))
|
||||
}).pipe(Effect.provide(fixedResponse(body, { status: 400 })))
|
||||
|
||||
yield* classify('{"code":"resource_exhausted"}')
|
||||
yield* classify('{"code":"service_unavailable"}')
|
||||
@@ -399,12 +348,10 @@ describe("RequestExecutor", () => {
|
||||
expect(error.reason.body).toBe("rate limited")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
new Response("rate limited", {
|
||||
status: 429,
|
||||
headers: { "retry-after-ms": "0", "x-request-id": "req_123", "x-api-key": "secret" },
|
||||
}),
|
||||
]),
|
||||
fixedResponse("rate limited", {
|
||||
status: 429,
|
||||
headers: { "retry-after-ms": "0", "x-request-id": "req_123", "x-api-key": "secret" },
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -417,7 +364,7 @@ describe("RequestExecutor", () => {
|
||||
expectAIError(error)
|
||||
expect(error.reason.http?.headers["x-safe"]).toBe("response-secret")
|
||||
}).pipe(
|
||||
Effect.provide(responsesLayer([new Response("bad", { status: 400, headers: { "x-safe": "response-secret" } })])),
|
||||
Effect.provide(fixedResponse("bad", { status: 400, headers: { "x-safe": "response-secret" } })),
|
||||
Effect.provideService(Headers.CurrentRedactedNames, ["x-safe"]),
|
||||
),
|
||||
)
|
||||
@@ -437,20 +384,18 @@ describe("RequestExecutor", () => {
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
new Response("rate limited", {
|
||||
status: 429,
|
||||
headers: {
|
||||
"retry-after-ms": "0",
|
||||
"x-ratelimit-limit-requests": "500",
|
||||
"x-ratelimit-limit-tokens": "30000",
|
||||
"x-ratelimit-remaining-requests": "499",
|
||||
"x-ratelimit-remaining-tokens": "29900",
|
||||
"x-ratelimit-reset-requests": "1s",
|
||||
"x-ratelimit-reset-tokens": "10s",
|
||||
},
|
||||
}),
|
||||
]),
|
||||
fixedResponse("rate limited", {
|
||||
status: 429,
|
||||
headers: {
|
||||
"retry-after-ms": "0",
|
||||
"x-ratelimit-limit-requests": "500",
|
||||
"x-ratelimit-limit-tokens": "30000",
|
||||
"x-ratelimit-remaining-requests": "499",
|
||||
"x-ratelimit-remaining-tokens": "29900",
|
||||
"x-ratelimit-reset-requests": "1s",
|
||||
"x-ratelimit-reset-tokens": "10s",
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -470,20 +415,18 @@ describe("RequestExecutor", () => {
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
new Response("rate limited", {
|
||||
status: 429,
|
||||
headers: {
|
||||
"retry-after-ms": "0",
|
||||
"anthropic-ratelimit-requests-limit": "100",
|
||||
"anthropic-ratelimit-requests-remaining": "12",
|
||||
"anthropic-ratelimit-requests-reset": "2026-05-06T12:00:00Z",
|
||||
"anthropic-ratelimit-input-tokens-limit": "10000",
|
||||
"anthropic-ratelimit-input-tokens-remaining": "9000",
|
||||
"anthropic-ratelimit-input-tokens-reset": "2026-05-06T12:00:10Z",
|
||||
},
|
||||
}),
|
||||
]),
|
||||
fixedResponse("rate limited", {
|
||||
status: 429,
|
||||
headers: {
|
||||
"retry-after-ms": "0",
|
||||
"anthropic-ratelimit-requests-limit": "100",
|
||||
"anthropic-ratelimit-requests-remaining": "12",
|
||||
"anthropic-ratelimit-requests-reset": "2026-05-06T12:00:00Z",
|
||||
"anthropic-ratelimit-input-tokens-limit": "10000",
|
||||
"anthropic-ratelimit-input-tokens-remaining": "9000",
|
||||
"anthropic-ratelimit-input-tokens-reset": "2026-05-06T12:00:10Z",
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -496,10 +439,14 @@ describe("RequestExecutor", () => {
|
||||
return yield* executor.execute(request).pipe(Effect.flip)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
countedResponsesLayer(attempts, [
|
||||
new Response("busy", { status: 503, headers: { "retry-after-ms": "0" } }),
|
||||
new Response("ok", { status: 200 }),
|
||||
]),
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const attempt = yield* Ref.getAndUpdate(attempts, (value) => value + 1)
|
||||
return attempt === 0
|
||||
? input.respond("busy", { status: 503, headers: { "retry-after-ms": "0" } })
|
||||
: input.respond("ok", { status: 200 })
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -522,12 +469,10 @@ describe("RequestExecutor", () => {
|
||||
expect(error.reason.http?.status).toBe(status)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
new Response("provider failure", {
|
||||
status,
|
||||
headers: { "retry-after-ms": "0" },
|
||||
}),
|
||||
]),
|
||||
fixedResponse("provider failure", {
|
||||
status,
|
||||
headers: { "retry-after-ms": "0" },
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -538,20 +483,28 @@ describe("RequestExecutor", () => {
|
||||
|
||||
it.effect("preserves large authentication error bodies", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
const attempts = yield* Ref.make(0)
|
||||
const error = yield* Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
return yield* executor.execute(request).pipe(Effect.flip)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const attempt = yield* Ref.getAndUpdate(attempts, (value) => value + 1)
|
||||
return attempt === 0
|
||||
? input.respond("x".repeat(20_000), { status: 401 })
|
||||
: input.respond("should not retry", { status: 200 })
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "Authentication" })
|
||||
expect(error.reason.body).toHaveLength(20_000)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
new Response("x".repeat(20_000), { status: 401 }),
|
||||
new Response("should not retry", { status: 200 }),
|
||||
]),
|
||||
),
|
||||
),
|
||||
expect(yield* Ref.get(attempts)).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves response body fields", () =>
|
||||
@@ -563,11 +516,9 @@ describe("RequestExecutor", () => {
|
||||
expect(error.reason.body).toBe('{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}')
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
new Response('{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}', {
|
||||
status: 400,
|
||||
}),
|
||||
]),
|
||||
fixedResponse('{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}', {
|
||||
status: 400,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -581,9 +532,7 @@ describe("RequestExecutor", () => {
|
||||
expect(error.reason.body).toBe("provider echoed query-secret-123 and authorization header-secret-456")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
new Response("provider echoed query-secret-123 and authorization header-secret-456", { status: 400 }),
|
||||
]),
|
||||
fixedResponse("provider echoed query-secret-123 and authorization header-secret-456", { status: 400 }),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -591,9 +540,7 @@ describe("RequestExecutor", () => {
|
||||
it.effect("does not re-execute after a successful response reaches stream parsing", () =>
|
||||
Effect.gen(function* () {
|
||||
const attempts = yield* Ref.make(0)
|
||||
const model = OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
|
||||
.model({ id: "gpt-4o-mini" })
|
||||
const model = route.with({ endpoint: { baseURL: "https://api.openai.test/v1" } }).model({ id: "gpt-4o-mini" })
|
||||
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
@@ -624,7 +571,7 @@ describe("RequestExecutor", () => {
|
||||
})
|
||||
|
||||
describe("WebSocket channel execution", () => {
|
||||
const model = OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini")
|
||||
const model = configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini")
|
||||
const request = LLM.request({ model, prompt: "Say hello." })
|
||||
const frames = [
|
||||
JSON.stringify({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
|
||||
|
||||
@@ -44,21 +44,23 @@ describe("Tool.make (dynamic JSON Schema)", () => {
|
||||
expect(definition?.inputSchema).toEqual(jsonSchema)
|
||||
})
|
||||
|
||||
test("execute receives the raw input untouched", async () => {
|
||||
const seen: unknown[] = []
|
||||
const tool = Tool.make({
|
||||
description: "echo",
|
||||
jsonSchema: { type: "object" },
|
||||
execute: (params) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(params)
|
||||
return { ok: true }
|
||||
}),
|
||||
})
|
||||
const result = await Effect.runPromise(tool.execute({ hello: "world" }))
|
||||
expect(seen).toEqual([{ hello: "world" }])
|
||||
expect(result).toEqual({ ok: true })
|
||||
})
|
||||
it.effect("execute receives the raw input untouched", () =>
|
||||
Effect.gen(function* () {
|
||||
const seen: unknown[] = []
|
||||
const tool = Tool.make({
|
||||
description: "echo",
|
||||
jsonSchema: { type: "object" },
|
||||
execute: (params) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(params)
|
||||
return { ok: true }
|
||||
}),
|
||||
})
|
||||
const result = yield* tool.execute({ hello: "world" })
|
||||
expect(seen).toEqual([{ hello: "world" }])
|
||||
expect(result).toEqual({ ok: true })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("LLM.generateObject", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ConfigProvider, Effect, Schema } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, LLMEvent } from "../../src/index.js"
|
||||
@@ -136,17 +136,15 @@ describe("Cloudflare", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("defaults AI Gateway id to default when omitted or blank", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
CloudflareAIGateway.configure({
|
||||
accountId: "test-account",
|
||||
gatewayId: "",
|
||||
gatewayApiKey: "test-token",
|
||||
}).model("workers-ai/@cf/meta/llama-3.3-70b-instruct").route.endpoint.baseURL,
|
||||
).toBe("https://gateway.ai.cloudflare.com/v1/test-account/default/compat")
|
||||
}),
|
||||
)
|
||||
test("defaults AI Gateway id to default when omitted or blank", () => {
|
||||
expect(
|
||||
CloudflareAIGateway.configure({
|
||||
accountId: "test-account",
|
||||
gatewayId: "",
|
||||
gatewayApiKey: "test-token",
|
||||
}).model("workers-ai/@cf/meta/llama-3.3-70b-instruct").route.endpoint.baseURL,
|
||||
).toBe("https://gateway.ai.cloudflare.com/v1/test-account/default/compat")
|
||||
})
|
||||
|
||||
it.effect("supports authenticated AI Gateway plus upstream provider auth", () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, Message, ToolCallPart } from "../../src/index.js"
|
||||
@@ -376,11 +376,9 @@ describe("Google Vertex providers", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects tuned Gemini models in express mode", () =>
|
||||
Effect.sync(() => {
|
||||
expect(() => GoogleVertex.configure({ apiKey: "fixture" }).model("endpoints/1234567890")).toThrow(
|
||||
"Google Vertex tuned models do not support Express Mode API keys",
|
||||
)
|
||||
}),
|
||||
)
|
||||
test("rejects tuned Gemini models in express mode", () => {
|
||||
expect(() => GoogleVertex.configure({ apiKey: "fixture" }).model("endpoints/1234567890")).toThrow(
|
||||
"Google Vertex tuned models do not support Express Mode API keys",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ConfigProvider, Effect } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, Message, ToolDefinition } from "../../src/index.js"
|
||||
@@ -31,82 +31,71 @@ import { dynamicResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
describe("native OpenAI-compatible providers", () => {
|
||||
it.effect("assigns provider-owned metadata namespaces across native routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const vertex = { project: "project", accessToken: "token" }
|
||||
const providers = [
|
||||
[OpenAI.configure({ apiKey: "test" }).chat("model"), "openai"],
|
||||
[OpenAI.configure({ apiKey: "test" }).responses("model"), "openai"],
|
||||
[Azure.configure({ resourceName: "resource", apiKey: "test" }).chat("model"), "azure"],
|
||||
[Azure.configure({ resourceName: "resource", apiKey: "test" }).responses("model"), "azure"],
|
||||
[AmazonBedrock.configure({ apiKey: "test" }).model("model"), "bedrock"],
|
||||
[AmazonBedrockMantle.configure({ apiKey: "test" }).chat("model"), "mantle"],
|
||||
[AmazonBedrockMantle.configure({ apiKey: "test" }).responses("model"), "mantle"],
|
||||
[Google.configure({ apiKey: "test" }).model("model"), "google"],
|
||||
[GoogleVertex.configure(vertex).model("model"), "vertex"],
|
||||
[GoogleVertexChat.configure(vertex).model("model"), "vertex"],
|
||||
[GoogleVertexResponses.configure(vertex).model("model"), "vertex"],
|
||||
[GoogleVertexMessages.configure(vertex).model("model"), "anthropic"],
|
||||
[Anthropic.configure({ apiKey: "test" }).model("model"), "anthropic"],
|
||||
[
|
||||
AnthropicCompatible.configure({ baseURL: "https://example.test/v1", provider: "minimax" }).model("model"),
|
||||
"minimax",
|
||||
],
|
||||
[
|
||||
OpenAICompatible.configure({ baseURL: "https://example.test/v1", provider: "custom" }).model("model"),
|
||||
"custom",
|
||||
],
|
||||
[
|
||||
OpenAICompatibleResponses.configure({ baseURL: "https://example.test/v1", provider: "custom" }).model(
|
||||
"model",
|
||||
),
|
||||
"custom",
|
||||
],
|
||||
[Cerebras.configure({ apiKey: "test" }).model("model"), "cerebras"],
|
||||
[DeepInfra.configure({ apiKey: "test" }).model("model"), "deepinfra"],
|
||||
[TogetherAI.configure({ apiKey: "test" }).model("model"), "togetherai"],
|
||||
[CloudflareAIGateway.configure({ accountId: "account" }).model("model"), "cloudflare-ai-gateway"],
|
||||
[CloudflareWorkersAI.configure({ accountId: "account" }).model("model"), "cloudflare-workers-ai"],
|
||||
[OpenRouter.configure({ apiKey: "test" }).model("model"), "openrouter"],
|
||||
[XAI.configure({ apiKey: "test" }).chat("model"), "xai"],
|
||||
[XAI.configure({ apiKey: "test" }).responses("model"), "xai"],
|
||||
] as const
|
||||
test("assigns provider-owned metadata namespaces across native routes", () => {
|
||||
const vertex = { project: "project", accessToken: "token" }
|
||||
const providers = [
|
||||
[OpenAI.configure({ apiKey: "test" }).chat("model"), "openai"],
|
||||
[OpenAI.configure({ apiKey: "test" }).responses("model"), "openai"],
|
||||
[Azure.configure({ resourceName: "resource", apiKey: "test" }).chat("model"), "azure"],
|
||||
[Azure.configure({ resourceName: "resource", apiKey: "test" }).responses("model"), "azure"],
|
||||
[AmazonBedrock.configure({ apiKey: "test" }).model("model"), "bedrock"],
|
||||
[AmazonBedrockMantle.configure({ apiKey: "test" }).chat("model"), "mantle"],
|
||||
[AmazonBedrockMantle.configure({ apiKey: "test" }).responses("model"), "mantle"],
|
||||
[Google.configure({ apiKey: "test" }).model("model"), "google"],
|
||||
[GoogleVertex.configure(vertex).model("model"), "vertex"],
|
||||
[GoogleVertexChat.configure(vertex).model("model"), "vertex"],
|
||||
[GoogleVertexResponses.configure(vertex).model("model"), "vertex"],
|
||||
[GoogleVertexMessages.configure(vertex).model("model"), "anthropic"],
|
||||
[Anthropic.configure({ apiKey: "test" }).model("model"), "anthropic"],
|
||||
[
|
||||
AnthropicCompatible.configure({ baseURL: "https://example.test/v1", provider: "minimax" }).model("model"),
|
||||
"minimax",
|
||||
],
|
||||
[OpenAICompatible.configure({ baseURL: "https://example.test/v1", provider: "custom" }).model("model"), "custom"],
|
||||
[
|
||||
OpenAICompatibleResponses.configure({ baseURL: "https://example.test/v1", provider: "custom" }).model("model"),
|
||||
"custom",
|
||||
],
|
||||
[Cerebras.configure({ apiKey: "test" }).model("model"), "cerebras"],
|
||||
[DeepInfra.configure({ apiKey: "test" }).model("model"), "deepinfra"],
|
||||
[TogetherAI.configure({ apiKey: "test" }).model("model"), "togetherai"],
|
||||
[CloudflareAIGateway.configure({ accountId: "account" }).model("model"), "cloudflare-ai-gateway"],
|
||||
[CloudflareWorkersAI.configure({ accountId: "account" }).model("model"), "cloudflare-workers-ai"],
|
||||
[OpenRouter.configure({ apiKey: "test" }).model("model"), "openrouter"],
|
||||
[XAI.configure({ apiKey: "test" }).chat("model"), "xai"],
|
||||
[XAI.configure({ apiKey: "test" }).responses("model"), "xai"],
|
||||
] as const
|
||||
|
||||
for (const [model, key] of providers) expect(model.route.providerMetadataKey).toBe(key)
|
||||
}),
|
||||
)
|
||||
for (const [model, key] of providers) expect(model.route.providerMetadataKey).toBe(key)
|
||||
})
|
||||
|
||||
it.effect("preserves native Together AI and Cerebras provider and route identities", () =>
|
||||
Effect.gen(function* () {
|
||||
const together = TogetherAI.configure({ apiKey: "fixture" }).model("meta-llama/Llama-3.3-70B")
|
||||
const cerebras = Cerebras.configure({ apiKey: "fixture" }).model("qwen-3-235b-a22b")
|
||||
test("preserves native Together AI and Cerebras provider and route identities", () => {
|
||||
const together = TogetherAI.configure({ apiKey: "fixture" }).model("meta-llama/Llama-3.3-70B")
|
||||
const cerebras = Cerebras.configure({ apiKey: "fixture" }).model("qwen-3-235b-a22b")
|
||||
|
||||
expect(together).toMatchObject({
|
||||
provider: "togetherai",
|
||||
compatibility: { maxTokensField: "max_tokens", supportsStore: false, supportsStrictMode: false },
|
||||
route: { id: "togetherai-chat", protocol: "openai-chat" },
|
||||
})
|
||||
expect(together.route.endpoint.baseURL).toBe("https://api.together.xyz/v1")
|
||||
expect(cerebras).toMatchObject({
|
||||
provider: "cerebras",
|
||||
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning", supportsStore: false },
|
||||
route: { id: "cerebras-chat", protocol: "openai-chat" },
|
||||
})
|
||||
expect(cerebras.route.endpoint.baseURL).toBe("https://api.cerebras.ai/v1")
|
||||
}),
|
||||
)
|
||||
expect(together).toMatchObject({
|
||||
provider: "togetherai",
|
||||
compatibility: { maxTokensField: "max_tokens", supportsStore: false, supportsStrictMode: false },
|
||||
route: { id: "togetherai-chat", protocol: "openai-chat" },
|
||||
})
|
||||
expect(together.route.endpoint.baseURL).toBe("https://api.together.xyz/v1")
|
||||
expect(cerebras).toMatchObject({
|
||||
provider: "cerebras",
|
||||
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning", supportsStore: false },
|
||||
route: { id: "cerebras-chat", protocol: "openai-chat" },
|
||||
})
|
||||
expect(cerebras.route.endpoint.baseURL).toBe("https://api.cerebras.ai/v1")
|
||||
})
|
||||
|
||||
it.effect("preserves native DeepInfra provider and route identity", () =>
|
||||
Effect.gen(function* () {
|
||||
const deepinfra = DeepInfra.configure({ apiKey: "fixture" }).model("google/gemma-3-27b-it")
|
||||
expect(deepinfra).toMatchObject({
|
||||
provider: "deepinfra",
|
||||
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning_content", supportsStore: false },
|
||||
route: { id: "deepinfra-chat", protocol: "openai-chat" },
|
||||
})
|
||||
expect(deepinfra.route.endpoint.baseURL).toBe("https://api.deepinfra.com/v1/openai")
|
||||
}),
|
||||
)
|
||||
test("preserves native DeepInfra provider and route identity", () => {
|
||||
const deepinfra = DeepInfra.configure({ apiKey: "fixture" }).model("google/gemma-3-27b-it")
|
||||
expect(deepinfra).toMatchObject({
|
||||
provider: "deepinfra",
|
||||
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning_content", supportsStore: false },
|
||||
route: { id: "deepinfra-chat", protocol: "openai-chat" },
|
||||
})
|
||||
expect(deepinfra.route.endpoint.baseURL).toBe("https://api.deepinfra.com/v1/openai")
|
||||
})
|
||||
|
||||
it.effect("applies native provider request defaults even with a custom gateway URL", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -161,39 +150,35 @@ describe("native OpenAI-compatible providers", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("normalizes DeepInfra API roots without duplicating the OpenAI path", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const baseURL of [
|
||||
"https://gateway.example/v1",
|
||||
"https://gateway.example/v1/",
|
||||
test("normalizes DeepInfra API roots without duplicating the OpenAI path", () => {
|
||||
for (const baseURL of [
|
||||
"https://gateway.example/v1",
|
||||
"https://gateway.example/v1/",
|
||||
"https://gateway.example/v1/openai",
|
||||
"https://gateway.example/v1/openai/",
|
||||
]) {
|
||||
expect(DeepInfra.configure({ apiKey: "fixture", baseURL }).model("gemma").route.endpoint.baseURL).toBe(
|
||||
"https://gateway.example/v1/openai",
|
||||
"https://gateway.example/v1/openai/",
|
||||
]) {
|
||||
expect(DeepInfra.configure({ apiKey: "fixture", baseURL }).model("gemma").route.endpoint.baseURL).toBe(
|
||||
"https://gateway.example/v1/openai",
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it.effect("maps package settings onto native executable models", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const native of [TogetherAI, Cerebras]) {
|
||||
const selected = native.model("provider-model", {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://gateway.example/v1",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { service_tier: "priority" },
|
||||
providerOptions: { reasoningEffort: "high" },
|
||||
})
|
||||
test("maps package settings onto native executable models", () => {
|
||||
for (const native of [TogetherAI, Cerebras]) {
|
||||
const selected = native.model("provider-model", {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://gateway.example/v1",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { service_tier: "priority" },
|
||||
providerOptions: { reasoningEffort: "high" },
|
||||
})
|
||||
|
||||
expect(selected.route.endpoint.baseURL).toBe("https://gateway.example/v1")
|
||||
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
|
||||
expect(selected.route.defaults.providerOptions).toEqual({ reasoningEffort: "high" })
|
||||
}
|
||||
}),
|
||||
)
|
||||
expect(selected.route.endpoint.baseURL).toBe("https://gateway.example/v1")
|
||||
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
|
||||
expect(selected.route.defaults.providerOptions).toEqual({ reasoningEffort: "high" })
|
||||
}
|
||||
})
|
||||
|
||||
it.effect("resolves provider environment credentials and preserves deprecated Together credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, LLMRequest, Message, ToolCallPart, ToolChoice, ToolDefinition } from "../../src/index.js"
|
||||
@@ -91,40 +91,38 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("provides model helpers for compatible provider families", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
providerFamilies.map(([provider, family]) => {
|
||||
const model = family.configure({ apiKey: "test-key" }).model(`${provider}-model`)
|
||||
return {
|
||||
id: String(model.id),
|
||||
provider: String(model.provider),
|
||||
route: model.route.id,
|
||||
baseURL: model.route.endpoint.baseURL,
|
||||
}
|
||||
}),
|
||||
).toEqual(
|
||||
providerFamilies.map(([provider, _, baseURL]) => ({
|
||||
id: `${provider}-model`,
|
||||
provider,
|
||||
route: "openai-compatible-chat",
|
||||
baseURL,
|
||||
})),
|
||||
)
|
||||
test("provides model helpers for compatible provider families", () => {
|
||||
expect(
|
||||
providerFamilies.map(([provider, family]) => {
|
||||
const model = family.configure({ apiKey: "test-key" }).model(`${provider}-model`)
|
||||
return {
|
||||
id: String(model.id),
|
||||
provider: String(model.provider),
|
||||
route: model.route.id,
|
||||
baseURL: model.route.endpoint.baseURL,
|
||||
}
|
||||
}),
|
||||
).toEqual(
|
||||
providerFamilies.map(([provider, _, baseURL]) => ({
|
||||
id: `${provider}-model`,
|
||||
provider,
|
||||
route: "openai-compatible-chat",
|
||||
baseURL,
|
||||
})),
|
||||
)
|
||||
|
||||
const custom = OpenAICompatible.deepseek
|
||||
.configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://custom.deepseek.test/v1",
|
||||
})
|
||||
.model("deepseek-chat")
|
||||
expect(custom).toMatchObject({
|
||||
provider: "deepseek",
|
||||
route: { id: "openai-compatible-chat" },
|
||||
const custom = OpenAICompatible.deepseek
|
||||
.configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://custom.deepseek.test/v1",
|
||||
})
|
||||
expect(custom.route.endpoint.baseURL).toBe("https://custom.deepseek.test/v1")
|
||||
}),
|
||||
)
|
||||
.model("deepseek-chat")
|
||||
expect(custom).toMatchObject({
|
||||
provider: "deepseek",
|
||||
route: { id: "openai-compatible-chat" },
|
||||
})
|
||||
expect(custom.route.endpoint.baseURL).toBe("https://custom.deepseek.test/v1")
|
||||
})
|
||||
|
||||
it.effect("matches AI SDK compatible basic request body fixture", () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Usage,
|
||||
} from "../src/schema/index.js"
|
||||
import { ProviderShared } from "../src/protocols/shared.js"
|
||||
import { it } from "./lib/effect.js"
|
||||
|
||||
const model = new LanguageModel({
|
||||
id: ModelID.make("fake-model"),
|
||||
@@ -101,49 +102,48 @@ describe("AI.Usage", () => {
|
||||
expect(ProviderShared.sumTokens()).toBeUndefined()
|
||||
})
|
||||
|
||||
test("sseFraming maps decoder failures to AI errors", async () => {
|
||||
const error = await Effect.runPromise(
|
||||
ProviderShared.sseFraming(Stream.make(new TextEncoder().encode(`data: ${"x".repeat(10 * 1024 * 1024)}`))).pipe(
|
||||
Stream.runCollect,
|
||||
Effect.flip,
|
||||
),
|
||||
)
|
||||
it.effect("sseFraming maps decoder failures to AI errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* ProviderShared.sseFraming(
|
||||
Stream.make(new TextEncoder().encode(`data: ${"x".repeat(10 * 1024 * 1024)}`)),
|
||||
).pipe(Stream.runCollect, Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(AIError)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
})
|
||||
expect(error).toBeInstanceOf(AIError)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
}),
|
||||
)
|
||||
|
||||
test("sseFraming ignores retry directives without ending the stream", async () => {
|
||||
const encoder = new TextEncoder()
|
||||
const frames = await Effect.runPromise(
|
||||
ProviderShared.sseFraming(
|
||||
it.effect("sseFraming ignores retry directives without ending the stream", () =>
|
||||
Effect.gen(function* () {
|
||||
const encoder = new TextEncoder()
|
||||
const frames = yield* ProviderShared.sseFraming(
|
||||
Stream.make(
|
||||
encoder.encode("retry: 1000\n\n"),
|
||||
encoder.encode('data: {"first":true}\n\n'),
|
||||
encoder.encode("retry: 2000\n\n"),
|
||||
encoder.encode('data: {"second":true}\n\n'),
|
||||
).pipe(Stream.rechunk(1)),
|
||||
).pipe(Stream.runCollect),
|
||||
)
|
||||
).pipe(Stream.runCollect)
|
||||
|
||||
expect(Array.from(frames)).toEqual(['{"first":true}', '{"second":true}'])
|
||||
})
|
||||
expect(Array.from(frames)).toEqual(['{"first":true}', '{"second":true}'])
|
||||
}),
|
||||
)
|
||||
|
||||
test("sseFraming preserves event data around retry directives", async () => {
|
||||
const encoder = new TextEncoder()
|
||||
const frames = await Effect.runPromise(
|
||||
ProviderShared.sseFraming(
|
||||
it.effect("sseFraming preserves event data around retry directives", () =>
|
||||
Effect.gen(function* () {
|
||||
const encoder = new TextEncoder()
|
||||
const frames = yield* ProviderShared.sseFraming(
|
||||
Stream.make(
|
||||
encoder.encode("event: update\ndata: first\n"),
|
||||
encoder.encode("retry: 1000\n"),
|
||||
encoder.encode("data: second\n\n"),
|
||||
).pipe(Stream.rechunk(1)),
|
||||
new Set(["update"]),
|
||||
).pipe(Stream.runCollect),
|
||||
)
|
||||
).pipe(Stream.runCollect)
|
||||
|
||||
expect(Array.from(frames)).toEqual(["first\nsecond"])
|
||||
})
|
||||
expect(Array.from(frames)).toEqual(["first\nsecond"])
|
||||
}),
|
||||
)
|
||||
|
||||
test("visibleOutputTokens clamps reasoning > output to zero", () => {
|
||||
expect(new Usage({ outputTokens: 10, reasoningTokens: 4 }).visibleOutputTokens).toBe(6)
|
||||
@@ -153,18 +153,18 @@ describe("AI.Usage", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("AI errors expose the shared runtime tag", async () => {
|
||||
const error = new AIError({
|
||||
reason: new InvalidRequestError({ message: "invalid" }),
|
||||
})
|
||||
expect(error._tag).toBe("AI.Error")
|
||||
expect(error.message).toBe("invalid")
|
||||
expect(error.cause).toBe(error.reason)
|
||||
expect(error.reason.cause).toBeUndefined()
|
||||
expect(
|
||||
await Effect.runPromise(Effect.fail(error).pipe(Effect.catchTag("AI.Error", () => Effect.succeed("caught")))),
|
||||
).toBe("caught")
|
||||
})
|
||||
it.effect("AI errors expose the shared runtime tag", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = new AIError({
|
||||
reason: new InvalidRequestError({ message: "invalid" }),
|
||||
})
|
||||
expect(error._tag).toBe("AI.Error")
|
||||
expect(error.message).toBe("invalid")
|
||||
expect(error.cause).toBe(error.reason)
|
||||
expect(error.reason.cause).toBeUndefined()
|
||||
expect(yield* Effect.fail(error).pipe(Effect.catchTag("AI.Error", () => Effect.succeed("caught")))).toBe("caught")
|
||||
}),
|
||||
)
|
||||
|
||||
test("transport errors serialize execution facts", () => {
|
||||
const reason = new TransportError({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Content } from "@opencode-ai/schema/tool"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import {
|
||||
@@ -277,65 +277,61 @@ describe("LLMClient tools", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("models canonical tool files with URIs", () =>
|
||||
Effect.sync(() => {
|
||||
const decode = Schema.decodeUnknownSync(Content)
|
||||
test("models canonical tool files with URIs", () => {
|
||||
const decode = Schema.decodeUnknownSync(Content)
|
||||
|
||||
expect(decode({ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" })).toEqual({
|
||||
type: "file",
|
||||
uri: "data:image/png;base64,AAAA",
|
||||
mime: "image/png",
|
||||
})
|
||||
expect(decode({ type: "file", uri: "https://example.test/image.png", mime: "image/png" })).toEqual({
|
||||
type: "file",
|
||||
uri: "https://example.test/image.png",
|
||||
mime: "image/png",
|
||||
})
|
||||
expect(decode({ type: "file", uri: "file:///tmp/image.png", mime: "image/png" })).toEqual({
|
||||
type: "file",
|
||||
uri: "file:///tmp/image.png",
|
||||
mime: "image/png",
|
||||
})
|
||||
}),
|
||||
)
|
||||
expect(decode({ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" })).toEqual({
|
||||
type: "file",
|
||||
uri: "data:image/png;base64,AAAA",
|
||||
mime: "image/png",
|
||||
})
|
||||
expect(decode({ type: "file", uri: "https://example.test/image.png", mime: "image/png" })).toEqual({
|
||||
type: "file",
|
||||
uri: "https://example.test/image.png",
|
||||
mime: "image/png",
|
||||
})
|
||||
expect(decode({ type: "file", uri: "file:///tmp/image.png", mime: "image/png" })).toEqual({
|
||||
type: "file",
|
||||
uri: "file:///tmp/image.png",
|
||||
mime: "image/png",
|
||||
})
|
||||
})
|
||||
|
||||
it.effect("preserves canonical tool file URIs", () =>
|
||||
Effect.sync(() => {
|
||||
expect(
|
||||
ToolOutput.toResultValue(
|
||||
ToolOutput.make({}, [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }]),
|
||||
),
|
||||
).toEqual({
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }],
|
||||
})
|
||||
expect(
|
||||
ToolOutput.toResultValue(
|
||||
ToolOutput.make({}, [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }]),
|
||||
),
|
||||
).toEqual({
|
||||
test("preserves canonical tool file URIs", () => {
|
||||
expect(
|
||||
ToolOutput.toResultValue(
|
||||
ToolOutput.make({}, [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }]),
|
||||
),
|
||||
).toEqual({
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }],
|
||||
})
|
||||
expect(
|
||||
ToolOutput.toResultValue(
|
||||
ToolOutput.make({}, [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }]),
|
||||
),
|
||||
).toEqual({
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
|
||||
})
|
||||
expect(
|
||||
ToolOutput.toResultValue(
|
||||
ToolOutput.make({}, [{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }]),
|
||||
),
|
||||
).toEqual({
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }],
|
||||
})
|
||||
expect(
|
||||
ToolOutput.fromResultValue({
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
|
||||
})
|
||||
expect(
|
||||
ToolOutput.toResultValue(
|
||||
ToolOutput.make({}, [{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }]),
|
||||
),
|
||||
).toEqual({
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }],
|
||||
})
|
||||
expect(
|
||||
ToolOutput.fromResultValue({
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
|
||||
}),
|
||||
).toEqual({
|
||||
structured: {},
|
||||
content: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
}),
|
||||
).toEqual({
|
||||
structured: {},
|
||||
content: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
|
||||
})
|
||||
})
|
||||
|
||||
it.effect("settles projected URL files as canonical tool results", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -364,24 +360,22 @@ describe("LLMClient tools", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("derives typed output schemas and preserves dynamic output schemas", () =>
|
||||
Effect.sync(() => {
|
||||
const [typed] = toDefinitions({ get_weather })
|
||||
const schema = { type: "object", properties: { result: { type: "string" } } } as const
|
||||
const [dynamic] = toDefinitions({
|
||||
dynamic: Tool.make({ description: "Dynamic tool.", jsonSchema: { type: "object" }, outputSchema: schema }),
|
||||
})
|
||||
test("derives typed output schemas and preserves dynamic output schemas", () => {
|
||||
const [typed] = toDefinitions({ get_weather })
|
||||
const schema = { type: "object", properties: { result: { type: "string" } } } as const
|
||||
const [dynamic] = toDefinitions({
|
||||
dynamic: Tool.make({ description: "Dynamic tool.", jsonSchema: { type: "object" }, outputSchema: schema }),
|
||||
})
|
||||
|
||||
expect(typed?.outputSchema).toMatchObject({
|
||||
type: "object",
|
||||
properties: { condition: { type: "string" } },
|
||||
required: ["temperature", "condition"],
|
||||
additionalProperties: false,
|
||||
})
|
||||
expect(Reflect.get(Reflect.get(typed?.outputSchema ?? {}, "properties") as object, "temperature")).toBeDefined()
|
||||
expect(dynamic?.outputSchema).toEqual(schema)
|
||||
}),
|
||||
)
|
||||
expect(typed?.outputSchema).toMatchObject({
|
||||
type: "object",
|
||||
properties: { condition: { type: "string" } },
|
||||
required: ["temperature", "condition"],
|
||||
additionalProperties: false,
|
||||
})
|
||||
expect(Reflect.get(Reflect.get(typed?.outputSchema ?? {}, "properties") as object, "temperature")).toBeDefined()
|
||||
expect(dynamic?.outputSchema).toEqual(schema)
|
||||
})
|
||||
|
||||
it.effect("preserves content tool results from dynamic tools", () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { AIError } from "../src/schema/index.js"
|
||||
import { ToolStream } from "../src/protocols/utils/tool-stream.js"
|
||||
@@ -38,44 +38,40 @@ describe("ToolStream", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("exposes cumulative partial string values", () =>
|
||||
Effect.gen(function* () {
|
||||
const result = ToolStream.appendOrStart(
|
||||
ADAPTER,
|
||||
ToolStream.empty<number>(),
|
||||
0,
|
||||
{ id: "call_1", name: "lookup", text: '{"query":"wea' },
|
||||
"missing tool",
|
||||
)
|
||||
if (ToolStream.isError(result)) return yield* result
|
||||
test("exposes cumulative partial string values", () => {
|
||||
const result = ToolStream.appendOrStart(
|
||||
ADAPTER,
|
||||
ToolStream.empty<number>(),
|
||||
0,
|
||||
{ id: "call_1", name: "lookup", text: '{"query":"wea' },
|
||||
"missing tool",
|
||||
)
|
||||
if (ToolStream.isError(result)) throw result
|
||||
|
||||
expect(result.events.at(-1)).toEqual({
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: '{"query":"wea',
|
||||
input: { query: "wea" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
expect(result.events.at(-1)).toEqual({
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: '{"query":"wea',
|
||||
input: { query: "wea" },
|
||||
})
|
||||
})
|
||||
|
||||
it.effect("defaults partial input to an empty object when the accumulated value cannot be parsed", () =>
|
||||
Effect.gen(function* () {
|
||||
const result = ToolStream.appendOrStart(
|
||||
ADAPTER,
|
||||
ToolStream.empty<number>(),
|
||||
0,
|
||||
{ id: "call_1", name: "lookup", text: "x" },
|
||||
"missing tool",
|
||||
)
|
||||
if (ToolStream.isError(result)) return yield* result
|
||||
test("defaults partial input to an empty object when the accumulated value cannot be parsed", () => {
|
||||
const result = ToolStream.appendOrStart(
|
||||
ADAPTER,
|
||||
ToolStream.empty<number>(),
|
||||
0,
|
||||
{ id: "call_1", name: "lookup", text: "x" },
|
||||
"missing tool",
|
||||
)
|
||||
if (ToolStream.isError(result)) throw result
|
||||
|
||||
expect(result.events).toEqual([
|
||||
{ type: "tool-input-start", id: "call_1", name: "lookup" },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: "x", input: {} },
|
||||
])
|
||||
}),
|
||||
)
|
||||
expect(result.events).toEqual([
|
||||
{ type: "tool-input-start", id: "call_1", name: "lookup" },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: "x", input: {} },
|
||||
])
|
||||
})
|
||||
|
||||
it.effect("keeps accumulated identity when later deltas contain empty strings", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -104,14 +100,12 @@ describe("ToolStream", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails appendExisting when the provider skipped the tool start", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool")
|
||||
test("fails appendExisting when the provider skipped the tool start", () => {
|
||||
const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool")
|
||||
|
||||
expect(error).toBeInstanceOf(AIError)
|
||||
if (ToolStream.isError(error)) expect(error.message).toBe("missing tool")
|
||||
}),
|
||||
)
|
||||
expect(error).toBeInstanceOf(AIError)
|
||||
if (ToolStream.isError(error)) expect(error.message).toBe("missing tool")
|
||||
})
|
||||
|
||||
it.effect("uses final input override without losing accumulated deltas", () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -25,6 +25,7 @@ for (const viewport of [
|
||||
const mock = await openDraft(page)
|
||||
const pending = await submitPending(page, mock)
|
||||
|
||||
expect(mock.worktreeRequests).toEqual([expect.objectContaining({ from: directory })])
|
||||
await expect(pending.message).toBeInViewport()
|
||||
await expect(pending.shimmer).toBeInViewport()
|
||||
await testInfo.attach("creating-worktree", {
|
||||
@@ -184,6 +185,7 @@ test("restores the draft after closing and revisiting a pending session that fai
|
||||
async function openDraft(page: Page, options?: { failSessionCreate?: boolean }) {
|
||||
const worktree = Promise.withResolvers<{ status: number; json: { directory?: string; message?: string } }>()
|
||||
const calls: string[] = []
|
||||
const worktreeRequests: Record<string, unknown>[] = []
|
||||
const creates: Record<string, unknown>[] = []
|
||||
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
|
||||
const project = {
|
||||
@@ -216,7 +218,10 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
|
||||
page.on("request", (request) => {
|
||||
if (request.method() !== "POST") return
|
||||
const path = new URL(request.url()).pathname
|
||||
if (path === `/api/worktree/${projectID}`) calls.push("worktree")
|
||||
if (path === `/api/worktree/${projectID}`) {
|
||||
calls.push("worktree")
|
||||
worktreeRequests.push(request.postDataJSON())
|
||||
}
|
||||
if (path === "/api/session") calls.push("session")
|
||||
if (/^\/api\/session\/[^/]+\/prompt$/.test(path)) calls.push("prompt")
|
||||
})
|
||||
@@ -274,7 +279,7 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
|
||||
await page.getByRole("menuitem", { name: "New workspace", exact: true }).click()
|
||||
await expect(page.getByRole("button", { name: "New workspace", exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
return { worktree, calls, creates, prompts }
|
||||
return { worktree, worktreeRequests, calls, creates, prompts }
|
||||
}
|
||||
|
||||
async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDraft>>) {
|
||||
|
||||
@@ -308,8 +308,8 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
})
|
||||
const tools = page.locator('[data-timeline-part-ids="tool_queue_read,tool_queue_grep"]')
|
||||
await expect(tools).toBeVisible()
|
||||
await expect(tools).toContainText(/Used\s*Read, Grep/)
|
||||
await expect(tools.locator('[data-component="tag"]')).toHaveText("2")
|
||||
await expect(tools).toHaveText(/^Used\s*2 Read, Grep$/)
|
||||
await expect(tools.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("2 Read, Grep")
|
||||
await expect(thinking).toHaveCount(0)
|
||||
await expect(pending).toBeVisible()
|
||||
expect(mock.rows.map((row) => ({ id: row.id, delivery: row.delivery }))).toEqual([
|
||||
@@ -318,7 +318,7 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
await transcript.screenshot({ path: testInfo.outputPath("pending-steer.png") })
|
||||
|
||||
// Soft assertions let delivery run too, even when the pending ordering regresses.
|
||||
await expect.soft(tools.or(pending)).toHaveText([/Used\s*Read, Grep/, /U2: Also check the retry path\./])
|
||||
await expect.soft(tools.or(pending)).toHaveText([/^Used\s*2 Read, Grep$/, /U2: Also check the retry path\./])
|
||||
await expect
|
||||
.soft(transcript.locator('[data-timeline-row="AssistantPart"]').filter({ has: tools }))
|
||||
.toHaveAttribute("data-message-id", userID)
|
||||
@@ -350,7 +350,7 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
await expect(response).toHaveAttribute("data-message-id", inboxID)
|
||||
await expect(thinking).toHaveCount(0)
|
||||
await expect(tools.or(pending).or(response)).toHaveText([
|
||||
/Used\s*Read, Grep/,
|
||||
/^Used\s*2 Read, Grep$/,
|
||||
/U2: Also check the retry path\./,
|
||||
/A3: Now checking the retry path for U2\./,
|
||||
])
|
||||
|
||||
@@ -25,7 +25,7 @@ test("space activates a focused timeline button instead of scrolling", async ({
|
||||
seedHistory: true,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const trigger = page.getByRole("button", { name: "Used Shell" })
|
||||
const trigger = page.getByRole("button", { name: "Used 1 Shell", exact: true })
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight))
|
||||
.toBeGreaterThan(300)
|
||||
|
||||
@@ -93,7 +93,8 @@ test.describe("regression: session timeline local row state", () => {
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
const summary = group.getByRole("button", { name: "Used Patch", exact: true })
|
||||
const summary = group.getByRole("button", { name: /^Used \d+ Patch$/ })
|
||||
await expect(summary).toHaveAccessibleName("Used 1 Patch")
|
||||
await summary.click()
|
||||
await group.locator(`[data-timeline-part-id="${editPartID}"]`).evaluate((element) => {
|
||||
element.setAttribute("data-disclosure-probe", "existing")
|
||||
@@ -109,7 +110,8 @@ test.describe("regression: session timeline local row state", () => {
|
||||
if (count === 3) await trigger.click()
|
||||
const id = `prt_patch_${count}`
|
||||
events.push(...toolEvents({ ...part, id, callID: id }))
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText(String(count))
|
||||
await expect(summary).toHaveAccessibleName(`Used ${count} Patch`)
|
||||
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText(`${count} Patch`)
|
||||
await expect(group).toHaveAttribute("data-timeline-part-ids", new RegExp(`${id}$`))
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(count === 2))
|
||||
await expect(summary).toHaveAttribute("aria-expanded", "true")
|
||||
|
||||
@@ -55,7 +55,7 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
await devtools.send("Emulation.setCPUThrottlingRate", { rate: 4 })
|
||||
const context = page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()
|
||||
await expectAppVisible(context)
|
||||
await expect(context.getByRole("button")).toHaveAccessibleName("Used Read, Glob, Grep, List")
|
||||
await expect(context.getByRole("button")).toHaveAccessibleName("Used 4 Read, Glob, Grep, List")
|
||||
|
||||
const contextSelector = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
|
||||
const regions = defineVisualRegions({
|
||||
@@ -88,7 +88,7 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
await page.waitForTimeout(delay)
|
||||
}
|
||||
|
||||
await expect(context.getByRole("button")).toHaveAccessibleName("Used Read, Glob, Grep, List")
|
||||
await expect(context.getByRole("button")).toHaveAccessibleName("Used 4 Read, Glob, Grep, List")
|
||||
await page.waitForTimeout(700)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
const labels = trace.samples
|
||||
@@ -107,7 +107,7 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
]),
|
||||
)
|
||||
|
||||
expect(labels).toEqual(["Used Read, Glob, Grep, List"])
|
||||
expect(labels).toEqual(["Used 4 Read, Glob, Grep, List"])
|
||||
expect(issues, JSON.stringify(trace.samples, null, 2)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,7 +28,7 @@ for (const expanded of [false, true]) {
|
||||
})
|
||||
const trigger = expanded
|
||||
? page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
|
||||
: page.getByRole("button", { name: "Used Shell" })
|
||||
: page.getByRole("button", { name: "Used 1 Shell", exact: true })
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
|
||||
@@ -141,7 +141,7 @@ for (const open of [false, true]) {
|
||||
await timeline.send(partUpdated(shell(shellID, "completed", "done")))
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant)))
|
||||
await timeline.send(status("idle"))
|
||||
const used = group.getByRole("button", { name: "Used Shell", exact: true })
|
||||
const used = group.getByRole("button", { name: "Used 1 Shell", exact: true })
|
||||
await expect(used).toHaveAttribute("aria-expanded", "false")
|
||||
await used.click()
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
@@ -150,7 +150,7 @@ for (const open of [false, true]) {
|
||||
"aria-expanded",
|
||||
String(open),
|
||||
)
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("1")
|
||||
await expect(used.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Shell")
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
if (!open) await thought.click()
|
||||
|
||||
@@ -16,8 +16,9 @@ for (const locale of ["de", "ar"] as const) {
|
||||
})
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName(/^Used /)
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
|
||||
const names = locale === "de" ? "Lesen, Glob" : "\u0642\u0631\u0627\u0621\u0629, Glob"
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName(`Used 2 ${names}`)
|
||||
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText(`2 ${names}`)
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", locale)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -105,10 +105,10 @@ for (const summaries of [false, true]) {
|
||||
if (profile === "tool") {
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
const used = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
|
||||
await expect(used).toContainText("UsedSkill")
|
||||
await expect(used).toHaveText(/^Used\s*1 Skill$/)
|
||||
await expect(used).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(page.getByText("Inspecting stability", { exact: true })).toBeHidden()
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("1")
|
||||
await expect(used.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Skill")
|
||||
await used.click()
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(group.locator(`[data-timeline-part-id="prt_reasoning_tool_${summaries}"]`)).toBeVisible()
|
||||
|
||||
@@ -45,10 +45,11 @@ test("expands a mixed collapsed tool stack without expanding its individual call
|
||||
const group = page.locator(
|
||||
'[data-timeline-part-ids="prt_stack_shell_1,prt_stack_explore,prt_stack_patch,prt_stack_shell_2"]',
|
||||
)
|
||||
const summary = group.getByRole("button", { name: "Used Shell, Agent, Patch" })
|
||||
const summary = group.getByRole("button", { name: "Used 4 Shell, Agent, Patch", exact: true })
|
||||
await expect(summary).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(summary).toHaveCSS("height", "28px")
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
|
||||
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("4 Shell, Agent, Patch")
|
||||
await expect(summary.locator('[data-component="tag"]')).toHaveCount(0)
|
||||
await summary.click()
|
||||
await expect(summary).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(group.locator('[data-slot="context-tool-group-item"]')).toHaveCount(4)
|
||||
@@ -74,8 +75,8 @@ test("leaves tools expanded by settings outside the collapsed stack", async ({ p
|
||||
|
||||
await expect(page.locator('[data-timeline-part-id="prt_expanded_shell"]')).toBeVisible()
|
||||
const group = page.locator('[data-timeline-part-ids="prt_collapsed_patch,prt_collapsed_read"]')
|
||||
await expect(group.getByRole("button", { name: "Used Patch, Read" })).toBeVisible()
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
|
||||
await expect(group.getByRole("button", { name: "Used 2 Patch, Read", exact: true })).toBeVisible()
|
||||
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("2 Patch, Read")
|
||||
await expect(page.locator('[data-timeline-spacing="tool"]')).toHaveCSS("padding-top", "8px")
|
||||
})
|
||||
|
||||
@@ -113,7 +114,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
|
||||
],
|
||||
})
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
await group.getByRole("button", { name: "Used Shell, Patch", exact: true }).click()
|
||||
await group.getByRole("button", { name: "Used 2 Shell, Patch", exact: true }).click()
|
||||
await expect(group.getByText("2 files", { exact: true })).toBeVisible()
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
@@ -128,7 +129,10 @@ test("combines follow-up patches into one three-file stack inside Used", async (
|
||||
),
|
||||
),
|
||||
)
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("3")
|
||||
await expect(group.getByRole("button", { name: "Used 3 Shell, Patch", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
)
|
||||
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(1)
|
||||
await expect(group.getByText("3 files", { exact: true })).toBeVisible()
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
|
||||
@@ -158,8 +162,8 @@ test("keeps failed search calls and their error cards inside the collapsed stack
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
const group = page.locator('[data-timeline-part-ids="prt_error_glob,prt_error_grep"]')
|
||||
const summary = group.getByRole("button", { name: "Used Glob, Grep" })
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
|
||||
const summary = group.getByRole("button", { name: "Used 2 Glob, Grep", exact: true })
|
||||
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("2 Glob, Grep")
|
||||
await summary.click()
|
||||
await expect(group.locator('[data-kind="tool-error-card"]')).toHaveCount(2)
|
||||
const glob = group.locator('[data-timeline-part-id="prt_error_glob"]')
|
||||
|
||||
@@ -52,7 +52,7 @@ test("shows parent lineage while the child timeline loads", async ({ page }) =>
|
||||
|
||||
await page.goto(sessionHref(parentID))
|
||||
await expectSessionTitle(page, parentTitle)
|
||||
await page.getByRole("button", { name: "Used Agent" }).click()
|
||||
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
|
||||
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
|
||||
await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))])
|
||||
await Promise.all([
|
||||
@@ -77,7 +77,7 @@ test("keeps the parent visible while the child session resolves", async ({ page
|
||||
await page.goto(sessionHref(parentID))
|
||||
await expectSessionTitle(page, parentTitle)
|
||||
|
||||
await page.getByRole("button", { name: "Used Agent" }).click()
|
||||
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
|
||||
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
|
||||
await requested.promise
|
||||
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)]).finally(
|
||||
@@ -195,7 +195,7 @@ async function setup(page: Page, events?: () => OpenCodeEvent[]) {
|
||||
async function openChildFromParent(page: Page) {
|
||||
await page.goto(sessionHref(parentID))
|
||||
await expectSessionTitle(page, parentTitle)
|
||||
await page.getByRole("button", { name: "Used Agent" }).click()
|
||||
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
|
||||
|
||||
const card = page.locator(`a[href="${sessionHref(childID)}"]`)
|
||||
await expect(card).toBeVisible()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { getDirectory } from "@opencode-ai/util/path"
|
||||
import type { SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { startTransition } from "solid-js"
|
||||
@@ -13,6 +12,7 @@ import { useData, useServer } from "@/runtime/server/current"
|
||||
import { type ServerSDK, useServerSDK } from "@/runtime/server/client"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { createWorktree } from "@/workspaces/create"
|
||||
import { useSessionKey } from "@/session/session-layout"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { SessionRouteKey, SessionStateKey } from "@/runtime/server/scope"
|
||||
@@ -192,25 +192,17 @@ async function resolveSessionDirectory(input: {
|
||||
if (input.worktree === "main") return input.projectDirectory
|
||||
if (input.worktree !== "create") return input.worktree
|
||||
|
||||
return input.serverSDK.api.worktree
|
||||
.create({
|
||||
projectID: input.data.location.info({ directory: input.projectDirectory })?.project.id ?? "",
|
||||
strategy: "git",
|
||||
branch: input.branch,
|
||||
directory: getDirectory(
|
||||
input.data.location.info({ directory: input.projectDirectory })?.project.directory ?? input.projectDirectory,
|
||||
),
|
||||
})
|
||||
.then(async (created) => {
|
||||
await input.serverSDK.api.location.get({ location: { directory: created.directory } })
|
||||
return created.directory
|
||||
})
|
||||
.catch((error) => {
|
||||
showToast({
|
||||
title: input.language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
description: errorMessage(input.language, error),
|
||||
})
|
||||
return createWorktree({
|
||||
api: input.serverSDK.api,
|
||||
directory: input.projectDirectory,
|
||||
project: input.data.location.info({ directory: input.projectDirectory })?.project,
|
||||
branch: input.branch,
|
||||
}).catch((error) => {
|
||||
showToast({
|
||||
title: input.language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
description: errorMessage(input.language, error),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function errorMessage(language: ReturnType<typeof useLanguage>, error: unknown) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSignal, For, Show, type ComponentProps, type JSX } from "solid-js"
|
||||
import type { Project } from "@/runtime/server/types"
|
||||
@@ -11,6 +11,7 @@ import { useSettingsDialog } from "@/settings/command"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { containsDirectory, sameDirectory, workspaceDirectories } from "@/workspaces/paths"
|
||||
import { createWorktree } from "@/workspaces/create"
|
||||
|
||||
export function SessionWorkspaceMenu(props: {
|
||||
eligible?: boolean
|
||||
@@ -55,7 +56,14 @@ export function SessionWorkspaceMenu(props: {
|
||||
setStore("selected", selection)
|
||||
|
||||
try {
|
||||
const destination = selection === "create" ? await createWorkspace(props.project, sdk) : selection
|
||||
const destination =
|
||||
selection === "create"
|
||||
? await createWorktree({
|
||||
api: sdk.api,
|
||||
directory: props.directory,
|
||||
project: data.location.info({ directory: props.directory })?.project,
|
||||
})
|
||||
: selection
|
||||
if (!destination) return
|
||||
|
||||
await sdk.api.session.move({ sessionID, directory: destination })
|
||||
@@ -124,13 +132,3 @@ export function SessionWorkspaceMenu(props: {
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
async function createWorkspace(project: Project, serverSDK: ReturnType<typeof useServerSDK>) {
|
||||
const created = await serverSDK.api.worktree.create({
|
||||
projectID: project.id,
|
||||
strategy: "git",
|
||||
directory: getDirectory(project.worktree),
|
||||
})
|
||||
await serverSDK.api.location.get({ location: { directory: created.directory } })
|
||||
return created.directory
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { OpenCode } from "@opencode-ai/client/promise"
|
||||
import { createWorktree } from "./create"
|
||||
|
||||
describe("worktree creation", () => {
|
||||
test.each(
|
||||
[
|
||||
{ name: "clone", directory: "/copies/repo", root: "/copies/repo", canonical: "/copies/repo", parent: "/copies/" },
|
||||
{
|
||||
name: "clone subdirectory",
|
||||
directory: "/copies/repo/packages/app",
|
||||
root: "/copies/repo",
|
||||
canonical: "/copies/repo",
|
||||
parent: "/copies/",
|
||||
},
|
||||
{
|
||||
name: "linked worktree subdirectory",
|
||||
directory: "/linked/task/packages/app",
|
||||
root: "/linked/task",
|
||||
canonical: "/copies/repo",
|
||||
parent: "/copies/",
|
||||
},
|
||||
{
|
||||
name: "Windows clone",
|
||||
directory: "C:\\copies\\repo\\packages\\app",
|
||||
root: "C:\\copies\\repo",
|
||||
canonical: "C:\\copies\\repo",
|
||||
parent: "C:/copies/",
|
||||
},
|
||||
].flatMap((input) => [true, false].map((cached) => ({ ...input, cached }))),
|
||||
)("uses the clone-local main for $name (cached: $cached)", async (input) => {
|
||||
const project = { id: "proj_clone", directory: input.root, canonical: input.canonical }
|
||||
const requests: Request[] = []
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: Object.assign(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input, init)
|
||||
requests.push(request)
|
||||
if (request.method === "POST") return Response.json({ directory: "/created" })
|
||||
return Response.json({ directory: new URL(request.url).searchParams.get("location[directory]"), project })
|
||||
},
|
||||
{ preconnect() {} },
|
||||
),
|
||||
})
|
||||
|
||||
expect(
|
||||
await createWorktree({
|
||||
api,
|
||||
directory: input.directory,
|
||||
project: input.cached ? project : undefined,
|
||||
branch: "clone-only",
|
||||
}),
|
||||
).toBe("/created")
|
||||
expect(await requests.find((request) => request.method === "POST")?.json()).toEqual({
|
||||
strategy: "git",
|
||||
from: input.canonical,
|
||||
branch: "clone-only",
|
||||
directory: input.parent,
|
||||
})
|
||||
expect(requests.find((request) => request.method === "POST")?.url).toBe(
|
||||
"http://localhost:3000/api/worktree/proj_clone",
|
||||
)
|
||||
expect(
|
||||
requests
|
||||
.filter((request) => request.method === "GET")
|
||||
.map((request) => new URL(request.url).searchParams.get("location[directory]")),
|
||||
).toEqual(input.cached ? ["/created"] : [input.directory, "/created"])
|
||||
})
|
||||
|
||||
test("does not fall back to a shared project when location lookup fails", async () => {
|
||||
const requests: Request[] = []
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: Object.assign(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
requests.push(new Request(input, init))
|
||||
return Response.json({ message: "unavailable" }, { status: 503 })
|
||||
},
|
||||
{ preconnect() {} },
|
||||
),
|
||||
})
|
||||
|
||||
await expect(createWorktree({ api, directory: "/copies/repo" })).rejects.toMatchObject({
|
||||
reason: "UnexpectedStatus",
|
||||
cause: { status: 503 },
|
||||
})
|
||||
expect(requests.map((request) => request.method)).toEqual(["GET"])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { LocationGetOutput, OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
import { getDirectory } from "@opencode-ai/util/path"
|
||||
|
||||
export async function createWorktree(input: {
|
||||
api: Pick<OpenCodeClient, "location" | "worktree">
|
||||
directory: string
|
||||
project?: LocationGetOutput["project"]
|
||||
branch?: string
|
||||
}) {
|
||||
const project = input.project ?? (await input.api.location.get({ location: { directory: input.directory } })).project
|
||||
const created = await input.api.worktree.create({
|
||||
projectID: project.id,
|
||||
strategy: "git",
|
||||
from: project.canonical,
|
||||
branch: input.branch,
|
||||
directory: getDirectory(project.canonical),
|
||||
})
|
||||
await input.api.location.get({ location: { directory: created.directory } })
|
||||
return created.directory
|
||||
}
|
||||
@@ -84,6 +84,7 @@ async function open(from?: string): Promise<Session> {
|
||||
const [failure, setFailure] = createSignal("")
|
||||
const [animating, setAnimating] = createSignal(true)
|
||||
const [visible, setVisible] = createSignal(true)
|
||||
const [backgroundKnown, setBackgroundKnown] = createSignal(false)
|
||||
let resolveOutcome: (() => void) | undefined
|
||||
const renderer = await createCliRenderer({
|
||||
stdin: process.stdin,
|
||||
@@ -102,6 +103,16 @@ async function open(from?: string): Promise<Session> {
|
||||
consoleMode: "disabled",
|
||||
})
|
||||
const terminalMode = renderer.waitForThemeMode(1000).catch(() => null)
|
||||
void renderer.getPalette({ size: 16 }).then(
|
||||
(colors) => {
|
||||
if (!colors.defaultBackground || renderer.isDestroyed) return
|
||||
const background = RGBA.fromHex(colors.defaultBackground)
|
||||
background.a = 0
|
||||
renderer.setBackgroundColor(background)
|
||||
setBackgroundKnown(true)
|
||||
},
|
||||
() => {},
|
||||
)
|
||||
await render(
|
||||
() => (
|
||||
<Show when={visible()}>
|
||||
@@ -112,6 +123,7 @@ async function open(from?: string): Promise<Session> {
|
||||
failure={failure}
|
||||
animating={animating}
|
||||
renderer={renderer}
|
||||
backgroundKnown={backgroundKnown}
|
||||
onOutcomeSettled={() => resolveOutcome?.()}
|
||||
/>
|
||||
</Show>
|
||||
@@ -242,11 +254,7 @@ const phrase = (...segments: ReadonlyArray<readonly [string, RGBA, boolean?]>):
|
||||
...styled(segment[0], segment[1], segment[2]),
|
||||
])
|
||||
|
||||
function Monogram(props: { ink: () => RGBA }) {
|
||||
const shadow = createMemo(() => {
|
||||
const ink = props.ink()
|
||||
return RGBA.fromValues(ink.r * 0.25, ink.g * 0.25, ink.b * 0.25)
|
||||
})
|
||||
function Monogram(props: { ink: () => RGBA; backgroundKnown: () => boolean }) {
|
||||
return (
|
||||
<box flexDirection="column">
|
||||
<For each={monogram}>
|
||||
@@ -255,7 +263,7 @@ function Monogram(props: { ink: () => RGBA }) {
|
||||
<For each={Array.from(line)}>
|
||||
{(char) =>
|
||||
char === "_" ? (
|
||||
<text bg={shadow()} selectable={false}>
|
||||
<text bg={props.ink()} opacity={props.backgroundKnown() ? 0.25 : 0} selectable={false}>
|
||||
{" "}
|
||||
</text>
|
||||
) : (
|
||||
@@ -338,6 +346,7 @@ function UpdateFooter(props: {
|
||||
failure: () => string
|
||||
animating: () => boolean
|
||||
renderer: CliRenderer
|
||||
backgroundKnown: () => boolean
|
||||
onOutcomeSettled: () => void
|
||||
}) {
|
||||
const term = useTerminalDimensions()
|
||||
@@ -448,7 +457,7 @@ function UpdateFooter(props: {
|
||||
|
||||
return (
|
||||
<box width="100%" height={4} flexDirection="row" gap={1} paddingLeft={1} live={props.animating()}>
|
||||
<Monogram ink={monogramInk} />
|
||||
<Monogram ink={monogramInk} backgroundKnown={props.backgroundKnown} />
|
||||
<box flexDirection="column" flexGrow={1} overflow="hidden">
|
||||
<CellLine cells={header()} />
|
||||
<Show
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
export * as ConfigMCPPlugin from "./mcp.js"
|
||||
export * as ConfigMcpPlugin from "./mcp.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Document, type Entry } from "@opencode-ai/schema/config"
|
||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import type { ServerConfig } from "@opencode-ai/schema/mcp"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { MCP } from "../../mcp/index.js"
|
||||
import { Mcp } from "../../mcp/index.js"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.mcp",
|
||||
@@ -18,7 +18,7 @@ export const register = Effect.fn("ConfigMCPPlugin.register")(function* (
|
||||
events: Stream.Stream<{ readonly type: string }, unknown>,
|
||||
) {
|
||||
const config = yield* Config.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const mcp = yield* Mcp.Service
|
||||
const loaded = { entries: [] as Entry[] }
|
||||
|
||||
yield* events.pipe(
|
||||
@@ -43,7 +43,7 @@ export const register = Effect.fn("ConfigMCPPlugin.register")(function* (
|
||||
{},
|
||||
...documents.flatMap((entry) => (entry.info.mcp?.timeout ? [entry.info.mcp.timeout] : [])),
|
||||
)
|
||||
const servers = new Map<string, Mcp.ServerConfig>()
|
||||
const servers = new Map<string, ServerConfig>()
|
||||
for (const document of documents) {
|
||||
for (const [name, server] of Object.entries(document.info.mcp?.servers ?? {})) {
|
||||
servers.set(name, { ...server, timeout: { ...timeout, ...server.timeout } })
|
||||
|
||||
@@ -23,7 +23,7 @@ import { Location } from "./location.js"
|
||||
import { LocationMutation } from "./location-mutation.js"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
import { ModelResolver } from "./model-resolver.js"
|
||||
import { MCP } from "./mcp/index.js"
|
||||
import { Mcp } from "./mcp/index.js"
|
||||
import { Permission } from "./permission.js"
|
||||
import { Plugin } from "./plugin.js"
|
||||
import { PluginHooks } from "./plugin/hooks.js"
|
||||
@@ -84,7 +84,7 @@ const locationServiceNodes = [
|
||||
LocationMutation.node,
|
||||
FileMutation.node,
|
||||
Formatter.node,
|
||||
MCP.node,
|
||||
Mcp.node,
|
||||
Permission.node,
|
||||
Tool.node,
|
||||
ToolOutput.node,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as MCPClient from "./client.js"
|
||||
export * as McpClient from "./client.js"
|
||||
|
||||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import { Cause, Effect, Exit, Schema } from "effect"
|
||||
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||
import { MCPStdio } from "./stdio.js"
|
||||
import { McpStdio } from "./stdio.js"
|
||||
|
||||
const DEFAULT_STARTUP_TIMEOUT = 30_000
|
||||
const DEFAULT_CATALOG_TIMEOUT = 30_000
|
||||
@@ -222,7 +222,7 @@ export const connect = Effect.fnUntraced(function* (
|
||||
const exit = yield* Effect.gen(function* () {
|
||||
if (config.type === "local") {
|
||||
const [command, ...args] = config.command
|
||||
const transport = yield* MCPStdio.make({
|
||||
const transport = yield* McpStdio.make({
|
||||
server,
|
||||
command,
|
||||
args,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as MCP from "./index.js"
|
||||
export * as Mcp from "./index.js"
|
||||
|
||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
@@ -16,7 +16,7 @@ import { KeyedMutex } from "../effect/keyed-mutex.js"
|
||||
import { Location } from "../location.js"
|
||||
import { waitForAbort } from "@opencode-ai/util/process"
|
||||
import { State } from "../state.js"
|
||||
import type { MCPClient } from "./client.js"
|
||||
import type { McpClient } from "./client.js"
|
||||
|
||||
export const ServerName = Schema.String.pipe(Schema.brand("MCP.ServerName"))
|
||||
export const PromptsChanged = ephemeral({ type: "mcp.prompts.changed", schema: { server: Schema.String } })
|
||||
@@ -114,7 +114,7 @@ type ServerEntry = {
|
||||
status: Status
|
||||
readonly startup: Latch.Latch
|
||||
scope?: Scope.Closeable
|
||||
client?: MCPClient.Connection
|
||||
client?: McpClient.Connection
|
||||
tools?: ReadonlyArray<Tool>
|
||||
prompts?: ReadonlyArray<Prompt>
|
||||
// Set when a remote server is registered as an OAuth integration; the credential lives in the global store.
|
||||
@@ -235,8 +235,8 @@ export const layer = (options?: Options) =>
|
||||
method: { id: methodID, type: "oauth", label: name },
|
||||
authorize: () =>
|
||||
Effect.gen(function* () {
|
||||
const { MCPOAuth } = yield* Effect.promise(() => import("./oauth.js"))
|
||||
return yield* MCPOAuth.authorize({ name, config: remote, methodID })
|
||||
const { McpOAuth } = yield* Effect.promise(() => import("./oauth.js"))
|
||||
return yield* McpOAuth.authorize({ name, config: remote, methodID })
|
||||
}),
|
||||
})
|
||||
})
|
||||
@@ -254,7 +254,7 @@ export const layer = (options?: Options) =>
|
||||
// opens a browser, so an auth-gated connect ends in UnauthorizedError -> needs_auth rather than a redirect.
|
||||
const connectProvider = Effect.fnUntraced(function* (entry: ServerEntry) {
|
||||
if (entry.config.type !== "remote" || !entry.integrationID) return undefined
|
||||
const { MCPOAuth } = yield* Effect.promise(() => import("./oauth.js"))
|
||||
const { McpOAuth } = yield* Effect.promise(() => import("./oauth.js"))
|
||||
const remote = entry.config
|
||||
const oauth = remote.oauth || undefined
|
||||
const base = {
|
||||
@@ -270,7 +270,7 @@ export const layer = (options?: Options) =>
|
||||
// ends in UnauthorizedError -> needs_auth. Returning no provider instead would let the transport throw
|
||||
// a raw HTTP error, hiding the auth requirement behind a generic failed status. Anonymous servers are
|
||||
// unaffected: tokens() returns undefined, so no auth header is sent and the SDK never calls auth().
|
||||
return MCPOAuth.provider({ ...base, store: MCPOAuth.memoryStore() })
|
||||
return McpOAuth.provider({ ...base, store: McpOAuth.memoryStore() })
|
||||
const credentialID = found.id
|
||||
const methodID = found.value.methodID
|
||||
const integrationID = entry.integrationID
|
||||
@@ -282,7 +282,7 @@ export const layer = (options?: Options) =>
|
||||
const match = stored.find((credential) => credential.id === credentialID)
|
||||
return match && match.value.type === "oauth" ? match.value : undefined
|
||||
}
|
||||
return MCPOAuth.provider({
|
||||
return McpOAuth.provider({
|
||||
...base,
|
||||
// Drop a credential the SDK rejected so the next connect cleanly reports needs_auth — but only if it is
|
||||
// still the stored one. Rotating servers hand out a fresh refresh token per use, so a concurrent
|
||||
@@ -303,22 +303,22 @@ export const layer = (options?: Options) =>
|
||||
const oauth = await readOAuthCredential()
|
||||
if (!oauth) return undefined
|
||||
presented = oauth.refresh
|
||||
return MCPOAuth.toTokens(oauth)
|
||||
return McpOAuth.toTokens(oauth)
|
||||
},
|
||||
saveTokens: async (tokens) => {
|
||||
const previous = await readOAuthCredential()
|
||||
const value = MCPOAuth.toCredential({
|
||||
const value = McpOAuth.toCredential({
|
||||
methodID,
|
||||
serverUrl: remote.url,
|
||||
tokens,
|
||||
client: previous ? MCPOAuth.clientFromCredential(previous) : undefined,
|
||||
client: previous ? McpOAuth.clientFromCredential(previous) : undefined,
|
||||
})
|
||||
presented = value.refresh
|
||||
await Effect.runPromise(credentials.update(credentialID, { value }))
|
||||
},
|
||||
clientInformation: async () => {
|
||||
const oauth = await readOAuthCredential()
|
||||
return oauth ? MCPOAuth.clientFromCredential(oauth) : undefined
|
||||
return oauth ? McpOAuth.clientFromCredential(oauth) : undefined
|
||||
},
|
||||
saveClientInformation: async () => {},
|
||||
codeVerifier: async () => undefined,
|
||||
@@ -330,7 +330,7 @@ export const layer = (options?: Options) =>
|
||||
const elicitation = {
|
||||
create: (input: {
|
||||
readonly server: string
|
||||
readonly params: MCPClient.ElicitationParams
|
||||
readonly params: McpClient.ElicitationParams
|
||||
readonly signal: AbortSignal
|
||||
}) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -355,7 +355,7 @@ export const layer = (options?: Options) =>
|
||||
Effect.raceFirst(waitForAbort(input.signal)),
|
||||
Effect.ensuring(Effect.sync(() => urlElicitations.delete(key))),
|
||||
Effect.map(
|
||||
(state): MCPClient.ElicitationResult => ({
|
||||
(state): McpClient.ElicitationResult => ({
|
||||
action: state.status === "answered" ? "accept" : "cancel",
|
||||
}),
|
||||
),
|
||||
@@ -375,13 +375,13 @@ export const layer = (options?: Options) =>
|
||||
})
|
||||
.pipe(
|
||||
Effect.raceFirst(waitForAbort(input.signal)),
|
||||
Effect.map((state): MCPClient.ElicitationResult => {
|
||||
Effect.map((state): McpClient.ElicitationResult => {
|
||||
if (state.status !== "answered") return { action: "cancel" }
|
||||
return {
|
||||
action: "accept",
|
||||
content: Object.fromEntries(
|
||||
Object.entries(state.answer).map(
|
||||
([key, value]): [string, NonNullable<MCPClient.ElicitationResult["content"]>[string]] =>
|
||||
([key, value]): [string, NonNullable<McpClient.ElicitationResult["content"]>[string]] =>
|
||||
typeof value === "object" ? [key, Array.from(value)] : [key, value],
|
||||
),
|
||||
),
|
||||
@@ -395,9 +395,9 @@ export const layer = (options?: Options) =>
|
||||
if (!formID) return
|
||||
yield* forms.reply({ id: formID, answer: { [URL_ELICITATION_FIELD_KEY]: true } }).pipe(Effect.ignore)
|
||||
}),
|
||||
} satisfies MCPClient.ElicitationHandler
|
||||
} satisfies McpClient.ElicitationHandler
|
||||
|
||||
const toTool = (server: ServerName, entry: ServerEntry, def: MCPClient.ToolDefinition) =>
|
||||
const toTool = (server: ServerName, entry: ServerEntry, def: McpClient.ToolDefinition) =>
|
||||
new Tool({
|
||||
server,
|
||||
name: def.name,
|
||||
@@ -407,7 +407,7 @@ export const layer = (options?: Options) =>
|
||||
outputSchema: def.outputSchema,
|
||||
})
|
||||
|
||||
const toPrompt = (server: ServerName, def: MCPClient.PromptDefinition) =>
|
||||
const toPrompt = (server: ServerName, def: McpClient.PromptDefinition) =>
|
||||
new Prompt({
|
||||
server,
|
||||
name: def.name,
|
||||
@@ -422,7 +422,7 @@ export const layer = (options?: Options) =>
|
||||
),
|
||||
})
|
||||
|
||||
const toResource = (server: ServerName, def: MCPClient.ResourceDefinition) =>
|
||||
const toResource = (server: ServerName, def: McpClient.ResourceDefinition) =>
|
||||
Resource.make({
|
||||
server,
|
||||
name: def.name,
|
||||
@@ -431,7 +431,7 @@ export const layer = (options?: Options) =>
|
||||
mimeType: def.mimeType,
|
||||
})
|
||||
|
||||
const toResourceTemplate = (server: ServerName, def: MCPClient.ResourceTemplateDefinition) =>
|
||||
const toResourceTemplate = (server: ServerName, def: McpClient.ResourceTemplateDefinition) =>
|
||||
ResourceTemplate.make({
|
||||
server,
|
||||
name: def.name,
|
||||
@@ -440,14 +440,14 @@ export const layer = (options?: Options) =>
|
||||
mimeType: def.mimeType,
|
||||
})
|
||||
|
||||
const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
|
||||
const refreshTools = (name: ServerName, entry: ServerEntry, connection: McpClient.Connection) =>
|
||||
connection.tools().pipe(
|
||||
Effect.map((defs) => {
|
||||
entry.tools = defs.map((def) => toTool(name, entry, def))
|
||||
}),
|
||||
)
|
||||
|
||||
const refreshPrompts = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
|
||||
const refreshPrompts = (name: ServerName, entry: ServerEntry, connection: McpClient.Connection) =>
|
||||
connection.prompts().pipe(
|
||||
Effect.orElseSucceed(() => []),
|
||||
Effect.map((defs) => {
|
||||
@@ -459,7 +459,7 @@ export const layer = (options?: Options) =>
|
||||
// Runs a connection callback under the server lock, dropping it if the connection is no longer
|
||||
// the entry's live client, so late SDK callbacks cannot commit obsolete state.
|
||||
const whenLive =
|
||||
(name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
|
||||
(name: ServerName, entry: ServerEntry, connection: McpClient.Connection) =>
|
||||
<E>(effect: Effect.Effect<void, E>) =>
|
||||
fork(
|
||||
Effect.suspend(() => (entry.client === connection ? effect : Effect.void)).pipe(
|
||||
@@ -468,7 +468,7 @@ export const layer = (options?: Options) =>
|
||||
),
|
||||
)
|
||||
|
||||
const watch = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => {
|
||||
const watch = (name: ServerName, entry: ServerEntry, connection: McpClient.Connection) => {
|
||||
const live = whenLive(name, entry, connection)
|
||||
connection.onClose(() =>
|
||||
live(
|
||||
@@ -491,7 +491,7 @@ export const layer = (options?: Options) =>
|
||||
connection.onResourcesChanged(() => live(bus.publish(McpEvent.ResourcesChanged, { server: name })))
|
||||
}
|
||||
|
||||
const serverLog = (server: ServerName, message: MCPClient.LogMessage) => {
|
||||
const serverLog = (server: ServerName, message: McpClient.LogMessage) => {
|
||||
const fields = { server, logger: message.logger, level: message.level, data: message.data }
|
||||
switch (message.level) {
|
||||
case "debug":
|
||||
@@ -518,10 +518,10 @@ export const layer = (options?: Options) =>
|
||||
const scope = yield* Scope.fork(root)
|
||||
entry.scope = scope
|
||||
const authProvider = yield* connectProvider(entry)
|
||||
const { MCPClient } = yield* Effect.promise(() => import("./client.js"))
|
||||
const { McpClient } = yield* Effect.promise(() => import("./client.js"))
|
||||
// List tools as part of connect so a failure here marks the server failed rather than
|
||||
// leaving it connected with a silently empty tool list and no path to recover.
|
||||
const result = yield* MCPClient.connect(
|
||||
const result = yield* McpClient.connect(
|
||||
name,
|
||||
entry.config,
|
||||
location.directory,
|
||||
@@ -555,7 +555,7 @@ export const layer = (options?: Options) =>
|
||||
entry.scope = undefined
|
||||
const error = Cause.squash(result.cause)
|
||||
entry.status =
|
||||
error instanceof MCPClient.NeedsAuthError
|
||||
error instanceof McpClient.NeedsAuthError
|
||||
? { status: "needs_auth" }
|
||||
: { status: "failed", error: error instanceof Error ? error.message : String(error) }
|
||||
yield* Effect.logWarning("mcp connect failed", { server: name, status: entry.status })
|
||||
@@ -938,4 +938,4 @@ function toElicitationField(key: string, property: ElicitationProperty, required
|
||||
}
|
||||
}
|
||||
|
||||
type ElicitationProperty = MCPClient.ElicitationFormParams["requestedSchema"]["properties"][string]
|
||||
type ElicitationProperty = McpClient.ElicitationFormParams["requestedSchema"]["properties"][string]
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Agent } from "../agent.js"
|
||||
import { Permission } from "../permission.js"
|
||||
import { McpTool } from "../tool/mcp.js"
|
||||
import { MCP } from "./index.js"
|
||||
import { Mcp } from "./index.js"
|
||||
import { Instructions } from "../instructions/index.js"
|
||||
|
||||
const Summary = Schema.Struct({
|
||||
@@ -63,7 +63,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Mc
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
const mcp = yield* Mcp.Service
|
||||
|
||||
return Service.of({
|
||||
load: Effect.fn("McpInstructions.load")(function* (selection) {
|
||||
@@ -110,4 +110,4 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [MCP.node] })
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Mcp.node] })
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as MCPOAuth from "./oauth.js"
|
||||
export * as McpOAuth from "./oauth.js"
|
||||
|
||||
import { auth, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as MCPStdio from "./stdio.js"
|
||||
export * as McpStdio from "./stdio.js"
|
||||
|
||||
import { ReadBuffer, serializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js"
|
||||
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Command } from "./command.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Integration } from "./integration.js"
|
||||
import { KV } from "./kv.js"
|
||||
import { MCP } from "./mcp/index.js"
|
||||
import { Mcp } from "./mcp/index.js"
|
||||
import { Location } from "./location.js"
|
||||
import { PluginHost } from "./plugin/host.js"
|
||||
import { PluginRuntime } from "./plugin/runtime.js"
|
||||
@@ -197,7 +197,7 @@ export const node = makeLocationNode({
|
||||
Command.node,
|
||||
Integration.node,
|
||||
KV.node,
|
||||
MCP.node,
|
||||
Mcp.node,
|
||||
Location.node,
|
||||
Reference.node,
|
||||
Skill.node,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Location } from "../location.js"
|
||||
import { MCP } from "../mcp/index.js"
|
||||
import { Mcp } from "../mcp/index.js"
|
||||
import PROMPT_INITIALIZE from "./command/initialize.txt"
|
||||
import PROMPT_REVIEW from "./command/review.txt"
|
||||
|
||||
@@ -12,20 +12,18 @@ export const Plugin = define({
|
||||
id: "opencode.command",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const location = yield* Location.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const mcp = yield* Mcp.Service
|
||||
const bus = yield* Bus.Service
|
||||
const loaded = { prompts: [] as MCP.Prompt[] }
|
||||
yield* bus
|
||||
.subscribe(MCP.PromptsChanged)
|
||||
.pipe(
|
||||
Stream.runForEach(() =>
|
||||
mcp.prompts().pipe(
|
||||
Effect.tap((prompts) => Effect.sync(() => (loaded.prompts = prompts))),
|
||||
Effect.andThen(ctx.command.reload()),
|
||||
),
|
||||
const loaded = { prompts: [] as Mcp.Prompt[] }
|
||||
yield* bus.subscribe(Mcp.PromptsChanged).pipe(
|
||||
Stream.runForEach(() =>
|
||||
mcp.prompts().pipe(
|
||||
Effect.tap((prompts) => Effect.sync(() => (loaded.prompts = prompts))),
|
||||
Effect.andThen(ctx.command.reload()),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
loaded.prompts = yield* mcp.prompts()
|
||||
yield* ctx.command.transform((draft) => {
|
||||
draft.add({
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as PluginHost from "./host.js"
|
||||
import { Plugin } from "@opencode-ai/plugin/effect"
|
||||
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
|
||||
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import { ServerConfig } from "@opencode-ai/schema/mcp"
|
||||
import { App } from "../app.js"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Agent } from "../agent.js"
|
||||
@@ -16,7 +16,7 @@ import { Integration } from "../integration.js"
|
||||
import { KV } from "../kv.js"
|
||||
import { Location } from "../location.js"
|
||||
import { Model } from "../model.js"
|
||||
import { MCP } from "../mcp/index.js"
|
||||
import { Mcp } from "../mcp/index.js"
|
||||
import { PluginRuntime } from "./runtime.js"
|
||||
import { Provider } from "../provider.js"
|
||||
import { Reference } from "../reference.js"
|
||||
@@ -41,7 +41,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
const bus = yield* Bus.Service
|
||||
const integration = yield* Integration.Service
|
||||
const kv = yield* KV.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const mcp = yield* Mcp.Service
|
||||
const location = yield* Location.Service
|
||||
const reference = yield* Reference.Service
|
||||
const skill = yield* Skill.Service
|
||||
@@ -295,7 +295,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
callback({
|
||||
list: () => draft.list().map(([name, config]) => [name, mutable(config)]),
|
||||
get: (name) => mutable(draft.get(name)),
|
||||
set: (name, config) => draft.set(name, Schema.decodeUnknownSync(Mcp.ServerConfig)(config)),
|
||||
set: (name, config) => draft.set(name, Schema.decodeUnknownSync(ServerConfig)(config)),
|
||||
update: draft.update,
|
||||
remove: draft.remove,
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@ import { ConfigFormatterPlugin } from "../config/plugin/formatter.js"
|
||||
import { ConfigImagePlugin } from "../config/plugin/image.js"
|
||||
import { ConfigInstructionPlugin } from "../config/plugin/instruction.js"
|
||||
import { ConfigLocationWatcherPlugin } from "../config/plugin/location-watcher.js"
|
||||
import { ConfigMCPPlugin } from "../config/plugin/mcp.js"
|
||||
import { ConfigMcpPlugin } from "../config/plugin/mcp.js"
|
||||
import { ConfigProviderPlugin } from "../config/plugin/provider.js"
|
||||
import { ConfigPolicyPlugin } from "../config/plugin/policy.js"
|
||||
import { ConfigReferencePlugin } from "../config/plugin/reference.js"
|
||||
@@ -44,7 +44,7 @@ import { KV } from "../kv.js"
|
||||
import { Location } from "../location.js"
|
||||
import { LocationMutation } from "../location-mutation.js"
|
||||
import { ModelsDev } from "../models-dev.js"
|
||||
import { MCP } from "../mcp/index.js"
|
||||
import { Mcp } from "../mcp/index.js"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Permission } from "../permission.js"
|
||||
import { Reference } from "../reference.js"
|
||||
@@ -78,7 +78,7 @@ import { AgentPlugin } from "./agent.js"
|
||||
import { CommandPlugin } from "./command.js"
|
||||
import { PlanPlugin } from "./plan.js"
|
||||
import { ModelsDevPlugin } from "./models-dev.js"
|
||||
import { MCPCodeModeExclusionPlugin } from "./mcp-codemode-exclusion.js"
|
||||
import { McpCodeModeExclusionPlugin } from "./mcp-codemode-exclusion.js"
|
||||
import { ProviderPlugins } from "./provider.js"
|
||||
import { WebSearchPlugins } from "./websearch/index.js"
|
||||
import { PluginRuntime } from "./runtime.js"
|
||||
@@ -114,7 +114,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
const location = yield* Location.Service
|
||||
const locationMutation = yield* LocationMutation.Service
|
||||
const models = yield* ModelsDev.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const mcp = yield* Mcp.Service
|
||||
const npm = yield* Npm.Service
|
||||
const permission = yield* Permission.Service
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
@@ -158,7 +158,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
Context.make(Location.Service, location),
|
||||
Context.make(LocationMutation.Service, locationMutation),
|
||||
Context.make(ModelsDev.Service, models),
|
||||
Context.make(MCP.Service, mcp),
|
||||
Context.make(Mcp.Service, mcp),
|
||||
Context.make(Npm.Service, npm),
|
||||
Context.make(Permission.Service, permission),
|
||||
Context.make(PluginRuntime.Service, runtime),
|
||||
@@ -209,7 +209,7 @@ export const requirements = LayerNode.group([
|
||||
Location.node,
|
||||
LocationMutation.node,
|
||||
ModelsDev.node,
|
||||
MCP.node,
|
||||
Mcp.node,
|
||||
Npm.node,
|
||||
Permission.node,
|
||||
PluginRuntime.node,
|
||||
@@ -234,8 +234,8 @@ export const requirements = LayerNode.group([
|
||||
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
|
||||
|
||||
const pre = [
|
||||
ConfigMCPPlugin.Plugin,
|
||||
MCPCodeModeExclusionPlugin.Plugin,
|
||||
ConfigMcpPlugin.Plugin,
|
||||
McpCodeModeExclusionPlugin.Plugin,
|
||||
WellKnownPlugin.Plugin,
|
||||
VcsGitPlugin.Plugin,
|
||||
AgentPlugin.Plugin,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as MCPCodeModeExclusionPlugin from "./mcp-codemode-exclusion.js"
|
||||
export * as McpCodeModeExclusionPlugin from "./mcp-codemode-exclusion.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect } from "effect"
|
||||
|
||||
@@ -6,7 +6,7 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Job } from "../job.js"
|
||||
import { Location } from "../location.js"
|
||||
import { LocationServiceMap } from "../location-service-map.js"
|
||||
import { MCP } from "../mcp/index.js"
|
||||
import { Mcp } from "../mcp/index.js"
|
||||
import { Session } from "../session.js"
|
||||
|
||||
export interface Interface {
|
||||
@@ -38,7 +38,7 @@ export interface Interface {
|
||||
readonly mcp: {
|
||||
readonly list: (
|
||||
ref: Location.Ref,
|
||||
) => Effect.Effect<{ readonly location: Location.Info; readonly data: MCP.ServerInfo[] }, unknown>
|
||||
) => Effect.Effect<{ readonly location: Location.Info; readonly data: Mcp.ServerInfo[] }, unknown>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,7 +130,7 @@ export const providerLayerWithCell = (cell: Cell) =>
|
||||
list: (ref) =>
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const mcp = yield* Mcp.Service
|
||||
return {
|
||||
location: new Location.Info({
|
||||
directory: location.directory,
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface Resolved {
|
||||
readonly previous?: ID
|
||||
readonly id: ID
|
||||
readonly directory: AbsolutePath
|
||||
// This checkout's main directory; the stored project canonical may be another clone.
|
||||
readonly canonical: AbsolutePath
|
||||
readonly vcs?: Vcs
|
||||
readonly vcsBackend?: string
|
||||
@@ -110,11 +111,17 @@ const layer = Layer.effect(
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
yield* upsertProject(db, project).pipe(Effect.orDie)
|
||||
if (previous && previous.canonical !== project.canonical) {
|
||||
// Clones share a project ID; only replace a canonical directory that is gone.
|
||||
if (
|
||||
previous &&
|
||||
previous.canonical !== project.canonical &&
|
||||
!(yield* fs.exists(previous.canonical).pipe(Effect.orElseSucceed(() => true)))
|
||||
) {
|
||||
const row = yield* db
|
||||
.select()
|
||||
.from(ProjectTable)
|
||||
.update(ProjectTable)
|
||||
.set({ worktree: project.canonical })
|
||||
.where(eq(ProjectTable.id, project.id))
|
||||
.returning()
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (row) yield* bus.publish(ProjectSchema.Event.Updated, fromRow(row))
|
||||
|
||||
@@ -51,11 +51,8 @@ export function upsertProject(
|
||||
.values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
|
||||
.onConflictDoUpdate({
|
||||
target: ProjectTable.id,
|
||||
set: { worktree: project.canonical, vcs: vcs ?? null },
|
||||
setWhere: or(
|
||||
ne(ProjectTable.worktree, project.canonical),
|
||||
vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
|
||||
),
|
||||
set: { vcs: vcs ?? null },
|
||||
setWhere: vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Context, Effect, Fiber, type JsonSchema, Layer, Semaphore, Stream } fro
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "../bus.js"
|
||||
|
||||
import { MCP } from "../mcp/index.js"
|
||||
import { Mcp } from "../mcp/index.js"
|
||||
import { Permission } from "../permission.js"
|
||||
import { Tool } from "../tool.js"
|
||||
|
||||
@@ -26,12 +26,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Mc
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
const mcp = yield* Mcp.Service
|
||||
const tools = yield* Tool.Service
|
||||
const bus = yield* Bus.Service
|
||||
const permission = yield* Permission.Service
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
let discovered: MCP.Tool[] = []
|
||||
let discovered: Mcp.Tool[] = []
|
||||
|
||||
// Register once after initial discovery; only subsequent updates need a debounced reload.
|
||||
const initial = yield* lock
|
||||
@@ -134,5 +134,5 @@ export const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Tool.node, MCP.node, Bus.node, Permission.node],
|
||||
deps: [Tool.node, Mcp.node, Bus.node, Permission.node],
|
||||
})
|
||||
|
||||
@@ -267,20 +267,20 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
const project = yield* db
|
||||
.select({ worktree: ProjectTable.worktree, commands: ProjectTable.commands })
|
||||
.select({ commands: ProjectTable.commands })
|
||||
.from(ProjectTable)
|
||||
.where(eq(ProjectTable.id, input.projectID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const command = project?.commands?.start?.trim()
|
||||
if (command && project) {
|
||||
if (command) {
|
||||
const windows = process.platform === "win32"
|
||||
yield* processService
|
||||
.run(
|
||||
ChildProcess.make(windows ? command : "bash", windows ? [] : ["-lc", command], {
|
||||
cwd: result.directory,
|
||||
env: {
|
||||
OPENCODE_WORKTREE_BASE: project.worktree,
|
||||
OPENCODE_WORKTREE_BASE: sourceDirectory,
|
||||
OPENCODE_WORKTREE_PATH: result.directory,
|
||||
},
|
||||
extendEnv: true,
|
||||
|
||||
@@ -19,7 +19,7 @@ import { WellKnown } from "@opencode-ai/core/wellknown"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { Mcp } from "@opencode-ai/core/mcp/index"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { ShellSelect } from "@opencode-ai/core/shell/select"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
@@ -43,7 +43,7 @@ const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Command.node, Bus.node, FSUtil.node, AppProcess.node, Location.node, ShellSelect.node]),
|
||||
[
|
||||
[MCP.node, emptyMcpLayer],
|
||||
[Mcp.node, emptyMcpLayer],
|
||||
[Config.node, emptyConfigLayer],
|
||||
[Location.node, testLocationLayer],
|
||||
[ShellSelect.node, shellLayer],
|
||||
@@ -431,9 +431,7 @@ function sourceCases() {
|
||||
{
|
||||
name: "updated",
|
||||
prepare: (directory: string) =>
|
||||
Effect.promise(() =>
|
||||
fs.writeFile(path.join(directory, "review.md"), markdown("Review first", "Review first")),
|
||||
),
|
||||
Effect.promise(() => fs.writeFile(path.join(directory, "review.md"), markdown("Review first", "Review first"))),
|
||||
mutate: (directory: string) =>
|
||||
Effect.promise(async () => {
|
||||
const file = path.join(directory, "review.md")
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { Mcp } from "@opencode-ai/core/mcp/index"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "./location"
|
||||
|
||||
export const emptyMcpLayer = Layer.succeed(
|
||||
MCP.Service,
|
||||
MCP.Service.of({
|
||||
Mcp.Service,
|
||||
Mcp.Service.of({
|
||||
transform: () => Effect.die("unused mcp.transform"),
|
||||
reload: () => Effect.die("unused mcp.reload"),
|
||||
servers: () => Effect.succeed([]),
|
||||
@@ -20,7 +20,7 @@ export const emptyMcpLayer = Layer.succeed(
|
||||
instructions: () => Effect.succeed([]),
|
||||
prompts: () => Effect.succeed([]),
|
||||
prompt: () => Effect.undefined,
|
||||
resourceCatalog: () => Effect.succeed(MCP.ResourceCatalog.make({ resources: [], templates: [] })),
|
||||
resourceCatalog: () => Effect.succeed(Mcp.ResourceCatalog.make({ resources: [], templates: [] })),
|
||||
readResource: () => Effect.undefined,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
export * as TestWebSearch from "./websearch"
|
||||
|
||||
import { Context, Deferred, Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
|
||||
export interface Interface extends WebSearch.Interface {
|
||||
readonly queries: readonly WebSearch.Input[]
|
||||
/** Waits for query arrivals, not provider execution or query completion. */
|
||||
readonly wait: (count: number) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("test/WebSearch") {}
|
||||
|
||||
// No providers are installed: tests register local executors through transform.
|
||||
// The normal Bus and KV implementations use the default in-memory database.
|
||||
export const layer = Layer.effectContext(
|
||||
Effect.gen(function* () {
|
||||
const context = yield* Layer.build(AppNodeBuilder.build(LayerNode.group([WebSearch.node, Bus.node, KV.node])))
|
||||
const websearch = Context.get(context, WebSearch.Service)
|
||||
const queries: WebSearch.Input[] = []
|
||||
let started = yield* Deferred.make<void>()
|
||||
const wait = (count: number): Effect.Effect<void> =>
|
||||
Effect.suspend(() =>
|
||||
queries.length >= count ? Effect.void : Deferred.await(started).pipe(Effect.andThen(() => wait(count))),
|
||||
)
|
||||
const test = Service.of({
|
||||
...websearch,
|
||||
queries,
|
||||
wait,
|
||||
query: Effect.fnUntraced(function* (input: WebSearch.Input) {
|
||||
queries.push({ ...input })
|
||||
const previous = started
|
||||
started = yield* Deferred.make<void>()
|
||||
yield* Deferred.succeed(previous, undefined)
|
||||
return yield* websearch.query(input)
|
||||
}),
|
||||
})
|
||||
return Context.add(context, WebSearch.Service, test).pipe(Context.add(Service, test))
|
||||
}),
|
||||
)
|
||||
@@ -31,7 +31,7 @@ import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { Mcp } from "@opencode-ai/core/mcp/index"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -998,7 +998,7 @@ describe("LocationServiceMap", () => {
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const mcp = yield* Mcp.Service
|
||||
yield* supervisor.flush
|
||||
expect(observed.example).toBe(false)
|
||||
yield* mcp.add("dynamic", {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { Mcp } from "@opencode-ai/core/mcp/index"
|
||||
import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { McpTool } from "@opencode-ai/core/tool/mcp"
|
||||
@@ -17,15 +17,15 @@ const selection = (permissions: Permission.Ruleset = []) => {
|
||||
}
|
||||
|
||||
const instructions = (server: string, text: string) =>
|
||||
new MCP.ServerInstructions({ server: MCP.ServerName.make(server), instructions: text })
|
||||
new Mcp.ServerInstructions({ server: Mcp.ServerName.make(server), instructions: text })
|
||||
|
||||
const tool = (server: string, name = "search") => new MCP.Tool({ server: MCP.ServerName.make(server), name })
|
||||
const tool = (server: string, name = "search") => new Mcp.Tool({ server: Mcp.ServerName.make(server), name })
|
||||
|
||||
const layer = (catalog: () => MCP.ServerInstructions[], tools: () => MCP.Tool[]) =>
|
||||
const layer = (catalog: () => Mcp.ServerInstructions[], tools: () => Mcp.Tool[]) =>
|
||||
AppNodeBuilder.build(McpInstructions.node, [
|
||||
[
|
||||
MCP.node,
|
||||
Layer.mock(MCP.Service, {
|
||||
Mcp.node,
|
||||
Layer.mock(Mcp.Service, {
|
||||
instructions: () => Effect.succeed(catalog()),
|
||||
tools: () => Effect.succeed(tools()),
|
||||
}),
|
||||
@@ -113,7 +113,7 @@ describe("McpInstructions", () => {
|
||||
Effect.provide(
|
||||
layer(
|
||||
() => [instructions("alpha", "Alpha instructions")],
|
||||
() => [new MCP.Tool({ server: MCP.ServerName.make("alpha"), name: "search", codemode: false })],
|
||||
() => [new Mcp.Tool({ server: Mcp.ServerName.make("alpha"), name: "search", codemode: false })],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -125,7 +125,7 @@ describe("McpInstructions", () => {
|
||||
const service = yield* McpInstructions.Service
|
||||
const initialized = yield* service.load(selection()).pipe(Effect.flatMap(readInitial))
|
||||
|
||||
tools = [new MCP.Tool({ server: MCP.ServerName.make("alpha"), name: "search", codemode: false })]
|
||||
tools = [new Mcp.Tool({ server: Mcp.ServerName.make("alpha"), name: "search", codemode: false })]
|
||||
const changed = yield* readUpdate(yield* service.load(selection()), initialized)
|
||||
expect(changed.text).toBe(
|
||||
[
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterAll, describe, expect, test } from "bun:test"
|
||||
import { refreshAuthorization } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { MCPOAuth } from "@opencode-ai/core/mcp/oauth"
|
||||
import { McpOAuth } from "@opencode-ai/core/mcp/oauth"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const authServer = Bun.serve({ port: 0, fetch: () => new Response(null, { status: 404 }) })
|
||||
@@ -12,7 +12,7 @@ const authorize = (redirect_uri?: string) =>
|
||||
Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const authorization = yield* MCPOAuth.authorize({
|
||||
const authorization = yield* McpOAuth.authorize({
|
||||
name: "test",
|
||||
config: new ConfigMCP.Remote({
|
||||
type: "remote",
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Document, Event, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigMCPPlugin } from "@opencode-ai/core/config/plugin/mcp"
|
||||
import { ConfigMcpPlugin } from "@opencode-ai/core/config/plugin/mcp"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
@@ -27,9 +27,9 @@ import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Environment } from "@opencode-ai/core/environment/index"
|
||||
import { EnvironmentUnavailable } from "@opencode-ai/core/environment/unavailable"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { MCPClient } from "@opencode-ai/core/mcp/client"
|
||||
import { MCPStdio } from "@opencode-ai/core/mcp/stdio"
|
||||
import { Mcp } from "@opencode-ai/core/mcp/index"
|
||||
import { McpClient } from "@opencode-ai/core/mcp/client"
|
||||
import { McpStdio } from "@opencode-ai/core/mcp/stdio"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
@@ -180,7 +180,7 @@ function resourceServer(
|
||||
function resourceMcpLayer(
|
||||
server: string | typeof ConfigMCP.Server.Type,
|
||||
onFormCreated?: (form: Form.Info) => Effect.Effect<void>,
|
||||
options?: MCP.Options,
|
||||
options?: Mcp.Options,
|
||||
overrides?: {
|
||||
entries?: Config.Interface["entries"]
|
||||
subscribe?: Bus.Interface["subscribe"]
|
||||
@@ -193,10 +193,10 @@ function resourceMcpLayer(
|
||||
return Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
yield* ConfigMCPPlugin.register(bus.subscribe())
|
||||
yield* ConfigMcpPlugin.register(bus.subscribe())
|
||||
}),
|
||||
).pipe(
|
||||
Layer.provideMerge(MCP.layer(options)),
|
||||
Layer.provideMerge(Mcp.layer(options)),
|
||||
Layer.provideMerge(Form.layer),
|
||||
Layer.provide(
|
||||
Layer.mergeAll(
|
||||
@@ -267,13 +267,13 @@ function resourceMcpLayer(
|
||||
}
|
||||
|
||||
const connect = (server: string, config: typeof ConfigMCP.Server.Type, directory: string) =>
|
||||
MCPClient.connect(server, config, directory).pipe(Effect.provide(hostEnvironmentLayer))
|
||||
McpClient.connect(server, config, directory).pipe(Effect.provide(hostEnvironmentLayer))
|
||||
|
||||
const mcp = Layer.mock(MCP.Service, {
|
||||
const mcp = Layer.mock(Mcp.Service, {
|
||||
tools: () =>
|
||||
Effect.succeed([
|
||||
new MCP.Tool({
|
||||
server: MCP.ServerName.make("demo"),
|
||||
new Mcp.Tool({
|
||||
server: Mcp.ServerName.make("demo"),
|
||||
name: "search",
|
||||
description: "Search",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
@@ -283,28 +283,28 @@ const mcp = Layer.mock(MCP.Service, {
|
||||
required: ["ok"],
|
||||
},
|
||||
}),
|
||||
new MCP.Tool({
|
||||
server: MCP.ServerName.make("demo"),
|
||||
new Mcp.Tool({
|
||||
server: Mcp.ServerName.make("demo"),
|
||||
name: "status",
|
||||
description: "Status",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
}),
|
||||
new MCP.Tool({
|
||||
server: MCP.ServerName.make("direct"),
|
||||
new Mcp.Tool({
|
||||
server: Mcp.ServerName.make("direct"),
|
||||
name: "lookup",
|
||||
codemode: false,
|
||||
description: "Lookup",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
}),
|
||||
new MCP.Tool({
|
||||
server: MCP.ServerName.make("direct"),
|
||||
new Mcp.Tool({
|
||||
server: Mcp.ServerName.make("direct"),
|
||||
name: "fail",
|
||||
codemode: false,
|
||||
description: "Always fails",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
}),
|
||||
new MCP.Tool({
|
||||
server: MCP.ServerName.make("direct"),
|
||||
new Mcp.Tool({
|
||||
server: Mcp.ServerName.make("direct"),
|
||||
name: "media",
|
||||
codemode: false,
|
||||
description: "Returns text and an image",
|
||||
@@ -315,15 +315,15 @@ const mcp = Layer.mock(MCP.Service, {
|
||||
Effect.sync(() => {
|
||||
calls += 1
|
||||
if (input.name === "fail")
|
||||
return new MCP.ToolResult({
|
||||
server: MCP.ServerName.make(input.server),
|
||||
return new Mcp.ToolResult({
|
||||
server: Mcp.ServerName.make(input.server),
|
||||
tool: input.name,
|
||||
isError: true,
|
||||
content: [{ type: "text", text: "search index unavailable" }],
|
||||
})
|
||||
if (input.name === "media")
|
||||
return new MCP.ToolResult({
|
||||
server: MCP.ServerName.make(input.server),
|
||||
return new Mcp.ToolResult({
|
||||
server: Mcp.ServerName.make(input.server),
|
||||
tool: input.name,
|
||||
isError: false,
|
||||
content: [
|
||||
@@ -332,14 +332,14 @@ const mcp = Layer.mock(MCP.Service, {
|
||||
],
|
||||
})
|
||||
if (input.name === "status")
|
||||
return new MCP.ToolResult({
|
||||
server: MCP.ServerName.make(input.server),
|
||||
return new Mcp.ToolResult({
|
||||
server: Mcp.ServerName.make(input.server),
|
||||
tool: input.name,
|
||||
isError: false,
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
})
|
||||
return new MCP.ToolResult({
|
||||
server: MCP.ServerName.make(input.server),
|
||||
return new Mcp.ToolResult({
|
||||
server: Mcp.ServerName.make(input.server),
|
||||
tool: input.name,
|
||||
isError: false,
|
||||
structured: { ok: true },
|
||||
@@ -358,7 +358,7 @@ const permissions = Layer.mock(Permission.Service, {
|
||||
const events = Layer.mock(Bus.Service, { subscribe: () => Stream.never })
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node]), [
|
||||
[MCP.node, mcp],
|
||||
[Mcp.node, mcp],
|
||||
[Permission.node, permissions],
|
||||
[Bus.node, events],
|
||||
[Image.node, imagePassthrough],
|
||||
@@ -367,12 +367,12 @@ const it = testEffect(
|
||||
|
||||
describe("MCP errors", () => {
|
||||
test("expose useful messages", () => {
|
||||
expect(new MCP.NotFoundError({ server: MCP.ServerName.make("demo") }).message).toBe("MCP server not found: demo")
|
||||
expect(new Mcp.NotFoundError({ server: Mcp.ServerName.make("demo") }).message).toBe("MCP server not found: demo")
|
||||
expect(
|
||||
new MCP.ToolCallError({ server: MCP.ServerName.make("demo"), tool: "search", message: "failed" }).message,
|
||||
new Mcp.ToolCallError({ server: Mcp.ServerName.make("demo"), tool: "search", message: "failed" }).message,
|
||||
).toBe("failed")
|
||||
expect(new MCPClient.NeedsAuthError({ server: "demo" }).message).toBe("MCP server requires authentication: demo")
|
||||
expect(new MCPClient.ConnectError({ server: "demo", message: "offline" }).message).toBe("offline")
|
||||
expect(new McpClient.NeedsAuthError({ server: "demo" }).message).toBe("MCP server requires authentication: demo")
|
||||
expect(new McpClient.ConnectError({ server: "demo", message: "offline" }).message).toBe("offline")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -488,7 +488,7 @@ test("spawns local MCP servers through the location environment", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const connection = yield* MCPClient.connect("environment", config, import.meta.dir)
|
||||
const connection = yield* McpClient.connect("environment", config, import.meta.dir)
|
||||
yield* connection.tools()
|
||||
}),
|
||||
).pipe(Effect.provide(recordingEnvironmentLayer(spawns))),
|
||||
@@ -513,7 +513,7 @@ test("reports a local MCP server as failed when the location has no execution pl
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const service = yield* MCP.Service
|
||||
const service = yield* Mcp.Service
|
||||
yield* service.tools()
|
||||
const status = (yield* service.servers()).find((server) => server.name === "resources")?.status
|
||||
expect(status).toEqual({
|
||||
@@ -528,7 +528,7 @@ test("rejects sends before the stdio transport is started", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* MCPStdio.make({
|
||||
const transport = yield* McpStdio.make({
|
||||
server: "not-started",
|
||||
command: process.execPath,
|
||||
args: [path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
|
||||
@@ -551,7 +551,7 @@ test("joins concurrent stdio transport closes", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* MCPStdio.make({
|
||||
const transport = yield* McpStdio.make({
|
||||
server: "concurrent-close",
|
||||
command: "unused",
|
||||
args: [],
|
||||
@@ -605,7 +605,7 @@ test("closes a stdio process that finishes spawning after close", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* MCPStdio.make({
|
||||
const transport = yield* McpStdio.make({
|
||||
server: "close-during-spawn",
|
||||
command: "unused",
|
||||
args: [],
|
||||
@@ -816,7 +816,7 @@ for (const entry of [
|
||||
})
|
||||
const error = yield* connect("resources", config, import.meta.dir).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(MCPClient.ConnectError)
|
||||
expect(error).toBeInstanceOf(McpClient.ConnectError)
|
||||
expect(server.state.initializations).toBe(entry.attempts)
|
||||
expect(server.state.urls).toHaveLength(entry.attempts)
|
||||
if (entry.query || entry.codemode === false) expect(server.state.urls).toEqual([config.url])
|
||||
@@ -956,7 +956,7 @@ test("accepts empty MCP elicitations without creating forms", async () => {
|
||||
Effect.gen(function* () {
|
||||
const server = yield* resourceServer({ resources: false, emptyElicitation: true })
|
||||
const result = yield* Effect.gen(function* () {
|
||||
const service = yield* MCP.Service
|
||||
const service = yield* Mcp.Service
|
||||
const forms = yield* Form.Service
|
||||
const result = yield* service.callTool({ server: "resources", name: "empty-elicitation" })
|
||||
expect(yield* forms.list()).toEqual([])
|
||||
@@ -976,7 +976,7 @@ test("acknowledges completed MCP URL elicitations without returning internal con
|
||||
const server = yield* resourceServer({ resources: false, urlElicitation: true })
|
||||
const created = yield* Deferred.make<Form.Info>()
|
||||
const result = yield* Effect.gen(function* () {
|
||||
const service = yield* MCP.Service
|
||||
const service = yield* Mcp.Service
|
||||
const forms = yield* Form.Service
|
||||
const call = yield* service.callTool({ server: "resources", name: "url-elicitation" }).pipe(Effect.forkScoped)
|
||||
|
||||
@@ -1006,7 +1006,7 @@ test("loads and reads MCP resources", async () => {
|
||||
server.state.templates = [{ name: "File", uriTemplate: "docs://{path}" }]
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const service = yield* MCP.Service
|
||||
const service = yield* Mcp.Service
|
||||
expect(yield* service.resourceCatalog()).toEqual({
|
||||
resources: [
|
||||
{
|
||||
@@ -1053,12 +1053,12 @@ test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.gen(function* () {
|
||||
const service = yield* MCP.Service
|
||||
const service = yield* Mcp.Service
|
||||
|
||||
expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
|
||||
expect(published).toContain(McpEvent.StatusChanged.type)
|
||||
expect(yield* service.connect("missing").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError)
|
||||
expect(yield* service.disconnect("missing").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError)
|
||||
expect(yield* service.connect("missing").pipe(Effect.flip)).toBeInstanceOf(Mcp.NotFoundError)
|
||||
expect(yield* service.disconnect("missing").pipe(Effect.flip)).toBeInstanceOf(Mcp.NotFoundError)
|
||||
yield* service.add(
|
||||
"dynamic",
|
||||
new ConfigMCP.Local({
|
||||
@@ -1101,7 +1101,7 @@ test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
|
||||
yield* service.remove("dynamic")
|
||||
expect((yield* service.servers()).some((server) => server.name === "dynamic")).toBe(false)
|
||||
expect(yield* service.tools()).toEqual([])
|
||||
expect(yield* service.remove("dynamic").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError)
|
||||
expect(yield* service.remove("dynamic").pipe(Effect.flip)).toBeInstanceOf(Mcp.NotFoundError)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
resourceMcpLayer(
|
||||
@@ -1125,7 +1125,7 @@ testEffect(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unus
|
||||
"manages live MCP servers entirely through scoped transforms",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* MCP.Service
|
||||
const service = yield* Mcp.Service
|
||||
|
||||
yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
@@ -1168,7 +1168,7 @@ testEffect(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unus
|
||||
}),
|
||||
)
|
||||
|
||||
expect((yield* service.servers()).map((server) => server.name)).toEqual([MCP.ServerName.make("resources")])
|
||||
expect((yield* service.servers()).map((server) => server.name)).toEqual([Mcp.ServerName.make("resources")])
|
||||
expect(yield* service.tools()).toEqual([])
|
||||
}),
|
||||
)
|
||||
@@ -1177,7 +1177,7 @@ test("restores runtime MCP config when a transform is disposed", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const service = yield* MCP.Service
|
||||
const service = yield* Mcp.Service
|
||||
const config = new ConfigMCP.Remote({
|
||||
type: "remote",
|
||||
url: "https://example.com/mcp",
|
||||
@@ -1220,7 +1220,7 @@ test("isolates nested configured MCP mutations and reconciles them", async () =>
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const service = yield* MCP.Service
|
||||
const service = yield* Mcp.Service
|
||||
expect(published.filter((type) => type === McpEvent.StatusChanged.type)).toHaveLength(1)
|
||||
yield* service.transform((draft) =>
|
||||
draft.update("resources", (server) => {
|
||||
@@ -1263,7 +1263,7 @@ test("reconciles only changed MCP server config", async () => {
|
||||
} satisfies Payload<typeof Event.Updated>)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const service = yield* MCP.Service
|
||||
const service = yield* Mcp.Service
|
||||
yield* service.tools()
|
||||
expect(server.state.toolLists).toBe(1)
|
||||
expect(server.state.initializations).toBe(1)
|
||||
@@ -1330,7 +1330,7 @@ test("serializes concurrent MCP lifecycle operations", async () => {
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.gen(function* () {
|
||||
const service = yield* MCP.Service
|
||||
const service = yield* Mcp.Service
|
||||
|
||||
// Whatever order the racing operations land in, the resulting state must be consistent.
|
||||
yield* Effect.all(
|
||||
@@ -1373,8 +1373,8 @@ test("serializes concurrent MCP lifecycle operations", async () => {
|
||||
testEffect(Layer.empty).live("isolates invalid MCP tools and preserves plugin transforms through catalog updates", () =>
|
||||
Effect.gen(function* () {
|
||||
const tool = (server: string, name: string, description = name) =>
|
||||
new MCP.Tool({
|
||||
server: MCP.ServerName.make(server),
|
||||
new Mcp.Tool({
|
||||
server: Mcp.ServerName.make(server),
|
||||
name,
|
||||
description,
|
||||
codemode: false,
|
||||
@@ -1498,13 +1498,13 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and preserves plugin tr
|
||||
Layer.fresh(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Bus.node]), [
|
||||
[
|
||||
MCP.node,
|
||||
Layer.mock(MCP.Service, {
|
||||
Mcp.node,
|
||||
Layer.mock(Mcp.Service, {
|
||||
tools: () => Ref.get(catalog),
|
||||
callTool: (input) =>
|
||||
Effect.succeed(
|
||||
new MCP.ToolResult({
|
||||
server: MCP.ServerName.make(input.server),
|
||||
new Mcp.ToolResult({
|
||||
server: Mcp.ServerName.make(input.server),
|
||||
tool: input.name,
|
||||
isError: false,
|
||||
content: [{ type: "text", text: "healthy" }],
|
||||
@@ -1541,12 +1541,12 @@ testEffect(Layer.empty).effect("coalesces queued MCP tool notifications after in
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Bus.node]), [
|
||||
[
|
||||
MCP.node,
|
||||
Layer.mock(MCP.Service, {
|
||||
Mcp.node,
|
||||
Layer.mock(Mcp.Service, {
|
||||
tools: () =>
|
||||
Effect.sync(() => [
|
||||
new MCP.Tool({
|
||||
server: MCP.ServerName.make("demo"),
|
||||
new Mcp.Tool({
|
||||
server: Mcp.ServerName.make("demo"),
|
||||
name: `read_${++reads}`,
|
||||
codemode: false,
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Command } from "@opencode-ai/core/command"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { Mcp } from "@opencode-ai/core/mcp/index"
|
||||
import { CommandPlugin } from "@opencode-ai/core/plugin/command"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
@@ -24,8 +24,8 @@ const locationLayer = Layer.succeed(
|
||||
Location.Service.of(location({ directory }, { projectDirectory: project })),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Command.node, MCP.node, Bus.node]), [
|
||||
[MCP.node, emptyMcpLayer],
|
||||
AppNodeBuilder.build(LayerNode.group([Command.node, Mcp.node, Bus.node]), [
|
||||
[Mcp.node, emptyMcpLayer],
|
||||
[Location.node, locationLayer],
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Generate } from "@opencode-ai/core/generate"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { Mcp } from "@opencode-ai/core/mcp/index"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
@@ -72,7 +72,7 @@ export const PluginTestLayer = LayerNode.compile(
|
||||
Command.node,
|
||||
Integration.node,
|
||||
KV.node,
|
||||
MCP.node,
|
||||
Mcp.node,
|
||||
PluginRuntime.node,
|
||||
Permission.node,
|
||||
PluginHooks.node,
|
||||
@@ -89,7 +89,7 @@ export const PluginTestLayer = LayerNode.compile(
|
||||
[Location.node, tempLocationLayer],
|
||||
[Npm.node, npmLayer],
|
||||
[Config.node, Config.testLayer()],
|
||||
[MCP.node, emptyMcpLayer],
|
||||
[Mcp.node, emptyMcpLayer],
|
||||
[Generate.node, generateLayer],
|
||||
[Permission.node, permissionLayer],
|
||||
],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect } from "bun:test"
|
||||
import { MCPCodeModeExclusionPlugin } from "@opencode-ai/core/plugin/mcp-codemode-exclusion"
|
||||
import { McpCodeModeExclusionPlugin } from "@opencode-ai/core/plugin/mcp-codemode-exclusion"
|
||||
import type { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import { Effect, type Types } from "effect"
|
||||
import { it } from "../lib/effect"
|
||||
@@ -51,7 +51,7 @@ it.effect("defaults only known Code Mode MCP servers to direct tools", () =>
|
||||
)
|
||||
const base = host()
|
||||
|
||||
yield* MCPCodeModeExclusionPlugin.Plugin.effect(
|
||||
yield* McpCodeModeExclusionPlugin.Plugin.effect(
|
||||
host({
|
||||
mcp: {
|
||||
...base.mcp,
|
||||
|
||||
@@ -352,6 +352,44 @@ describe("Project.resolve", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("keeps the canonical project directory when opening another clone", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const main = path.join(tmp.path, "repo")
|
||||
const clone = path.join(tmp.path, "other-clone")
|
||||
const linked = path.join(tmp.path, "linked")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(main)
|
||||
await initRepo(main, { commit: true, remote: "git@github.com:owner/repo.git" })
|
||||
await $`git clone --no-hardlinks ${main} ${clone}`.quiet()
|
||||
await $`git remote set-url origin https://github.com/owner/repo.git`.cwd(clone).quiet()
|
||||
await $`git worktree add ${linked} -b linked`.cwd(main).quiet()
|
||||
})
|
||||
const project = yield* Project.Service
|
||||
const bus = yield* Bus.Service
|
||||
const initial = yield* project.resolve(abs(main))
|
||||
const updates: Project.Info[] = []
|
||||
yield* bus.subscribe(ProjectSchema.Event.Updated).pipe(
|
||||
Stream.runForEach((event) => Effect.sync(() => updates.push(event.data))),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
|
||||
for (const directory of [clone, linked, main, clone]) {
|
||||
const resolved = yield* project.resolve(abs(directory))
|
||||
expect(resolved.id).toBe(initial.id)
|
||||
expect(resolved.directory).toBe(abs(directory))
|
||||
expect(resolved.canonical).toBe(abs(directory === clone ? clone : main))
|
||||
expect((yield* project.list()).find((item) => item.id === initial.id)?.canonical).toBe(abs(main))
|
||||
}
|
||||
yield* Effect.yieldNow
|
||||
|
||||
expect(updates).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns git global for repo with no commits and no remote", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
|
||||
@@ -92,6 +92,33 @@ function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
|
||||
}
|
||||
|
||||
describe("Session.create", () => {
|
||||
liveIt.live("preserves the project canonical directory when creating a session in another clone", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const main = AbsolutePath.make(path.join(directory, "repo"))
|
||||
const clone = AbsolutePath.make(path.join(directory, "other-clone"))
|
||||
yield* Effect.promise(async () => {
|
||||
await $`git init -q ${main}`.cwd(directory)
|
||||
await $`git -c user.name=Test -c user.email=test@opencode.test -c commit.gpgsign=false commit --allow-empty -qm root`
|
||||
.cwd(main)
|
||||
.quiet()
|
||||
await $`git remote add origin git@github.com:owner/repo.git`.cwd(main)
|
||||
await $`git clone --no-hardlinks ${main} ${clone}`.quiet()
|
||||
await $`git remote set-url origin https://github.com/owner/repo.git`.cwd(clone)
|
||||
})
|
||||
const sessions = yield* Session.Service
|
||||
const projects = yield* Project.Service
|
||||
const first = yield* sessions.create({ location: Location.Ref.make({ directory: main }) })
|
||||
const second = yield* sessions.create({ location: Location.Ref.make({ directory: clone }) })
|
||||
|
||||
expect(second.projectID).toBe(first.projectID)
|
||||
expect((yield* projects.list()).find((project) => project.id === first.projectID)?.canonical).toBe(main)
|
||||
expect((yield* sessions.get(first.id)).location.directory).toBe(main)
|
||||
expect((yield* sessions.get(second.id)).location.directory).toBe(clone)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
liveIt.live("follows the directory's project identity established after creation", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { beforeEach, describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Layer, Stream } from "effect"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
@@ -19,7 +18,7 @@ import { imagePassthrough } from "./lib/image"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
import { webSearchHost } from "./plugin/host"
|
||||
import { produce } from "immer"
|
||||
import { TestWebSearch } from "./lib/websearch"
|
||||
|
||||
const webSearchToolNode = makeLocationNode({
|
||||
name: "test/websearch-tool-plugin",
|
||||
@@ -33,142 +32,78 @@ const webSearchToolNode = makeLocationNode({
|
||||
})
|
||||
|
||||
const sessionID = Session.ID.make("ses_websearch_test")
|
||||
const assertions: Permission.AssertInput[] = []
|
||||
const queries: WebSearch.Input[] = []
|
||||
const formRequests: Form.CreateInput[] = []
|
||||
let selection: WebSearch.ID | "random" | false | undefined
|
||||
const providers = [
|
||||
{ id: WebSearch.ID.make("exa"), name: "Exa" },
|
||||
{ id: WebSearch.ID.make("parallel"), name: "Parallel" },
|
||||
]
|
||||
let providerRequired = false
|
||||
let formResponse: Form.TerminalState = { status: "cancelled" }
|
||||
const formResponses: Form.TerminalState[] = []
|
||||
let queryBarrier: Deferred.Deferred<void> | undefined
|
||||
let synchronizedQueries = 0
|
||||
let queryError: WebSearch.Error | undefined
|
||||
let result = new WebSearch.Response({
|
||||
providerID: WebSearch.ID.make("exa"),
|
||||
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
assertions.length = 0
|
||||
queries.length = 0
|
||||
formRequests.length = 0
|
||||
selection = undefined
|
||||
providerRequired = false
|
||||
formResponse = { status: "cancelled" }
|
||||
formResponses.length = 0
|
||||
queryBarrier = undefined
|
||||
synchronizedQueries = 0
|
||||
queryError = undefined
|
||||
result = new WebSearch.Response({
|
||||
providerID: WebSearch.ID.make("exa"),
|
||||
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
|
||||
})
|
||||
})
|
||||
class Fixture {
|
||||
assertions: Permission.AssertInput[] = []
|
||||
events: string[] = []
|
||||
formRequests: Form.CreateInput[] = []
|
||||
formResponse: Form.TerminalState = { status: "cancelled" }
|
||||
formResponses: Form.TerminalState[] = []
|
||||
formWait = Effect.void
|
||||
error: HttpClientError.HttpClientError | undefined
|
||||
results: readonly WebSearch.Result[] = [
|
||||
{ url: "https://example.com", title: "Search results", content: "search results", time: {} },
|
||||
]
|
||||
}
|
||||
|
||||
const permission = permissionLayer({
|
||||
assert: (input) => Effect.sync(() => assertions.push(input)),
|
||||
})
|
||||
const websearch = Layer.succeed(
|
||||
WebSearch.Service,
|
||||
WebSearch.Service.of({
|
||||
transform: (transform) =>
|
||||
Effect.sync(() => {
|
||||
transform({
|
||||
add: () => undefined,
|
||||
default: {
|
||||
get: () => selection,
|
||||
set: (next) => (selection = next),
|
||||
},
|
||||
})
|
||||
return { dispose: Effect.void }
|
||||
}),
|
||||
reload: () => Effect.die("unused"),
|
||||
providers: () => Effect.succeed(providers),
|
||||
default: () =>
|
||||
Effect.gen(function* () {
|
||||
if (selection === false) return yield* new WebSearch.DisabledError()
|
||||
return selection ? providers.find((provider) => provider.id === selection) : undefined
|
||||
}),
|
||||
select: (next) => Effect.sync(() => (selection = next)),
|
||||
query: (input) =>
|
||||
Effect.gen(function* () {
|
||||
queries.push(input)
|
||||
if (queryBarrier && synchronizedQueries < 5) {
|
||||
synchronizedQueries++
|
||||
if (synchronizedQueries === 5) yield* Deferred.succeed(queryBarrier, undefined)
|
||||
yield* Deferred.await(queryBarrier)
|
||||
}
|
||||
if (queryError) return yield* queryError
|
||||
if (providerRequired && !selection) return yield* new WebSearch.ProviderRequiredError()
|
||||
if (selection)
|
||||
return new WebSearch.Response({
|
||||
providerID: selection === "random" ? result.providerID : WebSearch.ID.make(selection),
|
||||
results: result.results,
|
||||
})
|
||||
return result
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const form = Layer.succeed(
|
||||
Form.Service,
|
||||
Form.Service.of({
|
||||
create: () => Effect.die("unused"),
|
||||
ask: (input) =>
|
||||
Effect.sync(() => {
|
||||
formRequests.push(input)
|
||||
return formResponses.shift() ?? formResponse
|
||||
}),
|
||||
get: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
state: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
cancel: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const config = Layer.succeed(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({
|
||||
websearch: selection === undefined ? undefined : selection === false ? false : { provider: selection },
|
||||
const it = testEffect(TestWebSearch.layer)
|
||||
const setup = Effect.gen(function* () {
|
||||
const fixture = new Fixture()
|
||||
const websearch = yield* TestWebSearch.Service
|
||||
const kv = yield* KV.Service
|
||||
yield* websearch.transform((draft) =>
|
||||
providers.forEach((provider) =>
|
||||
draft.add({
|
||||
...provider,
|
||||
execute: () =>
|
||||
Effect.gen(function* () {
|
||||
fixture.events.push("query")
|
||||
if (fixture.error) return yield* fixture.error
|
||||
return fixture.results
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
const context = yield* Layer.build(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, webSearchToolNode]), [
|
||||
[
|
||||
Permission.node,
|
||||
permissionLayer({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => {
|
||||
fixture.events.push("permission")
|
||||
fixture.assertions.push(input)
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
update: (update) =>
|
||||
Effect.sync(() => {
|
||||
const info = produce(
|
||||
new Info({
|
||||
websearch: selection === undefined ? undefined : selection === false ? false : { provider: selection },
|
||||
}),
|
||||
update,
|
||||
)
|
||||
selection = info.websearch === false ? false : info.websearch?.provider
|
||||
return info
|
||||
}),
|
||||
changes: () => Stream.never,
|
||||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, WebSearch.node, webSearchToolNode]), [
|
||||
[Permission.node, permission],
|
||||
[WebSearch.node, websearch],
|
||||
[Form.node, form],
|
||||
[Config.node, config],
|
||||
[Image.node, imagePassthrough],
|
||||
]),
|
||||
)
|
||||
],
|
||||
[WebSearch.node, Layer.succeed(WebSearch.Service, websearch)],
|
||||
[
|
||||
Form.node,
|
||||
Layer.mock(Form.Service, {
|
||||
ask: (input) =>
|
||||
Effect.gen(function* () {
|
||||
fixture.formRequests.push(input)
|
||||
yield* fixture.formWait
|
||||
return fixture.formResponses.shift() ?? fixture.formResponse
|
||||
}),
|
||||
}),
|
||||
],
|
||||
[Image.node, imagePassthrough],
|
||||
]),
|
||||
)
|
||||
return Object.assign(fixture, { websearch, kv, registry: Context.get(context, Tool.Service) })
|
||||
})
|
||||
|
||||
describe("WebSearchTool registration", () => {
|
||||
it.effect("asserts permission before delegating to WebSearch", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* Tool.Service
|
||||
const fixture = yield* setup
|
||||
const registry = fixture.registry
|
||||
yield* fixture.websearch.select(WebSearch.ID.make("exa"))
|
||||
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["websearch", "execute"])
|
||||
expect(
|
||||
@@ -186,7 +121,7 @@ describe("WebSearchTool registration", () => {
|
||||
status: "completed",
|
||||
content: [{ type: "text", text: "## [Search results](https://example.com)\n\nsearch results" }],
|
||||
})
|
||||
expect(assertions).toMatchObject([
|
||||
expect(fixture.assertions).toMatchObject([
|
||||
{
|
||||
sessionID,
|
||||
action: "websearch",
|
||||
@@ -195,28 +130,29 @@ describe("WebSearchTool registration", () => {
|
||||
metadata: { query: "effect typescript" },
|
||||
},
|
||||
])
|
||||
expect(queries).toEqual([
|
||||
expect(fixture.websearch.queries).toEqual([
|
||||
{
|
||||
query: "effect typescript",
|
||||
providerID: WebSearch.ID.make("exa"),
|
||||
},
|
||||
])
|
||||
expect(fixture.events).toEqual(["permission", "query"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps normalized results in structured output", () =>
|
||||
Effect.gen(function* () {
|
||||
result = new WebSearch.Response({
|
||||
providerID: WebSearch.ID.make("parallel"),
|
||||
results: [
|
||||
{
|
||||
url: "https://effect.website",
|
||||
title: "Effect",
|
||||
content: "parallel results",
|
||||
time: { published: Date.parse("2026-07-25T00:00:00.000Z") },
|
||||
},
|
||||
],
|
||||
})
|
||||
const registry = yield* Tool.Service
|
||||
const fixture = yield* setup
|
||||
yield* fixture.websearch.select(WebSearch.ID.make("parallel"))
|
||||
fixture.results = [
|
||||
{
|
||||
url: "https://effect.website",
|
||||
title: "Effect",
|
||||
content: "parallel results",
|
||||
time: { published: Date.parse("2026-07-25T00:00:00.000Z") },
|
||||
},
|
||||
]
|
||||
const registry = fixture.registry
|
||||
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
@@ -250,8 +186,10 @@ describe("WebSearchTool registration", () => {
|
||||
|
||||
it.effect("uses the concise no-results fallback", () =>
|
||||
Effect.gen(function* () {
|
||||
result = new WebSearch.Response({ providerID: WebSearch.ID.make("exa"), results: [] })
|
||||
const registry = yield* Tool.Service
|
||||
const fixture = yield* setup
|
||||
yield* fixture.websearch.select(WebSearch.ID.make("exa"))
|
||||
fixture.results = []
|
||||
const registry = fixture.registry
|
||||
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
@@ -268,20 +206,21 @@ describe("WebSearchTool registration", () => {
|
||||
|
||||
it.effect("asks once and uses the default provider when web search is first enabled", () =>
|
||||
Effect.gen(function* () {
|
||||
providerRequired = true
|
||||
formResponse = { status: "answered", answer: { choice: "allow" } }
|
||||
const registry = yield* Tool.Service
|
||||
const fixture = yield* setup
|
||||
fixture.formResponse = { status: "answered", answer: { choice: "allow" } }
|
||||
const registry = fixture.registry
|
||||
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-enable", name: "websearch", input: { query: "effect" } },
|
||||
}),
|
||||
).toMatchObject({ status: "completed", metadata: { provider: "exa" } })
|
||||
expect(selection).toBe("random")
|
||||
expect(queries).toHaveLength(2)
|
||||
expect(formRequests).toEqual([
|
||||
const first = yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-enable", name: "websearch", input: { query: "effect" } },
|
||||
})
|
||||
expect(first.status).toBe("completed")
|
||||
expect(["exa", "parallel"]).toContain(first.metadata?.provider)
|
||||
expect(first.metadata?.provider).toBe(fixture.websearch.queries[1]?.providerID)
|
||||
expect(yield* fixture.kv.get(WebSearch.ProviderKey)).toBe("random")
|
||||
expect(fixture.websearch.queries).toHaveLength(2)
|
||||
expect(fixture.formRequests).toEqual([
|
||||
{
|
||||
sessionID,
|
||||
title: "Web Search",
|
||||
@@ -309,26 +248,27 @@ describe("WebSearchTool registration", () => {
|
||||
},
|
||||
])
|
||||
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-enabled", name: "websearch", input: { query: "effect schema" } },
|
||||
}),
|
||||
).toMatchObject({ status: "completed", metadata: { provider: "exa" } })
|
||||
expect(formRequests).toHaveLength(1)
|
||||
expect(queries).toHaveLength(3)
|
||||
const second = yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-enabled", name: "websearch", input: { query: "effect schema" } },
|
||||
})
|
||||
expect(second.status).toBe("completed")
|
||||
expect(["exa", "parallel"]).toContain(second.metadata?.provider)
|
||||
expect(second.metadata?.provider).toBe(fixture.websearch.queries[2]?.providerID)
|
||||
expect(fixture.formRequests).toHaveLength(1)
|
||||
expect(fixture.websearch.queries).toHaveLength(3)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("asks a second form when choosing another provider", () =>
|
||||
Effect.gen(function* () {
|
||||
providerRequired = true
|
||||
formResponses.push(
|
||||
const fixture = yield* setup
|
||||
fixture.formResponses.push(
|
||||
{ status: "answered", answer: { choice: "choose" } },
|
||||
{ status: "answered", answer: { provider: "parallel" } },
|
||||
)
|
||||
const registry = yield* Tool.Service
|
||||
const registry = fixture.registry
|
||||
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
@@ -337,9 +277,10 @@ describe("WebSearchTool registration", () => {
|
||||
call: { type: "tool-call", id: "call-choose", name: "websearch", input: { query: "effect" } },
|
||||
}),
|
||||
).toMatchObject({ status: "completed", metadata: { provider: "parallel" } })
|
||||
expect(selection).toBe(WebSearch.ID.make("parallel"))
|
||||
expect(queries).toHaveLength(2)
|
||||
expect(formRequests[1]).toEqual({
|
||||
expect(yield* fixture.kv.get(WebSearch.ProviderKey)).toBe(WebSearch.ID.make("parallel"))
|
||||
expect(fixture.websearch.queries).toHaveLength(2)
|
||||
expect(fixture.websearch.queries[1]?.providerID).toBe(WebSearch.ID.make("parallel"))
|
||||
expect(fixture.formRequests[1]).toEqual({
|
||||
sessionID,
|
||||
title: "Choose a web search provider",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
@@ -362,10 +303,10 @@ describe("WebSearchTool registration", () => {
|
||||
|
||||
it.effect("shares provider consent across concurrent searches", () =>
|
||||
Effect.gen(function* () {
|
||||
providerRequired = true
|
||||
formResponse = { status: "answered", answer: { choice: "allow" } }
|
||||
queryBarrier = yield* Deferred.make<void>()
|
||||
const registry = yield* Tool.Service
|
||||
const fixture = yield* setup
|
||||
fixture.formResponse = { status: "answered", answer: { choice: "allow" } }
|
||||
fixture.formWait = fixture.websearch.wait(5)
|
||||
const registry = fixture.registry
|
||||
|
||||
const results = yield* Effect.all(
|
||||
Array.from({ length: 5 }, (_, index) =>
|
||||
@@ -384,16 +325,16 @@ describe("WebSearchTool registration", () => {
|
||||
)
|
||||
|
||||
expect(results.every((item) => item.status === "completed")).toBe(true)
|
||||
expect(formRequests).toHaveLength(1)
|
||||
expect(selection).toBe("random")
|
||||
expect(fixture.formRequests).toHaveLength(1)
|
||||
expect(yield* fixture.kv.get(WebSearch.ProviderKey)).toBe("random")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("persists the choice to disable web search", () =>
|
||||
Effect.gen(function* () {
|
||||
providerRequired = true
|
||||
formResponse = { status: "answered", answer: { choice: "disable" } }
|
||||
const registry = yield* Tool.Service
|
||||
const fixture = yield* setup
|
||||
fixture.formResponse = { status: "answered", answer: { choice: "disable" } }
|
||||
const registry = fixture.registry
|
||||
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
@@ -402,16 +343,18 @@ describe("WebSearchTool registration", () => {
|
||||
call: { type: "tool-call", id: "call-disable", name: "websearch", input: { query: "effect" } },
|
||||
}),
|
||||
).toMatchObject({ status: "error" })
|
||||
expect(selection).toBe(false)
|
||||
expect(queries).toHaveLength(1)
|
||||
expect(yield* fixture.kv.get(WebSearch.ProviderKey)).toBe(false)
|
||||
expect(yield* fixture.websearch.default().pipe(Effect.flip)).toBeInstanceOf(WebSearch.DisabledError)
|
||||
expect(fixture.websearch.queries).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reports safe HTTP failures with the attempted provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* Tool.Service
|
||||
const fixture = yield* setup
|
||||
const registry = fixture.registry
|
||||
const tools = yield* registry.snapshot()
|
||||
selection = WebSearch.ID.make("exa")
|
||||
yield* fixture.websearch.select(WebSearch.ID.make("exa"))
|
||||
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
@@ -422,14 +365,11 @@ describe("WebSearchTool registration", () => {
|
||||
({ status, message }, index) =>
|
||||
Effect.gen(function* () {
|
||||
const request = HttpClientRequest.post("https://mcp.exa.ai/mcp?exaApiKey=secret")
|
||||
queryError = new WebSearch.RequestError({
|
||||
providerID: WebSearch.ID.make("exa"),
|
||||
cause: new HttpClientError.HttpClientError({
|
||||
reason: new HttpClientError.StatusCodeError({
|
||||
request,
|
||||
response: HttpClientResponse.fromWeb(request, new Response(null, { status })),
|
||||
description: "non 2xx status code",
|
||||
}),
|
||||
fixture.error = new HttpClientError.HttpClientError({
|
||||
reason: new HttpClientError.StatusCodeError({
|
||||
request,
|
||||
response: HttpClientResponse.fromWeb(request, new Response(null, { status })),
|
||||
description: "non 2xx status code",
|
||||
}),
|
||||
})
|
||||
const progress: Tool.Metadata[] = []
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Scope } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Effect, Exit, Fiber, Scope } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { TestWebSearch } from "./lib/websearch"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([WebSearch.node, Bus.node, KV.node])))
|
||||
const it = testEffect(TestWebSearch.layer)
|
||||
|
||||
const register = (id: string) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -36,11 +35,28 @@ const register = (id: string) =>
|
||||
})
|
||||
|
||||
describe("WebSearch", () => {
|
||||
it.effect("shares the normal and test interfaces without installing live providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const websearch = yield* WebSearch.Service
|
||||
const test = yield* TestWebSearch.Service
|
||||
|
||||
expect(websearch).toBe(test)
|
||||
expect(yield* websearch.providers()).toEqual([])
|
||||
expect(test.queries).toEqual([])
|
||||
expect((yield* websearch.query({ query: "unconfigured" }).pipe(Effect.flip))._tag).toBe(
|
||||
"WebSearch.ProviderRequired",
|
||||
)
|
||||
yield* test.wait(1)
|
||||
expect(test.queries).toEqual([{ query: "unconfigured" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("executes an explicit provider without changing the default", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* register("exa")
|
||||
const parallel = yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
const test = yield* TestWebSearch.Service
|
||||
|
||||
expect(yield* websearch.query({ query: "effect", providerID: parallel.providerID })).toEqual(
|
||||
new WebSearch.Response({
|
||||
@@ -57,6 +73,7 @@ describe("WebSearch", () => {
|
||||
)
|
||||
expect((yield* websearch.query({ query: "default" }).pipe(Effect.flip))._tag).toBe("WebSearch.ProviderRequired")
|
||||
expect(parallel.calls).toEqual([{ query: "effect" }])
|
||||
expect(test.queries).toEqual([{ query: "effect", providerID: parallel.providerID }, { query: "default" }])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -81,6 +98,23 @@ describe("WebSearch", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reloads active transforms from their current source", () =>
|
||||
Effect.gen(function* () {
|
||||
const exa = yield* register("exa")
|
||||
const parallel = yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
const source = { providerID: exa.providerID }
|
||||
yield* websearch.transform((draft) => draft.default.set(source.providerID))
|
||||
|
||||
expect((yield* websearch.default())?.id).toBe(exa.providerID)
|
||||
source.providerID = parallel.providerID
|
||||
const reload = yield* websearch.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(reload)
|
||||
expect((yield* websearch.default())?.id).toBe(parallel.providerID)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("persists the selected provider in KV", () =>
|
||||
Effect.gen(function* () {
|
||||
const parallel = yield* register("parallel")
|
||||
|
||||
@@ -228,6 +228,57 @@ describe("Worktree", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
projectIt.live("creates worktrees and runs setup from the selected clone", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
)
|
||||
const main = abs(path.join(root.path, "repo"))
|
||||
const clone = abs(path.join(root.path, "other-clone"))
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(main)
|
||||
await initRepo(main)
|
||||
await $`git remote add origin git@github.com:owner/repo.git`.cwd(main).quiet()
|
||||
await $`git clone --no-hardlinks ${main} ${clone}`.quiet()
|
||||
await $`git remote set-url origin https://github.com/owner/repo.git`.cwd(clone).quiet()
|
||||
await $`git -c user.name=Test -c user.email=test@opencode.test -c commit.gpgsign=false commit --allow-empty -m clone`
|
||||
.cwd(clone)
|
||||
.quiet()
|
||||
})
|
||||
const projects = yield* Project.Service
|
||||
const worktrees = yield* Worktree.Service
|
||||
const initial = yield* projects.resolve(main)
|
||||
const selected = yield* projects.resolve(clone)
|
||||
yield* projects.update({
|
||||
projectID: initial.id,
|
||||
commands: {
|
||||
start:
|
||||
"bun -e \"await Bun.write('setup.json', JSON.stringify([process.env.OPENCODE_WORKTREE_BASE, process.env.OPENCODE_WORKTREE_PATH, process.cwd()]))\"",
|
||||
},
|
||||
})
|
||||
|
||||
const created = yield* worktrees.create({
|
||||
projectID: selected.id,
|
||||
strategy: gitWorktree,
|
||||
from: selected.canonical,
|
||||
directory: abs(path.join(root.path, "worktrees")),
|
||||
name: "selected-clone",
|
||||
})
|
||||
|
||||
expect(selected.id).toBe(initial.id)
|
||||
expect((yield* projects.list()).find((project) => project.id === initial.id)?.canonical).toBe(main)
|
||||
expect(yield* Effect.promise(() => $`git rev-parse HEAD`.cwd(created.directory).text())).toBe(
|
||||
yield* Effect.promise(() => $`git rev-parse HEAD`.cwd(clone).text()),
|
||||
)
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, "setup.json")).json())).toEqual([
|
||||
clone,
|
||||
created.directory,
|
||||
created.directory,
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("creates a git worktree from a selected branch", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
|
||||
@@ -7,22 +7,24 @@ type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
|
||||
|
||||
const layer = Layer.mergeAll(TestConsole.layer, TestClock.layer())
|
||||
|
||||
const effect = <A, E>(name: string, body: Body<A, E, Scope>, options?: Parameters<typeof test>[2]) =>
|
||||
test(
|
||||
name,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Effect.suspend(() => (typeof body === "function" ? body() : body)).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(layer),
|
||||
Effect.exit,
|
||||
)
|
||||
if (Exit.isFailure(exit)) {
|
||||
yield* Effect.forEach(Cause.prettyErrors(exit.cause), Effect.logError, { discard: true })
|
||||
}
|
||||
return yield* exit
|
||||
}).pipe(Effect.runPromise),
|
||||
options,
|
||||
)
|
||||
const make =
|
||||
<R>(testLayer: Layer.Layer<R>) =>
|
||||
<A, E>(name: string, body: Body<A, E, Scope>, options?: Parameters<typeof test>[2]) =>
|
||||
test(
|
||||
name,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Effect.suspend(() => (typeof body === "function" ? body() : body)).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(testLayer),
|
||||
Effect.exit,
|
||||
)
|
||||
if (Exit.isFailure(exit)) {
|
||||
yield* Effect.forEach(Cause.prettyErrors(exit.cause), Effect.logError, { discard: true })
|
||||
}
|
||||
return yield* exit
|
||||
}).pipe(Effect.runPromise),
|
||||
options,
|
||||
)
|
||||
|
||||
export const it = { effect }
|
||||
export const it = { effect: make(layer), live: make(TestConsole.layer) }
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
emitPromise,
|
||||
generate,
|
||||
GenerationError,
|
||||
type Output,
|
||||
} from "../src"
|
||||
import { it } from "./effect"
|
||||
import { Api as FixtureApi, Missing } from "./fixture"
|
||||
@@ -32,6 +33,21 @@ function compile<Id extends string, Groups extends HttpApiGroup.Constraint>(sour
|
||||
return emitEffect(compileContract(source))
|
||||
}
|
||||
|
||||
async function emittedModule(output: Output) {
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
|
||||
const dispose = () => rm(directory, { recursive: true, force: true })
|
||||
|
||||
try {
|
||||
// Finish each write before cleanup can run, even when a later write fails.
|
||||
await Array.fromAsync(output.files, (file) => Bun.write(join(directory, file.path), file.content))
|
||||
const module = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
|
||||
return { module, [Symbol.asyncDispose]: dispose }
|
||||
} catch (cause) {
|
||||
await dispose()
|
||||
throw cause
|
||||
}
|
||||
}
|
||||
|
||||
describe("HttpApiCodegen.generate", () => {
|
||||
test("compiles one contract for Promise and Effect emitters", () => {
|
||||
const contract = compileContract(
|
||||
@@ -352,27 +368,21 @@ describe("HttpApiCodegen.generate", () => {
|
||||
),
|
||||
)
|
||||
const output = emitPromise(compileContract(source))
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
|
||||
await using emitted = await emittedModule(output)
|
||||
const methods: Array<string> = []
|
||||
|
||||
try {
|
||||
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
|
||||
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
|
||||
const client = generated.OpenCode.make({
|
||||
baseUrl: "https://example.com",
|
||||
fetch: async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
methods.push(init?.method ?? "GET")
|
||||
return Response.json("ok")
|
||||
},
|
||||
})
|
||||
const client = emitted.module.OpenCode.make({
|
||||
baseUrl: "https://example.com",
|
||||
fetch: async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
methods.push(init?.method ?? "GET")
|
||||
return Response.json("ok")
|
||||
},
|
||||
})
|
||||
|
||||
expect(await client.session.instructions.list()).toBe("ok")
|
||||
expect(await client.session.instructions.put()).toBe("ok")
|
||||
expect(await client.session.instructions.remove()).toBe("ok")
|
||||
expect(methods).toEqual(["GET", "PUT", "DELETE"])
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
expect(await client.session.instructions.list()).toBe("ok")
|
||||
expect(await client.session.instructions.put()).toBe("ok")
|
||||
expect(await client.session.instructions.remove()).toBe("ok")
|
||||
expect(methods).toEqual(["GET", "PUT", "DELETE"])
|
||||
})
|
||||
|
||||
test("rejects duplicate and leaf-namespace endpoint paths", () => {
|
||||
@@ -825,26 +835,19 @@ describe("HttpApiCodegen.generate", () => {
|
||||
),
|
||||
),
|
||||
)
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
|
||||
await using emitted = await emittedModule(output)
|
||||
let request: Request | undefined
|
||||
const client = emitted.module.OpenCode.make({
|
||||
baseUrl: "https://example.com",
|
||||
fetch: async (input: RequestInfo | URL) => {
|
||||
request = input instanceof Request ? input : new Request(input)
|
||||
return Response.json({ data: "hello" })
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
|
||||
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
|
||||
let request: Request | undefined
|
||||
const client = generated.OpenCode.make({
|
||||
baseUrl: "https://example.com",
|
||||
fetch: async (input: RequestInfo | URL) => {
|
||||
request = input instanceof Request ? input : new Request(input)
|
||||
return Response.json({ data: "hello" })
|
||||
},
|
||||
})
|
||||
|
||||
expect(await client.session.get({ sessionID: "a/b" })).toBe("hello")
|
||||
expect(request?.method).toBe("GET")
|
||||
expect(request?.url).toBe("https://example.com/session/a%2Fb")
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
expect(await client.session.get({ sessionID: "a/b" })).toBe("hello")
|
||||
expect(request?.method).toBe("GET")
|
||||
expect(request?.url).toBe("https://example.com/session/a%2Fb")
|
||||
})
|
||||
|
||||
test("maps an emitted no-content response to undefined", async () => {
|
||||
@@ -858,20 +861,13 @@ describe("HttpApiCodegen.generate", () => {
|
||||
),
|
||||
),
|
||||
)
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
|
||||
await using emitted = await emittedModule(output)
|
||||
const client = emitted.module.OpenCode.make({
|
||||
baseUrl: "https://example.com",
|
||||
fetch: async () => new Response(null, { status: 204 }),
|
||||
})
|
||||
|
||||
try {
|
||||
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
|
||||
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
|
||||
const client = generated.OpenCode.make({
|
||||
baseUrl: "https://example.com",
|
||||
fetch: async () => new Response(null, { status: 204 }),
|
||||
})
|
||||
|
||||
expect(await client.session.interrupt({ sessionID: "session" })).toBeUndefined()
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
expect(await client.session.interrupt({ sessionID: "session" })).toBeUndefined()
|
||||
})
|
||||
|
||||
test("executes an emitted binary wildcard GET through fetch", async () => {
|
||||
@@ -885,28 +881,21 @@ describe("HttpApiCodegen.generate", () => {
|
||||
),
|
||||
),
|
||||
)
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
|
||||
await using emitted = await emittedModule(output)
|
||||
let request: Request | undefined
|
||||
const client = emitted.module.OpenCode.make({
|
||||
baseUrl: "https://example.com",
|
||||
fetch: async (input: RequestInfo | URL) => {
|
||||
request = input instanceof Request ? input : new Request(input)
|
||||
return new Response(new Uint8Array([1, 2, 3]))
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
|
||||
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
|
||||
let request: Request | undefined
|
||||
const client = generated.OpenCode.make({
|
||||
baseUrl: "https://example.com",
|
||||
fetch: async (input: RequestInfo | URL) => {
|
||||
request = input instanceof Request ? input : new Request(input)
|
||||
return new Response(new Uint8Array([1, 2, 3]))
|
||||
},
|
||||
})
|
||||
|
||||
const result = await client.session.read({ path: "src/a b#c.ts", token: "x/y" })
|
||||
expect(result).toBeInstanceOf(Uint8Array)
|
||||
expect(Array.from(result)).toEqual([1, 2, 3])
|
||||
expect(request?.method).toBe("GET")
|
||||
expect(request?.url).toBe("https://example.com/file/src/a%20b%23c.ts?token=x%2Fy")
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
const result = await client.session.read({ path: "src/a b#c.ts", token: "x/y" })
|
||||
expect(result).toBeInstanceOf(Uint8Array)
|
||||
expect(Array.from(result)).toEqual([1, 2, 3])
|
||||
expect(request?.method).toBe("GET")
|
||||
expect(request?.url).toBe("https://example.com/file/src/a%20b%23c.ts?token=x%2Fy")
|
||||
})
|
||||
|
||||
test("serializes flattened query, header, and JSON payload inputs", async () => {
|
||||
@@ -923,29 +912,22 @@ describe("HttpApiCodegen.generate", () => {
|
||||
),
|
||||
),
|
||||
)
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
|
||||
await using emitted = await emittedModule(output)
|
||||
let request: Request | undefined
|
||||
const client = emitted.module.OpenCode.make({
|
||||
baseUrl: "https://example.com",
|
||||
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
request = input instanceof Request ? input : new Request(input, init)
|
||||
return Response.json({ data: "admitted" })
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
|
||||
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
|
||||
let request: Request | undefined
|
||||
const client = generated.OpenCode.make({
|
||||
baseUrl: "https://example.com",
|
||||
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
request = input instanceof Request ? input : new Request(input, init)
|
||||
return Response.json({ data: "admitted" })
|
||||
},
|
||||
})
|
||||
|
||||
expect(
|
||||
await client.session.prompt({ sessionID: "session", resume: true, traceID: "trace", prompt: "hello" }),
|
||||
).toBe("admitted")
|
||||
expect(request?.url).toBe("https://example.com/session/session?resume=true")
|
||||
expect(request?.headers.get("traceID")).toBe("trace")
|
||||
expect(await request?.json()).toEqual({ prompt: "hello" })
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
expect(await client.session.prompt({ sessionID: "session", resume: true, traceID: "trace", prompt: "hello" })).toBe(
|
||||
"admitted",
|
||||
)
|
||||
expect(request?.url).toBe("https://example.com/session/session?resume=true")
|
||||
expect(request?.headers.get("traceID")).toBe("trace")
|
||||
expect(await request?.json()).toEqual({ prompt: "hello" })
|
||||
})
|
||||
|
||||
test("serializes an opaque union payload as the direct JSON body", async () => {
|
||||
@@ -962,26 +944,19 @@ describe("HttpApiCodegen.generate", () => {
|
||||
),
|
||||
),
|
||||
)
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
|
||||
await using emitted = await emittedModule(output)
|
||||
let request: Request | undefined
|
||||
const client = emitted.module.OpenCode.make({
|
||||
baseUrl: "https://example.com",
|
||||
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
request = input instanceof Request ? input : new Request(input, init)
|
||||
return new Response(null, { status: 204 })
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
|
||||
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
|
||||
let request: Request | undefined
|
||||
const client = generated.OpenCode.make({
|
||||
baseUrl: "https://example.com",
|
||||
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
request = input instanceof Request ? input : new Request(input, init)
|
||||
return new Response(null, { status: 204 })
|
||||
},
|
||||
})
|
||||
await client.session.configure({ payload: { type: "local", command: ["opencode"] } })
|
||||
|
||||
await client.session.configure({ payload: { type: "local", command: ["opencode"] } })
|
||||
|
||||
expect(await request?.json()).toEqual({ type: "local", command: ["opencode"] })
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
expect(await request?.json()).toEqual({ type: "local", command: ["opencode"] })
|
||||
})
|
||||
|
||||
test("serializes explicit null query values", async () => {
|
||||
@@ -995,26 +970,19 @@ describe("HttpApiCodegen.generate", () => {
|
||||
),
|
||||
),
|
||||
)
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
|
||||
await using emitted = await emittedModule(output)
|
||||
let request: Request | undefined
|
||||
const client = emitted.module.OpenCode.make({
|
||||
baseUrl: "https://example.com",
|
||||
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
request = input instanceof Request ? input : new Request(input, init)
|
||||
return Response.json({ data: [] })
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
|
||||
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
|
||||
let request: Request | undefined
|
||||
const client = generated.OpenCode.make({
|
||||
baseUrl: "https://example.com",
|
||||
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
request = input instanceof Request ? input : new Request(input, init)
|
||||
return Response.json({ data: [] })
|
||||
},
|
||||
})
|
||||
await client.session.list({ parentID: null })
|
||||
|
||||
await client.session.list({ parentID: null })
|
||||
|
||||
expect(request?.url).toBe("https://example.com/session?parentID=null")
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
expect(request?.url).toBe("https://example.com/session?parentID=null")
|
||||
})
|
||||
|
||||
test("rejects with declared tagged errors and exports a type guard", async () => {
|
||||
@@ -1029,22 +997,15 @@ describe("HttpApiCodegen.generate", () => {
|
||||
),
|
||||
),
|
||||
)
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
|
||||
await using emitted = await emittedModule(output)
|
||||
const client = emitted.module.OpenCode.make({
|
||||
baseUrl: "https://example.com",
|
||||
fetch: async () => Response.json({ _tag: "Missing", message: "gone" }, { status: 404 }),
|
||||
})
|
||||
|
||||
try {
|
||||
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
|
||||
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
|
||||
const client = generated.OpenCode.make({
|
||||
baseUrl: "https://example.com",
|
||||
fetch: async () => Response.json({ _tag: "Missing", message: "gone" }, { status: 404 }),
|
||||
})
|
||||
|
||||
const error = await client.session.get({ sessionID: "missing" }).catch((cause: unknown) => cause)
|
||||
expect(error).toEqual({ _tag: "Missing", message: "gone" })
|
||||
expect(generated.isMissing(error)).toBeTrue()
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
const error = await client.session.get({ sessionID: "missing" }).catch((cause: unknown) => cause)
|
||||
expect(error).toEqual({ _tag: "Missing", message: "gone" })
|
||||
expect(emitted.module.isMissing(error)).toBeTrue()
|
||||
})
|
||||
|
||||
test("iterates an emitted SSE stream lazily without reconnecting", async () => {
|
||||
@@ -1060,42 +1021,35 @@ describe("HttpApiCodegen.generate", () => {
|
||||
),
|
||||
),
|
||||
)
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
|
||||
await using emitted = await emittedModule(output)
|
||||
let requests = 0
|
||||
let url: string | undefined
|
||||
const client = emitted.module.OpenCode.make({
|
||||
baseUrl: "https://example.com",
|
||||
fetch: async (input: RequestInfo | URL) => {
|
||||
requests++
|
||||
url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
|
||||
const encoder = new TextEncoder()
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode('data: {"type":"ready","count":"1"}\r'))
|
||||
controller.enqueue(encoder.encode("\n\r\n"))
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
},
|
||||
})
|
||||
const events = client.session.subscribe({ after: 2 })
|
||||
|
||||
try {
|
||||
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
|
||||
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
|
||||
let requests = 0
|
||||
let url: string | undefined
|
||||
const client = generated.OpenCode.make({
|
||||
baseUrl: "https://example.com",
|
||||
fetch: async (input: RequestInfo | URL) => {
|
||||
requests++
|
||||
url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
|
||||
const encoder = new TextEncoder()
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode('data: {"type":"ready","count":"1"}\r'))
|
||||
controller.enqueue(encoder.encode("\n\r\n"))
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
},
|
||||
})
|
||||
const events = client.session.subscribe({ after: 2 })
|
||||
|
||||
expect(requests).toBe(0)
|
||||
const received = []
|
||||
for await (const event of events) received.push(event)
|
||||
expect(received).toEqual([{ type: "ready", count: "1" }])
|
||||
expect(requests).toBe(1)
|
||||
expect(url).toBe("https://example.com/event?after=2")
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
expect(requests).toBe(0)
|
||||
const received = []
|
||||
for await (const event of events) received.push(event)
|
||||
expect(received).toEqual([{ type: "ready", count: "1" }])
|
||||
expect(requests).toBe(1)
|
||||
expect(url).toBe("https://example.com/event?after=2")
|
||||
})
|
||||
|
||||
test("preserves public group and endpoint identifiers exactly", () => {
|
||||
@@ -1138,7 +1092,7 @@ describe("HttpApiCodegen.generate", () => {
|
||||
for (const file of output.files) expect(() => transpiler.transformSync(file.content)).not.toThrow()
|
||||
})
|
||||
|
||||
it.effect("keeps the strict generated-consumer fixture current", () =>
|
||||
it.live("keeps the strict generated-consumer fixture current", () =>
|
||||
Effect.gen(function* () {
|
||||
const output = compile(FixtureApi)
|
||||
const actual = yield* Effect.promise(() =>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { Mcp } from "@opencode-ai/core/mcp/index"
|
||||
import { McpServerNotFoundError } from "@opencode-ai/protocol/errors"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { response } from "../location"
|
||||
|
||||
const notFound = <A, R>(effect: Effect.Effect<A, MCP.NotFoundError, R>) =>
|
||||
const notFound = <A, R>(effect: Effect.Effect<A, Mcp.NotFoundError, R>) =>
|
||||
effect.pipe(Effect.mapError((error) => new McpServerNotFoundError({ server: error.server, message: error.message })))
|
||||
|
||||
export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
|
||||
@@ -14,7 +14,7 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
|
||||
.handle(
|
||||
"mcp.list",
|
||||
Effect.fn(function* () {
|
||||
const service = yield* MCP.Service
|
||||
const service = yield* Mcp.Service
|
||||
return yield* response(
|
||||
service
|
||||
.servers()
|
||||
@@ -29,7 +29,7 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
|
||||
.handle(
|
||||
"mcp.add",
|
||||
Effect.fn(function* (ctx) {
|
||||
const service = yield* MCP.Service
|
||||
const service = yield* Mcp.Service
|
||||
yield* service.add(ctx.params.server, ctx.payload.config)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
@@ -37,7 +37,7 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
|
||||
.handle(
|
||||
"mcp.remove",
|
||||
Effect.fn(function* (ctx) {
|
||||
const service = yield* MCP.Service
|
||||
const service = yield* Mcp.Service
|
||||
yield* notFound(service.remove(ctx.params.server))
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
@@ -45,7 +45,7 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
|
||||
.handle(
|
||||
"mcp.connect",
|
||||
Effect.fn(function* (ctx) {
|
||||
const service = yield* MCP.Service
|
||||
const service = yield* Mcp.Service
|
||||
yield* notFound(service.connect(ctx.params.server))
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
@@ -53,7 +53,7 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
|
||||
.handle(
|
||||
"mcp.disconnect",
|
||||
Effect.fn(function* (ctx) {
|
||||
const service = yield* MCP.Service
|
||||
const service = yield* Mcp.Service
|
||||
yield* notFound(service.disconnect(ctx.params.server))
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
@@ -61,7 +61,7 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
|
||||
.handle(
|
||||
"mcp.resource.catalog",
|
||||
Effect.fn(function* () {
|
||||
const service = yield* MCP.Service
|
||||
const service = yield* Mcp.Service
|
||||
return yield* response(service.resourceCatalog())
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
|
||||
import { ShellSelect } from "@opencode-ai/core/shell/select"
|
||||
import { Job } from "@opencode-ai/core/job"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { Mcp } from "@opencode-ai/core/mcp/index"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
@@ -118,8 +118,8 @@ function makeRoutes<AuthError, AuthServices>(
|
||||
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: options.config?.project })],
|
||||
[ShellSelect.node, ShellSelect.configured({ gitbash: options.windows?.gitbash })],
|
||||
[
|
||||
MCP.node,
|
||||
MCP.configured({
|
||||
Mcp.node,
|
||||
Mcp.configured({
|
||||
clientInfo: {
|
||||
name: options.app?.name ?? "opencode",
|
||||
version: options.app?.version ?? "unknown",
|
||||
|
||||
@@ -3,75 +3,60 @@ import path from "node:path"
|
||||
import { expect } from "bun:test"
|
||||
import { Config } from "@opencode-ai/schema/config"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { tmpdir } from "../../core/test/fixture/tmpdir"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerProcess } from "../src/process"
|
||||
import { startServer } from "./fixture/server"
|
||||
import { AbsolutePath } from "@opencode-ai/schema/schema"
|
||||
|
||||
it.live("returns ordered config entries for the requested directory", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir("opencode-config-endpoint-")),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const project = path.join(tmp.path, "project")
|
||||
const config = path.join(project, "opencode.json")
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([fs.mkdir(global, { recursive: true }), fs.mkdir(project, { recursive: true })]),
|
||||
)
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(
|
||||
config,
|
||||
JSON.stringify({
|
||||
permissions: [
|
||||
{ action: "shell", resource: "*", effect: "ask" },
|
||||
{ action: "shell", resource: "git status", effect: "allow" },
|
||||
],
|
||||
mcp: { servers: { docs: { type: "remote", url: "https://example.com/mcp" } } },
|
||||
}),
|
||||
),
|
||||
)
|
||||
const server = yield* ServerProcess.start<never, never>({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
password: "secret",
|
||||
app: { version: "test-version" },
|
||||
database: { path: ":memory:" },
|
||||
config: { directory: global },
|
||||
fs: { filewatcher: false },
|
||||
})
|
||||
const url = new URL("/api/config", HttpServer.formatAddress(server.address))
|
||||
url.searchParams.set("location[directory]", project)
|
||||
const response = yield* Effect.promise(() =>
|
||||
fetch(url, { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }),
|
||||
)
|
||||
const body: unknown = yield* Effect.promise(() => response.json())
|
||||
const entries = Schema.decodeUnknownSync(Schema.Array(Config.Entry))(body)
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-config-endpoint-")))
|
||||
const global = path.join(tmp.path, "global")
|
||||
const project = path.join(tmp.path, "project")
|
||||
const config = path.join(project, "opencode.json")
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([fs.mkdir(global, { recursive: true }), fs.mkdir(project, { recursive: true })]),
|
||||
)
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(
|
||||
config,
|
||||
JSON.stringify({
|
||||
permissions: [
|
||||
{ action: "shell", resource: "*", effect: "ask" },
|
||||
{ action: "shell", resource: "git status", effect: "allow" },
|
||||
],
|
||||
mcp: { servers: { docs: { type: "remote", url: "https://example.com/mcp" } } },
|
||||
}),
|
||||
),
|
||||
)
|
||||
const server = yield* startServer(global)
|
||||
const url = new URL("/api/config", server.base)
|
||||
url.searchParams.set("location[directory]", project)
|
||||
const response = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
|
||||
const body: unknown = yield* Effect.promise(() => response.json())
|
||||
const entries = Schema.decodeUnknownSync(Schema.Array(Config.Entry))(body)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(Array.isArray(entries)).toBe(true)
|
||||
const document = entries.find(
|
||||
(entry): entry is Config.Document => entry.type === "document" && entry.path === config,
|
||||
)
|
||||
expect(document?.info.permissions).toEqual([
|
||||
{ action: "shell", resource: "*", effect: "ask" },
|
||||
{ action: "shell", resource: "git status", effect: "allow" },
|
||||
])
|
||||
expect(document?.path).toBe(AbsolutePath.make(config))
|
||||
if (!Array.isArray(body)) throw new Error("Expected a config entry array")
|
||||
const raw = body.find((entry) => isRecord(entry) && entry["type"] === "document" && entry["path"] === config)
|
||||
if (!isRecord(raw) || !isRecord(raw["info"])) throw new Error("Expected a config document")
|
||||
expect(raw["info"]).not.toHaveProperty("default_agent")
|
||||
expect(raw["info"]).not.toHaveProperty("model")
|
||||
const mcp = raw["info"]["mcp"]
|
||||
if (!isRecord(mcp) || !isRecord(mcp["servers"]) || !isRecord(mcp["servers"]["docs"]))
|
||||
throw new Error("Expected an MCP server config")
|
||||
expect(mcp["servers"]["docs"]).not.toHaveProperty("headers")
|
||||
expect(mcp["servers"]["docs"]).not.toHaveProperty("oauth")
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
expect(response.status).toBe(200)
|
||||
expect(Array.isArray(entries)).toBe(true)
|
||||
const document = entries.find(
|
||||
(entry): entry is Config.Document => entry.type === "document" && entry.path === config,
|
||||
)
|
||||
expect(document?.info.permissions).toEqual([
|
||||
{ action: "shell", resource: "*", effect: "ask" },
|
||||
{ action: "shell", resource: "git status", effect: "allow" },
|
||||
])
|
||||
expect(document?.path).toBe(AbsolutePath.make(config))
|
||||
if (!Array.isArray(body)) throw new Error("Expected a config entry array")
|
||||
const raw = body.find((entry) => isRecord(entry) && entry["type"] === "document" && entry["path"] === config)
|
||||
if (!isRecord(raw) || !isRecord(raw["info"])) throw new Error("Expected a config document")
|
||||
expect(raw["info"]).not.toHaveProperty("default_agent")
|
||||
expect(raw["info"]).not.toHaveProperty("model")
|
||||
const mcp = raw["info"]["mcp"]
|
||||
if (!isRecord(mcp) || !isRecord(mcp["servers"]) || !isRecord(mcp["servers"]["docs"]))
|
||||
throw new Error("Expected an MCP server config")
|
||||
expect(mcp["servers"]["docs"]).not.toHaveProperty("headers")
|
||||
expect(mcp["servers"]["docs"]).not.toHaveProperty("oauth")
|
||||
}),
|
||||
)
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
|
||||
@@ -61,7 +61,7 @@ it.live("serves the HttpApi and enforces Basic auth like the Node server", () =>
|
||||
const body: unknown = yield* Effect.promise(() => response.json())
|
||||
if (typeof body !== "object" || body === null) throw new Error("Expected a health response object")
|
||||
expect((body as Record<string, unknown>)["healthy"]).toBe(true)
|
||||
}).pipe(Effect.scoped),
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("activates credentials through the HttpApi", () =>
|
||||
@@ -71,7 +71,7 @@ it.live("activates credentials through the HttpApi", () =>
|
||||
handler(new Request("http://opencode.local/api/credential/cred_missing/activate", { method: "POST" })),
|
||||
)
|
||||
expect(response.status).toBe(204)
|
||||
}).pipe(Effect.scoped),
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("serves unauthenticated and answers CORS preflight when no password is configured", () =>
|
||||
@@ -93,7 +93,7 @@ it.live("serves unauthenticated and answers CORS preflight when no password is c
|
||||
),
|
||||
)
|
||||
expect(preflight.headers.get("access-control-allow-origin")).toBe("http://localhost:3000")
|
||||
}).pipe(Effect.scoped),
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("cancels a stale OpenAI OAuth callback server before falling back", () =>
|
||||
@@ -113,7 +113,7 @@ it.live("cancels a stale OpenAI OAuth callback server before falling back", () =
|
||||
expect(requests).toContain("/cancel")
|
||||
const body = (yield* Effect.promise(() => response.json())) as { data: { url: string } }
|
||||
expect(new URL(body.data.url).searchParams.get("redirect_uri")).toBe("http://localhost:1455/auth/callback")
|
||||
}).pipe(Effect.scoped),
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("falls back to port 1457 when OpenAI OAuth port 1455 remains busy", () =>
|
||||
@@ -133,7 +133,7 @@ it.live("falls back to port 1457 when OpenAI OAuth port 1455 remains busy", () =
|
||||
expect(requests).toContain("/cancel")
|
||||
const body = (yield* Effect.promise(() => response.json())) as { data: { url: string } }
|
||||
expect(new URL(body.data.url).searchParams.get("redirect_uri")).toBe("http://localhost:1457/auth/callback")
|
||||
}).pipe(Effect.scoped),
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("explains how to recover when both OpenAI OAuth callback ports are busy", () =>
|
||||
@@ -155,7 +155,7 @@ it.live("explains how to recover when both OpenAI OAuth callback ports are busy"
|
||||
"OpenAI browser login needs local port 1455 or 1457, but both are already in use. Stop the processes using those ports or choose ChatGPT Pro/Plus (headless), then try again.",
|
||||
kind: "integration_authorization",
|
||||
})
|
||||
}).pipe(Effect.scoped),
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("treats destroying a missing workspace as success", () =>
|
||||
@@ -171,7 +171,7 @@ it.live("treats destroying a missing workspace as success", () =>
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => response.json())).toEqual({ destroyed: false })
|
||||
}).pipe(Effect.scoped),
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("creates idempotent caller-identified workspaces through the HttpApi", () =>
|
||||
@@ -213,7 +213,7 @@ it.live("creates idempotent caller-identified workspaces through the HttpApi", (
|
||||
const minted = yield* create({ provider: "fake" })
|
||||
expect(minted.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => minted.json())).toMatchObject({ data: expect.stringMatching(/^wrk_/) })
|
||||
}).pipe(Effect.scoped),
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("serves the session view operation and missing-session error", () =>
|
||||
@@ -260,7 +260,7 @@ it.live("serves the session view operation and missing-session error", () =>
|
||||
),
|
||||
)
|
||||
expect(missing.status).toBe(404)
|
||||
}).pipe(Effect.scoped),
|
||||
}),
|
||||
)
|
||||
|
||||
// Pins the eager-boot guarantee: the application layer is built before the handler returns, so
|
||||
@@ -283,5 +283,5 @@ it.live("stays serviceable when the first request aborts", () =>
|
||||
|
||||
const second = yield* Effect.promise(() => handler(new Request("http://opencode.local/api/health")))
|
||||
expect(second.status).toBe(200)
|
||||
}).pipe(Effect.scoped),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Effect } from "effect"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { ServerProcess } from "../../src/process"
|
||||
|
||||
export const startServer = Effect.fnUntraced(function* (directory: string) {
|
||||
const server = yield* ServerProcess.start<never, never>({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
password: "secret",
|
||||
app: { version: "test-version" },
|
||||
database: { path: ":memory:" },
|
||||
config: { directory },
|
||||
fs: { filewatcher: false },
|
||||
})
|
||||
return {
|
||||
base: HttpServer.formatAddress(server.address),
|
||||
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
|
||||
}
|
||||
})
|
||||
@@ -31,41 +31,37 @@ const generate = makeLocationNode({
|
||||
})
|
||||
|
||||
it.live("uses base configuration without depending on process.cwd()", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir("opencode-generate-endpoint-")),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const project = path.join(tmp.path, "project")
|
||||
yield* Effect.promise(() => Promise.all([fs.mkdir(global), fs.mkdir(project)]))
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([
|
||||
fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ model: "base/default" })),
|
||||
fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ model: "project/default" })),
|
||||
]),
|
||||
)
|
||||
const handler = yield* ServerFetch.make(
|
||||
{
|
||||
database: { path: ":memory:" },
|
||||
config: { directory: global },
|
||||
fs: { filewatcher: false },
|
||||
},
|
||||
{ overrides: [[Generate.node, generate]] },
|
||||
)
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-generate-endpoint-")))
|
||||
const global = path.join(tmp.path, "global")
|
||||
const project = path.join(tmp.path, "project")
|
||||
yield* Effect.promise(() => Promise.all([fs.mkdir(global), fs.mkdir(project)]))
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([
|
||||
fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ model: "base/default" })),
|
||||
fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ model: "project/default" })),
|
||||
]),
|
||||
)
|
||||
const handler = yield* ServerFetch.make(
|
||||
{
|
||||
database: { path: ":memory:" },
|
||||
config: { directory: global },
|
||||
fs: { filewatcher: false },
|
||||
},
|
||||
{ overrides: [[Generate.node, generate]] },
|
||||
)
|
||||
|
||||
expect(global).not.toBe(process.cwd())
|
||||
expect(yield* request(handler, new URL("http://opencode.local/api/generate"))).toEqual({
|
||||
model: { providerID: "base", model: "default" },
|
||||
})
|
||||
expect(global).not.toBe(process.cwd())
|
||||
expect(yield* request(handler, new URL("http://opencode.local/api/generate"))).toEqual({
|
||||
model: { providerID: "base", model: "default" },
|
||||
})
|
||||
|
||||
const legacy = new URL("http://opencode.local/api/generate")
|
||||
legacy.searchParams.set("location[directory]", project)
|
||||
expect(yield* request(handler, legacy)).toEqual({
|
||||
model: { providerID: "base", model: "default" },
|
||||
})
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
const legacy = new URL("http://opencode.local/api/generate")
|
||||
legacy.searchParams.set("location[directory]", project)
|
||||
expect(yield* request(handler, legacy)).toEqual({
|
||||
model: { providerID: "base", model: "default" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
function request(handler: (request: Request) => Promise<Response>, url: URL) {
|
||||
|
||||
@@ -2,54 +2,39 @@ import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { tmpdir } from "../../core/test/fixture/tmpdir"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerProcess } from "../src/process"
|
||||
import { startServer } from "./fixture/server"
|
||||
|
||||
it.live("waits for plugin initialization before listing models", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir("opencode-model-endpoint-")),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(
|
||||
path.join(tmp.path, "opencode.json"),
|
||||
JSON.stringify({
|
||||
providers: {
|
||||
custom: {
|
||||
package: "aisdk:@ai-sdk/openai-compatible",
|
||||
settings: { apiKey: "secret" },
|
||||
models: { chat: {} },
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
const server = yield* ServerProcess.start<never, never>({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
password: "secret",
|
||||
app: { version: "test-version" },
|
||||
database: { path: ":memory:" },
|
||||
config: { directory: tmp.path },
|
||||
fs: { filewatcher: false },
|
||||
})
|
||||
const url = new URL("/api/model", HttpServer.formatAddress(server.address))
|
||||
url.searchParams.set("location[directory]", tmp.path)
|
||||
const response = yield* Effect.promise(() =>
|
||||
fetch(url, { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }),
|
||||
)
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-model-endpoint-")))
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(
|
||||
path.join(tmp.path, "opencode.json"),
|
||||
JSON.stringify({
|
||||
providers: {
|
||||
custom: {
|
||||
package: "aisdk:@ai-sdk/openai-compatible",
|
||||
settings: { apiKey: "secret" },
|
||||
models: { chat: {} },
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
const server = yield* startServer(tmp.path)
|
||||
const url = new URL("/api/model", server.base)
|
||||
url.searchParams.set("location[directory]", tmp.path)
|
||||
const response = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const body: unknown = yield* Effect.promise(() => response.json())
|
||||
if (!isRecord(body) || !Array.isArray(body["data"])) throw new Error("Expected a model list response")
|
||||
expect(
|
||||
body["data"].some((model) => isRecord(model) && model["providerID"] === "custom" && model["id"] === "chat"),
|
||||
).toBeTrue()
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
expect(response.status).toBe(200)
|
||||
const body: unknown = yield* Effect.promise(() => response.json())
|
||||
if (!isRecord(body) || !Array.isArray(body["data"])) throw new Error("Expected a model list response")
|
||||
expect(
|
||||
body["data"].some((model) => isRecord(model) && model["providerID"] === "custom" && model["id"] === "chat"),
|
||||
).toBeTrue()
|
||||
}),
|
||||
)
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
|
||||
@@ -141,5 +141,5 @@ it.live("updates completed assistant message content through the session HTTP AP
|
||||
_tag: "ConflictError",
|
||||
resource: state.assistant,
|
||||
})
|
||||
}).pipe(Effect.scoped),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -29,5 +29,5 @@ it.live("boots the workerd profile over durable object storage", () =>
|
||||
|
||||
const body: unknown = yield* Effect.promise(() => health.json())
|
||||
expect(body).toMatchObject({ healthy: true, version: "workerd-test" })
|
||||
}).pipe(Effect.scoped),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -3,67 +3,58 @@ import path from "node:path"
|
||||
import { $ } from "bun"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { tmpdir } from "../../core/test/fixture/tmpdir"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerProcess } from "../src/process"
|
||||
import { startServer } from "./fixture/server"
|
||||
|
||||
it.live("lists, creates, and removes worktrees by project ID", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir("opencode-worktree-endpoint-")),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const project = path.join(tmp.path, "project")
|
||||
const destination = path.join(tmp.path, "worktrees")
|
||||
yield* Effect.promise(() => fs.mkdir(project, { recursive: true }))
|
||||
yield* Effect.promise(() => $`git init`.cwd(project).quiet())
|
||||
yield* Effect.promise(() => $`git config user.email test@opencode.test`.cwd(project).quiet())
|
||||
yield* Effect.promise(() => $`git config user.name Test`.cwd(project).quiet())
|
||||
yield* Effect.promise(() => $`git commit --allow-empty -m root`.cwd(project).quiet())
|
||||
const server = yield* ServerProcess.start<never, never>({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
password: "secret",
|
||||
app: { version: "test-version" },
|
||||
database: { path: ":memory:" },
|
||||
config: { directory: path.join(tmp.path, "config") },
|
||||
fs: { filewatcher: false },
|
||||
})
|
||||
const base = HttpServer.formatAddress(server.address)
|
||||
const headers = { authorization: `Basic ${btoa("opencode:secret")}` }
|
||||
const location = new URL("/api/location", base)
|
||||
location.searchParams.set("location[directory]", project)
|
||||
const resolved = yield* Effect.promise(() => fetch(location, { headers }).then((response) => response.json()))
|
||||
if (!isRecord(resolved) || !isRecord(resolved.project) || typeof resolved.project.id !== "string")
|
||||
throw new Error("Expected resolved project")
|
||||
const url = new URL(`/api/worktree/${resolved.project.id}`, base)
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-worktree-endpoint-")))
|
||||
const project = path.join(tmp.path, "project")
|
||||
const destination = path.join(tmp.path, "worktrees")
|
||||
yield* Effect.promise(() => fs.mkdir(project, { recursive: true }))
|
||||
yield* Effect.promise(() => $`git init`.cwd(project).quiet())
|
||||
yield* Effect.promise(() => $`git config user.email test@opencode.test`.cwd(project).quiet())
|
||||
yield* Effect.promise(() => $`git config user.name Test`.cwd(project).quiet())
|
||||
yield* Effect.promise(() => $`git commit --allow-empty -m root`.cwd(project).quiet())
|
||||
const server = yield* startServer(path.join(tmp.path, "config"))
|
||||
const location = new URL("/api/location", server.base)
|
||||
location.searchParams.set("location[directory]", project)
|
||||
const resolved = yield* Effect.promise(() =>
|
||||
fetch(location, { headers: server.headers }).then((response) => response.json()),
|
||||
)
|
||||
if (!isRecord(resolved) || !isRecord(resolved.project) || typeof resolved.project.id !== "string")
|
||||
throw new Error("Expected resolved project")
|
||||
const url = new URL(`/api/worktree/${resolved.project.id}`, server.base)
|
||||
|
||||
const initial = yield* Effect.promise(() => fetch(url, { headers }).then((response) => response.json()))
|
||||
expect(initial).toEqual([{ directory: project }])
|
||||
const initial = yield* Effect.promise(() =>
|
||||
fetch(url, { headers: server.headers }).then((response) => response.json()),
|
||||
)
|
||||
expect(initial).toEqual([{ directory: project }])
|
||||
|
||||
const created = yield* Effect.promise(() =>
|
||||
fetch(url, {
|
||||
method: "POST",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ strategy: "git", directory: destination, name: "api" }),
|
||||
}).then((response) => response.json()),
|
||||
)
|
||||
expect(created).toEqual({ directory: path.join(destination, "api") })
|
||||
const created = yield* Effect.promise(() =>
|
||||
fetch(url, {
|
||||
method: "POST",
|
||||
headers: { ...server.headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ strategy: "git", directory: destination, name: "api" }),
|
||||
}).then((response) => response.json()),
|
||||
)
|
||||
expect(created).toEqual({ directory: path.join(destination, "api") })
|
||||
|
||||
const listed = yield* Effect.promise(() => fetch(url, { headers }).then((response) => response.json()))
|
||||
expect(listed).toContainEqual({ directory: path.join(destination, "api"), strategy: "git" })
|
||||
const listed = yield* Effect.promise(() =>
|
||||
fetch(url, { headers: server.headers }).then((response) => response.json()),
|
||||
)
|
||||
expect(listed).toContainEqual({ directory: path.join(destination, "api"), strategy: "git" })
|
||||
|
||||
const removed = yield* Effect.promise(() =>
|
||||
fetch(url, {
|
||||
method: "DELETE",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ directory: path.join(destination, "api"), force: false }),
|
||||
}),
|
||||
)
|
||||
expect(removed.status).toBe(204)
|
||||
const removed = yield* Effect.promise(() =>
|
||||
fetch(url, {
|
||||
method: "DELETE",
|
||||
headers: { ...server.headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ directory: path.join(destination, "api"), force: false }),
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
expect(removed.status).toBe(204)
|
||||
}),
|
||||
)
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
|
||||
@@ -10,7 +10,9 @@ story("merges follow-up patches into one stack with a distinct file count", asyn
|
||||
await first.click()
|
||||
await expect(first).toHaveAttribute("aria-expanded", "true")
|
||||
await root.getByRole("button", { name: "Start follow-up patch" }).click()
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("3")
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText(/^3 /)
|
||||
await expect(patches).toHaveCount(1)
|
||||
await expect(patches.getByText("2 files", { exact: true })).toBeVisible()
|
||||
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
|
||||
|
||||
@@ -17,7 +17,9 @@ for (const tool of ["shell", "execute", "subagent"]) {
|
||||
for (const action of [undefined, "Complete input", "Run command", "Complete command"]) {
|
||||
if (action) await timeline.getByRole("button", { name: action, exact: true }).click()
|
||||
await expect(group).toHaveAttribute("data-timeline-part-ids", "tool_context_lifecycle,tool_shell_lifecycle")
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText(/^2 /)
|
||||
await expect(timeline.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(1)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(open))
|
||||
expect(await original!.evaluate((node) => node.isConnected)).toBe(true)
|
||||
@@ -33,7 +35,7 @@ for (const expanded of [false, true]) {
|
||||
const timeline = await mount("current-session-terminal-work--terminal-commands", { args: { expanded } })
|
||||
const trigger = expanded
|
||||
? timeline.locator('[data-timeline-part-id="tool_shell_lifecycle"] [data-slot="collapsible-trigger"]')
|
||||
: timeline.getByRole("button", { name: "Used Shell", exact: true })
|
||||
: timeline.getByRole("button", { name: "Used 1 Shell", exact: true })
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
|
||||
@@ -81,7 +83,7 @@ story("transitions a streaming shell from writing through command execution", as
|
||||
await expect(subtitle).toHaveText("printf ready")
|
||||
await expect(tool).not.toContainText("Writing command...")
|
||||
await timeline.getByRole("button", { name: "Complete command" }).click()
|
||||
const summary = timeline.getByRole("button", { name: "Used Shell", exact: true })
|
||||
const summary = timeline.getByRole("button", { name: "Used 1 Shell", exact: true })
|
||||
await expect(summary).toHaveAttribute("aria-expanded", "false")
|
||||
await summary.click()
|
||||
await expect(subtitle).toHaveText("printf ready")
|
||||
@@ -128,7 +130,7 @@ for (const open of [false, true]) {
|
||||
await expect(thought).not.toContainText("Inspecting stability")
|
||||
await expect(thought).toHaveAttribute("aria-expanded", String(open))
|
||||
await timeline.getByRole("button", { name: "Finish session" }).click()
|
||||
const used = group.getByRole("button", { name: "Used Shell", exact: true })
|
||||
const used = group.getByRole("button", { name: "Used 1 Shell", exact: true })
|
||||
await expect(used).toHaveAttribute("aria-expanded", "false")
|
||||
await used.click()
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
@@ -137,7 +139,9 @@ for (const open of [false, true]) {
|
||||
"aria-expanded",
|
||||
String(open),
|
||||
)
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("1")
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText("1 Shell")
|
||||
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
if (!open) await thought.click()
|
||||
@@ -183,8 +187,10 @@ for (const locale of ["de", "ar"] as const) {
|
||||
await timeline.getByRole("button", { name: "Complete read" }).click()
|
||||
await timeline.getByRole("button", { name: "Complete glob" }).click()
|
||||
const group = timeline.locator('[data-timeline-part-ids="tool_context_read,tool_context_glob"]')
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName(/^Used /)
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName(/^Used 2 /)
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText(/^2 /)
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", locale)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -50,9 +50,11 @@ for (const mode of ["hidden", "compact", "full"] as const) {
|
||||
if (following === "tool") {
|
||||
const group = timeline.locator('[data-component="collapsed-tool-group"]')
|
||||
const trigger = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
|
||||
await expect(trigger).toContainText("UsedSkill")
|
||||
await expect(trigger).toHaveText(/^Used\s*1 Skill$/)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("1")
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText("1 Skill")
|
||||
await expect(timeline.getByText("Inspecting stability", { exact: true })).toBeHidden()
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
|
||||
@@ -25,7 +25,7 @@ story("space activates a focused timeline button instead of scrolling", async ({
|
||||
await page.setViewportSize({ width: 800, height: 240 })
|
||||
const timeline = await mount("current-session-terminal-work--terminal-commands", { args: { scenario: "collapsed" } })
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollHeight - innerHeight)).toBeGreaterThan(0)
|
||||
const trigger = timeline.getByRole("button", { name: "Used Shell", exact: true })
|
||||
const trigger = timeline.getByRole("button", { name: "Used 1 Shell", exact: true })
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await trigger.focus()
|
||||
const before = await page.evaluate(() => window.scrollY)
|
||||
|
||||
@@ -42,7 +42,9 @@ story("renders every tool error outcome without leaking hidden tools", async ({
|
||||
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "failures" } })
|
||||
const names = ["shell", "edit", "write", "patch", "webfetch", "websearch", "subagent", "skill", "mcp_probe"]
|
||||
const group = timeline.locator(`[data-timeline-part-ids="${names.map((name) => `tool_error_${name}`).join(",")}"]`)
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText(String(names.length))
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText(new RegExp(`^${names.length} `))
|
||||
await group.getByRole("button").click()
|
||||
await expect(timeline.locator('[data-kind="tool-error-card"]')).toHaveCount(names.length + 1)
|
||||
const dismissed = timeline.locator('[data-timeline-part-id="tool_error_question_dismissed"]')
|
||||
@@ -68,7 +70,7 @@ story("transitions shell and question through running error outcomes", async ({
|
||||
// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts
|
||||
story("labels all web search provider variants", async ({ mount }) => {
|
||||
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "providers" } })
|
||||
await timeline.getByRole("button", { name: "Used Parallel Web Search, Exa Web Search, Web Search" }).click()
|
||||
await timeline.getByRole("button", { name: "Used 3 Parallel Web Search, Exa Web Search, Web Search" }).click()
|
||||
const tools = timeline.locator('[data-component="context-tool-group-list"]')
|
||||
await expect(tools.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible()
|
||||
await expect(tools.getByRole("button", { name: /Exa Web Search/ })).toBeVisible()
|
||||
@@ -101,8 +103,10 @@ story("labels read tools from their path input", async ({ mount }) => {
|
||||
story("labels skill tools from IDs and result metadata", async ({ mount }) => {
|
||||
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "skills" } })
|
||||
const group = timeline.locator('[data-timeline-part-ids="tool_skill_id,tool_skill_name"]')
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName("Used Skill")
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName("Used 2 Skill")
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText("2 Skill")
|
||||
await group.getByRole("button").click()
|
||||
const loaded = group.locator('[data-component="tool-loaded-item"]')
|
||||
await expect(loaded).toHaveCount(1)
|
||||
@@ -124,8 +128,10 @@ story("groups every collapsed tool until visible text separates the stack", asyn
|
||||
'[data-timeline-part-ids="tool_boundary_glob,tool_boundary_grep,tool_boundary_shell,tool_boundary_list"]',
|
||||
)
|
||||
await expect(group).toBeVisible()
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName("Used Glob, Grep, Shell, List")
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName("Used 4 Glob, Grep, Shell, List")
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText("4 Glob, Grep, Shell, List")
|
||||
await expect(timeline.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(3)
|
||||
await expect(timeline.locator('[data-timeline-spacing="content"]')).toHaveCount(2)
|
||||
await expect(timeline.locator('[data-timeline-spacing="content"]').nth(0)).toHaveCSS("padding-top", "16px")
|
||||
|
||||
@@ -6,12 +6,14 @@ for (const open of [true, false]) {
|
||||
async ({ mount }, info) => {
|
||||
const root = await mount("current-session-file-changes--appending-tool-calls")
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]')
|
||||
await group.getByRole("button", { name: "Used Shell, Patch", exact: true }).click()
|
||||
const trigger = group.getByRole("button", { name: /^Used \d+ Shell, Patch$/ })
|
||||
await expect(trigger).toHaveAccessibleName("Used 2 Shell, Patch")
|
||||
await trigger.click()
|
||||
const shell = group.locator('[data-timeline-part-id="tool_shell_existing"] [data-slot="collapsible-trigger"]')
|
||||
await group.locator('[data-timeline-part-id="tool_patch_existing"]').evaluate((element) => {
|
||||
element.setAttribute("data-disclosure-probe", "existing")
|
||||
})
|
||||
const patch = group.locator('[data-disclosure-probe="existing"]')
|
||||
await group.locator('[data-timeline-part-id="tool_patch_existing"]').evaluate((element) => {
|
||||
element.setAttribute("data-disclosure-probe", "existing")
|
||||
})
|
||||
const patch = group.locator('[data-disclosure-probe="existing"]')
|
||||
const first = patch.locator('[data-scope="apply-patch"] button').filter({ hasText: "a.ts" })
|
||||
const second = patch.locator('[data-scope="apply-patch"] button').filter({ hasText: "b.ts" })
|
||||
const diff = patch.locator('[data-type="update"]').filter({ hasText: "b.ts" }).locator('[data-component="file"]')
|
||||
@@ -29,7 +31,10 @@ for (const open of [true, false]) {
|
||||
const original = await patch.elementHandle()
|
||||
for (const count of [3, 4]) {
|
||||
await root.getByRole("button", { name: "Append tool call", exact: true }).click()
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText(String(count))
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText(`${count} Shell, Patch`)
|
||||
await expect(trigger).toHaveAccessibleName(`Used ${count} Shell, Patch`)
|
||||
await expect(diff).toBeVisible()
|
||||
await root
|
||||
.locator('[data-component="session-timeline"]')
|
||||
@@ -38,10 +43,7 @@ for (const open of [true, false]) {
|
||||
await expect(first).toHaveAttribute("aria-expanded", String(open))
|
||||
await expect(second).toHaveAttribute("aria-expanded", "true")
|
||||
expect(await original!.evaluate((node) => node.isConnected)).toBe(true)
|
||||
await expect(group.getByRole("button", { name: "Used Shell, Patch", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -6,11 +6,14 @@ for (const reasoningDefaultOpen of [false, true]) {
|
||||
async ({ mount }) => {
|
||||
const root = await mount("current-tool-group--mixed-reasoning", { args: { reasoningDefaultOpen } })
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]')
|
||||
const used = group.getByRole("button", { name: "Used Read, Skill", exact: true })
|
||||
const used = group.getByRole("button", { name: /^Used \d+ Read, Skill$/ })
|
||||
const first = group.locator('[data-timeline-part-id="reasoning_first"]')
|
||||
const second = group.locator('[data-timeline-part-id="reasoning_second"]')
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
|
||||
await expect(used).toHaveAccessibleName("Used 4 Read, Skill")
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText("4 Read, Skill")
|
||||
await expect(group.locator('[data-slot="context-tool-group-item"]')).toHaveText([
|
||||
/Read.*group\.ts/,
|
||||
/Thought/,
|
||||
@@ -31,7 +34,10 @@ for (const reasoningDefaultOpen of [false, true]) {
|
||||
)
|
||||
await first.getByRole("button", { name: "Thought", exact: true }).click()
|
||||
await root.getByRole("button", { name: "Append follow-up read", exact: true }).click()
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("5")
|
||||
await expect(used).toHaveAccessibleName("Used 5 Read, Skill")
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText("5 Read, Skill")
|
||||
await expect(group.locator('[data-slot="context-tool-group-item"]')).toHaveText([
|
||||
/Read.*group\.ts/,
|
||||
/Thought/,
|
||||
@@ -64,8 +70,10 @@ for (const reasoningDefaultOpen of [false, true]) {
|
||||
story("summarizes subagents as Agent while retaining their card titles", async ({ mount }) => {
|
||||
const root = await mount("current-tool-group--mixed-tools")
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]')
|
||||
await expect(group.getByRole("button", { name: "Used Shell, Read, Agent", exact: true })).toBeVisible()
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
|
||||
await expect(group.getByRole("button", { name: "Used 4 Shell, Read, Agent", exact: true })).toBeVisible()
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText("4 Shell, Read, Agent")
|
||||
await expect(group.locator('[data-component="task-tool-title"]')).toHaveText(["General", "Explore"])
|
||||
})
|
||||
|
||||
@@ -74,6 +82,23 @@ for (const width of [840, 390]) {
|
||||
await page.setViewportSize({ width, height: 600 })
|
||||
const root = await mount("current-tool-group--mixed-tools")
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]')
|
||||
const trigger = group.getByRole("button", { name: "Used 4 Shell, Read, Agent", exact: true })
|
||||
const header = group.locator('[data-component="context-tool-group-trigger"]')
|
||||
await expect(header.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("4 Shell, Read, Agent")
|
||||
await expect(header.locator('[data-component="tag"]')).toHaveCount(0)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
for (const action of ["click", "Enter", "Space"] as const) {
|
||||
if (action === "click") await trigger.click()
|
||||
if (action !== "click") await trigger.press(action)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(group.locator('[data-component="context-tool-group-list"]')).toBeHidden()
|
||||
await expect(trigger).toBeFocused()
|
||||
if (action === "click") await trigger.click()
|
||||
if (action !== "click") await trigger.press(action)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(group.locator('[data-component="context-tool-group-list"]')).toBeVisible()
|
||||
await expect(trigger).toBeFocused()
|
||||
}
|
||||
const cards = group.locator('[data-component="task-tool-surface"]')
|
||||
await expect(cards).toHaveCount(2)
|
||||
await expect
|
||||
|
||||
@@ -702,12 +702,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-component="tag"] {
|
||||
flex-shrink: 0;
|
||||
border: 0;
|
||||
background: var(--v2-background-bg-layer-03);
|
||||
}
|
||||
|
||||
[data-slot="collapsible-arrow"] {
|
||||
color: var(--icon-weaker);
|
||||
cursor: pointer;
|
||||
|
||||
@@ -20,7 +20,6 @@ import { useFileComponent } from "@opencode-ai/ui/context/file"
|
||||
import { type UiI18n, useI18n } from "@opencode-ai/ui/context/i18n"
|
||||
import { BasicTool, GenericTool } from "../components/basic-tool"
|
||||
import { Accordion } from "@opencode-ai/ui/accordion"
|
||||
import { Badge } from "@opencode-ai/ui/badge"
|
||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
@@ -503,10 +502,10 @@ export function CurrentContextToolGroup(props: {
|
||||
].join(", "),
|
||||
)
|
||||
const label = createMemo(() => {
|
||||
const tools = names()
|
||||
const text = i18n.t("ui.messagePart.tools.used", { tools })
|
||||
const index = text.indexOf(tools)
|
||||
return { text, before: text.slice(0, index).trim(), after: text.slice(index + tools.length).trim() }
|
||||
const title = `${tools().length} ${names()}`
|
||||
const text = i18n.t("ui.messagePart.tools.used", { tools: title })
|
||||
const index = text.indexOf(title)
|
||||
return { text, title, before: text.slice(0, index).trim(), after: text.slice(index + title.length).trim() }
|
||||
})
|
||||
const items = createMemo(() =>
|
||||
props.parts.reduce<(SessionMessageAssistantTool[] | (SessionMessageAssistantReasoning & { id: string }))[]>(
|
||||
@@ -564,11 +563,10 @@ export function CurrentContextToolGroup(props: {
|
||||
<Show when={label().before}>
|
||||
{(before) => <span data-slot="context-tool-group-prefix">{before()}</span>}
|
||||
</Show>
|
||||
<span data-slot="basic-tool-tool-title">{names()}</span>
|
||||
<span data-slot="basic-tool-tool-title">{label().title}</span>
|
||||
<Show when={label().after}>
|
||||
{(after) => <span data-slot="context-tool-group-prefix">{after()}</span>}
|
||||
</Show>
|
||||
<Badge>{tools().length}</Badge>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -123,8 +123,8 @@ export const DEFAULT_THEME = {
|
||||
},
|
||||
status: {
|
||||
running: "$hue.interactive.800",
|
||||
question: "$text.feedback.info.default",
|
||||
permission: "$text.feedback.warning.default",
|
||||
question: "$text.status.unread",
|
||||
permission: "$text.status.unread",
|
||||
unread: "$hue.accent.800",
|
||||
},
|
||||
feedback: {
|
||||
@@ -344,8 +344,8 @@ export const DEFAULT_THEME = {
|
||||
},
|
||||
status: {
|
||||
running: "$hue.interactive.200",
|
||||
question: "$text.feedback.info.default",
|
||||
permission: "$text.feedback.warning.default",
|
||||
question: "$text.status.unread",
|
||||
permission: "$text.status.unread",
|
||||
unread: "$hue.accent.200",
|
||||
},
|
||||
feedback: {
|
||||
|
||||
@@ -11,13 +11,33 @@ test.each(["light", "dark"] as const)("built-in %s themes resolve status colors"
|
||||
for (const document of [DEFAULT_THEME, migrateV1(source)]) {
|
||||
const theme = resolveThemeDocument(document, mode)
|
||||
expect(theme.text.status.running.equals(theme.hue.interactive[mode === "light" ? 800 : 200])).toBeTrue()
|
||||
expect(theme.text.status.question.equals(theme.text.feedback.info.default)).toBeTrue()
|
||||
expect(theme.text.status.permission.equals(theme.text.feedback.warning.default)).toBeTrue()
|
||||
expect(theme.text.status.question.equals(theme.text.status.unread)).toBeTrue()
|
||||
expect(theme.text.status.permission.equals(theme.text.status.unread)).toBeTrue()
|
||||
expect(theme.text.status.unread.equals(theme.hue.accent[mode === "light" ? 800 : 200])).toBeTrue()
|
||||
expect(theme.contextual.elevated.text.status).toEqual(theme.text.status)
|
||||
}
|
||||
})
|
||||
|
||||
test.each(["light", "dark"] as const)("custom %s themes inherit the unread attention color", (mode) => {
|
||||
for (const standalone of [false, true]) {
|
||||
const theme = resolveThemeDocument(
|
||||
Schema.decodeUnknownSync(ThemeDocument)({
|
||||
version: 2,
|
||||
standalone,
|
||||
[mode]: {
|
||||
hue: { ...DEFAULT_THEME[mode].hue, accent: "$hue.purple" },
|
||||
text: { status: { unread: "#abcdef" } },
|
||||
},
|
||||
}),
|
||||
mode,
|
||||
)
|
||||
expect(theme.text.status.unread.equals(RGBA.fromHex("#abcdef"))).toBeTrue()
|
||||
expect(theme.text.status.question.equals(theme.text.status.unread)).toBeTrue()
|
||||
expect(theme.text.status.permission.equals(theme.text.status.unread)).toBeTrue()
|
||||
expect(theme.contextual.elevated.text.status).toEqual(theme.text.status)
|
||||
}
|
||||
})
|
||||
|
||||
test.each(["light", "dark"] as const)("custom %s themes inherit and override status colors", (mode) => {
|
||||
for (const standalone of [false, true]) {
|
||||
const theme = resolveThemeDocument(
|
||||
@@ -27,8 +47,7 @@ test.each(["light", "dark"] as const)("custom %s themes inherit and override sta
|
||||
[mode]: {
|
||||
hue: { ...DEFAULT_THEME[mode].hue, interactive: "$hue.purple", accent: "$hue.orange" },
|
||||
text: {
|
||||
feedback: { warning: { default: "#654321" } },
|
||||
status: { question: "#123456" },
|
||||
status: { question: "#123456", permission: "#654321" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ClipboardProvider, useClipboard } from "./context/clipboard"
|
||||
import { LogProvider, useLog, type LogSink } from "./context/log"
|
||||
import { ExitProvider, useExit } from "./context/exit"
|
||||
import { EpilogueProvider } from "./context/epilogue"
|
||||
import * as Selection from "./util/selection"
|
||||
import { Selection } from "./util/selection"
|
||||
import {
|
||||
CliRenderEvents,
|
||||
createCliRenderer,
|
||||
@@ -558,14 +558,16 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
}
|
||||
})
|
||||
|
||||
// Let selection copy/dismiss win ahead of normal bindings when explicit copy is required.
|
||||
const copyOnSelectEnabled = () =>
|
||||
(config.data.terminal?.copy ?? (process.platform === "win32" ? "manual" : "select")) === "select"
|
||||
|
||||
// Selection copy/dismiss must precede both app bindings and the terminal pane's raw key forwarding.
|
||||
const offSelectionKeys = keymap.intercept(
|
||||
"key",
|
||||
({ event }) => {
|
||||
if ((config.data.terminal?.copy ?? (process.platform === "win32" ? "manual" : "select")) === "select") return
|
||||
Selection.handleSelectionKey(renderer, toast, event, clipboard)
|
||||
Selection.handleSelectionKey(renderer, toast, event, clipboard, copyOnSelectEnabled())
|
||||
},
|
||||
{ priority: 1 },
|
||||
{ priority: 101 },
|
||||
)
|
||||
onCleanup(() => {
|
||||
offSelectionKeys()
|
||||
@@ -583,8 +585,6 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
renderer.clearSelection()
|
||||
}
|
||||
const terminalTitleEnabled = () => config.data.terminal?.title ?? true
|
||||
const copyOnSelectEnabled = () =>
|
||||
(config.data.terminal?.copy ?? (process.platform === "win32" ? "manual" : "select")) === "select"
|
||||
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
|
||||
const tabsVertical = () =>
|
||||
config.data.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width, preferredTabsWidth())
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
import { RGBA, TextAttributes } from "@opentui/core"
|
||||
import { For, type JSX } from "solid-js"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { tint } from "../theme/color"
|
||||
import { useTheme, useThemes } from "../context/theme"
|
||||
import { go, logo } from "../logo"
|
||||
|
||||
export function Logo() {
|
||||
const theme = useTheme()
|
||||
const themes = useThemes()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const opacity = () =>
|
||||
(theme.background.default.a === 1 && theme.background.default.intent !== "default") ||
|
||||
themes.terminalBackgroundKnown()
|
||||
? 0.25
|
||||
: 0
|
||||
|
||||
const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => {
|
||||
const shadow = tint(theme.background.default, fg, 0.25)
|
||||
const shadow = RGBA.clone(fg)
|
||||
shadow.a = opacity()
|
||||
const attrs = bold ? TextAttributes.BOLD : undefined
|
||||
return Array.from(line).map((char) => {
|
||||
if (char === "_") {
|
||||
return (
|
||||
<text fg={fg} bg={shadow} attributes={attrs} selectable={false}>
|
||||
<text fg={fg} bg={fg} opacity={opacity()} attributes={attrs} selectable={false}>
|
||||
{" "}
|
||||
</text>
|
||||
)
|
||||
@@ -29,14 +35,14 @@ export function Logo() {
|
||||
}
|
||||
if (char === "~") {
|
||||
return (
|
||||
<text fg={shadow} attributes={attrs} selectable={false}>
|
||||
<text fg={fg} opacity={opacity()} attributes={attrs} selectable={false}>
|
||||
▀
|
||||
</text>
|
||||
)
|
||||
}
|
||||
if (char === ",") {
|
||||
return (
|
||||
<text fg={shadow} attributes={attrs} selectable={false}>
|
||||
<text fg={fg} opacity={opacity()} attributes={attrs} selectable={false}>
|
||||
▄
|
||||
</text>
|
||||
)
|
||||
|
||||
@@ -7,28 +7,41 @@ import { useClient } from "../../context/client"
|
||||
import { useToast } from "../../ui/toast"
|
||||
import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session"
|
||||
import { useData } from "../../context/data"
|
||||
import { useLocation } from "../../context/location"
|
||||
|
||||
export function usePromptMove(input: { projectID: () => string | undefined; sessionID: () => string | undefined }) {
|
||||
const dialog = useDialog()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const data = useData()
|
||||
const currentLocation = useLocation()
|
||||
const paths = useTuiPaths()
|
||||
const [creating, setCreating] = createSignal(false)
|
||||
const [creatingDots, setCreatingDots] = createSignal(3)
|
||||
const [progress, setProgress] = createSignal<string>()
|
||||
const [destination, setDestination] = createSignal<MoveSessionSelection>()
|
||||
|
||||
function homeLocation() {
|
||||
const location = currentLocation.ref ?? data.location.default()
|
||||
return { ...location, directory: location.directory || paths.cwd }
|
||||
}
|
||||
|
||||
async function create(name: string) {
|
||||
const projectID = await resolveProjectID()
|
||||
if (!projectID) return
|
||||
setCreating(true)
|
||||
setProgress("Creating worktree")
|
||||
try {
|
||||
const sessionID = input.sessionID()
|
||||
const session = sessionID ? await resolveSession(sessionID) : undefined
|
||||
if (sessionID && !session) throw new Error("Unable to determine current session location")
|
||||
const location = session?.location ?? homeLocation()
|
||||
if (!data.location.info(location)) await data.location.syncInfo(location)
|
||||
const project = data.location.info(location)?.project
|
||||
if (!project) throw new Error("Unable to determine current project")
|
||||
const result = await client.api.worktree.create({
|
||||
projectID,
|
||||
projectID: project.id,
|
||||
strategy: "git",
|
||||
directory: path.join(paths.worktree, projectID.slice(0, 6)),
|
||||
from: project.canonical,
|
||||
directory: path.join(paths.worktree, project.id.slice(0, 6)),
|
||||
name,
|
||||
})
|
||||
const directory = result.directory
|
||||
@@ -71,8 +84,8 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
||||
}
|
||||
: {
|
||||
type: "directory",
|
||||
directory: data.location.default().directory,
|
||||
subdirectory: data.location.default().directory !== data.location.info()?.project.directory,
|
||||
directory: homeLocation().directory,
|
||||
subdirectory: homeLocation().directory !== data.location.info(homeLocation())?.project.directory,
|
||||
})
|
||||
}
|
||||
onCurrentChange={setDestination}
|
||||
@@ -111,14 +124,13 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
||||
}
|
||||
|
||||
async function resolveProjectID() {
|
||||
const projectID = input.projectID()
|
||||
if (projectID) return projectID
|
||||
const sessionID = input.sessionID()
|
||||
if (sessionID) return (await resolveSession(sessionID))?.projectID
|
||||
const current = data.location.info()
|
||||
if (sessionID) return input.projectID() ?? (await resolveSession(sessionID))?.projectID
|
||||
const location = homeLocation()
|
||||
const current = data.location.info(location)
|
||||
if (current) return current.project.id
|
||||
return client.api.project
|
||||
.current({ location: { directory: data.location.default().directory || paths.cwd } })
|
||||
.current({ location: { directory: location.directory, workspace: location.workspaceID } })
|
||||
.then((project) => project.id)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CliRenderEvents, SyntaxStyle, type TerminalColors } from "@opentui/core"
|
||||
import { CliRenderEvents, RGBA, SyntaxStyle, type TerminalColors } from "@opentui/core"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import {
|
||||
generateSyntax,
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import { generateSystem, terminalMode } from "../theme/system"
|
||||
import { discoverThemes } from "../theme/discovery"
|
||||
import { createComponentTheme, type ComponentTheme } from "../theme/component"
|
||||
import { createEffect, createMemo, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useConfig } from "../config"
|
||||
@@ -118,6 +118,7 @@ type Themes = {
|
||||
set(theme: string): boolean
|
||||
onError(handler: ThemeErrorHandler): () => void
|
||||
readonly ready: boolean
|
||||
terminalBackgroundKnown: Accessor<boolean>
|
||||
}
|
||||
|
||||
type ThemeContextValue = {
|
||||
@@ -140,6 +141,7 @@ const themeContext = createSimpleContext({
|
||||
name: "Theme",
|
||||
init: (props: { mode: "dark" | "light"; source: ThemeSource }): ThemeContextValue => {
|
||||
const renderer = useRenderer()
|
||||
const [terminalBackground, setTerminalBackground] = createSignal<RGBA>()
|
||||
const configState = useConfig()
|
||||
const config = configState.data
|
||||
const themes = props.source
|
||||
@@ -197,6 +199,7 @@ const themeContext = createSimpleContext({
|
||||
return renderer
|
||||
.getPalette({ size: 16 })
|
||||
.then((colors: TerminalColors) => {
|
||||
setTerminalBackground(colors.defaultBackground ? RGBA.fromHex(colors.defaultBackground) : undefined)
|
||||
if (!colors.palette[0]) {
|
||||
if (hasResolvedSystemTheme) return
|
||||
setSystemTheme(undefined)
|
||||
@@ -213,6 +216,7 @@ const themeContext = createSimpleContext({
|
||||
setSystemTheme(generateSystem(colors, next))
|
||||
})
|
||||
.catch(() => {
|
||||
setTerminalBackground(undefined)
|
||||
if (hasResolvedSystemTheme) return
|
||||
setSystemTheme(undefined)
|
||||
if (store.active === "system") setStore("active", "opencode")
|
||||
@@ -320,13 +324,25 @@ const themeContext = createSimpleContext({
|
||||
themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`)
|
||||
const current = createComponentTheme(valuesV2, mode)
|
||||
|
||||
createEffect(() => renderer.setBackgroundColor(valuesV2().background.default))
|
||||
createEffect(() => {
|
||||
const background = valuesV2().background.default
|
||||
const terminal = terminalBackground()
|
||||
if (background.a === 0 && terminal) {
|
||||
// Supply the compositor's backdrop without painting over terminal transparency.
|
||||
const transparent = RGBA.clone(terminal)
|
||||
transparent.a = 0
|
||||
renderer.setBackgroundColor(transparent)
|
||||
return
|
||||
}
|
||||
renderer.setBackgroundColor(background)
|
||||
})
|
||||
|
||||
const currentSyntax = createSyntaxStyleMemo(() => generateSyntax(valuesV2(), mode()))
|
||||
const service: Themes = {
|
||||
current,
|
||||
currentTokens: valuesV2,
|
||||
currentSyntax,
|
||||
terminalBackgroundKnown: () => terminalBackground() !== undefined,
|
||||
get selected() {
|
||||
return store.active
|
||||
},
|
||||
|
||||
@@ -252,6 +252,8 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback
|
||||
top: 0,
|
||||
width,
|
||||
height,
|
||||
// Scrollback snapshots have their own buffer, separate from the renderer background.
|
||||
backgroundColor: input.theme.background,
|
||||
})
|
||||
|
||||
for (const line of lines) {
|
||||
|
||||
@@ -25,6 +25,7 @@ export type RunSplashTheme = {
|
||||
left: ColorInput
|
||||
right: ColorInput
|
||||
leftShadow: ColorInput
|
||||
background?: ColorInput
|
||||
}
|
||||
|
||||
export type RunFooterTheme = {
|
||||
@@ -180,11 +181,6 @@ function paletteColor(colors: TerminalColors, index: number): RGBA {
|
||||
return value ? RGBA.fromHex(value) : ansiToRgba(index)
|
||||
}
|
||||
|
||||
function splashShadow(indexed: RGBA[], base: RGBA, overlay: RGBA, value: number): RGBA {
|
||||
const mixed = tint(base, overlay, value)
|
||||
return nearestIndexed(indexed, mixed)
|
||||
}
|
||||
|
||||
export function resolveTheme(theme: ThemeV1Json, pick: "dark" | "light"): ThemeCurrent {
|
||||
const resolved = resolveThemeColors(theme, pick, (code) => RGBA.fromIndex(code, ansiToRgba(code)))
|
||||
return {
|
||||
@@ -354,13 +350,17 @@ function quantizeTheme(theme: ThemeCurrent, indexed: RGBA[]): ThemeCurrent {
|
||||
}
|
||||
}
|
||||
|
||||
function splashTheme(theme: ThemeCurrent, indexed: RGBA[]): RunSplashTheme {
|
||||
function splashTheme(theme: ThemeCurrent, indexed: RGBA[], background: string | null): RunSplashTheme {
|
||||
if (!background) {
|
||||
return { left: theme.text, right: theme.text, leftShadow: transparent }
|
||||
}
|
||||
const left = nearestIndexed(indexed, theme.textMuted)
|
||||
const right = nearestIndexed(indexed, theme.text)
|
||||
return {
|
||||
left,
|
||||
right,
|
||||
leftShadow: splashShadow(indexed, theme.background, left, 0.14),
|
||||
leftShadow: alpha(left, 0.25),
|
||||
background: RGBA.defaultBackground(background),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,10 +457,6 @@ function tone(body: ColorInput, start?: ColorInput): Tone {
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackSplashIndexed = Array.from({ length: 256 }, (_, index) => RGBA.fromIndex(index))
|
||||
const fallbackSplashLeft = RGBA.fromIndex(67)
|
||||
const fallbackSplashRight = RGBA.fromIndex(110)
|
||||
|
||||
export const RUN_THEME_FALLBACK: RunTheme = {
|
||||
background: RGBA.fromValues(0, 0, 0, 0),
|
||||
footer: {
|
||||
@@ -488,9 +484,9 @@ export const RUN_THEME_FALLBACK: RunTheme = {
|
||||
error: tone(seed.error),
|
||||
},
|
||||
splash: {
|
||||
left: fallbackSplashLeft,
|
||||
right: fallbackSplashRight,
|
||||
leftShadow: splashShadow(fallbackSplashIndexed, RGBA.fromValues(0, 0, 0, 0), fallbackSplashLeft, 0.14),
|
||||
left: seed.text,
|
||||
right: seed.text,
|
||||
leftShadow: transparent,
|
||||
},
|
||||
block: {
|
||||
text: seed.text,
|
||||
@@ -595,7 +591,12 @@ export async function resolveRunTheme(
|
||||
...scrollbackTheme,
|
||||
_hasSelectedListItemText: true,
|
||||
}
|
||||
return map(footerTheme, scrollbackTheme, splashTheme(scrollbackTheme, indexed), generateSyntax(syntaxTheme))
|
||||
return map(
|
||||
footerTheme,
|
||||
scrollbackTheme,
|
||||
splashTheme(scrollbackTheme, indexed, colors.defaultBackground),
|
||||
generateSyntax(syntaxTheme),
|
||||
)
|
||||
} catch {
|
||||
return RUN_THEME_FALLBACK
|
||||
}
|
||||
|
||||
@@ -5,21 +5,11 @@ const bold = "\x1b[1m"
|
||||
const dim = "\x1b[90m"
|
||||
|
||||
function wordmark(pad = "") {
|
||||
const draw = (line: string, fg: string, shadow: string, bg: string) =>
|
||||
[...line]
|
||||
.map((char) => {
|
||||
if (char === "_") return `${bg} ${reset}`
|
||||
if (char === "^") return `${fg}${bg}▀${reset}`
|
||||
if (char === "~") return `${shadow}▀${reset}`
|
||||
if (char === " ") return " "
|
||||
return `${fg}${char}${reset}`
|
||||
})
|
||||
.join("")
|
||||
// Outside the renderer the terminal background is unknown. Keep only the letter faces.
|
||||
const draw = (line: string) => line.replace(/[_~,]/g, " ").replace(/\^/g, "▀")
|
||||
|
||||
return logo.left.map((line, index) => {
|
||||
const left = draw(line, dim, "\x1b[38;5;235m", "\x1b[48;5;235m")
|
||||
const right = draw(logo.right[index] ?? "", reset, "\x1b[38;5;238m", "\x1b[48;5;238m")
|
||||
return `${pad}${left} ${right}`
|
||||
return `${reset}${pad}${draw(line)} ${draw(logo.right[index] ?? "")}`
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -20,10 +20,12 @@ type Renderer = {
|
||||
} | null
|
||||
clearSelection: () => void
|
||||
currentFocusedRenderable?: FocusableSelectionTarget | null
|
||||
currentFocusedEditor?: FocusableSelectionTarget | null
|
||||
}
|
||||
|
||||
type SelectionKeyEvent = {
|
||||
ctrl?: boolean
|
||||
baseCode?: number
|
||||
name: string
|
||||
preventDefault: () => void
|
||||
stopPropagation: () => void
|
||||
@@ -36,25 +38,16 @@ export function copyOnSelectRelease(
|
||||
clipboard: ClipboardService,
|
||||
): boolean {
|
||||
if (!event.isDragging) return false
|
||||
const selection = renderer.getSelection()
|
||||
// Preserve the first click so OpenTUI can recognize the following double/triple click.
|
||||
if (selection?.isStart && selection.behavior === "cell") return false
|
||||
return copy(renderer, toast, clipboard)
|
||||
}
|
||||
|
||||
export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardService): boolean {
|
||||
const selection = renderer.getSelection()
|
||||
if (!selection) return false
|
||||
if (selection.isStart && selection.behavior === "cell") {
|
||||
renderer.clearSelection()
|
||||
return false
|
||||
}
|
||||
if (selection.isStart && selection.behavior === "cell") return false
|
||||
|
||||
const text = selection.getSelectedText()
|
||||
if (!text) {
|
||||
renderer.clearSelection()
|
||||
return false
|
||||
}
|
||||
if (!text) return false
|
||||
|
||||
const focus = renderer.currentFocusedRenderable
|
||||
const clipboardText =
|
||||
@@ -65,8 +58,7 @@ export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardServi
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
|
||||
// Keep the highlight. clearSelection() also resets OpenTUI's click
|
||||
// counter, so clearing here would turn a triple-click into a new single-click.
|
||||
// Copy never clears selection, including empty releases: clearing also resets multi-click history.
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -75,12 +67,16 @@ export function handleSelectionKey(
|
||||
toast: Toast,
|
||||
event: SelectionKeyEvent,
|
||||
clipboard: ClipboardService,
|
||||
copyOnSelect: boolean,
|
||||
) {
|
||||
const selection = renderer.getSelection()
|
||||
if (!selection) return
|
||||
const focus = renderer.currentFocusedEditor
|
||||
const editing = focus?.hasSelection() && selection.selectedRenderables.includes(focus)
|
||||
|
||||
if (event.ctrl && event.name === "c") {
|
||||
if (!copy(renderer, toast, clipboard)) {
|
||||
// Kitty can report a non-Latin key name with a Latin base-layout C.
|
||||
if (event.ctrl && (event.name === "c" || event.baseCode === 99 || event.baseCode === 67)) {
|
||||
if ((copyOnSelect && !editing) || !copy(renderer, toast, clipboard)) {
|
||||
renderer.clearSelection()
|
||||
return
|
||||
}
|
||||
@@ -91,14 +87,15 @@ export function handleSelectionKey(
|
||||
}
|
||||
|
||||
if (event.name === "escape") {
|
||||
const text = selection.isStart && selection.behavior === "cell" ? "" : selection.getSelectedText()
|
||||
renderer.clearSelection()
|
||||
if (!text) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
|
||||
const focus = renderer.currentFocusedRenderable
|
||||
if (focus?.hasSelection() && selection.selectedRenderables.includes(focus)) return
|
||||
if (editing) return
|
||||
|
||||
renderer.clearSelection()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { type Renderable, ScrollBoxRenderable } from "@opentui/core"
|
||||
import { EmbeddedTerminalRenderable, type Renderable, ScrollBoxRenderable } from "@opentui/core"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -851,3 +851,144 @@ test("ctrl+c dismisses autocomplete and shell mode before exiting", async () =>
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test.each(["manual", "select"] as const)(
|
||||
"selection copy and dismissal respect %s mode in the prompt and terminal pane",
|
||||
async (copy) => {
|
||||
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
|
||||
setup.renderer.start()
|
||||
const ready = Promise.withResolvers<void>()
|
||||
const session = {
|
||||
id: "dummy",
|
||||
title: "Selection fixture",
|
||||
projectID: "project",
|
||||
location: { directory },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
}
|
||||
const pty = {
|
||||
id: "pty_fixture",
|
||||
sessionID: session.id,
|
||||
title: "Terminal",
|
||||
command: "/bin/sh",
|
||||
args: [],
|
||||
cwd: directory,
|
||||
status: "running",
|
||||
pid: 1,
|
||||
foregroundProcess: null,
|
||||
size: { cols: 48, rows: 24 },
|
||||
output: { head: 0, tail: 0 },
|
||||
}
|
||||
const input: string[] = []
|
||||
const calls = createFetch((url, request) => {
|
||||
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
|
||||
if (url.pathname === "/api/session/dummy") return json({ data: session })
|
||||
if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
|
||||
if (url.pathname === "/api/session/dummy/inbox") return json({ data: [] })
|
||||
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
|
||||
if (url.pathname === "/api/experimental/session/dummy/terminal")
|
||||
return json({ data: request.method === "POST" ? pty : [pty] })
|
||||
if (url.pathname === "/api/experimental/persistent-pty/pty_fixture/snapshot")
|
||||
return json({
|
||||
data: {
|
||||
info: pty,
|
||||
text: "alpha beta gamma",
|
||||
checkpoint: Buffer.from("alpha beta gamma").toString("base64"),
|
||||
cursor: { x: 16, y: 0 },
|
||||
},
|
||||
})
|
||||
if (url.pathname === "/api/experimental/persistent-pty/pty_fixture/connect-token")
|
||||
return json({ data: { ticket: "fixture" } })
|
||||
return undefined
|
||||
}, createEventStream())
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request, server) {
|
||||
if (new URL(request.url).pathname.endsWith("/connect") && server.upgrade(request)) return undefined
|
||||
return calls.fetch(request)
|
||||
},
|
||||
websocket: {
|
||||
open(socket) {
|
||||
socket.send(JSON.stringify({ type: "attached", inputProtocol: 1, role: "controller", info: pty }))
|
||||
socket.send(JSON.stringify({ type: "replay_complete" }))
|
||||
},
|
||||
message(_socket, message) {
|
||||
const data = Buffer.from(message)
|
||||
if (data[0] === 1) input.push(data.subarray(5).toString())
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const { run } = await import("../src/app")
|
||||
const task = Effect.runPromise(
|
||||
run({
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: {
|
||||
get: async () => ({
|
||||
animations: false,
|
||||
terminal: { copy },
|
||||
session: { terminal: true },
|
||||
}),
|
||||
update: async () => ({}),
|
||||
},
|
||||
packages: { resolve: async () => undefined },
|
||||
args: { sessionID: session.id },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||
)
|
||||
|
||||
await ready.promise
|
||||
await setup.waitForFrame((frame) => frame.includes("commands"))
|
||||
await setup.mockInput.typeText("selection audit draft")
|
||||
setup.mockInput.pressKey("a", { ctrl: true, shift: true })
|
||||
expect(setup.renderer.getSelection()?.getSelectedText()).toBe("selection audit draft")
|
||||
|
||||
setup.mockInput.pressEscape()
|
||||
expect(setup.renderer.hasSelection).toBeFalse()
|
||||
expect(setup.renderer.currentFocusedEditor?.plainText).toBe("selection audit draft")
|
||||
|
||||
setup.mockInput.pressKey("c", { ctrl: true })
|
||||
await setup.waitForFrame((frame) => !frame.includes("selection audit draft"))
|
||||
expect(setup.renderer.currentFocusedEditor?.plainText).toBe("")
|
||||
expect(setup.renderer.hasSelection).toBeFalse()
|
||||
expect(setup.renderer.isDestroyed).toBeFalse()
|
||||
|
||||
await setup.mockInput.typeText("/terminal")
|
||||
await setup.waitForFrame((frame) => frame.includes("New terminal"))
|
||||
setup.mockInput.pressEnter()
|
||||
await setup.waitForFrame((frame) => frame.includes("alpha beta gamma"))
|
||||
setup.mockInput.pressKey("x", { ctrl: true })
|
||||
setup.mockInput.pressArrow("right")
|
||||
const terminal = setup.renderer.currentFocusedRenderable
|
||||
if (!(terminal instanceof EmbeddedTerminalRenderable)) throw new Error("Terminal was not focused")
|
||||
setup.renderer.startSelection(terminal, terminal.x + 6, terminal.y)
|
||||
setup.renderer.updateSelection(terminal, terminal.x + 9, terminal.y, { finishDragging: true })
|
||||
expect(setup.renderer.getSelection()?.getSelectedText()).toBe("beta")
|
||||
|
||||
setup.mockInput.pressEscape()
|
||||
expect(setup.renderer.hasSelection).toBeFalse()
|
||||
expect(terminal.hasSelection()).toBeFalse()
|
||||
|
||||
await setup.mockMouse.click(terminal.x + 6, terminal.y)
|
||||
if (copy === "select") {
|
||||
setup.renderer.updateSelection(terminal, terminal.x + 9, terminal.y, { finishDragging: true })
|
||||
expect(setup.renderer.getSelection()?.getSelectedText()).toBe("beta")
|
||||
}
|
||||
setup.mockInput.pressKey("c", { ctrl: true })
|
||||
await setup.waitFor(() => input.length > 0)
|
||||
expect(input).toEqual(["\x03"])
|
||||
expect(setup.renderer.hasSelection).toBeFalse()
|
||||
expect(setup.renderer.isDestroyed).toBeFalse()
|
||||
|
||||
setup.renderer.destroy()
|
||||
await task
|
||||
} finally {
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
await server.stop()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { RGBA, type TerminalColors } from "@opentui/core"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { render } from "@opentui/solid"
|
||||
import { expect, spyOn, test } from "bun:test"
|
||||
import { Logo } from "../../../src/component/logo"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
import { ThemeProvider, useThemes } from "../../../src/context/theme"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
|
||||
const palette: TerminalColors = {
|
||||
palette: Array.from({ length: 16 }, () => "#000000"),
|
||||
defaultForeground: "#eeeeee",
|
||||
defaultBackground: null,
|
||||
cursorColor: null,
|
||||
mouseForeground: null,
|
||||
mouseBackground: null,
|
||||
tekForeground: null,
|
||||
tekBackground: null,
|
||||
highlightBackground: null,
|
||||
highlightForeground: null,
|
||||
}
|
||||
|
||||
async function setup(background: string, surface: string | undefined, colors = palette) {
|
||||
const app = await createTestRenderer({ width: 80, height: 24 })
|
||||
const query = spyOn(app.renderer, "getPalette").mockResolvedValue(colors)
|
||||
let themes: ReturnType<typeof useThemes> | undefined
|
||||
function Content() {
|
||||
themes = useThemes()
|
||||
return (
|
||||
<box backgroundColor={surface} width="100%" height="100%">
|
||||
<Logo />
|
||||
</box>
|
||||
)
|
||||
}
|
||||
await render(
|
||||
() => (
|
||||
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "logo", mode: "dark" } })}>
|
||||
<ThemeProvider
|
||||
mode="dark"
|
||||
source={{
|
||||
discover: async () => ({
|
||||
logo: {
|
||||
version: 2,
|
||||
dark: { background: { default: background }, text: { default: "#eeeeee", subdued: "#eeeeee" } },
|
||||
},
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Content />
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
),
|
||||
app.renderer,
|
||||
)
|
||||
await app.waitFor(() => themes?.ready === true)
|
||||
await app.renderOnce()
|
||||
return {
|
||||
...app,
|
||||
async palette(colors: TerminalColors) {
|
||||
query.mockResolvedValue(colors)
|
||||
await app.mockInput.pressKeys(["\x1b[?997;1n"])
|
||||
await app.waitFor(() => themes?.terminalBackgroundKnown() === !!colors.defaultBackground)
|
||||
await app.renderOnce()
|
||||
},
|
||||
cell(x: number, y: number) {
|
||||
return app.captureSpans().lines[y].spans.flatMap((span) => Array.from(span.text, (char) => ({ ...span, char })))[
|
||||
x
|
||||
]
|
||||
},
|
||||
close() {
|
||||
query.mockRestore()
|
||||
app.renderer.destroy()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test.each(["#fdf6e3", "#18181b", "#292237"])("composites logo shadows over the actual %s surface", async (surface) => {
|
||||
// A different opaque theme background catches preblending against the wrong surface.
|
||||
const app = await setup("#000000", surface)
|
||||
try {
|
||||
const bg = RGBA.fromHex(surface).toInts()
|
||||
const expected = bg.slice(0, 3).map((channel) => Math.round(channel * 0.75 + 238 * 0.25))
|
||||
const full = app.cell(1, 2).bg.toInts()
|
||||
const mixed = app.cell(11, 2)
|
||||
const top = app.cell(16, 3).fg.toInts()
|
||||
for (const actual of [full, mixed.bg.toInts(), top]) {
|
||||
expected.forEach((channel, index) => expect(Math.abs(actual[index] - channel)).toBeLessThanOrEqual(1))
|
||||
}
|
||||
expect(mixed.char).toBe("▀")
|
||||
expect(mixed.fg.toInts()).toEqual([238, 238, 238, 255])
|
||||
} finally {
|
||||
app.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("leaves only letter faces when a transparent theme has no terminal background evidence", async () => {
|
||||
const app = await setup("transparent", "#fdf6e3")
|
||||
try {
|
||||
expect(app.cell(1, 2).bg.toInts()).toEqual(RGBA.fromHex("#fdf6e3").toInts())
|
||||
expect(app.cell(11, 2).char).toBe("▀")
|
||||
expect(app.cell(11, 2).bg.toInts()).toEqual(RGBA.fromHex("#fdf6e3").toInts())
|
||||
expect(app.cell(16, 3).char).toBe(" ")
|
||||
} finally {
|
||||
app.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps shadows for a transparent theme with a detected terminal background", async () => {
|
||||
const app = await setup("transparent", "#fdf6e3", { ...palette, defaultBackground: "#fdf6e3" })
|
||||
try {
|
||||
expect(app.cell(1, 2).bg.toInts()).not.toEqual(RGBA.fromHex("#fdf6e3").toInts())
|
||||
expect(app.cell(16, 3).char).toBe("▀")
|
||||
} finally {
|
||||
app.close()
|
||||
}
|
||||
})
|
||||
|
||||
test.each(["#fdf6e3", "#18181b", "#292237"])(
|
||||
"uses detected %s as the transparent compositor backdrop",
|
||||
async (background) => {
|
||||
const app = await setup("transparent", undefined, { ...palette, defaultBackground: background })
|
||||
try {
|
||||
const base = RGBA.fromHex(background).toInts()
|
||||
const expected = base.slice(0, 3).map((channel) => Math.round(channel * 0.75 + 238 * 0.25))
|
||||
for (const actual of [app.cell(1, 2).bg.toInts(), app.cell(11, 2).bg.toInts(), app.cell(16, 3).fg.toInts()]) {
|
||||
expected.forEach((channel, index) => expect(Math.abs(actual[index] - channel)).toBeLessThanOrEqual(1))
|
||||
}
|
||||
expect(app.cell(0, 0).bg.a).toBe(0)
|
||||
} finally {
|
||||
app.close()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
test("updates every shadow cell when terminal background detection changes", async () => {
|
||||
const app = await setup("transparent", "#fdf6e3")
|
||||
try {
|
||||
const background = app.cell(1, 2).bg.toInts()
|
||||
await app.palette({ ...palette, defaultBackground: "#fdf6e3" })
|
||||
expect(app.cell(1, 2).bg.toInts()).not.toEqual(background)
|
||||
expect(app.cell(11, 2).bg.toInts()).not.toEqual(background)
|
||||
expect(app.cell(16, 3).char).toBe("▀")
|
||||
await app.palette(palette)
|
||||
expect(app.cell(1, 2).bg.toInts()).toEqual(background)
|
||||
expect(app.cell(11, 2).bg.toInts()).toEqual(background)
|
||||
expect(app.cell(16, 3).char).toBe(" ")
|
||||
} finally {
|
||||
app.close()
|
||||
}
|
||||
})
|
||||
|
||||
test.each([
|
||||
[80, 24, 4],
|
||||
[30, 24, 7],
|
||||
[20, 24, 3],
|
||||
[80, 11, 0],
|
||||
])("preserves logo layout at %ix%i", async (width, height, rows) => {
|
||||
const app = await setup("#18181b", "#18181b")
|
||||
try {
|
||||
app.resize(width, height)
|
||||
await app.renderOnce()
|
||||
expect(
|
||||
app
|
||||
.captureCharFrame()
|
||||
.split("\n")
|
||||
.filter((line) => line.trim()).length,
|
||||
).toBe(rows)
|
||||
} finally {
|
||||
app.close()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,253 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { InputRenderable } from "@opentui/core"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { usePromptMove } from "../../../src/component/prompt/move"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
import { ClientProvider } from "../../../src/context/client"
|
||||
import { DataProvider, useData } from "../../../src/context/data"
|
||||
import { Keymap } from "../../../src/context/keymap"
|
||||
import { LocationProvider, useLocation } from "../../../src/context/location"
|
||||
import { RouteProvider } from "../../../src/context/route"
|
||||
import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { DialogProvider } from "../../../src/ui/dialog"
|
||||
import { ToastProvider, useToast } from "../../../src/ui/toast"
|
||||
import { emptyThemeSource } from "../../fixture/fixture"
|
||||
import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
|
||||
const main = "/tmp/opencode/main"
|
||||
const clone = "/tmp/opencode/other-clone"
|
||||
const linked = "/tmp/opencode/linked"
|
||||
const created = "/tmp/opencode/proj_t/fresh"
|
||||
|
||||
test.each([
|
||||
{ name: "a cached session in another clone", directory: clone, warm: true },
|
||||
{ name: "an uncached session in a clone subdirectory", directory: `${clone}/packages/tui` },
|
||||
{ name: "an uncached session in a linked worktree", directory: linked, worktree: linked },
|
||||
{ name: "a session in a linked worktree subdirectory", directory: `${linked}/packages/tui`, worktree: linked },
|
||||
{ name: "the home/default location", directory: `${clone}/packages/tui`, home: true },
|
||||
])("creates from the clone's main worktree for $name", async (input) => {
|
||||
const fixture = await renderMove(input)
|
||||
try {
|
||||
await fixture.data.project.sync()
|
||||
expect(fixture.data.project.get("proj_test")?.canonical).toBe(main)
|
||||
if (input.warm) {
|
||||
await fixture.data.session.sync("ses_clone")
|
||||
await fixture.data.location.syncInfo({ directory: input.directory })
|
||||
}
|
||||
if (!input.home && !input.warm) {
|
||||
expect(fixture.data.session.get("ses_clone")).toBeUndefined()
|
||||
expect(fixture.data.location.info({ directory: input.directory })).toBeUndefined()
|
||||
}
|
||||
if (!input.home) fixture.location.set({ directory: main })
|
||||
|
||||
await fixture.create()
|
||||
|
||||
expect(fixture.requests).toEqual([
|
||||
{ strategy: "git", from: clone, directory: path.join("/tmp/opencode", "proj_t"), name: "fresh" },
|
||||
])
|
||||
expect(fixture.data.location.info({ directory: created })?.project.canonical).toBe(clone)
|
||||
expect(fixture.reads.locations.filter((directory) => directory === input.directory)).toHaveLength(1)
|
||||
expect(fixture.reads.session).toBe(input.home ? 0 : 1)
|
||||
expect(fixture.moves).toEqual(input.home ? [] : [{ directory: created }])
|
||||
} finally {
|
||||
fixture.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test.each([
|
||||
{ name: "another clone", launch: main },
|
||||
{ name: "another project", launch: "/tmp/opencode/elsewhere", launchProjectID: "proj_launch" },
|
||||
{ name: "another workspace", launch: main, workspaceID: "wrk_clone" },
|
||||
])("uses Home's selected location instead of launch in $name", async (input) => {
|
||||
const fixture = await renderMove({ ...input, directory: `${clone}/packages/tui`, home: true })
|
||||
try {
|
||||
await fixture.data.location.syncInfo()
|
||||
const selected = { directory: `${clone}/packages/tui`, workspaceID: input.workspaceID }
|
||||
fixture.location.set(selected)
|
||||
expect(fixture.data.location.default().directory).toBe(input.launch)
|
||||
expect(fixture.data.location.info(selected)).toBeUndefined()
|
||||
|
||||
const frame = await fixture.create()
|
||||
|
||||
expect(fixture.reads.worktrees).toEqual(["proj_test"])
|
||||
expect(frame).toContain(clone)
|
||||
expect(frame.indexOf(clone)).toBeLessThan(frame.indexOf(main))
|
||||
expect(fixture.requests).toEqual([
|
||||
{ strategy: "git", from: clone, directory: path.join("/tmp/opencode", "proj_t"), name: "fresh" },
|
||||
])
|
||||
expect(fixture.data.location.info(selected)?.project.canonical).toBe(clone)
|
||||
expect(fixture.moves).toEqual([])
|
||||
} finally {
|
||||
fixture.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test.each([
|
||||
{ name: "session", unavailable: "session" as const },
|
||||
{ name: "location", unavailable: "location" as const },
|
||||
{ name: "selected Home location", unavailable: "location" as const, home: true, launch: main },
|
||||
])("does not create from another clone when $name lookup fails", async (input) => {
|
||||
const fixture = await renderMove({ ...input, directory: `${linked}/packages/tui`, worktree: linked })
|
||||
try {
|
||||
if (input.home) fixture.location.set({ directory: `${linked}/packages/tui` })
|
||||
await fixture.create()
|
||||
|
||||
expect(fixture.requests).toEqual([])
|
||||
expect(fixture.moves).toEqual([])
|
||||
expect(fixture.toast.currentToast).toMatchObject({ title: "Creating workspace failed", variant: "error" })
|
||||
expect(fixture.move.creating()).toBe(false)
|
||||
} finally {
|
||||
fixture.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
async function renderMove(input: {
|
||||
directory: string
|
||||
worktree?: string
|
||||
home?: boolean
|
||||
launch?: string
|
||||
launchProjectID?: string
|
||||
unavailable?: "session" | "location"
|
||||
}) {
|
||||
const launch = input.launch ?? (input.home ? input.directory : main)
|
||||
const requests: unknown[] = []
|
||||
const moves: unknown[] = []
|
||||
const reads = { session: 0, locations: [] as string[], worktrees: [] as string[] }
|
||||
const calls = createFetch(async (url, request) => {
|
||||
if (url.pathname === "/api/location" || url.pathname === "/api/project/current") {
|
||||
const directory = url.searchParams.get("location[directory]") ?? launch
|
||||
const project = {
|
||||
id: directory === launch ? (input.launchProjectID ?? "proj_test") : "proj_test",
|
||||
directory: directory === input.directory ? (input.worktree ?? clone) : directory,
|
||||
canonical:
|
||||
directory === input.directory || directory === created
|
||||
? clone
|
||||
: input.launchProjectID && directory === launch
|
||||
? launch
|
||||
: main,
|
||||
}
|
||||
if (url.pathname === "/api/project/current") return json(project)
|
||||
reads.locations.push(directory)
|
||||
if (input.unavailable === "location" && directory === input.directory)
|
||||
return json({ message: "Location unavailable" }, { status: 503 })
|
||||
return json({
|
||||
directory,
|
||||
workspaceID: url.searchParams.get("location[workspace]") ?? undefined,
|
||||
project,
|
||||
})
|
||||
}
|
||||
if (url.pathname === "/api/project")
|
||||
return json([{ id: "proj_test", canonical: main, time: { created: 1, updated: 1 }, sandboxes: [] }])
|
||||
if (url.pathname === "/api/session/ses_clone") {
|
||||
reads.session++
|
||||
if (input.unavailable === "session") return json({ message: "Session unavailable" }, { status: 404 })
|
||||
return json({
|
||||
data: {
|
||||
id: "ses_clone",
|
||||
projectID: "proj_test",
|
||||
location: { directory: input.directory },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 1 },
|
||||
},
|
||||
})
|
||||
}
|
||||
if (url.pathname === "/api/worktree/proj_test" || url.pathname === "/api/worktree/proj_launch") {
|
||||
if (request.method === "GET") {
|
||||
reads.worktrees.push(url.pathname.slice("/api/worktree/".length))
|
||||
return json(
|
||||
url.pathname === "/api/worktree/proj_launch"
|
||||
? [{ directory: launch }]
|
||||
: [{ directory: main }, { directory: clone }, { directory: linked, strategy: "git" }],
|
||||
)
|
||||
}
|
||||
if (request.method === "POST") {
|
||||
requests.push(await request.json())
|
||||
return json({ directory: created })
|
||||
}
|
||||
}
|
||||
if (url.pathname === "/api/worktree/proj_launch/refresh") return new Response(null, { status: 204 })
|
||||
if (url.pathname === "/api/session/ses_clone/move") {
|
||||
moves.push(await request.json())
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return undefined
|
||||
}, createEventStream())
|
||||
let data!: ReturnType<typeof useData>
|
||||
let move!: ReturnType<typeof usePromptMove>
|
||||
let toast!: ReturnType<typeof useToast>
|
||||
let location!: ReturnType<typeof useLocation>
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
toast = useToast()
|
||||
location = useLocation()
|
||||
move = usePromptMove({
|
||||
projectID: () => (input.home ? data.location.info()?.project.id : "proj_test"),
|
||||
sessionID: () => (input.home ? undefined : "ses_clone"),
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<TestTuiContexts cwd={launch}>
|
||||
<ConfigProvider config={createTuiResolvedConfig()}>
|
||||
<Keymap.Provider>
|
||||
<ToastProvider>
|
||||
<RouteProvider>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<DataProvider directory={launch}>
|
||||
<LocationProvider>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<DialogProvider>
|
||||
<Probe />
|
||||
</DialogProvider>
|
||||
</ThemeProvider>
|
||||
</LocationProvider>
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</TestTuiContexts>
|
||||
),
|
||||
{ width: 100, height: 30, kittyKeyboard: true },
|
||||
)
|
||||
app.renderer.start()
|
||||
await app.waitFor(() => move !== undefined)
|
||||
|
||||
return {
|
||||
app,
|
||||
data,
|
||||
move,
|
||||
toast,
|
||||
location,
|
||||
requests,
|
||||
moves,
|
||||
reads,
|
||||
async create() {
|
||||
await move.open()
|
||||
const frame = await app.waitForFrame(
|
||||
(frame) => frame.includes("Move session") && (frame.includes(clone) || frame.includes(launch)),
|
||||
)
|
||||
app.mockInput.pressKey("m", { ctrl: true })
|
||||
await app.waitForFrame((frame) => frame.includes("Name worktree"))
|
||||
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
|
||||
await app.mockInput.typeText("fresh")
|
||||
app.mockInput.pressEnter()
|
||||
if (input.home) {
|
||||
await app.waitFor(() => move.pendingNew())
|
||||
await move.getDirectory()
|
||||
return frame
|
||||
}
|
||||
await app.waitFor(() => moves.length > 0 || toast.currentToast !== null)
|
||||
return frame
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,12 @@ for (const orientation of ["horizontal", "vertical"] as const) {
|
||||
setActive("second")
|
||||
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true, attention })
|
||||
await app.renderOnce()
|
||||
const indicatorColor = () =>
|
||||
app
|
||||
.captureSpans()
|
||||
.lines.flatMap((line) => line.spans)
|
||||
.find((span) => span.text.trim() === (attention === "question" ? "?" : "!"))?.fg
|
||||
expect(indicatorColor()?.toInts()).toEqual(theme.text.status[attention].toInts())
|
||||
const glow = () => {
|
||||
const colors = app
|
||||
.captureSpans()
|
||||
@@ -140,6 +146,7 @@ for (const orientation of ["horizontal", "vertical"] as const) {
|
||||
expect(full).toBeGreaterThan(0)
|
||||
setActive("first")
|
||||
await app.renderOnce()
|
||||
expect(indicatorColor()?.toInts()).toEqual(theme.text.status[attention].toInts())
|
||||
const dim = glow()
|
||||
expect(dim).toBeGreaterThan(0)
|
||||
expect(dim).toBeLessThan(full)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { RGBA, type CliRenderer, type TerminalColors } from "@opentui/core"
|
||||
import { CliRenderEvents, RGBA, type CapturedLine, type CliRenderer, type TerminalColors } from "@opentui/core"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { entrySplash, exitSplash } from "../../src/mini/splash"
|
||||
import { RUN_THEME_MONO, RUN_THEME_FALLBACK, generateSystem, resolveRunTheme, resolveTheme } from "../../src/mini/theme"
|
||||
import { DEFAULT_THEMES } from "../../src/theme"
|
||||
|
||||
@@ -92,7 +94,7 @@ test("resolveTheme preserves Mini indexed color and result shape semantics", ()
|
||||
expect("_hasSelectedListItemText" in theme).toBe(false)
|
||||
})
|
||||
|
||||
test("returns syntax styles and indexed splash colors", async () => {
|
||||
test("returns syntax styles and native alpha splash shadows", async () => {
|
||||
const theme = await resolveRunTheme(renderer({ themeMode: "dark" }))
|
||||
|
||||
try {
|
||||
@@ -100,7 +102,10 @@ test("returns syntax styles and indexed splash colors", async () => {
|
||||
expect([...theme.block.syntax!.getAllStyles()].length).toBeGreaterThan(0)
|
||||
expectIndexed(theme.splash.left)
|
||||
expectIndexed(theme.splash.right)
|
||||
expectIndexed(theme.splash.leftShadow)
|
||||
const shadow = expectRgba(theme.splash.leftShadow)
|
||||
expect(shadow.intent).toBe("rgb")
|
||||
expect(shadow.a).toBeCloseTo(0.25, 2)
|
||||
expect(shadow.toInts().slice(0, 3)).toEqual(expectRgba(theme.splash.left).toInts().slice(0, 3))
|
||||
expectRgba(theme.footer.highlight)
|
||||
expectRgba(theme.footer.statusAccent)
|
||||
expectRgba(theme.footer.surface)
|
||||
@@ -110,6 +115,67 @@ test("returns syntax styles and indexed splash colors", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("omits splash shadows without an actual terminal background", async () => {
|
||||
const theme = await resolveRunTheme(renderer({ colors: { ...terminalColors(), defaultBackground: null } }))
|
||||
|
||||
try {
|
||||
expect(expectRgba(theme.splash.leftShadow).a).toBe(0)
|
||||
expect(expectRgba(theme.splash.left).intent).toBe("default")
|
||||
expect(expectRgba(theme.splash.right).intent).toBe("default")
|
||||
expect(expectRgba(theme.splash.left).toInts()).toEqual(expectRgba(theme.footer.text).toInts())
|
||||
} finally {
|
||||
theme.block.syntax?.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("fallback splash uses default foreground without shadows", () => {
|
||||
expect(expectRgba(RUN_THEME_FALLBACK.splash.leftShadow).a).toBe(0)
|
||||
expect(expectRgba(RUN_THEME_FALLBACK.splash.left).intent).toBe("default")
|
||||
expect(expectRgba(RUN_THEME_FALLBACK.splash.right).intent).toBe("default")
|
||||
})
|
||||
|
||||
test("native scrollback composes splash shadows against the reported background", async () => {
|
||||
for (const background of ["#101820", "#faf0dc", "#0000ff", null]) {
|
||||
const theme = await resolveRunTheme(renderer({ colors: { ...terminalColors(), defaultBackground: background } }))
|
||||
const out = await createTestRenderer({
|
||||
width: 80,
|
||||
screenMode: "split-footer",
|
||||
footerHeight: 6,
|
||||
externalOutputMode: "capture-stdout",
|
||||
consoleMode: "disabled",
|
||||
})
|
||||
out.renderer.setBackgroundColor(theme.background)
|
||||
let lines: CapturedLine[] = []
|
||||
let text = ""
|
||||
out.renderer.on(CliRenderEvents.EXTERNAL_OUTPUT, (event) => {
|
||||
lines = event.snapshot.getSpanLines()
|
||||
text = new TextDecoder().decode(event.snapshot.getRealCharBytes(false))
|
||||
})
|
||||
|
||||
try {
|
||||
for (const splash of [entrySplash, exitSplash]) {
|
||||
out.renderer.writeToScrollback(splash({ theme: theme.splash, title: "Test", session_id: "ses-test" }))
|
||||
expect([1, 2, 3].map((row) => text.slice(row * 80, row * 80 + 4))).toEqual(["█▀▀█", "█ █", "▀▀▀▀"])
|
||||
const interior = lines[2]!.spans[lines[2]!.spans[0]!.width > 1 ? 0 : 1]!
|
||||
expect(interior).toBeDefined()
|
||||
if (!background) {
|
||||
expect(interior.bg.a).toBe(0)
|
||||
continue
|
||||
}
|
||||
const foreground = expectRgba(theme.splash.left)
|
||||
const base = RGBA.fromHex(background)
|
||||
expect(interior.bg.a).toBe(1)
|
||||
expect(interior.bg.r).toBeCloseTo(base.r * 0.75 + foreground.r * 0.25, 2)
|
||||
expect(interior.bg.g).toBeCloseTo(base.g * 0.75 + foreground.g * 0.25, 2)
|
||||
expect(interior.bg.b).toBeCloseTo(base.b * 0.75 + foreground.b * 0.25, 2)
|
||||
}
|
||||
} finally {
|
||||
out.renderer.destroy()
|
||||
theme.block.syntax?.destroy()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps footer surfaces exact while scrollback stays palette matched", async () => {
|
||||
const colors = terminalColors({
|
||||
defaultBackground: "#0f172a",
|
||||
|
||||
@@ -6,3 +6,21 @@ test("formats session continuation summary", () => {
|
||||
expect(epilogue).toContain("A session")
|
||||
expect(epilogue).toContain("opencode2 -s ses_123")
|
||||
})
|
||||
|
||||
test("uses the terminal foreground without painting shadows when the background is unknown", () => {
|
||||
const output = sessionEpilogue({ title: "Logo", sessionID: "ses_logo" })
|
||||
const mark = output.split("\n").slice(0, 4).join("\n")
|
||||
|
||||
expect(mark).not.toMatch(/\x1b\[(?:38|48);/)
|
||||
expect(mark).not.toContain("\x1b[90m")
|
||||
expect(Bun.stripANSI(mark)).toBe(
|
||||
[
|
||||
" ▄ ",
|
||||
" █▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█",
|
||||
" █ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀",
|
||||
" ▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀",
|
||||
].join("\n"),
|
||||
)
|
||||
expect(Bun.stripANSI(output)).toContain("Session Logo")
|
||||
expect(Bun.stripANSI(output)).toContain("Continue opencode2 -s ses_logo")
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { ManualClock } from "@opentui/core/testing"
|
||||
import { testRender, useRenderer } from "@opentui/solid"
|
||||
import { useClipboard } from "../../src/context/clipboard"
|
||||
import { copyOnSelectRelease } from "../../src/util/selection"
|
||||
@@ -19,7 +20,10 @@ function CopyOnSelectText() {
|
||||
)
|
||||
}
|
||||
|
||||
test("copy-on-select keeps a word highlight so a third click can select the line", async () => {
|
||||
test.each([
|
||||
{ column: 6, word: "beta" },
|
||||
{ column: 17, word: "" },
|
||||
])("copy-on-select preserves multi-clicks at column $column", async (input) => {
|
||||
const writes: string[] = []
|
||||
const app = await testRender(
|
||||
() => (
|
||||
@@ -36,24 +40,24 @@ test("copy-on-select keeps a word highlight so a third click can select the line
|
||||
<CopyOnSelectText />
|
||||
</TestTuiContexts>
|
||||
),
|
||||
{ width: 20, height: 2 },
|
||||
{ width: 20, height: 2, clock: new ManualClock() },
|
||||
)
|
||||
|
||||
try {
|
||||
app.renderer.start()
|
||||
await app.waitForFrame((frame) => frame.includes("beta"))
|
||||
|
||||
await app.mockMouse.click(6, 0)
|
||||
await app.mockMouse.click(input.column, 0)
|
||||
expect(app.renderer.getSelection()?.getSelectedText() ?? "").toBe("")
|
||||
expect(writes).toEqual([])
|
||||
|
||||
await app.mockMouse.click(6, 0)
|
||||
expect(app.renderer.getSelection()?.getSelectedText()).toBe("beta")
|
||||
expect(writes).toEqual(["beta"])
|
||||
await app.mockMouse.click(input.column, 0)
|
||||
expect(app.renderer.getSelection()?.getSelectedText() ?? "").toBe(input.word)
|
||||
expect(writes).toEqual(input.word ? [input.word] : [])
|
||||
|
||||
await app.mockMouse.click(6, 0)
|
||||
await app.mockMouse.click(input.column, 0)
|
||||
expect(app.renderer.getSelection()?.getSelectedText()).toBe("alpha beta gamma")
|
||||
expect(writes).toEqual(["beta", "alpha beta gamma"])
|
||||
expect(writes).toEqual([...(input.word ? [input.word] : []), "alpha beta gamma"])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { EmbeddedTerminalRenderable, InputRenderable, TextareaRenderable, TextRenderable } from "@opentui/core"
|
||||
import { createTestRenderer, ManualClock } from "@opentui/core/testing"
|
||||
import type { ClipboardService } from "../../src/context/clipboard"
|
||||
import { Selection } from "../../src/util/selection"
|
||||
|
||||
async function setup(copyOnSelect = false) {
|
||||
const clock = new ManualClock()
|
||||
const app = await createTestRenderer({
|
||||
width: 24,
|
||||
height: 3,
|
||||
useThread: false,
|
||||
exitOnCtrlC: false,
|
||||
useKittyKeyboard: {},
|
||||
clock,
|
||||
})
|
||||
const writes: string[] = []
|
||||
const clipboard: ClipboardService = {
|
||||
read: async () => undefined,
|
||||
write: async (text) => {
|
||||
writes.push(text)
|
||||
},
|
||||
}
|
||||
app.renderer.keyInput.on("keypress", (event) =>
|
||||
Selection.handleSelectionKey(app.renderer, { show() {}, error() {} }, event, clipboard, copyOnSelect),
|
||||
)
|
||||
return { ...app, clock, writes }
|
||||
}
|
||||
|
||||
async function terminal(copyOnSelect = false) {
|
||||
const app = await setup(copyOnSelect)
|
||||
const input: string[] = []
|
||||
const terminal = new EmbeddedTerminalRenderable(app.renderer, {
|
||||
width: 24,
|
||||
height: 3,
|
||||
onData(data, source) {
|
||||
if (source === "input") input.push(Buffer.from(data).toString())
|
||||
},
|
||||
})
|
||||
app.renderer.root.add(terminal)
|
||||
terminal.write("alpha beta gamma")
|
||||
terminal.focus()
|
||||
await app.renderOnce()
|
||||
return { ...app, terminal, input }
|
||||
}
|
||||
|
||||
test("terminal selections retain repeated copies until Escape or typing dismisses them", async () => {
|
||||
const app = await terminal()
|
||||
try {
|
||||
await app.mockMouse.drag(6, 0, 9, 0)
|
||||
|
||||
app.mockInput.pressCtrlC()
|
||||
app.mockInput.pressCtrlC()
|
||||
expect(app.writes).toEqual(["beta", "beta"])
|
||||
expect(app.renderer.getSelection()?.getSelectedText()).toBe("beta")
|
||||
expect(app.input).toEqual([])
|
||||
|
||||
app.mockInput.pressEscape()
|
||||
app.clock.advance(20)
|
||||
expect(app.renderer.hasSelection).toBeFalse()
|
||||
expect(app.terminal.hasSelection()).toBeFalse()
|
||||
expect(app.input).toEqual([])
|
||||
|
||||
app.mockInput.pressCtrlC()
|
||||
expect(app.input).toEqual(["\x03"])
|
||||
|
||||
await app.mockMouse.drag(6, 0, 9, 0)
|
||||
app.mockInput.pressKey("x")
|
||||
expect(app.renderer.hasSelection).toBeFalse()
|
||||
expect(app.terminal.hasSelection()).toBeFalse()
|
||||
expect(app.input).toEqual(["\x03", "x"])
|
||||
|
||||
app.mockInput.pressCtrlC()
|
||||
expect(app.input).toEqual(["\x03", "x", "\x03"])
|
||||
expect(app.writes).toEqual(["beta", "beta"])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("copy-on-select forwards Ctrl+C without recopying the selection", async () => {
|
||||
const app = await terminal(true)
|
||||
try {
|
||||
await app.mockMouse.drag(6, 0, 9, 0)
|
||||
expect(app.renderer.getSelection()?.getSelectedText()).toBe("beta")
|
||||
|
||||
app.mockInput.pressCtrlC()
|
||||
expect(app.writes).toEqual([])
|
||||
expect(app.input).toEqual(["\x03"])
|
||||
expect(app.renderer.hasSelection).toBeFalse()
|
||||
expect(app.terminal.hasSelection()).toBeFalse()
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test.each(["click", "empty drag"])("a terminal %s does not consume Ctrl+C or Escape", async (gesture) => {
|
||||
const app = await terminal()
|
||||
const select = () => (gesture === "click" ? app.mockMouse.click(6, 0) : app.mockMouse.drag(18, 0, 21, 0))
|
||||
try {
|
||||
await select()
|
||||
app.mockInput.pressCtrlC()
|
||||
expect(app.renderer.hasSelection).toBeFalse()
|
||||
expect(app.input).toEqual(["\x03"])
|
||||
|
||||
await select()
|
||||
app.mockInput.pressEscape()
|
||||
app.clock.advance(20)
|
||||
expect(app.renderer.hasSelection).toBeFalse()
|
||||
expect(app.input).toEqual(["\x03", "\x1b"])
|
||||
expect(app.writes).toEqual([])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test.each(["manual", "select"].flatMap((mode) => ["textarea", "input"].map((kind) => ({ mode, kind }))))(
|
||||
"$kind selections can be copied and edited in $mode mode",
|
||||
async (input) => {
|
||||
const app = await setup(input.mode === "select")
|
||||
const editor =
|
||||
input.kind === "input"
|
||||
? new InputRenderable(app.renderer, { width: 24, value: "draft" })
|
||||
: new TextareaRenderable(app.renderer, { width: 24, height: 3, initialValue: "draft" })
|
||||
app.renderer.root.add(editor)
|
||||
editor.focus()
|
||||
try {
|
||||
await app.renderOnce()
|
||||
app.mockInput.pressKey("END")
|
||||
app.mockInput.pressArrow("left", { shift: true })
|
||||
|
||||
app.mockInput.pressCtrlC()
|
||||
app.mockInput.pressKey("\x1b[1089::99;5u")
|
||||
expect(app.writes).toEqual(["t", "t"])
|
||||
expect(app.renderer.getSelection()?.getSelectedText()).toBe("t")
|
||||
expect(editor.plainText).toBe("draft")
|
||||
|
||||
app.mockInput.pressArrow("left", { shift: true })
|
||||
expect(app.renderer.getSelection()?.getSelectedText()).toBe("ft")
|
||||
app.mockInput.pressKey("x")
|
||||
expect(editor.plainText).toBe("drax")
|
||||
expect(app.renderer.hasSelection).toBeFalse()
|
||||
expect(app.writes).toEqual(["t", "t"])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
test("copy-on-select does not treat output selection as editor selection when an editor is focused", async () => {
|
||||
const app = await setup(true)
|
||||
const editor = new TextareaRenderable(app.renderer, { width: 24, height: 1, initialValue: "draft" })
|
||||
app.renderer.root.add(new TextRenderable(app.renderer, { width: 24, height: 1, content: "alpha beta gamma" }))
|
||||
app.renderer.root.add(editor)
|
||||
editor.focus()
|
||||
const forwarded: string[] = []
|
||||
app.renderer.keyInput.on("keypress", (event) => {
|
||||
if (!event.defaultPrevented) forwarded.push(event.name)
|
||||
})
|
||||
try {
|
||||
await app.renderOnce()
|
||||
await app.mockMouse.drag(6, 0, 9, 0)
|
||||
expect(app.renderer.currentFocusedEditor === editor).toBeTrue()
|
||||
expect(app.renderer.getSelection()?.getSelectedText()).toBe("beta")
|
||||
|
||||
app.mockInput.pressCtrlC()
|
||||
expect(app.writes).toEqual([])
|
||||
expect(forwarded).toEqual(["c"])
|
||||
expect(app.renderer.hasSelection).toBeFalse()
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
@@ -92,17 +92,17 @@ test.each(["word", "line"] as const)("copy-on-select copies a %s selection witho
|
||||
expect(value.writes).toEqual(["selected"])
|
||||
})
|
||||
|
||||
test("clears a click-only selection without copying", () => {
|
||||
test("ignores a click-only selection without copying or clearing", () => {
|
||||
const value = setup("x", true)
|
||||
expect(Selection.copy(value.renderer, value.toast, value.clipboard)).toBeFalse()
|
||||
expect(value.clears()).toBe(1)
|
||||
expect(value.clears()).toBe(0)
|
||||
expect(value.writes).toEqual([])
|
||||
})
|
||||
|
||||
test("clears an empty dragged selection without copying", () => {
|
||||
test("ignores an empty dragged selection without copying or clearing", () => {
|
||||
const value = setup("", false)
|
||||
expect(Selection.copy(value.renderer, value.toast, value.clipboard)).toBeFalse()
|
||||
expect(value.clears()).toBe(1)
|
||||
expect(value.clears()).toBe(0)
|
||||
expect(value.writes).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user