mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-29 21:16:10 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e506cfbde | ||
|
|
1f77f4a4ed | ||
|
|
e8fa7daed5 | ||
|
|
936c73b54d | ||
|
|
b9cb4fc36a | ||
|
|
a808a02f05 | ||
|
|
e142a783f7 | ||
|
|
201536f265 | ||
|
|
0d42e76006 | ||
|
|
ca47949475 |
+29
-11
@@ -214,22 +214,40 @@ the requests sent by code under test:
|
||||
import { Effect } from "effect"
|
||||
import { TestLLM } from "@opencode-ai/ai/testing"
|
||||
|
||||
const testLLM = TestLLM.layer({
|
||||
fallback: TestLLM.text("Hello from the test model", "text-1"),
|
||||
})
|
||||
|
||||
// TestLLM.clientLayer provides LLMClient.Service and consumes TestLLM.Service.
|
||||
const programWithTestClient = Effect.gen(function* () {
|
||||
const test = yield* TestLLM.Test
|
||||
yield* test.push(TestLLM.text("Hello from the test model", "text-1"))
|
||||
const result = yield* program
|
||||
const test = yield* TestLLM.Service
|
||||
console.log(test.requests)
|
||||
console.log(yield* test.requests())
|
||||
return result
|
||||
}).pipe(Effect.provide(TestLLM.clientLayer), Effect.provide(testLLM))
|
||||
}).pipe(Effect.provide(TestLLM.testLayer()))
|
||||
```
|
||||
|
||||
`TestLLM.push(...)` scripts one-shot responses, `TestLLM.always(...)` changes the fallback, and
|
||||
`TestLLM.wait(...)` lets concurrent tests wait until a request has arrived. Every received canonical request is
|
||||
available on the yielded `TestLLM.Service`.
|
||||
`testLayer()` provides the same object under `LLMClient.Service` and `TestLLM.Test`. Production consumes the
|
||||
normal client; tests use the additional controls. Each layer build has fresh state.
|
||||
|
||||
- `test.push(...)` queues one-shot responses in execution order. Each argument is one response.
|
||||
- `test.always(response)` installs a repeatable fallback. The layer's `fallback` option sets its initial value.
|
||||
- `test.serve(request => response)` installs a request-dependent fallback. `always` and `serve` replace each
|
||||
other without changing queued replies; queued replies take precedence.
|
||||
- `test.requests()` returns an array snapshot. `transformRequest` changes only the recorded observation;
|
||||
`serve` receives the original canonical request.
|
||||
- `test.wait(count)` waits for request arrivals, not output or completion, and supports concurrent waiters.
|
||||
- `test.gate()` returns a scoped gate with countable `started` notifications and a `release` Effect. Release
|
||||
unblocks all requests captured by that gate; closing its scope also releases it. Effect-aware test runners
|
||||
already provide Scope.
|
||||
|
||||
Constructing `stream()` or `generate()` does not record a request, invoke a responder, or consume a script.
|
||||
Each execution does. An exhausted queue without a fallback defects immediately rather than waiting for a
|
||||
future reply.
|
||||
|
||||
Responses remain canonical event arrays or arbitrary `Stream<LLMEvent, AIError>` values. The client consumes
|
||||
supplied streams directly, preserving failure identity, finalizers, incomplete output, and post-finish tails;
|
||||
it does not repair or truncate them.
|
||||
|
||||
The published legacy `Service`, `layer`, `clientLayer`, and module-level controls remain available as adapters
|
||||
over the same implementation, including the legacy live `requests` array. New tests should use `Test` and
|
||||
`testLayer`.
|
||||
|
||||
## Caching
|
||||
|
||||
|
||||
+103
-52
@@ -1,6 +1,6 @@
|
||||
export * as TestLLM from "./testing.js"
|
||||
|
||||
import { LLMClient, type Interface as LLMClientShape } from "./route/client.js"
|
||||
import { LLMClient } from "./route/client.js"
|
||||
import {
|
||||
LLMEvent,
|
||||
LLMResponse,
|
||||
@@ -16,13 +16,33 @@ export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, AIError>
|
||||
|
||||
export type Gate = Readonly<{ started: Effect.Effect<void>; release: Effect.Effect<void> }>
|
||||
|
||||
type ClientInterface = Context.Service.Shape<typeof LLMClient.Service>
|
||||
|
||||
export type Responder = (request: LLMRequest) => Response
|
||||
|
||||
export interface TestInterface extends ClientInterface {
|
||||
/** Returns a snapshot of requests observed at execution time. */
|
||||
readonly requests: () => Effect.Effect<readonly LLMRequest[]>
|
||||
readonly push: (...responses: readonly Response[]) => Effect.Effect<void>
|
||||
/** Replaces the fallback without changing queued responses. */
|
||||
readonly always: (response: Response) => Effect.Effect<void>
|
||||
/** Answers requests after the one-shot queue is exhausted; receives the original request. */
|
||||
readonly serve: (responder: Responder) => Effect.Effect<void>
|
||||
/** Waits for request arrivals, not output or completion. */
|
||||
readonly wait: (count: number) => Effect.Effect<void>
|
||||
readonly gate: () => Effect.Effect<Gate, never, Scope.Scope>
|
||||
}
|
||||
|
||||
export class Test extends Context.Service<Test, TestInterface>()("@opencode/ai/TestLLM/Test") {}
|
||||
|
||||
/** @deprecated Use TestInterface through Test and testLayer. */
|
||||
export interface Interface {
|
||||
readonly requests: LLMRequest[]
|
||||
readonly push: (...responses: readonly Response[]) => Effect.Effect<void>
|
||||
readonly always: (response: Response) => Effect.Effect<void>
|
||||
readonly wait: (count: number) => Effect.Effect<void>
|
||||
readonly gate: Effect.Effect<Gate, never, Scope.Scope>
|
||||
readonly client: LLMClientShape
|
||||
readonly client: ClientInterface
|
||||
}
|
||||
|
||||
export interface LayerOptions {
|
||||
@@ -31,6 +51,7 @@ export interface LayerOptions {
|
||||
readonly fallback?: Response
|
||||
}
|
||||
|
||||
/** @deprecated Use Test and testLayer for normal client methods and test controls. */
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ai/TestLLM") {}
|
||||
|
||||
export const complete = (
|
||||
@@ -80,59 +101,64 @@ export const hangAfter = (...events: readonly LLMEvent[]) => Stream.concat(Strea
|
||||
|
||||
const toStream = (response: Response) => (Stream.isStream(response) ? response : Stream.fromIterable(response))
|
||||
|
||||
export const layer = (options: LayerOptions = {}) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const requests: LLMRequest[] = []
|
||||
const responses: Response[] = []
|
||||
let started = Deferred.makeUnsafe<void>()
|
||||
let fallback = options.fallback
|
||||
let activeGate: { readonly started: Queue.Queue<void>; readonly release: Latch.Latch } | undefined
|
||||
const wait = (count: number): Effect.Effect<void> =>
|
||||
Effect.suspend(() =>
|
||||
requests.length >= count ? Effect.void : Deferred.await(started).pipe(Effect.andThen(wait(count))),
|
||||
)
|
||||
const make = (options: LayerOptions) =>
|
||||
Effect.sync(() => {
|
||||
const requests: LLMRequest[] = []
|
||||
const responses: Response[] = []
|
||||
let started = Deferred.makeUnsafe<void>()
|
||||
let fallback: Response | Responder | undefined = options.fallback
|
||||
let activeGate: { readonly started: Queue.Queue<void>; readonly release: Latch.Latch } | undefined
|
||||
const wait = (count: number): Effect.Effect<void> =>
|
||||
Effect.suspend(() =>
|
||||
requests.length >= count ? Effect.void : Deferred.await(started).pipe(Effect.andThen(wait(count))),
|
||||
)
|
||||
|
||||
const stream = ((request: LLMRequest) => {
|
||||
requests.push(options.transformRequest?.(request) ?? request)
|
||||
const stream: ClientInterface["stream"] = (request) =>
|
||||
Stream.suspend(() => {
|
||||
const count = requests.push(options.transformRequest?.(request) ?? request)
|
||||
const waiting = started
|
||||
started = Deferred.makeUnsafe()
|
||||
Deferred.doneUnsafe(waiting, Effect.void)
|
||||
const response = responses.shift() ?? fallback
|
||||
if (!response) return Stream.die(new Error(`TestLLM has no response for request ${requests.length}`))
|
||||
const streamed = toStream(response)
|
||||
const gate = activeGate
|
||||
if (!gate) return streamed
|
||||
return Stream.unwrap(
|
||||
Queue.offer(gate.started, undefined).pipe(Effect.andThen(gate.release.await), Effect.as(streamed)),
|
||||
)
|
||||
}) as LLMClientShape["stream"]
|
||||
const client = LLMClient.Service.of({
|
||||
stream,
|
||||
generate: (request) =>
|
||||
stream(request).pipe(
|
||||
Stream.runFold(LLMResponse.empty, LLMResponse.reduce),
|
||||
Effect.flatMap((state) => {
|
||||
const response = LLMResponse.complete(state)
|
||||
if (response) return Effect.succeed(response)
|
||||
return Effect.die("TestLLM response ended without a terminal finish event")
|
||||
}),
|
||||
),
|
||||
try {
|
||||
const response = responses.shift() ?? (typeof fallback === "function" ? fallback(request) : fallback)
|
||||
if (!response) return Stream.die(new Error(`TestLLM has no response for request ${count}`))
|
||||
const streamed = toStream(response)
|
||||
if (!gate) return streamed
|
||||
return Stream.unwrap(
|
||||
Queue.offer(gate.started, undefined).pipe(Effect.andThen(gate.release.await), Effect.as(streamed)),
|
||||
)
|
||||
} finally {
|
||||
// Waiters can resume synchronously; assign the reply and gate before notifying them.
|
||||
Deferred.doneUnsafe(waiting, Effect.void)
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
requests,
|
||||
push: (...input) =>
|
||||
Effect.sync(() => {
|
||||
responses.push(...input)
|
||||
const test = Test.of({
|
||||
stream,
|
||||
generate: (request) =>
|
||||
stream(request).pipe(
|
||||
Stream.runFold(LLMResponse.empty, LLMResponse.reduce),
|
||||
Effect.flatMap((state) => {
|
||||
const response = LLMResponse.complete(state)
|
||||
if (response) return Effect.succeed(response)
|
||||
return Effect.die("TestLLM response ended without a terminal finish event")
|
||||
}),
|
||||
always: (response) =>
|
||||
Effect.sync(() => {
|
||||
fallback = response
|
||||
}),
|
||||
wait,
|
||||
gate: Effect.gen(function* () {
|
||||
),
|
||||
requests: () => Effect.sync(() => [...requests]),
|
||||
push: (...input) =>
|
||||
Effect.sync(() => {
|
||||
responses.push(...input)
|
||||
}),
|
||||
always: (response) =>
|
||||
Effect.sync(() => {
|
||||
fallback = response
|
||||
}),
|
||||
serve: (responder) =>
|
||||
Effect.sync(() => {
|
||||
fallback = responder
|
||||
}),
|
||||
wait,
|
||||
gate: () =>
|
||||
Effect.gen(function* () {
|
||||
const gate = {
|
||||
started: yield* Effect.acquireRelease(Queue.unbounded<void>(), Queue.shutdown),
|
||||
release: yield* Latch.make(),
|
||||
@@ -147,11 +173,36 @@ export const layer = (options: LayerOptions = {}) =>
|
||||
release,
|
||||
}
|
||||
}),
|
||||
client,
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
return { test, requests }
|
||||
})
|
||||
|
||||
/** Provides one shared implementation under the normal client and test-control tags. */
|
||||
export const testLayer = (options: LayerOptions = {}) =>
|
||||
Layer.effectContext(
|
||||
Effect.map(make(options), (implementation) =>
|
||||
Context.make(LLMClient.Service, implementation.test).pipe(Context.add(Test, implementation.test)),
|
||||
),
|
||||
)
|
||||
|
||||
/** @deprecated Use testLayer; retained for published callers of the legacy control interface. */
|
||||
export const layer = (options: LayerOptions = {}) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.map(make(options), (implementation) =>
|
||||
Service.of({
|
||||
requests: implementation.requests,
|
||||
push: implementation.test.push,
|
||||
always: implementation.test.always,
|
||||
wait: implementation.test.wait,
|
||||
gate: implementation.test.gate(),
|
||||
client: implementation.test,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
/** @deprecated testLayer provides LLMClient.Service directly. */
|
||||
export const clientLayer = Layer.effect(
|
||||
LLMClient.Service,
|
||||
Effect.map(Service, (service) => service.client),
|
||||
|
||||
@@ -574,6 +574,7 @@ describe("WebSocket channel execution", () => {
|
||||
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_item.added", item: { type: "message", id: "msg_1" } }),
|
||||
JSON.stringify({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
|
||||
JSON.stringify({ type: "response.completed", response: { id: "resp_1" } }),
|
||||
]
|
||||
|
||||
@@ -32,6 +32,8 @@ describe("public exports", () => {
|
||||
expect(Provider.make).toBeFunction()
|
||||
expect(ProviderSubpath.make).toBe(Provider.make)
|
||||
expect(TestLLM.layer).toBeFunction()
|
||||
expect(TestLLM.testLayer).toBeFunction()
|
||||
expect(TestLLM.Test.of).toBeFunction()
|
||||
})
|
||||
|
||||
test("route barrel exposes route-authoring APIs", () => {
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.anthropic.com/v1/messages",
|
||||
"url": "https://api.anthropic.com/v1/messages?beta=true",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
@@ -29,7 +29,7 @@
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.anthropic.com/v1/messages",
|
||||
"url": "https://api.anthropic.com/v1/messages?beta=true",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.anthropic.com/v1/messages",
|
||||
"url": "https://api.anthropic.com/v1/messages?beta=true",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
@@ -29,7 +29,7 @@
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.anthropic.com/v1/messages",
|
||||
"url": "https://api.anthropic.com/v1/messages?beta=true",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
|
||||
Vendored
+1
-1
@@ -24,7 +24,7 @@
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.anthropic.com/v1/messages",
|
||||
"url": "https://api.anthropic.com/v1/messages?beta=true",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":1536,\"reasoning\":{\"max_tokens\":1024}}"
|
||||
"body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning\":{\"max_tokens\":1024},\"max_completion_tokens\":1536,\"store\":false,\"usage\":{\"include\":true}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
@@ -41,7 +41,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":\"Sure! Let me check the weather in Paris for you right now!\",\"tool_calls\":[{\"id\":\"toolu_01PaChhcyw3yu2P2bDS2bgAA\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}],\"reasoning\":\"The user wants to know the weather in Paris. I'll use the get_weather tool.\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"The user wants to know the weather in Paris. I'll use the get_weather tool.\",\"format\":\"anthropic-claude-v1\",\"index\":0,\"signature\":\"ErkCCosBCA8YAipAjKnRKpxkZ4eHrMPJ63IWEOYPSzb+XSHyG+vLK+2ks2O9T4N9M37Xn2kausQSH1rfsrdmKxgUlBg6yUFRgMVR7DIRY2xhdWRlLXNvbm5ldC00LTY4AEIIdGhpbmtpbmdaJDRjMGYwNDZmLTI1ZmQtNDVmYi1iZmIzLWEwOGE4ZTI0OWNhNxIMNFb5O6pb4nX0HojdGgyL5h+CAIpsxpdM1QgiMGm/i3ST6F5mAhxB+Uez0Cm95ra9yvQkrzHaA/AmWoXpdmPlczSn1S1RDk2IqeA57Spbf7JT44jygtLQt6yZmGzoTBHn3VkwaNZsuuAtbdo4B5QJXooa/AoKKs54QZ2kfS640vsv5flQVCg7CoQCFuLKjIeLMO7MnxVyuskXJr1DgesTa7I0ScF53U9JGhgB\"}]},{\"role\":\"tool\",\"tool_call_id\":\"toolu_01PaChhcyw3yu2P2bDS2bgAA\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":1536,\"reasoning\":{\"max_tokens\":1024}}"
|
||||
"body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":\"Sure! Let me check the weather in Paris for you right now!\",\"tool_calls\":[{\"id\":\"toolu_01PaChhcyw3yu2P2bDS2bgAA\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}],\"reasoning\":\"The user wants to know the weather in Paris. I'll use the get_weather tool.\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"The user wants to know the weather in Paris. I'll use the get_weather tool.\",\"format\":\"anthropic-claude-v1\",\"index\":0,\"signature\":\"ErkCCosBCA8YAipAjKnRKpxkZ4eHrMPJ63IWEOYPSzb+XSHyG+vLK+2ks2O9T4N9M37Xn2kausQSH1rfsrdmKxgUlBg6yUFRgMVR7DIRY2xhdWRlLXNvbm5ldC00LTY4AEIIdGhpbmtpbmdaJDRjMGYwNDZmLTI1ZmQtNDVmYi1iZmIzLWEwOGE4ZTI0OWNhNxIMNFb5O6pb4nX0HojdGgyL5h+CAIpsxpdM1QgiMGm/i3ST6F5mAhxB+Uez0Cm95ra9yvQkrzHaA/AmWoXpdmPlczSn1S1RDk2IqeA57Spbf7JT44jygtLQt6yZmGzoTBHn3VkwaNZsuuAtbdo4B5QJXooa/AoKKs54QZ2kfS640vsv5flQVCg7CoQCFuLKjIeLMO7MnxVyuskXJr1DgesTa7I0ScF53U9JGhgB\"}]},{\"role\":\"tool\",\"tool_call_id\":\"toolu_01PaChhcyw3yu2P2bDS2bgAA\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning\":{\"max_tokens\":1024},\"max_completion_tokens\":1536,\"store\":false,\"usage\":{\"include\":true}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":\"Think through the arithmetic, then reply with only the final integer.\"},{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219?\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":1536,\"temperature\":0,\"reasoning\":{\"max_tokens\":1024}}"
|
||||
"body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":\"Think through the arithmetic, then reply with only the final integer.\"},{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219?\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"temperature\":0,\"reasoning\":{\"max_tokens\":1024},\"max_completion_tokens\":1536,\"store\":false,\"usage\":{\"include\":true}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.anthropic.com/v1/messages",
|
||||
"url": "https://api.anthropic.com/v1/messages?beta=true",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"input\":{}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_pdf_1\",\"content\":[{\"type\":\"text\",\"text\":\"PDF read successfully\"},{\"type\":\"document\",\"source\":{\"type\":\"base64\",\"media_type\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}}]}]}],\"tools\":[{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"input_schema\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
|
||||
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"input\":{}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_pdf_1\",\"content\":[{\"type\":\"text\",\"text\":\"PDF read successfully\"},{\"type\":\"document\",\"source\":{\"type\":\"base64\",\"media_type\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"},\"title\":\"verification.pdf\"}]}]}],\"tools\":[{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"input_schema\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.anthropic.com/v1/messages",
|
||||
"url": "https://api.anthropic.com/v1/messages?beta=true",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"document\",\"source\":{\"type\":\"base64\",\"media_type\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}},{\"type\":\"text\",\"text\":\"Return only the verification code from the PDF.\"}]}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
|
||||
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"document\",\"source\":{\"type\":\"base64\",\"media_type\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"},\"title\":\"verification.pdf\"},{\"type\":\"text\",\"text\":\"Return only the verification code from the PDF.\"}]}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { model } from "@opencode-ai/ai/providers/openai"
|
||||
import { LLM } from "../src/index.js"
|
||||
import { Endpoint } from "../src/route/endpoint.js"
|
||||
|
||||
describe("provider package entrypoints", () => {
|
||||
test("semantic API aliases expose the same contract", async () => {
|
||||
@@ -36,7 +38,8 @@ describe("provider package entrypoints", () => {
|
||||
expect(modules[0].model).toBe(modules[1].model)
|
||||
expect(modules[8].model).toBe(modules[9].model)
|
||||
expect(modules[12].model).toBe(modules[13].model)
|
||||
expect(modules[19].model).toBe(modules[20].model)
|
||||
expect(modules[19].model).toBe(modules[21].model)
|
||||
expect(modules[19].model).not.toBe(modules[20].model)
|
||||
})
|
||||
|
||||
test("maps DeepInfra package settings onto its native executable model", async () => {
|
||||
@@ -139,8 +142,10 @@ describe("provider package entrypoints", () => {
|
||||
expect(selected.route.id).toBe("anthropic-messages")
|
||||
expect(selected.route.endpoint).toMatchObject({
|
||||
baseURL: "https://messages.example.test/v1",
|
||||
path: "/messages",
|
||||
})
|
||||
expect(
|
||||
Endpoint.render(selected.route.endpoint, { request: LLM.request({ model: selected }), body: {} }).toString(),
|
||||
).toBe("https://messages.example.test/v1/messages")
|
||||
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||
expect(selected.route.defaults.http?.body).toEqual({ metadata: { user_id: "user_1" } })
|
||||
expect(selected.route.defaults.providerOptions).toEqual({ effort: "low" })
|
||||
|
||||
@@ -305,9 +305,11 @@ describe("OpenRouter", () => {
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
content: "",
|
||||
reasoning: "Thinking",
|
||||
reasoning_content: undefined,
|
||||
reasoning_details: details,
|
||||
reasoning_text: undefined,
|
||||
},
|
||||
])
|
||||
}),
|
||||
@@ -335,7 +337,14 @@ describe("OpenRouter", () => {
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "assistant", content: null, reasoning: "Thinking", reasoning_details: details },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "Thinking",
|
||||
reasoning_content: undefined,
|
||||
reasoning_details: details,
|
||||
reasoning_text: undefined,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -361,7 +370,14 @@ describe("OpenRouter", () => {
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "assistant", content: null, reasoning: "AB", reasoning_details: details },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "AB",
|
||||
reasoning_content: undefined,
|
||||
reasoning_details: details,
|
||||
reasoning_text: undefined,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -376,7 +392,16 @@ describe("OpenRouter", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([{ role: "assistant", content: null }])
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: undefined,
|
||||
reasoning_content: undefined,
|
||||
reasoning_details: undefined,
|
||||
reasoning_text: undefined,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { AIError, LanguageModel, LLM, LLMClient, LLMEvent, LLMRequest, RateLimitError } from "../src/index.js"
|
||||
import { OpenAIChat } from "../src/protocols/openai-chat.js"
|
||||
import { TestLLM } from "../src/testing.js"
|
||||
import { Effect, Fiber, Latch, Stream } from "effect"
|
||||
import { testEffect } from "./lib/effect.js"
|
||||
|
||||
const request = LLM.request({
|
||||
model: LanguageModel.make({ id: "fictional-model", provider: "fixture", route: OpenAIChat.route }),
|
||||
prompt: "Say hello",
|
||||
})
|
||||
const legacy = testEffect(TestLLM.layer())
|
||||
const it = testEffect(TestLLM.testLayer())
|
||||
|
||||
describe("TestLLM legacy client", () => {
|
||||
legacy.effect("does not observe requests or consume responses until execution", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Service
|
||||
yield* llm.push(TestLLM.text("first", "first"), TestLLM.text("second", "second"))
|
||||
|
||||
llm.client.stream(request)
|
||||
llm.client.generate(request)
|
||||
expect(llm.requests).toEqual([])
|
||||
|
||||
expect((yield* llm.client.generate(request)).text).toBe("first")
|
||||
expect((yield* llm.client.generate(request)).text).toBe("second")
|
||||
expect(llm.requests).toEqual([request, request])
|
||||
}),
|
||||
)
|
||||
|
||||
legacy.effect("assigns and records a fresh response for each execution", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Service
|
||||
yield* llm.push(
|
||||
TestLLM.text("first", "first"),
|
||||
TestLLM.text("second", "second"),
|
||||
TestLLM.text("third", "third"),
|
||||
TestLLM.text("fourth", "fourth"),
|
||||
)
|
||||
const stream = llm.client.stream(request)
|
||||
const generate = llm.client.generate(request)
|
||||
|
||||
expect(yield* Stream.runCollect(stream)).toEqual(TestLLM.text("first", "first"))
|
||||
expect(yield* Stream.runCollect(stream)).toEqual(TestLLM.text("second", "second"))
|
||||
expect((yield* generate).text).toBe("third")
|
||||
expect((yield* generate).text).toBe("fourth")
|
||||
expect(llm.requests).toEqual([request, request, request, request])
|
||||
}),
|
||||
)
|
||||
|
||||
legacy.effect("keeps module-level controls and clientLayer on the same backing state", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Service
|
||||
const requests = llm.requests
|
||||
yield* TestLLM.push(TestLLM.text("queued", "queued"))
|
||||
yield* TestLLM.always(TestLLM.text("fallback", "fallback"))
|
||||
expect((yield* LLMClient.generate(request).pipe(Effect.provide(TestLLM.clientLayer))).text).toBe("queued")
|
||||
yield* TestLLM.wait(1)
|
||||
expect(requests).toEqual([request])
|
||||
requests.length = 0
|
||||
expect((yield* llm.client.generate(request)).text).toBe("fallback")
|
||||
expect(llm.requests).toBe(requests)
|
||||
expect(requests).toEqual([request])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("TestLLM first-class client", () => {
|
||||
it.effect("provides the same object under normal and test tags with snapshot observations", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
const client = yield* LLMClient.Service
|
||||
expect(client).toBe(llm)
|
||||
const before = yield* llm.requests()
|
||||
yield* llm.push(TestLLM.text("hello", "answer"))
|
||||
const generate = client.generate(request)
|
||||
client.stream(request)
|
||||
expect(yield* llm.requests()).toEqual([])
|
||||
|
||||
expect((yield* generate).text).toBe("hello")
|
||||
expect(before).toEqual([])
|
||||
expect(yield* llm.requests()).toEqual([request])
|
||||
expect(yield* llm.requests()).not.toBe(yield* llm.requests())
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("prioritizes queued replies over request-dependent and constant fallbacks", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
const served: LLMRequest[] = []
|
||||
yield* llm.always(TestLLM.text("old fallback", "old"))
|
||||
yield* llm.push(TestLLM.text("first", "first"), TestLLM.text("second", "second"))
|
||||
yield* llm.serve((request) => {
|
||||
served.push(request)
|
||||
return TestLLM.text(request.promptCacheKey ?? "default", "served")
|
||||
})
|
||||
|
||||
expect((yield* LLMClient.generate(request)).text).toBe("first")
|
||||
expect((yield* LLMClient.generate(request)).text).toBe("second")
|
||||
expect(served).toEqual([])
|
||||
const selected = LLMRequest.update(request, { promptCacheKey: "selected" })
|
||||
expect((yield* LLMClient.generate(selected)).text).toBe("selected")
|
||||
expect((yield* LLMClient.generate(request)).text).toBe("default")
|
||||
expect(served).toEqual([selected, request])
|
||||
|
||||
yield* llm.push(TestLLM.text("queued again", "queued"))
|
||||
yield* llm.always(TestLLM.text("constant", "constant"))
|
||||
expect((yield* LLMClient.generate(request)).text).toBe("queued again")
|
||||
expect((yield* LLMClient.generate(request)).text).toBe("constant")
|
||||
expect((yield* LLMClient.generate(request)).text).toBe("constant")
|
||||
expect(served).toEqual([selected, request])
|
||||
}),
|
||||
)
|
||||
|
||||
testEffect(
|
||||
TestLLM.testLayer({
|
||||
transformRequest: (request) => LLMRequest.update(request, { promptCacheKey: "observation" }),
|
||||
}),
|
||||
).effect("transforms observations without changing the request passed to the responder", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
yield* llm.serve((input) => {
|
||||
expect(input).toBe(request)
|
||||
return TestLLM.text("original", "answer")
|
||||
})
|
||||
const generate = llm.generate(request)
|
||||
expect(yield* llm.requests()).toEqual([])
|
||||
expect((yield* generate).text).toBe("original")
|
||||
expect(yield* llm.requests()).toEqual([LLMRequest.update(request, { promptCacheKey: "observation" })])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("broadcasts request-arrival waits and satisfies waits registered afterward", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
yield* llm.always(TestLLM.stop())
|
||||
const first = yield* llm.wait(2).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
const second = yield* llm.wait(2).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* llm.generate(request)
|
||||
expect(first.pollUnsafe()).toBeUndefined()
|
||||
expect(second.pollUnsafe()).toBeUndefined()
|
||||
yield* llm.generate(request)
|
||||
yield* Fiber.join(first)
|
||||
yield* Fiber.join(second)
|
||||
yield* llm.wait(2)
|
||||
expect(yield* llm.requests()).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
;(["queued", "served"] as const).forEach((mode) => {
|
||||
it.effect(`assigns ${mode} replies before resuming request-arrival continuations`, () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
const responses = [TestLLM.text("first", "first"), TestLLM.text("second", "second")]
|
||||
yield* mode === "queued" ? llm.push(...responses) : llm.serve(() => responses.shift() ?? [])
|
||||
const later = yield* llm
|
||||
.wait(1)
|
||||
.pipe(Effect.andThen(llm.generate(request)), Effect.forkChild({ startImmediately: true }))
|
||||
|
||||
expect((yield* llm.generate(request)).text).toBe("first")
|
||||
expect((yield* Fiber.join(later)).text).toBe("second")
|
||||
expect(yield* llm.requests()).toEqual([request, request])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("notifies arrival waiters even when the responder defects", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
const defect = new Error("Broken fixture responder")
|
||||
yield* llm.serve(() => {
|
||||
throw defect
|
||||
})
|
||||
const waiter = yield* llm.wait(1).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
expect(yield* llm.generate(request).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
|
||||
yield* Fiber.join(waiter)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("builds independent state even when the same layer is provided concurrently", () => {
|
||||
const layer = TestLLM.testLayer()
|
||||
const run = Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
expect(yield* llm.requests()).toEqual([])
|
||||
yield* llm.push(TestLLM.text("one", "answer"))
|
||||
expect((yield* LLMClient.generate(request)).text).toBe("one")
|
||||
return yield* llm.requests()
|
||||
}).pipe(Effect.provide(layer))
|
||||
return Effect.gen(function* () {
|
||||
expect(yield* Effect.all([run, run], { concurrency: "unbounded" })).toEqual([[request], [request]])
|
||||
})
|
||||
})
|
||||
|
||||
it.effect("counts concurrent starts on one gate without serializing their response assignment", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
yield* llm.push(TestLLM.text("first", "first"), TestLLM.text("second", "second"))
|
||||
const generate = llm.generate(request)
|
||||
const gate = yield* llm.gate()
|
||||
const first = yield* generate.pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* gate.started
|
||||
const second = yield* generate.pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* gate.started
|
||||
yield* llm.wait(2)
|
||||
expect(first.pollUnsafe()).toBeUndefined()
|
||||
expect(second.pollUnsafe()).toBeUndefined()
|
||||
yield* gate.release
|
||||
expect((yield* Fiber.join(first)).text).toBe("first")
|
||||
expect((yield* Fiber.join(second)).text).toBe("second")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not clear a replacement gate when the previous gate is released", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
yield* llm.always(TestLLM.stop())
|
||||
const previous = yield* llm.gate()
|
||||
const first = yield* llm.generate(request).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* previous.started
|
||||
const next = yield* llm.gate()
|
||||
yield* previous.release
|
||||
yield* Fiber.join(first)
|
||||
|
||||
const second = yield* llm.generate(request).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* next.started
|
||||
expect(second.pollUnsafe()).toBeUndefined()
|
||||
yield* next.release
|
||||
yield* Fiber.join(second)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("releases a gate when its deliberately narrower scope closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
yield* llm.always(TestLLM.stop())
|
||||
// Only the gate is scoped here; its release must happen before the test ends.
|
||||
const run = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const gate = yield* llm.gate()
|
||||
const run = yield* llm.generate(request).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* gate.started
|
||||
return run
|
||||
}),
|
||||
)
|
||||
yield* Fiber.join(run)
|
||||
yield* llm.generate(request)
|
||||
expect(yield* llm.requests()).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps an executed response consumed after interruption and permits later requests", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
yield* llm.push(TestLLM.text("interrupted", "first"), TestLLM.text("next", "second"))
|
||||
const gate = yield* llm.gate()
|
||||
const run = yield* llm.generate(request).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* gate.started
|
||||
yield* Fiber.interrupt(run)
|
||||
yield* gate.release
|
||||
expect((yield* llm.generate(request)).text).toBe("next")
|
||||
expect(yield* llm.requests()).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("consumes a supplied stream's post-finish tail and runs its finalizer", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
const tail = yield* Latch.make()
|
||||
const release = yield* Latch.make()
|
||||
const finalized = yield* Latch.make()
|
||||
yield* llm.push(
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.addFinalizer(() => finalized.open)
|
||||
return Stream.fromIterable(TestLLM.text("complete", "answer")).pipe(
|
||||
Stream.concat(Stream.fromEffect(tail.open.pipe(Effect.andThen(release.await))).pipe(Stream.drain)),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
const run = yield* llm.generate(request).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* tail.await
|
||||
expect(run.pollUnsafe()).toBeUndefined()
|
||||
yield* release.open
|
||||
expect((yield* Fiber.join(run)).text).toBe("complete")
|
||||
yield* finalized.await
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves irregular events, ordinary EOF, typed failures, and responder defects", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
const events = [LLMEvent.textDelta({ id: "without-start", text: "partial" })]
|
||||
yield* llm.push(events, [])
|
||||
expect(yield* Stream.runCollect(llm.stream(request))).toEqual(events)
|
||||
expect(yield* Stream.runCollect(llm.stream(request))).toEqual([])
|
||||
|
||||
const failure = new AIError({ reason: new RateLimitError({ message: "Try later" }) })
|
||||
const observed: LLMEvent[] = []
|
||||
yield* llm.serve(() => TestLLM.failAfter(failure, ...events))
|
||||
expect(
|
||||
yield* llm.stream(request).pipe(
|
||||
Stream.runForEach((event) => Effect.sync(() => observed.push(event))),
|
||||
Effect.flip,
|
||||
),
|
||||
).toBe(failure)
|
||||
expect(observed).toEqual(events)
|
||||
expect(yield* llm.generate(request).pipe(Effect.flip)).toBe(failure)
|
||||
|
||||
const defect = new Error("Broken fixture responder")
|
||||
yield* llm.serve(() => {
|
||||
throw defect
|
||||
})
|
||||
expect(yield* llm.generate(request).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
|
||||
yield* llm.push(TestLLM.text("recovered", "answer"))
|
||||
expect((yield* llm.generate(request)).text).toBe("recovered")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("defects on unexpected requests instead of waiting for a late script", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
const defect = yield* llm.generate(request).pipe(Effect.catchDefect(Effect.succeed))
|
||||
expect(defect).toBeInstanceOf(Error)
|
||||
if (!(defect instanceof Error)) return
|
||||
expect(defect.message).toBe("TestLLM has no response for request 1")
|
||||
expect(yield* llm.requests()).toEqual([request])
|
||||
yield* llm.push(TestLLM.stop())
|
||||
yield* llm.generate(request)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -5,5 +5,5 @@
|
||||
"noEmit": true,
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["test/**/*.types.ts"]
|
||||
"include": ["test/**/*.types.ts", "test/testing.test.ts"]
|
||||
}
|
||||
|
||||
@@ -1639,6 +1639,23 @@ export interface PtyApi<E = never> {
|
||||
readonly connect: { readonly token: PtyConnectTokenOperation<E> }
|
||||
}
|
||||
|
||||
export type ExperimentalPersistentPtyReadInput = { readonly sessionID: Session.ID; readonly lines?: number | undefined }
|
||||
export type ExperimentalPersistentPtyReadOutput = {
|
||||
readonly ptyID: Pty.ID
|
||||
readonly title: string
|
||||
readonly cwd: string
|
||||
readonly foregroundProcess: string | null
|
||||
readonly screen: {
|
||||
readonly text: string
|
||||
readonly cols: number
|
||||
readonly rows: number
|
||||
readonly cursor: { readonly x: number; readonly y: number }
|
||||
}
|
||||
} | null
|
||||
export type ExperimentalPersistentPtyReadOperation<E = never> = (
|
||||
input: ExperimentalPersistentPtyReadInput,
|
||||
) => Effect.Effect<ExperimentalPersistentPtyReadOutput, E>
|
||||
|
||||
export type ExperimentalPersistentPtyListInput = { readonly sessionID: Session.ID }
|
||||
export type ExperimentalPersistentPtyListOutput = ReadonlyArray<{
|
||||
readonly id: Pty.ID
|
||||
@@ -1787,6 +1804,7 @@ export type ExperimentalPersistentPtyConnectTokenOperation<E = never> = (
|
||||
|
||||
export interface ExperimentalApi<E = never> {
|
||||
readonly persistentPty: {
|
||||
readonly read: ExperimentalPersistentPtyReadOperation<E>
|
||||
readonly list: ExperimentalPersistentPtyListOperation<E>
|
||||
readonly create: ExperimentalPersistentPtyCreateOperation<E>
|
||||
readonly shutdown: ExperimentalPersistentPtyShutdownOperation<E>
|
||||
|
||||
@@ -198,6 +198,8 @@ import type {
|
||||
PtyRemoveOutput,
|
||||
PtyConnectTokenInput,
|
||||
PtyConnectTokenOutput,
|
||||
ExperimentalPersistentPtyReadInput,
|
||||
ExperimentalPersistentPtyReadOutput,
|
||||
ExperimentalPersistentPtyListInput,
|
||||
ExperimentalPersistentPtyListOutput,
|
||||
ExperimentalPersistentPtyCreateInput,
|
||||
@@ -1234,6 +1236,15 @@ const adaptGroupPty = (raw: RawClient["server.pty"]) => ({
|
||||
connect: { token: EndpointPtyConnectToken(raw) },
|
||||
})
|
||||
|
||||
const EndpointExperimentalPersistentPtyRead =
|
||||
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyReadInput) =>
|
||||
preserveEffect<ExperimentalPersistentPtyReadOutput>()(
|
||||
raw["persistentPty.read"]({ params: { sessionID: input["sessionID"] }, query: { lines: input["lines"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointExperimentalPersistentPtyList =
|
||||
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyListInput) =>
|
||||
preserveEffect<ExperimentalPersistentPtyListOutput>()(
|
||||
@@ -1322,6 +1333,7 @@ const EndpointExperimentalPersistentPtyConnectToken =
|
||||
|
||||
const adaptGroupExperimental = (raw: RawClient["server.experimental"]) => ({
|
||||
persistentPty: {
|
||||
read: EndpointExperimentalPersistentPtyRead(raw),
|
||||
list: EndpointExperimentalPersistentPtyList(raw),
|
||||
create: EndpointExperimentalPersistentPtyCreate(raw),
|
||||
shutdown: EndpointExperimentalPersistentPtyShutdown(raw),
|
||||
|
||||
@@ -194,6 +194,8 @@ import type {
|
||||
PtyRemoveOutput,
|
||||
PtyConnectTokenInput,
|
||||
PtyConnectTokenOutput,
|
||||
ExperimentalPersistentPtyReadInput,
|
||||
ExperimentalPersistentPtyReadOutput,
|
||||
ExperimentalPersistentPtyListInput,
|
||||
ExperimentalPersistentPtyListOutput,
|
||||
ExperimentalPersistentPtyCreateInput,
|
||||
@@ -1684,6 +1686,18 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
experimental: {
|
||||
persistentPty: {
|
||||
read: (input: ExperimentalPersistentPtyReadInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ExperimentalPersistentPtyReadOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/terminal/read`,
|
||||
query: { lines: input["lines"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [503, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
list: (input: ExperimentalPersistentPtyListInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ExperimentalPersistentPtyListOutput }>(
|
||||
{
|
||||
|
||||
@@ -378,6 +378,14 @@ export type SessionStatus =
|
||||
|
||||
export type PtyTicketConnectToken = { ticket: string; expires_in: number }
|
||||
|
||||
export type PersistentPtyReadResult = {
|
||||
ptyID: string
|
||||
title: string
|
||||
cwd: string
|
||||
foregroundProcess: string | null
|
||||
screen: { text: string; cols: number; rows: number; cursor: { x: number; y: number } }
|
||||
}
|
||||
|
||||
export type PersistentPtyHandoff = { directory: string; instanceID: string; ticket: string; expiresAt: number }
|
||||
|
||||
export type ShellInfo1 = {
|
||||
@@ -5769,6 +5777,13 @@ export type PtyConnectTokenOutput = {
|
||||
data: PtyTicketConnectToken
|
||||
}
|
||||
|
||||
export type ExperimentalPersistentPtyReadInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly lines?: { readonly lines?: number | undefined }["lines"]
|
||||
}
|
||||
|
||||
export type ExperimentalPersistentPtyReadOutput = { data: PersistentPtyReadResult | null }["data"]
|
||||
|
||||
export type ExperimentalPersistentPtyListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type ExperimentalPersistentPtyListOutput = { data: Array<PersistentPtyInfo> }["data"]
|
||||
|
||||
@@ -215,8 +215,10 @@ export function createData(config: CreateDataInput) {
|
||||
)
|
||||
const messageIndex = new Map<string, Map<string, number>>()
|
||||
const sync = createSync()
|
||||
let activeUpdates: Map<string, DataSessionStatus | undefined> | undefined
|
||||
|
||||
function setSessionActive(sessionID: string, status: DataSessionStatus) {
|
||||
activeUpdates?.set(sessionID, status)
|
||||
setStore("session", "active", sessionID, status)
|
||||
}
|
||||
|
||||
@@ -473,6 +475,7 @@ export function createData(config: CreateDataInput) {
|
||||
}
|
||||
|
||||
function removeSession(sessionID: string) {
|
||||
activeUpdates?.set(sessionID, undefined)
|
||||
store.session.pending[sessionID]?.forEach((item) => outbox.delete(item.id))
|
||||
messageIndex.delete(sessionID)
|
||||
sync.invalidate(`session:${sessionID}`)
|
||||
@@ -504,17 +507,25 @@ export function createData(config: CreateDataInput) {
|
||||
|
||||
function handleEvent(event: OpenCodeEvent) {
|
||||
switch (event.type) {
|
||||
case "server.connected":
|
||||
case "server.connected": {
|
||||
const updates = new Map<string, DataSessionStatus | undefined>()
|
||||
activeUpdates = updates
|
||||
void api()
|
||||
.session.active()
|
||||
.then((active) => {
|
||||
setStore(
|
||||
"session",
|
||||
"active",
|
||||
reconcile(Object.fromEntries(Object.keys(active).map((sessionID) => [sessionID, "running" as const]))),
|
||||
)
|
||||
if (activeUpdates !== updates) return
|
||||
// Lifecycle events received during hydration supersede the snapshot.
|
||||
const snapshot = new Map<string, DataSessionStatus>(Object.keys(active).map((id) => [id, "running"]))
|
||||
updates.forEach((status, id) => {
|
||||
if (status === undefined) return snapshot.delete(id)
|
||||
snapshot.set(id, status)
|
||||
})
|
||||
activeUpdates = undefined
|
||||
setStore("session", "active", reconcile(Object.fromEntries(snapshot)))
|
||||
})
|
||||
.catch(() => {
|
||||
if (activeUpdates === updates) activeUpdates = undefined
|
||||
})
|
||||
.catch(() => undefined)
|
||||
void api()
|
||||
.location.get({ location: locationQuery(defaultLocation()) })
|
||||
.then((location) => {
|
||||
@@ -525,6 +536,7 @@ export function createData(config: CreateDataInput) {
|
||||
void result.location.vcs.sync().catch((error) => console.error("Failed to preload VCS info", error))
|
||||
void result.project.sync().catch((error) => console.error("Failed to preload projects", error))
|
||||
return
|
||||
}
|
||||
case "project.updated":
|
||||
setStore("project", "info", event.data.id, reconcile(event.data))
|
||||
return
|
||||
|
||||
@@ -50,6 +50,8 @@ test("exposes every standard HTTP API group", () => {
|
||||
expect(Object.keys(client.vcs)).toEqual(["get", "status", "branches", "diff"])
|
||||
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove", "connect"])
|
||||
expect(Object.keys(client.pty.connect)).toEqual(["token"])
|
||||
expect(Object.keys(client.experimental)).toEqual(["persistentPty"])
|
||||
expect(client.experimental.persistentPty.read).toBeFunction()
|
||||
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
|
||||
expect(Object.keys(client.project)).toEqual(["list", "update", "current"])
|
||||
expect(Object.keys(client.worktree)).toEqual(["list", "create", "remove", "refresh"])
|
||||
|
||||
@@ -485,6 +485,102 @@ test("preserves assistant content replacement events across an active message re
|
||||
}
|
||||
})
|
||||
|
||||
test.each([
|
||||
"session.execution.succeeded",
|
||||
"session.execution.failed",
|
||||
"session.execution.interrupted",
|
||||
"session.execution.started",
|
||||
"session.deleted",
|
||||
] as const)("preserves %s activity when an older snapshot arrives", async (type) => {
|
||||
const release = Promise.withResolvers<void>()
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const setup = activityFixture(async () => {
|
||||
requested.resolve()
|
||||
await release.promise
|
||||
return Response.json({
|
||||
data: {
|
||||
...(type === "session.execution.started" ? {} : { ses_refresh: { type: "running" } }),
|
||||
ses_hydrated: { type: "running" },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
if (type !== "session.execution.started") setup.data.session.setStatus("ses_refresh", "running")
|
||||
setup.emit({ type: "server.connected", data: {} })
|
||||
await requested.promise
|
||||
setup.emit({
|
||||
id: "evt_activity",
|
||||
created: 2,
|
||||
type,
|
||||
durable: { aggregateID: "ses_refresh", seq: 2, version: 1 },
|
||||
data: { sessionID: "ses_refresh", reason: "user" },
|
||||
})
|
||||
expect(setup.data.session.status("ses_refresh")).toBe(type === "session.execution.started" ? "running" : "idle")
|
||||
release.resolve()
|
||||
await wait(() => setup.data.session.status("ses_hydrated") === "running")
|
||||
expect(setup.data.session.status("ses_refresh")).toBe(type === "session.execution.started" ? "running" : "idle")
|
||||
} finally {
|
||||
release.resolve()
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("ignores activity snapshots from an older connection", async () => {
|
||||
const reads: ReturnType<typeof Promise.withResolvers<Response>>[] = []
|
||||
const setup = activityFixture(() => {
|
||||
const read = Promise.withResolvers<Response>()
|
||||
reads.push(read)
|
||||
return read.promise
|
||||
})
|
||||
|
||||
try {
|
||||
setup.emit({ type: "server.connected", data: {} })
|
||||
await wait(() => reads.length === 1)
|
||||
setup.emit({ type: "server.connected", data: {} })
|
||||
await wait(() => reads.length === 2)
|
||||
reads[1]?.resolve(Response.json({ data: { ses_new: { type: "running" } } }))
|
||||
await wait(() => setup.data.session.status("ses_new") === "running")
|
||||
reads[0]?.resolve(Response.json({ data: { ses_old: { type: "running" } } }))
|
||||
await Bun.sleep(20)
|
||||
expect(setup.data.session.status("ses_new")).toBe("running")
|
||||
expect(setup.data.session.status("ses_old")).toBe("idle")
|
||||
} finally {
|
||||
reads.forEach((read) => read.resolve(Response.json({ data: {} })))
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
function activityFixture(read: () => Response | Promise<Response>) {
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
const path = new URL(request.url).pathname
|
||||
if (path === "/api/session/active") return read()
|
||||
if (path === "/api/project") return Response.json([])
|
||||
if (path === "/api/location") return Response.json({ directory: "/project" })
|
||||
return Response.json({ location: { directory: "/project" }, data: { branch: "main" } })
|
||||
},
|
||||
})
|
||||
return createRoot((dispose) => ({
|
||||
data: createData({
|
||||
api: () => api,
|
||||
directory: "/project",
|
||||
event: {
|
||||
on: () => () => {},
|
||||
listen(handler) {
|
||||
listeners.add(handler)
|
||||
return () => listeners.delete(handler)
|
||||
},
|
||||
},
|
||||
}),
|
||||
emit: (details: OpenCodeEvent) => listeners.forEach((listener) => listener({ name: details.type, details })),
|
||||
dispose,
|
||||
}))
|
||||
}
|
||||
|
||||
async function wait(check: () => boolean) {
|
||||
const started = Date.now()
|
||||
while (!check()) {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
export * as CommandInvocation from "./invocation.js"
|
||||
|
||||
import type { Plugin } from "@opencode-ai/plugin/effect"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { ConfigCommand } from "@opencode-ai/schema/config/command"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Effect } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import type { Command } from "../command.js"
|
||||
import { Location } from "../location.js"
|
||||
import { ShellSelect } from "../shell/select.js"
|
||||
|
||||
// Invocation for configured template commands; source loading and registration stay with the caller.
|
||||
export const make = Effect.fnUntraced(function* (ctx: Pick<Plugin.Context, "agent" | "session">) {
|
||||
const location = yield* Location.Service
|
||||
const processes = yield* AppProcess.Service
|
||||
const shell = yield* ShellSelect.Service
|
||||
return Effect.fn("CommandInvocation.invoke")(function* (command: ConfigCommand.Info, input: Command.Invocation) {
|
||||
const agent = command.agent === undefined ? undefined : Agent.ID.make(command.agent)
|
||||
const commandAgent = yield* Effect.gen(function* () {
|
||||
if (agent === undefined) return
|
||||
const session = yield* ctx.session.get({ sessionID: input.sessionID })
|
||||
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
|
||||
return (yield* ctx.agent.get({ agentID: agent })).data
|
||||
})
|
||||
const model =
|
||||
command.model === undefined
|
||||
? commandAgent?.model
|
||||
: {
|
||||
id: Model.ID.make(command.model.model),
|
||||
providerID: Provider.ID.make(command.model.providerID),
|
||||
...(command.model.variant === undefined ? {} : { variant: Model.VariantID.make(command.model.variant) }),
|
||||
}
|
||||
if (model !== undefined) yield* ctx.session.switchModel({ sessionID: input.sessionID, model })
|
||||
yield* ctx.session.prompt({
|
||||
...input.prompt,
|
||||
sessionID: input.sessionID,
|
||||
text: yield* evaluateTemplate(command.template, input.prompt.text, { location, processes, shell }),
|
||||
delivery: input.delivery,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function evaluateTemplate(
|
||||
template: string,
|
||||
input: string,
|
||||
services: {
|
||||
readonly location: Location.Info
|
||||
readonly processes: AppProcess.Interface
|
||||
readonly shell: ShellSelect.Interface
|
||||
},
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const args = parseArguments(input)
|
||||
const placeholders = template.match(placeholderRegex) ?? []
|
||||
const last = Math.max(0, ...placeholders.map((item) => Number(item.slice(1))))
|
||||
const expanded = template.replaceAll(placeholderRegex, (_, index) => {
|
||||
const position = Number(index)
|
||||
const argIndex = position - 1
|
||||
if (argIndex >= args.length) return ""
|
||||
if (position === last) return args.slice(argIndex).join(" ")
|
||||
return args[argIndex]
|
||||
})
|
||||
const withArguments = expanded.replaceAll("$ARGUMENTS", input)
|
||||
const text =
|
||||
placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim()
|
||||
? `${withArguments}\n\n${input}`.trim()
|
||||
: withArguments.trim()
|
||||
const matches = Array.from(text.matchAll(shellRegex))
|
||||
if (matches.length === 0) return text
|
||||
const shell = yield* services.shell.resolve({ priority: "config" })
|
||||
const outputs = yield* Effect.forEach(
|
||||
matches,
|
||||
(match) => {
|
||||
const source = match[1] ?? ""
|
||||
return services.processes
|
||||
.run(
|
||||
ChildProcess.make(shell, ShellSelect.args(shell, source), {
|
||||
cwd: services.location.directory,
|
||||
stdin: "ignore",
|
||||
}),
|
||||
{ combineOutput: true },
|
||||
)
|
||||
.pipe(
|
||||
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
|
||||
Effect.mapError(
|
||||
(error) => new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
|
||||
),
|
||||
)
|
||||
},
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const iterator = outputs[Symbol.iterator]()
|
||||
return text.replace(shellRegex, () => iterator.next().value ?? "")
|
||||
})
|
||||
}
|
||||
|
||||
function parseArguments(input: string) {
|
||||
return (input.match(argsRegex) ?? []).map((arg) => arg.replace(quoteTrimRegex, ""))
|
||||
}
|
||||
|
||||
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
|
||||
const placeholderRegex = /\$(\d+)/g
|
||||
const quoteTrimRegex = /^["']|["']$/g
|
||||
const shellRegex = /!`([^`]+)`/g
|
||||
@@ -4,9 +4,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import path from "path"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { type ParseError, parse } from "jsonc-parser"
|
||||
import { applyEdits, modify } from "jsonc-parser"
|
||||
import { Context, Effect, Layer, Option, PubSub, Ref, Schema, Semaphore, Stream } from "effect"
|
||||
import { produce, type Draft } from "immer"
|
||||
import {
|
||||
AgentsDirectory,
|
||||
ClaudeDirectory,
|
||||
@@ -16,7 +14,6 @@ import {
|
||||
type Entry,
|
||||
Event,
|
||||
} from "@opencode-ai/schema/config"
|
||||
import { isRecord } from "@opencode-ai/ai/utils/record"
|
||||
import { Credential } from "./credential.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Watcher } from "./filesystem/watcher.js"
|
||||
@@ -36,8 +33,6 @@ export function latest<K extends keyof Info>(entries: readonly Entry[], key: K):
|
||||
export interface Interface {
|
||||
/** Returns location config documents and discovery sources from lowest to highest priority. */
|
||||
readonly entries: () => Effect.Effect<Entry[]>
|
||||
/** Updates the first file-backed configuration document. */
|
||||
readonly update: (update: (draft: Draft<Info>) => void) => Effect.Effect<Info, UpdateError>
|
||||
/**
|
||||
* Streams raw filesystem updates under config roots. Config owns root
|
||||
* topology and watch reconciliation; domain owners filter this feed for the
|
||||
@@ -46,11 +41,6 @@ export interface Interface {
|
||||
readonly changes: () => Stream.Stream<Watcher.Update>
|
||||
}
|
||||
|
||||
export class UpdateError extends Schema.TaggedError<UpdateError>()("Config.UpdateError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
export const Options = Schema.Struct({
|
||||
project: Schema.optional(Schema.Boolean),
|
||||
// false skips the global config dir, ~/.claude, and ~/.agents; wellknown,
|
||||
@@ -80,20 +70,6 @@ export const testLayer = (initial: Entry[] = []) =>
|
||||
const updates = yield* PubSub.unbounded<Watcher.Update>()
|
||||
const service = Test.of({
|
||||
entries: () => Ref.get(entries),
|
||||
update: (update) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Ref.get(entries)
|
||||
const index = current.findIndex((entry) => entry.type === "document" && entry.path !== undefined)
|
||||
const entry = current[index]
|
||||
if (!entry || entry.type !== "document")
|
||||
return yield* Effect.fail(new UpdateError({ message: "No editable config document found" }))
|
||||
const info = yield* Effect.try({
|
||||
try: () => produce(entry.info, update),
|
||||
catch: (cause) => new UpdateError({ message: "Config update failed", cause }),
|
||||
})
|
||||
yield* Ref.set(entries, current.with(index, new Document({ type: "document", path: entry.path, info })))
|
||||
return info
|
||||
}),
|
||||
changes: () => Stream.fromPubSub(updates),
|
||||
setEntries: (next) => Ref.set(entries, next),
|
||||
emitChange: (update) => PubSub.publish(updates, update).pipe(Effect.asVoid),
|
||||
@@ -400,54 +376,10 @@ export const layer = (options?: Options) =>
|
||||
)
|
||||
yield* reconcile(initial)
|
||||
|
||||
const update = Effect.fn("Config.update")((mutate: (draft: Draft<Info>) => void) =>
|
||||
reloadLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
// TODO: Replace entry-order selection with an explicit config scope/target model.
|
||||
const document = configs.find((entry) => entry.type === "document" && entry.path !== undefined)
|
||||
if (!document || document.type !== "document" || !document.path)
|
||||
return yield* Effect.fail(new UpdateError({ message: "No editable config document found" }))
|
||||
const next = yield* Effect.try({
|
||||
try: () => produce(document.info, mutate),
|
||||
catch: (cause) => new UpdateError({ message: "Config update failed", cause }),
|
||||
})
|
||||
const edits = changes(document.info, next)
|
||||
if (!edits.length) return document.info
|
||||
const text = yield* fs
|
||||
.readFileString(document.path)
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(cause) => new UpdateError({ message: `Failed to read config: ${document.path}`, cause }),
|
||||
),
|
||||
)
|
||||
const updated = edits.reduce(
|
||||
(text, edit) =>
|
||||
applyEdits(
|
||||
text,
|
||||
modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
|
||||
),
|
||||
text,
|
||||
)
|
||||
const info = yield* parseInfo(updated, document.path)
|
||||
if (!info)
|
||||
return yield* Effect.fail(new UpdateError({ message: `Invalid config update: ${document.path}` }))
|
||||
const temporary = document.path + ".tmp"
|
||||
yield* fs.writeFileString(temporary, updated.endsWith("\n") ? updated : updated + "\n").pipe(
|
||||
Effect.andThen(fs.rename(temporary, document.path)),
|
||||
Effect.mapError(
|
||||
(cause) => new UpdateError({ message: `Failed to write config: ${document.path}`, cause }),
|
||||
),
|
||||
)
|
||||
return info
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
entries: Effect.fnUntraced(function* () {
|
||||
return configs
|
||||
}),
|
||||
update,
|
||||
changes: () => Stream.fromPubSub(updates),
|
||||
})
|
||||
}),
|
||||
@@ -462,17 +394,3 @@ export function configured(options?: Options) {
|
||||
}
|
||||
|
||||
export const node = configured()
|
||||
|
||||
type Edit = { readonly path: (string | number)[]; readonly value: unknown }
|
||||
|
||||
function changes(before: unknown, after: unknown, path: (string | number)[] = []): Edit[] {
|
||||
if (Object.is(before, after)) return []
|
||||
if (isRecord(before) && isRecord(after)) {
|
||||
return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap((key) => {
|
||||
if (!(key in after)) return [{ path: [...path, key], value: undefined }]
|
||||
if (!(key in before)) return [{ path: [...path, key], value: after[key] }]
|
||||
return changes(before[key], after[key], [...path, key])
|
||||
})
|
||||
}
|
||||
return [{ path, value: after }]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
export * as ConfigFile from "./file.js"
|
||||
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { isRecord } from "@opencode-ai/ai/utils/record"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Effect, Schema, Semaphore } from "effect"
|
||||
import {
|
||||
applyEdits,
|
||||
createScanner,
|
||||
findNodeAtLocation,
|
||||
modify,
|
||||
parseTree,
|
||||
type Node,
|
||||
type ParseError,
|
||||
} from "jsonc-parser"
|
||||
|
||||
export class UpdateError extends Schema.TaggedError<UpdateError>()("ConfigFile.UpdateError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
const isJson = Schema.is(Schema.MutableJson)
|
||||
const isDocument = (value: unknown): value is Schema.MutableJsonObject => isRecord(value) && isJson(value)
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
|
||||
/**
|
||||
* Edits an existing JSON(C) file using raw source values, not resolved Config.Info.
|
||||
* The synchronous callback mutates a source clone; its return value is ignored.
|
||||
* Validates JSON only; normalization and substitution remain the reader's job.
|
||||
* Does not discover files, start watchers, or refresh Config state.
|
||||
* Read-modify-write calls are serialized within this process.
|
||||
*/
|
||||
export const update = Effect.fn("ConfigFile.update")(
|
||||
function* (
|
||||
filepath: string,
|
||||
mutate: (draft: Schema.MutableJsonObject) => void,
|
||||
): Effect.fn.Return<Schema.JsonObject, UpdateError, FSUtil.Service> {
|
||||
const fs = yield* FSUtil.Service
|
||||
const text = yield* fs
|
||||
.readFileString(filepath)
|
||||
.pipe(Effect.mapError((cause) => new UpdateError({ message: `Failed to read config: ${filepath}`, cause })))
|
||||
const errors: ParseError[] = []
|
||||
const current = parseSource(text, errors)
|
||||
if (errors.length || !isDocument(current))
|
||||
return yield* Effect.fail(new UpdateError({ message: `Invalid config file: ${filepath}` }))
|
||||
|
||||
const next = yield* Effect.try({
|
||||
try: () => {
|
||||
const draft = structuredClone(current)
|
||||
mutate(draft)
|
||||
return draft
|
||||
},
|
||||
catch: (cause) => new UpdateError({ message: "Config update failed", cause }),
|
||||
})
|
||||
if (!isDocument(next))
|
||||
return yield* Effect.fail(new UpdateError({ message: `Config update must produce a JSON object: ${filepath}` }))
|
||||
|
||||
const edits = changes(current, next)
|
||||
if (!edits.length) return next
|
||||
const updated = yield* Effect.try({
|
||||
try: () => edits.reduce(patch, text),
|
||||
catch: (cause) => new UpdateError({ message: `Failed to patch config: ${filepath}`, cause }),
|
||||
})
|
||||
// Duplicate keys can make parse choose the last value while modify edits the first.
|
||||
const written = parseSource(updated, errors)
|
||||
if (errors.length || !isDeepStrictEqual(written, next))
|
||||
return yield* Effect.fail(
|
||||
new UpdateError({ message: `Config patch does not match the requested update: ${filepath}` }),
|
||||
)
|
||||
const temporary = filepath + ".tmp"
|
||||
yield* fs.writeFileString(temporary, updated.endsWith("\n") ? updated : updated + "\n").pipe(
|
||||
Effect.andThen(fs.rename(temporary, filepath)),
|
||||
Effect.mapError((cause) => new UpdateError({ message: `Failed to write config: ${filepath}`, cause })),
|
||||
)
|
||||
return next
|
||||
},
|
||||
(effect) => lock.withPermit(effect),
|
||||
)
|
||||
|
||||
type Edit = { readonly path: (string | number)[]; readonly value: unknown }
|
||||
|
||||
function parseSource(text: string, errors: ParseError[]) {
|
||||
const root = parseTree(text, errors, { allowTrailingComma: true })
|
||||
if (!root || errors.length) return undefined
|
||||
// parse() assigns onto {}, invoking the __proto__ setter instead of retaining
|
||||
// an own JSON key. Construct object entries from the AST without those setters.
|
||||
const value = (node: Node): unknown => {
|
||||
if (node.type === "array") return (node.children ?? []).map(value)
|
||||
if (node.type === "object")
|
||||
return Object.fromEntries(
|
||||
(node.children ?? []).map((property) => {
|
||||
const child = property.children?.[1]
|
||||
return [property.children?.[0]?.value, child && value(child)]
|
||||
}),
|
||||
)
|
||||
return node.value
|
||||
}
|
||||
return value(root)
|
||||
}
|
||||
|
||||
function patch(text: string, edit: Edit) {
|
||||
if (edit.value !== undefined)
|
||||
return applyEdits(
|
||||
text,
|
||||
modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
|
||||
)
|
||||
|
||||
const tree = parseTree(text)
|
||||
const node = tree && findNodeAtLocation(tree, edit.path)
|
||||
if (!node) return text
|
||||
// jsonc-parser removes adjacent comments along with the separator. Remove only
|
||||
// the property/element itself and one comma, leaving surrounding comments intact.
|
||||
const target = node.parent?.type === "property" ? node.parent : node
|
||||
const siblings = target.parent?.children ?? []
|
||||
const previous = siblings[siblings.indexOf(target) - 1]
|
||||
const scanner = createScanner(text, true)
|
||||
scanner.setPosition(target.offset + target.length)
|
||||
scanner.scan()
|
||||
const following = text[scanner.getTokenOffset()] === ","
|
||||
if (!following && previous) {
|
||||
scanner.setPosition(previous.offset + previous.length)
|
||||
scanner.scan()
|
||||
}
|
||||
return applyEdits(text, [
|
||||
{ offset: target.offset, length: target.length, content: "" },
|
||||
...(following || previous ? [{ offset: scanner.getTokenOffset(), length: 1, content: "" }] : []),
|
||||
])
|
||||
}
|
||||
|
||||
function changes(before: unknown, after: unknown, path: (string | number)[] = []): Edit[] {
|
||||
if (isDeepStrictEqual(before, after)) return []
|
||||
if (Array.isArray(before) && Array.isArray(after)) {
|
||||
return [
|
||||
...after.flatMap((value, index) => changes(before[index], value, [...path, index])),
|
||||
// Remove from the end so earlier deletions cannot shift later paths.
|
||||
...before
|
||||
.slice(after.length)
|
||||
.map((_, index) => ({ path: [...path, after.length + index], value: undefined }))
|
||||
.toReversed(),
|
||||
]
|
||||
}
|
||||
if (isRecord(before) && isRecord(after)) {
|
||||
return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap((key) => {
|
||||
if (!Object.hasOwn(after, key)) return [{ path: [...path, key], value: undefined }]
|
||||
if (!Object.hasOwn(before, key)) return [{ path: [...path, key], value: after[key] }]
|
||||
return changes(before[key], after[key], [...path, key])
|
||||
})
|
||||
}
|
||||
return [{ path, value: after }]
|
||||
}
|
||||
@@ -1,18 +1,12 @@
|
||||
export * as ConfigCommandPlugin from "./command.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Info, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigCommand } from "@opencode-ai/schema/config/command"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import path from "path"
|
||||
import { Effect, Option, Schema, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { CommandInvocation } from "../../command/invocation.js"
|
||||
import { Config } from "../../config.js"
|
||||
import { Location } from "../../location.js"
|
||||
import { ShellSelect } from "../../shell/select.js"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { ConfigMarkdown } from "../markdown.js"
|
||||
|
||||
@@ -29,9 +23,7 @@ export const Plugin = define({
|
||||
const commands = yield* loadDirectory(fs, entry.path)
|
||||
return [{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }]
|
||||
})
|
||||
const location = yield* Location.Service
|
||||
const processes = yield* AppProcess.Service
|
||||
const shell = yield* ShellSelect.Service
|
||||
const invoke = yield* CommandInvocation.make(ctx)
|
||||
const load = Effect.fn("ConfigCommandPlugin.load")(function* () {
|
||||
return yield* Effect.forEach(yield* config.entries(), loadEntry).pipe(Effect.map((documents) => documents.flat()))
|
||||
})
|
||||
@@ -63,38 +55,7 @@ export const Plugin = define({
|
||||
draft.add({
|
||||
name,
|
||||
description: command.description,
|
||||
execute: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const agent = command.agent === undefined ? undefined : Agent.ID.make(command.agent)
|
||||
const commandAgent = yield* Effect.gen(function* () {
|
||||
if (agent === undefined) return
|
||||
const session = yield* ctx.session.get({ sessionID: input.sessionID })
|
||||
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
|
||||
return (yield* ctx.agent.get({ agentID: agent })).data
|
||||
})
|
||||
const model =
|
||||
command.model === undefined
|
||||
? commandAgent?.model
|
||||
: {
|
||||
id: Model.ID.make(command.model.model),
|
||||
providerID: Provider.ID.make(command.model.providerID),
|
||||
...(command.model.variant === undefined
|
||||
? {}
|
||||
: { variant: Model.VariantID.make(command.model.variant) }),
|
||||
}
|
||||
if (model !== undefined) yield* ctx.session.switchModel({ sessionID: input.sessionID, model })
|
||||
yield* ctx.session.prompt({
|
||||
...input.prompt,
|
||||
sessionID: input.sessionID,
|
||||
text: yield* evaluateTemplate(command.template, input.prompt.text, {
|
||||
config,
|
||||
location,
|
||||
processes,
|
||||
shell,
|
||||
}),
|
||||
delivery: input.delivery,
|
||||
})
|
||||
}).pipe(Effect.asVoid),
|
||||
execute: (input) => invoke(command, input),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -147,67 +108,3 @@ function decode(directory: string, filepath: string, content: string) {
|
||||
info,
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateTemplate(
|
||||
template: string,
|
||||
input: string,
|
||||
services: {
|
||||
readonly config: Config.Interface
|
||||
readonly location: Location.Info
|
||||
readonly processes: AppProcess.Interface
|
||||
readonly shell: ShellSelect.Interface
|
||||
},
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const args = parseArguments(input)
|
||||
const placeholders = template.match(placeholderRegex) ?? []
|
||||
const last = Math.max(0, ...placeholders.map((item) => Number(item.slice(1))))
|
||||
const expanded = template.replaceAll(placeholderRegex, (_, index) => {
|
||||
const position = Number(index)
|
||||
const argIndex = position - 1
|
||||
if (argIndex >= args.length) return ""
|
||||
if (position === last) return args.slice(argIndex).join(" ")
|
||||
return args[argIndex]
|
||||
})
|
||||
const withArguments = expanded.replaceAll("$ARGUMENTS", input)
|
||||
const text =
|
||||
placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim()
|
||||
? `${withArguments}\n\n${input}`.trim()
|
||||
: withArguments.trim()
|
||||
const matches = Array.from(text.matchAll(shellRegex))
|
||||
if (matches.length === 0) return text
|
||||
const shell = yield* services.shell.resolve({ priority: "config" })
|
||||
const outputs = yield* Effect.forEach(
|
||||
matches,
|
||||
(match) => {
|
||||
const source = match[1] ?? ""
|
||||
return services.processes
|
||||
.run(
|
||||
ChildProcess.make(shell, ShellSelect.args(shell, source), {
|
||||
cwd: services.location.directory,
|
||||
stdin: "ignore",
|
||||
}),
|
||||
{ combineOutput: true },
|
||||
)
|
||||
.pipe(
|
||||
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
|
||||
Effect.mapError((error) =>
|
||||
new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
|
||||
),
|
||||
)
|
||||
},
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const iterator = outputs[Symbol.iterator]()
|
||||
return text.replace(shellRegex, () => iterator.next().value ?? "")
|
||||
})
|
||||
}
|
||||
|
||||
function parseArguments(input: string) {
|
||||
return (input.match(argsRegex) ?? []).map((arg) => arg.replace(quoteTrimRegex, ""))
|
||||
}
|
||||
|
||||
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
|
||||
const placeholderRegex = /\$(\d+)/g
|
||||
const quoteTrimRegex = /^["']|["']$/g
|
||||
const shellRegex = /!`([^`]+)`/g
|
||||
|
||||
@@ -59,6 +59,13 @@ export const WireResponse = Schema.Union([
|
||||
cursor_x: Schema.Number,
|
||||
cursor_y: Schema.Number,
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("rows"),
|
||||
terminal: WireTerminal,
|
||||
lines: Schema.Array(Schema.String),
|
||||
cursor_x: Schema.Number,
|
||||
cursor_y: Schema.Number,
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("attached"),
|
||||
terminal: WireTerminal,
|
||||
|
||||
@@ -4,7 +4,7 @@ import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Added, Handoff, Removed } from "@opencode-ai/schema/persistent-pty"
|
||||
import { Added, Handoff, ReadLines, Removed, type ReadResult } from "@opencode-ai/schema/persistent-pty"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Pty } from "@opencode-ai/schema/pty"
|
||||
@@ -103,6 +103,7 @@ export interface Interface {
|
||||
data: Uint8Array,
|
||||
) => Effect.Effect<void, NotFoundError | UnavailableError>
|
||||
readonly snapshot: (id: Pty.ID) => Effect.Effect<Snapshot, NotFoundError | UnavailableError>
|
||||
readonly read: (sessionID: Session.ID, lines?: number) => Effect.Effect<ReadResult | null, UnavailableError>
|
||||
readonly remove: (id: Pty.ID) => Effect.Effect<void, NotFoundError | UnavailableError>
|
||||
readonly shutdown: () => Effect.Effect<void, UnavailableError>
|
||||
readonly handoff: () => Effect.Effect<Handoff | null, UnavailableError>
|
||||
@@ -140,6 +141,8 @@ export const configured = (options: Options = {}) =>
|
||||
options.handoff,
|
||||
).pipe(Effect.mapError(unavailable))
|
||||
const removing = new Set<Pty.ID>()
|
||||
// Controller activity selects a terminal; observer reads and pane visibility do not.
|
||||
const current = new Map<Session.ID, Pty.ID>()
|
||||
|
||||
const list = Effect.fn("PersistentPty.list")(function* (sessionID?: Session.ID) {
|
||||
const response = yield* optionalRequest(daemon, { op: "list" })
|
||||
@@ -207,7 +210,7 @@ export const configured = (options: Options = {}) =>
|
||||
rows: number,
|
||||
attachmentID?: string,
|
||||
) {
|
||||
yield* get(id)
|
||||
const terminal = yield* get(id)
|
||||
const response = yield* request(daemon, {
|
||||
op: "resize",
|
||||
id: fromID(id),
|
||||
@@ -216,6 +219,7 @@ export const configured = (options: Options = {}) =>
|
||||
rows,
|
||||
})
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
current.set(terminal.sessionID, id)
|
||||
return undefined
|
||||
})
|
||||
|
||||
@@ -225,7 +229,7 @@ export const configured = (options: Options = {}) =>
|
||||
cols: number,
|
||||
rows: number,
|
||||
) {
|
||||
yield* get(id)
|
||||
const terminal = yield* get(id)
|
||||
const response = yield* request(daemon, {
|
||||
op: "control",
|
||||
id: fromID(id),
|
||||
@@ -234,6 +238,7 @@ export const configured = (options: Options = {}) =>
|
||||
rows,
|
||||
})
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
current.set(terminal.sessionID, id)
|
||||
return undefined
|
||||
})
|
||||
|
||||
@@ -244,7 +249,7 @@ export const configured = (options: Options = {}) =>
|
||||
rows: number,
|
||||
data: Uint8Array,
|
||||
) {
|
||||
yield* get(id)
|
||||
const terminal = yield* get(id)
|
||||
const response = yield* request(daemon, {
|
||||
op: "input",
|
||||
id: fromID(id),
|
||||
@@ -254,6 +259,7 @@ export const configured = (options: Options = {}) =>
|
||||
data_base64: Buffer.from(data).toString("base64"),
|
||||
})
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
current.set(terminal.sessionID, id)
|
||||
return undefined
|
||||
})
|
||||
|
||||
@@ -269,16 +275,46 @@ export const configured = (options: Options = {}) =>
|
||||
}
|
||||
})
|
||||
|
||||
const read = Effect.fn("PersistentPty.read")(function* (sessionID: Session.ID, lines?: number) {
|
||||
if (lines !== undefined && !Schema.is(ReadLines)(lines))
|
||||
return yield* new UnavailableError({ message: "lines must be an integer between 1 and 65535" })
|
||||
const id = current.get(sessionID)
|
||||
if (!id) return null
|
||||
const terminal = yield* get(id).pipe(Effect.catchTag("PersistentPty.NotFoundError", () => Effect.succeed(null)))
|
||||
if (!terminal || terminal.sessionID !== sessionID) {
|
||||
if (current.get(sessionID) === id) current.delete(sessionID)
|
||||
return null
|
||||
}
|
||||
// Let the daemon choose the live height in the same snapshot when lines is omitted.
|
||||
const response = yield* request(daemon, { op: "read_rows", id: fromID(id), rows: lines })
|
||||
if (response.type !== "rows") return yield* unexpected(response)
|
||||
const info = toInfo(response.terminal)
|
||||
return {
|
||||
ptyID: info.id,
|
||||
title: info.title,
|
||||
cwd: info.cwd,
|
||||
foregroundProcess: info.foregroundProcess,
|
||||
screen: {
|
||||
text: response.lines.join("\n"),
|
||||
cols: info.size.cols,
|
||||
rows: info.size.rows,
|
||||
cursor: { x: response.cursor_x, y: response.cursor_y },
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const remove = Effect.fn("PersistentPty.remove")(function* (id: Pty.ID) {
|
||||
const terminal = yield* get(id)
|
||||
const response = yield* request(daemon, { op: "terminate", id: fromID(id) })
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
if (current.get(terminal.sessionID) === id) current.delete(terminal.sessionID)
|
||||
yield* bus.publish(Removed, { sessionID: terminal.sessionID, ptyID: id })
|
||||
return undefined
|
||||
})
|
||||
|
||||
const shutdown = Effect.fn("PersistentPty.shutdown")(function* () {
|
||||
const response = yield* daemon.shutdown.pipe(Effect.mapError(unavailable))
|
||||
current.clear()
|
||||
if (!response) return
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
})
|
||||
@@ -321,6 +357,7 @@ export const configured = (options: Options = {}) =>
|
||||
},
|
||||
})
|
||||
.pipe(Effect.mapError(unavailable))
|
||||
if (attachment.role === "controller") current.set(Session.ID.make(attachment.terminal.group_id), id)
|
||||
return {
|
||||
info: toInfo(attachment.terminal),
|
||||
role: attachment.role,
|
||||
@@ -340,6 +377,7 @@ export const configured = (options: Options = {}) =>
|
||||
control,
|
||||
input,
|
||||
snapshot,
|
||||
read,
|
||||
remove,
|
||||
shutdown,
|
||||
handoff,
|
||||
|
||||
@@ -193,6 +193,11 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
event: {
|
||||
subscribe: () => bus.subscribe().pipe(Stream.filter(EventManifest.isServer)),
|
||||
},
|
||||
experimental: {
|
||||
terminal: {
|
||||
read: (input) => runtime.persistentPty.read(input.sessionID, input.lines),
|
||||
},
|
||||
},
|
||||
generate: {
|
||||
text: (input) => generate.text(input).pipe(Effect.map((text) => ({ text }))),
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Job } from "../job.js"
|
||||
import { Location } from "../location.js"
|
||||
import { LocationServiceMap } from "../location-service-map.js"
|
||||
import { Mcp } from "../mcp/index.js"
|
||||
import { PersistentPty } from "../persistent-pty.js"
|
||||
import { Session } from "../session.js"
|
||||
|
||||
export interface Interface {
|
||||
@@ -29,6 +30,7 @@ export interface Interface {
|
||||
| "context"
|
||||
>
|
||||
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel" | "completeBackground">
|
||||
readonly persistentPty: Pick<PersistentPty.Interface, "read">
|
||||
readonly location: {
|
||||
readonly agent: {
|
||||
readonly list: (
|
||||
@@ -90,6 +92,9 @@ export const layerWithCell = (cell: Cell) =>
|
||||
completeBackground: (notificationID) =>
|
||||
require(cell, (runtime) => runtime.job.completeBackground(notificationID)),
|
||||
},
|
||||
persistentPty: {
|
||||
read: (sessionID, lines) => require(cell, (runtime) => runtime.persistentPty.read(sessionID, lines)),
|
||||
},
|
||||
location: {
|
||||
agent: {
|
||||
list: (ref) => require(cell, (runtime) => runtime.location.agent.list(ref)),
|
||||
@@ -107,9 +112,11 @@ export const providerLayerWithCell = (cell: Cell) =>
|
||||
const sessions = yield* Session.Service
|
||||
const jobs = yield* Job.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const persistentPty = yield* PersistentPty.Service
|
||||
const runtime: Interface = {
|
||||
session: sessions,
|
||||
job: jobs,
|
||||
persistentPty,
|
||||
location: {
|
||||
agent: {
|
||||
list: (ref) =>
|
||||
@@ -162,7 +169,7 @@ export const providerNodeWithCell = (cell: Cell) =>
|
||||
makeGlobalNode({
|
||||
name: "plugin-runtime-provider",
|
||||
layer: providerLayerWithCell(cell),
|
||||
deps: [node, Session.node, Job.node, LocationServiceMap.node],
|
||||
deps: [node, Session.node, Job.node, LocationServiceMap.node, PersistentPty.node],
|
||||
})
|
||||
|
||||
export const providerNode = providerNodeWithCell(defaultCell)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as SessionCompaction from "./compaction.js"
|
||||
|
||||
import { LLMClient, AIError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
|
||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||
import { LLMClient, LLMEvent, Message } from "@opencode-ai/ai"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import { Bus } from "../bus.js"
|
||||
@@ -65,13 +64,6 @@ export type Draft = {
|
||||
configure: (settings: Partial<Settings>) => void
|
||||
}
|
||||
|
||||
type Dependencies = {
|
||||
readonly bus: Bus.Interface
|
||||
readonly llm: {
|
||||
readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream<LLMEvent, AIError>
|
||||
}
|
||||
}
|
||||
|
||||
export type AutoInput = {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly messages: readonly SessionMessage.Info[]
|
||||
@@ -240,195 +232,196 @@ const planContent = (messages: readonly SessionMessage.Info[], tokens: number) =
|
||||
}
|
||||
}
|
||||
|
||||
const make = (dependencies: Dependencies) => {
|
||||
const state = State.create<Settings, Draft>({
|
||||
name: "session-compaction",
|
||||
initial: () => ({ auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS }),
|
||||
draft: (draft) => ({
|
||||
configure: (settings) => {
|
||||
if (settings.auto !== undefined) draft.auto = settings.auto
|
||||
if (settings.buffer !== undefined) draft.buffer = settings.buffer
|
||||
if (settings.tokens !== undefined) draft.tokens = settings.tokens
|
||||
},
|
||||
}),
|
||||
})
|
||||
const failed = Effect.fnUntraced(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly reason: SessionMessage.Compaction["reason"]
|
||||
readonly error: SessionError.Error
|
||||
readonly inputID?: SessionMessage.ID
|
||||
}) {
|
||||
yield* dependencies.bus.publish(SessionEvent.Compaction.Failed, input)
|
||||
return { status: "failed" as const, error: input.error }
|
||||
})
|
||||
const execute = Effect.fn("SessionCompaction.execute")(function* (plan: Plan) {
|
||||
if (!plan.started)
|
||||
yield* dependencies.bus.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID: plan.session.id,
|
||||
reason: plan.reason,
|
||||
recent: plan.recent,
|
||||
inputID: plan.inputID,
|
||||
})
|
||||
|
||||
const chunks: string[] = []
|
||||
let failure: SessionError.Error | undefined
|
||||
let usage: SessionUsage.Recorded | undefined
|
||||
const recordUsage = Effect.suspend(() =>
|
||||
usage
|
||||
? dependencies.bus.publish(SessionEvent.UsageRecorded, {
|
||||
sessionID: plan.session.id,
|
||||
source: "compaction",
|
||||
...usage,
|
||||
})
|
||||
: Effect.void,
|
||||
)
|
||||
const prepared = yield* plan.prepare({
|
||||
scope: { session: plan.session, agentID: Agent.ID.make("compaction"), model: plan.resolved },
|
||||
transcript: { system: [], messages: [Message.user(plan.prompt)] },
|
||||
contextHooks: false,
|
||||
})
|
||||
yield* dependencies.llm.stream(prepared.request, prepared.options).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event))
|
||||
failure = {
|
||||
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
|
||||
message: event.message,
|
||||
}
|
||||
if (LLMEvent.is.textDelta(event)) {
|
||||
chunks.push(event.text)
|
||||
return dependencies.bus.publish(SessionEvent.Compaction.Delta, {
|
||||
sessionID: plan.session.id,
|
||||
text: event.text,
|
||||
})
|
||||
}
|
||||
if (LLMEvent.is.stepFinish(event)) {
|
||||
const step = SessionUsage.record(event.usage, plan.resolved.cost)
|
||||
usage = usage ? SessionUsage.add(usage, step) : step
|
||||
}
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.catchTag("AI.Error", (error) =>
|
||||
Effect.sync(() => {
|
||||
failure = toSessionError(error)
|
||||
}),
|
||||
),
|
||||
Effect.onInterrupt(() =>
|
||||
recordUsage.pipe(
|
||||
Effect.andThen(
|
||||
plan.reason === "auto"
|
||||
? failed({
|
||||
sessionID: plan.session.id,
|
||||
reason: plan.reason,
|
||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||
inputID: plan.inputID,
|
||||
}).pipe(Effect.asVoid)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
yield* recordUsage
|
||||
const summary = chunks.join("")
|
||||
if (failure || !summary.trim()) {
|
||||
const error = failure ?? { type: "compaction.failed" as const, message: "Compaction produced no summary" }
|
||||
return yield* failed({
|
||||
sessionID: plan.session.id,
|
||||
reason: plan.reason,
|
||||
error,
|
||||
inputID: plan.inputID,
|
||||
})
|
||||
}
|
||||
yield* dependencies.bus.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID: plan.session.id,
|
||||
reason: plan.reason,
|
||||
text: summary,
|
||||
recent: plan.recent,
|
||||
})
|
||||
return { status: "completed" as const }
|
||||
})
|
||||
const compact = Effect.fn("SessionCompaction.compact")(function* (input: AutoInput) {
|
||||
const content = planContent(input.messages, state.get().tokens)
|
||||
if (content)
|
||||
return yield* execute({
|
||||
session: input.session,
|
||||
resolved: input.resolved,
|
||||
prepare: input.prepare,
|
||||
reason: "auto",
|
||||
...content,
|
||||
})
|
||||
return yield* failed({
|
||||
sessionID: input.session.id,
|
||||
reason: "auto",
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
})
|
||||
})
|
||||
const required = (input: RequiredInput) => {
|
||||
const config = state.get()
|
||||
if (!config.auto) return false
|
||||
const limit = input.resolved.limit
|
||||
const context = limit.context
|
||||
if (context <= 0) return false
|
||||
const last = input.messages.findLast(
|
||||
(message): message is SessionMessage.Assistant & { tokens: NonNullable<SessionMessage.Assistant["tokens"]> } =>
|
||||
message.type === "assistant" && message.tokens !== undefined,
|
||||
)
|
||||
if (!last) return false
|
||||
const output = Math.min(limit.output, OUTPUT_TOKEN_MAX)
|
||||
const promptCeiling = Math.min(
|
||||
limit.input === undefined ? Number.POSITIVE_INFINITY : limit.input - config.buffer,
|
||||
context - Math.max(output, config.buffer),
|
||||
)
|
||||
const used =
|
||||
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
||||
if (used <= 0) return false
|
||||
return used >= promptCeiling
|
||||
}
|
||||
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
|
||||
const content = planContent(input.messages, state.get().tokens)
|
||||
if (!content)
|
||||
return yield* failed({
|
||||
sessionID: input.session.id,
|
||||
reason: "manual",
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
inputID: input.inputID,
|
||||
})
|
||||
const resolved = yield* input.resolveModel(input.session).pipe(
|
||||
Effect.catch((cause) =>
|
||||
failed({
|
||||
sessionID: input.session.id,
|
||||
reason: "manual",
|
||||
error: toSessionError(cause),
|
||||
inputID: input.inputID,
|
||||
}),
|
||||
),
|
||||
)
|
||||
if ("status" in resolved) return resolved
|
||||
return yield* execute({
|
||||
session: input.session,
|
||||
resolved,
|
||||
prepare: input.prepare,
|
||||
reason: "manual",
|
||||
inputID: input.inputID,
|
||||
started: input.started,
|
||||
...content,
|
||||
})
|
||||
})
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
enabled: () => state.get().auto,
|
||||
required,
|
||||
compact,
|
||||
compactManual,
|
||||
})
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const llm = yield* LLMClient.Service
|
||||
return make({ bus, llm })
|
||||
|
||||
const state = State.create<Settings, Draft>({
|
||||
name: "session-compaction",
|
||||
initial: () => ({ auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS }),
|
||||
draft: (draft) => ({
|
||||
configure: (settings) => {
|
||||
if (settings.auto !== undefined) draft.auto = settings.auto
|
||||
if (settings.buffer !== undefined) draft.buffer = settings.buffer
|
||||
if (settings.tokens !== undefined) draft.tokens = settings.tokens
|
||||
},
|
||||
}),
|
||||
})
|
||||
const failed = Effect.fnUntraced(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly reason: SessionMessage.Compaction["reason"]
|
||||
readonly error: SessionError.Error
|
||||
readonly inputID?: SessionMessage.ID
|
||||
}) {
|
||||
yield* bus.publish(SessionEvent.Compaction.Failed, input)
|
||||
return { status: "failed" as const, error: input.error }
|
||||
})
|
||||
const execute = Effect.fn("SessionCompaction.execute")(function* (plan: Plan) {
|
||||
if (!plan.started)
|
||||
yield* bus.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID: plan.session.id,
|
||||
reason: plan.reason,
|
||||
recent: plan.recent,
|
||||
inputID: plan.inputID,
|
||||
})
|
||||
|
||||
const chunks: string[] = []
|
||||
let failure: SessionError.Error | undefined
|
||||
let usage: SessionUsage.Recorded | undefined
|
||||
const recordUsage = Effect.suspend(() =>
|
||||
usage
|
||||
? bus.publish(SessionEvent.UsageRecorded, {
|
||||
sessionID: plan.session.id,
|
||||
source: "compaction",
|
||||
...usage,
|
||||
})
|
||||
: Effect.void,
|
||||
)
|
||||
const prepared = yield* plan.prepare({
|
||||
scope: { session: plan.session, agentID: Agent.ID.make("compaction"), model: plan.resolved },
|
||||
transcript: { system: [], messages: [Message.user(plan.prompt)] },
|
||||
contextHooks: false,
|
||||
})
|
||||
yield* llm.stream(prepared.request, prepared.options).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event))
|
||||
failure = {
|
||||
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
|
||||
message: event.message,
|
||||
}
|
||||
if (LLMEvent.is.textDelta(event)) {
|
||||
chunks.push(event.text)
|
||||
return bus.publish(SessionEvent.Compaction.Delta, {
|
||||
sessionID: plan.session.id,
|
||||
text: event.text,
|
||||
})
|
||||
}
|
||||
if (LLMEvent.is.stepFinish(event)) {
|
||||
const step = SessionUsage.record(event.usage, plan.resolved.cost)
|
||||
usage = usage ? SessionUsage.add(usage, step) : step
|
||||
}
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.catchTag("AI.Error", (error) =>
|
||||
Effect.sync(() => {
|
||||
failure = toSessionError(error)
|
||||
}),
|
||||
),
|
||||
Effect.onInterrupt(() =>
|
||||
recordUsage.pipe(
|
||||
Effect.andThen(
|
||||
plan.reason === "auto"
|
||||
? failed({
|
||||
sessionID: plan.session.id,
|
||||
reason: plan.reason,
|
||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||
inputID: plan.inputID,
|
||||
}).pipe(Effect.asVoid)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
yield* recordUsage
|
||||
const summary = chunks.join("")
|
||||
if (failure || !summary.trim()) {
|
||||
const error = failure ?? { type: "compaction.failed" as const, message: "Compaction produced no summary" }
|
||||
return yield* failed({
|
||||
sessionID: plan.session.id,
|
||||
reason: plan.reason,
|
||||
error,
|
||||
inputID: plan.inputID,
|
||||
})
|
||||
}
|
||||
yield* bus.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID: plan.session.id,
|
||||
reason: plan.reason,
|
||||
text: summary,
|
||||
recent: plan.recent,
|
||||
})
|
||||
return { status: "completed" as const }
|
||||
})
|
||||
const compact = Effect.fn("SessionCompaction.compact")(function* (input: AutoInput) {
|
||||
const content = planContent(input.messages, state.get().tokens)
|
||||
if (content)
|
||||
return yield* execute({
|
||||
session: input.session,
|
||||
resolved: input.resolved,
|
||||
prepare: input.prepare,
|
||||
reason: "auto",
|
||||
...content,
|
||||
})
|
||||
return yield* failed({
|
||||
sessionID: input.session.id,
|
||||
reason: "auto",
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
})
|
||||
})
|
||||
const required = (input: RequiredInput) => {
|
||||
const config = state.get()
|
||||
if (!config.auto) return false
|
||||
const limit = input.resolved.limit
|
||||
const context = limit.context
|
||||
if (context <= 0) return false
|
||||
const last = input.messages.findLast(
|
||||
(message): message is SessionMessage.Assistant & { tokens: NonNullable<SessionMessage.Assistant["tokens"]> } =>
|
||||
message.type === "assistant" && message.tokens !== undefined,
|
||||
)
|
||||
if (!last) return false
|
||||
const output = Math.min(limit.output, OUTPUT_TOKEN_MAX)
|
||||
const promptCeiling = Math.min(
|
||||
limit.input === undefined ? Number.POSITIVE_INFINITY : limit.input - config.buffer,
|
||||
context - Math.max(output, config.buffer),
|
||||
)
|
||||
const used =
|
||||
last.tokens.input +
|
||||
last.tokens.output +
|
||||
last.tokens.reasoning +
|
||||
last.tokens.cache.read +
|
||||
last.tokens.cache.write
|
||||
if (used <= 0) return false
|
||||
return used >= promptCeiling
|
||||
}
|
||||
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
|
||||
const content = planContent(input.messages, state.get().tokens)
|
||||
if (!content)
|
||||
return yield* failed({
|
||||
sessionID: input.session.id,
|
||||
reason: "manual",
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
inputID: input.inputID,
|
||||
})
|
||||
const resolved = yield* input.resolveModel(input.session).pipe(
|
||||
Effect.catch((cause) =>
|
||||
failed({
|
||||
sessionID: input.session.id,
|
||||
reason: "manual",
|
||||
error: toSessionError(cause),
|
||||
inputID: input.inputID,
|
||||
}),
|
||||
),
|
||||
)
|
||||
if ("status" in resolved) return resolved
|
||||
return yield* execute({
|
||||
session: input.session,
|
||||
resolved,
|
||||
prepare: input.prepare,
|
||||
reason: "manual",
|
||||
inputID: input.inputID,
|
||||
started: input.started,
|
||||
...content,
|
||||
})
|
||||
})
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
enabled: () => state.get().auto,
|
||||
required,
|
||||
compact,
|
||||
compactManual,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
export * as PromptCacheDiagnostics from "./prompt-cache-diagnostics.js"
|
||||
|
||||
import type { LLMRequest } from "@opencode-ai/ai"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
|
||||
interface Entry {
|
||||
readonly label: string
|
||||
readonly hash: string
|
||||
}
|
||||
|
||||
export interface Snapshot {
|
||||
readonly settings: string
|
||||
readonly tools: ReadonlyArray<Entry>
|
||||
readonly system: ReadonlyArray<Entry>
|
||||
readonly messages: ReadonlyArray<Entry>
|
||||
}
|
||||
|
||||
export type Comparison =
|
||||
| { readonly status: "initial" }
|
||||
| { readonly status: "stable"; readonly messages: number }
|
||||
| { readonly status: "append-only"; readonly previousMessages: number; readonly currentMessages: number }
|
||||
| {
|
||||
readonly status: "changed"
|
||||
readonly component: "settings" | "tools" | "system" | "messages"
|
||||
readonly index: number
|
||||
readonly label: string
|
||||
}
|
||||
|
||||
const hash = (value: unknown) => Hash.sha256(JSON.stringify(value)).slice(0, 16)
|
||||
|
||||
export function snapshot(request: LLMRequest): Snapshot {
|
||||
return {
|
||||
settings: hash({
|
||||
route: request.model.route.id,
|
||||
provider: request.model.provider,
|
||||
model: request.model.id,
|
||||
modelDefaults: request.model.defaults,
|
||||
compatibility: request.model.compatibility,
|
||||
routeDefaults: {
|
||||
generation: request.model.route.defaults.generation,
|
||||
providerOptions: request.model.route.defaults.providerOptions,
|
||||
http: request.model.route.defaults.http,
|
||||
},
|
||||
generation: request.generation,
|
||||
providerOptions: request.providerOptions,
|
||||
http: request.http,
|
||||
toolChoice: request.toolChoice,
|
||||
cache: request.cache,
|
||||
}),
|
||||
tools: request.tools.map((tool) => ({ label: tool.name, hash: hash(tool) })),
|
||||
system: request.system.map((part, index) => ({ label: `system[${index}]`, hash: hash(part) })),
|
||||
messages: request.messages.map((message, index) => ({
|
||||
label: message.id ?? `${message.role}[${index}]`,
|
||||
hash: hash(message),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function compare(previous: Snapshot | undefined, current: Snapshot): Comparison {
|
||||
if (!previous) return { status: "initial" }
|
||||
if (previous.settings !== current.settings)
|
||||
return {
|
||||
status: "changed",
|
||||
component: "settings",
|
||||
index: 0,
|
||||
label: "model settings",
|
||||
}
|
||||
const tools = firstChange(previous.tools, current.tools, false)
|
||||
if (tools) return { status: "changed", component: "tools", ...tools }
|
||||
const system = firstChange(previous.system, current.system, false)
|
||||
if (system) return { status: "changed", component: "system", ...system }
|
||||
const messages = firstChange(previous.messages, current.messages, true)
|
||||
if (messages) return { status: "changed", component: "messages", ...messages }
|
||||
if (previous.messages.length === current.messages.length)
|
||||
return { status: "stable", messages: current.messages.length }
|
||||
return {
|
||||
status: "append-only",
|
||||
previousMessages: previous.messages.length,
|
||||
currentMessages: current.messages.length,
|
||||
}
|
||||
}
|
||||
|
||||
function firstChange(previous: ReadonlyArray<Entry>, current: ReadonlyArray<Entry>, allowAppend: boolean) {
|
||||
const index = previous.findIndex((entry, index) => entry.hash !== current[index]?.hash)
|
||||
if (index >= 0)
|
||||
return {
|
||||
index,
|
||||
label: current[index]?.label ?? previous[index]?.label ?? `entry[${index}]`,
|
||||
}
|
||||
if (current.length === previous.length || (allowAppend && current.length > previous.length)) return
|
||||
return {
|
||||
index: previous.length,
|
||||
label: current[previous.length]?.label ?? `entry[${previous.length}]`,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as SessionRunnerLLM from "./llm.js"
|
||||
|
||||
import { Message } from "@opencode-ai/ai"
|
||||
import { Cause, Config, Effect, Exit, FiberMap, Layer, Pull, Schedule } from "effect"
|
||||
import { Cause, Effect, Exit, FiberMap, Layer, Pull, Schedule } from "effect"
|
||||
import { Database } from "../../database/database.js"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { InstructionState } from "../instruction-state.js"
|
||||
@@ -24,7 +24,6 @@ import { SessionRunnerRetry } from "./retry.js"
|
||||
import { SessionStep } from "./step.js"
|
||||
import { ToolOutput } from "../../tool-output.js"
|
||||
import { PluginSupervisor } from "../../plugin/supervisor.js"
|
||||
import { PromptCacheDiagnostics } from "../prompt-cache-diagnostics.js"
|
||||
import { MAX_STEPS_PROMPT } from "./max-steps.js"
|
||||
|
||||
const CONTINUE_AFTER_INCOMPLETE_STREAM =
|
||||
@@ -42,32 +41,6 @@ const layer = Layer.effect(
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
const title = yield* SessionTitle.Service
|
||||
const steps = yield* SessionStep.make
|
||||
const diagnostics = yield* Config.boolean("OPENCODE_PROMPT_CACHE_DIAGNOSTICS").pipe(
|
||||
Config.withDefault(false),
|
||||
Effect.orDie,
|
||||
)
|
||||
const promptCacheSnapshots = diagnostics ? new Map<string, PromptCacheDiagnostics.Snapshot>() : undefined
|
||||
const diagnosePromptCache = Effect.fn("SessionRunner.diagnosePromptCache")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
request: Parameters<typeof PromptCacheDiagnostics.snapshot>[0],
|
||||
) {
|
||||
if (!promptCacheSnapshots) return
|
||||
const current = PromptCacheDiagnostics.snapshot(request)
|
||||
const comparison = PromptCacheDiagnostics.compare(promptCacheSnapshots.get(sessionID), current)
|
||||
promptCacheSnapshots.delete(sessionID)
|
||||
promptCacheSnapshots.set(sessionID, current)
|
||||
const oldest = promptCacheSnapshots.keys().next().value
|
||||
if (promptCacheSnapshots.size > 100 && oldest !== undefined) promptCacheSnapshots.delete(oldest)
|
||||
yield* Effect.logInfo("prompt cache prefix").pipe(
|
||||
Effect.annotateLogs({
|
||||
sessionID,
|
||||
toolCount: current.tools.length,
|
||||
systemParts: current.system.length,
|
||||
messageCount: current.messages.length,
|
||||
...comparison,
|
||||
}),
|
||||
)
|
||||
})
|
||||
// Title generation starts once input is visible and must not delay model execution.
|
||||
const titles = yield* FiberMap.make<SessionSchema.ID, void, never>()
|
||||
|
||||
@@ -243,14 +216,12 @@ const layer = Layer.effect(
|
||||
toolChoice: stepLimitReached ? "none" : undefined,
|
||||
webSocket: "session",
|
||||
})
|
||||
yield* diagnosePromptCache(sessionID, prepared.request)
|
||||
const outcome = yield* steps.attempt({
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
agent: loaded.agent.id,
|
||||
model: loaded.model,
|
||||
prepared,
|
||||
toolsDisabled: stepLimitReached,
|
||||
recoverContinuation,
|
||||
recoverOverflow: Effect.suspend(() =>
|
||||
recoverOverflow && compaction.enabled()
|
||||
|
||||
@@ -44,7 +44,6 @@ interface Input {
|
||||
readonly agent: Agent.ID
|
||||
readonly model: SessionRunnerModel.Resolved
|
||||
readonly prepared: SessionModelRequest.Prepared
|
||||
readonly toolsDisabled: boolean
|
||||
readonly recoverContinuation: boolean
|
||||
/** The runner owns compaction policy; the attempt invokes it only before durable output. */
|
||||
readonly recoverOverflow: Effect.Effect<boolean>
|
||||
@@ -73,11 +72,12 @@ export const make = Effect.gen(function* () {
|
||||
})
|
||||
const toolRuns: Array<{
|
||||
readonly call: ToolCall
|
||||
readonly fiber: Fiber.Fiber<void, SessionModelRequest.ExecuteError>
|
||||
readonly fiber: Fiber.Fiber<void, Permission.DeclinedError | QuestionTool.CancelledError>
|
||||
}> = []
|
||||
const interruptTools = Effect.suspend(() => Fiber.interruptAll(toolRuns.map((run) => run.fiber)))
|
||||
const executeTool = (call: ToolCall) => {
|
||||
if (input.toolsDisabled) return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
|
||||
if (input.prepared.request.toolChoice?.type === "none")
|
||||
return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
|
||||
return input.prepared.executeTool({
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
@@ -132,10 +132,7 @@ export const make = Effect.gen(function* () {
|
||||
if (streamInterrupted) yield* interruptTools
|
||||
const joined = yield* restore(Fiber.awaitAll(toolRuns.map((run) => run.fiber))).pipe(Effect.exit)
|
||||
if (Exit.isFailure(joined)) yield* interruptTools
|
||||
const tools = classifyToolExits(
|
||||
joined,
|
||||
toolRuns.map((run) => run.call),
|
||||
)
|
||||
const tools = classifyToolExits(joined, toolRuns)
|
||||
|
||||
if (
|
||||
!publisher.record().outputStarted &&
|
||||
@@ -240,7 +237,7 @@ export const make = Effect.gen(function* () {
|
||||
if (tools.interrupted && Exit.isFailure(joined)) return yield* Effect.failCause(joined.cause)
|
||||
if (record.failure) return yield* new StepFailedError({ error: record.failure })
|
||||
return Outcome.Completed({
|
||||
needsContinuation: !input.toolsDisabled && record.needsContinuation,
|
||||
needsContinuation: input.prepared.request.toolChoice?.type !== "none" && record.needsContinuation,
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -249,27 +246,22 @@ export const make = Effect.gen(function* () {
|
||||
return { attempt }
|
||||
})
|
||||
|
||||
const isDecline = (
|
||||
error: SessionModelRequest.ExecuteError,
|
||||
): error is Permission.DeclinedError | QuestionTool.CancelledError =>
|
||||
error._tag === "Permission.DeclinedError" || error._tag === "QuestionTool.CancelledError"
|
||||
|
||||
const isInterruptedStream = (failure: AIError) => {
|
||||
if (failure.reason._tag === "InvalidProviderOutput") return failure.reason.classification === "incomplete-stream"
|
||||
if (failure.reason._tag === "Transport") return failure.reason.operation === "read"
|
||||
return false
|
||||
}
|
||||
|
||||
/** Keep every joined exit associated with its call; a decline is not an infrastructure failure. */
|
||||
/** Tool.Error settles in each fiber; only user declines remain in the typed error channel. */
|
||||
const classifyToolExits = (
|
||||
settled: Exit.Exit<Array<Exit.Exit<void, SessionModelRequest.ExecuteError>>>,
|
||||
calls: ReadonlyArray<ToolCall>,
|
||||
settled: Exit.Exit<Array<Exit.Exit<void, Permission.DeclinedError | QuestionTool.CancelledError>>>,
|
||||
runs: ReadonlyArray<{ readonly call: ToolCall }>,
|
||||
) => {
|
||||
const exits = Exit.isSuccess(settled) ? settled.value : []
|
||||
const declines = exits.flatMap((exit, index) =>
|
||||
Exit.isFailure(exit)
|
||||
? exit.cause.reasons.flatMap((reason) =>
|
||||
Cause.isFailReason(reason) && isDecline(reason.error) ? [{ call: calls[index], reason: reason.error }] : [],
|
||||
Cause.isFailReason(reason) ? [{ call: runs[index].call, reason: reason.error }] : [],
|
||||
)
|
||||
: [],
|
||||
)
|
||||
@@ -279,11 +271,8 @@ const classifyToolExits = (
|
||||
const failure = causes
|
||||
.flatMap((cause) => {
|
||||
if (Cause.hasInterrupts(cause)) return []
|
||||
const reasons = cause.reasons.flatMap(
|
||||
(reason): Array<Cause.Reason<never>> =>
|
||||
Cause.isFailReason(reason) ? (isDecline(reason.error) ? [] : [Cause.makeDieReason(reason.error)]) : [reason],
|
||||
)
|
||||
return reasons.length > 0 ? [Cause.fromReasons(reasons)] : []
|
||||
const reasons = cause.reasons.filter(Cause.isDieReason)
|
||||
return reasons.length > 0 ? [Cause.fromReasons<never>(reasons)] : []
|
||||
})
|
||||
.at(0)
|
||||
return { interrupted: causes.some(Cause.hasInterrupts), declines, failure }
|
||||
|
||||
+104
-126
@@ -1,8 +1,7 @@
|
||||
export * as SessionTitle from "./title.js"
|
||||
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { LLMClient, AIError, LLMEvent, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||
import { LLMClient, LLMEvent, Message, SystemPart } from "@opencode-ai/ai"
|
||||
import { Context, DateTime, Effect, Layer, Stream } from "effect"
|
||||
import type { Agent } from "../agent.js"
|
||||
import { Database } from "../database/database.js"
|
||||
@@ -23,15 +22,6 @@ const MAX_CONTEXT_LENGTH = 8_000
|
||||
const MAX_FIRST_MESSAGE_LENGTH = 2_000
|
||||
const titleChanged = Symbol("Session title changed")
|
||||
|
||||
type Dependencies = {
|
||||
readonly bus: Bus.Interface
|
||||
readonly llm: {
|
||||
readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream<LLMEvent, AIError>
|
||||
}
|
||||
readonly context: SessionContext.Interface
|
||||
readonly store: SessionStore.Interface
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
/** Generates an initial title or regenerates one from bounded conversation history. */
|
||||
readonly generate: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
@@ -46,117 +36,6 @@ export const isUntitled = (session: SessionSchema.Info) =>
|
||||
time: { created: DateTime.toEpochMillis(session.time.created) },
|
||||
})
|
||||
|
||||
const attempt = Effect.fn("SessionTitle.attempt")(function* (
|
||||
dependencies: Dependencies,
|
||||
input: {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly agent: Agent.Info
|
||||
readonly text: string
|
||||
readonly model: SessionRunnerModel.Resolved
|
||||
},
|
||||
) {
|
||||
const chunks: string[] = []
|
||||
let failed = false
|
||||
let usage: SessionUsage.Recorded | undefined
|
||||
const recordUsage = Effect.suspend(() =>
|
||||
usage
|
||||
? dependencies.bus.publish(SessionEvent.UsageRecorded, {
|
||||
sessionID: input.session.id,
|
||||
source: "title",
|
||||
...usage,
|
||||
})
|
||||
: Effect.void,
|
||||
)
|
||||
const prepared = yield* dependencies.context.prepare({
|
||||
scope: { session: input.session, agentID: input.agent.id, model: input.model },
|
||||
transcript: {
|
||||
system: input.agent.system ? [SystemPart.make(input.agent.system)] : [],
|
||||
messages: [Message.user(input.text)],
|
||||
},
|
||||
contextHooks: false,
|
||||
})
|
||||
yield* dependencies.llm.stream(prepared.request, prepared.options).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event)) failed = true
|
||||
if (LLMEvent.is.textDelta(event)) chunks.push(event.text)
|
||||
if (LLMEvent.is.stepFinish(event)) {
|
||||
const step = SessionUsage.record(event.usage, input.model.cost)
|
||||
usage = usage ? SessionUsage.add(usage, step) : step
|
||||
}
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.catchTag("AI.Error", () =>
|
||||
Effect.sync(() => {
|
||||
failed = true
|
||||
}),
|
||||
),
|
||||
Effect.onInterrupt(() => recordUsage.pipe(Effect.asVoid)),
|
||||
)
|
||||
yield* recordUsage
|
||||
if (failed) return
|
||||
return chunks
|
||||
.join("")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0)
|
||||
})
|
||||
|
||||
const make = (dependencies: Dependencies) => {
|
||||
const generate = Effect.fn("SessionTitle.generate")(function* (
|
||||
db: Database.Interface["db"],
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
const session = yield* dependencies.store.get(sessionID)
|
||||
if (!session) return
|
||||
const firstUser = yield* SessionHistory.firstUserMessage(db, session.id)
|
||||
if (!firstUser) return
|
||||
const text = !isUntitled(session)
|
||||
? yield* dependencies.store.context(session.id).pipe(
|
||||
Effect.map((messages) => {
|
||||
const original = `Original request:\n${firstUser.text.slice(0, MAX_FIRST_MESSAGE_LENGTH)}`
|
||||
const recent = messages
|
||||
.flatMap((message) => {
|
||||
if (message.type === "user" && message.id !== firstUser.id) return [`User: ${message.text.trim()}`]
|
||||
if (message.type !== "assistant") return []
|
||||
const text = message.content
|
||||
.flatMap((part) => (part.type === "text" ? [part.text.trim()] : []))
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
return text ? [`Assistant: ${text}`] : []
|
||||
})
|
||||
.join("\n\n")
|
||||
if (!recent) return original
|
||||
const prefix = `${original}\n\nRecent conversation:\n`
|
||||
return `${prefix}${recent.slice(-(MAX_CONTEXT_LENGTH - prefix.length))}`
|
||||
}),
|
||||
Effect.orElseSucceed(() => firstUser.text),
|
||||
)
|
||||
: firstUser.text
|
||||
const selection = yield* dependencies.context.selectTitle(session)
|
||||
if (!selection) return
|
||||
const title =
|
||||
(yield* attempt(dependencies, { session, agent: selection.agent, text, model: selection.selected })) ??
|
||||
(selection.primary && !isDeepStrictEqual(selection.selected.ref, selection.primary.ref)
|
||||
? yield* attempt(dependencies, { session, agent: selection.agent, text, model: selection.primary })
|
||||
: undefined)
|
||||
if (!title) return
|
||||
const expectedSequence = (yield* Bus.latestSequence(db, sessionID)) + 1
|
||||
const current = yield* dependencies.store.get(sessionID)
|
||||
if (!current || current.title !== session.title || current.title === truncate(title)) return
|
||||
yield* dependencies.bus
|
||||
.publish(
|
||||
SessionEvent.Renamed,
|
||||
{
|
||||
sessionID: session.id,
|
||||
title: truncate(title),
|
||||
},
|
||||
{ commit: (sequence) => (sequence === expectedSequence ? Effect.void : Effect.die(titleChanged)) },
|
||||
)
|
||||
.pipe(Effect.catchDefect((defect) => (defect === titleChanged ? Effect.void : Effect.die(defect))))
|
||||
})
|
||||
return { generate }
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -164,11 +43,110 @@ export const layer = Layer.effect(
|
||||
const llm = yield* LLMClient.Service
|
||||
const context = yield* SessionContext.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const database = yield* Database.Service
|
||||
const title = make({ bus, llm, context, store })
|
||||
return Service.of({
|
||||
generate: (sessionID) => title.generate(database.db, sessionID),
|
||||
const db = (yield* Database.Service).db
|
||||
|
||||
const attempt = Effect.fn("SessionTitle.attempt")(function* (input: {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly agent: Agent.Info
|
||||
readonly text: string
|
||||
readonly model: SessionRunnerModel.Resolved
|
||||
}) {
|
||||
const chunks: string[] = []
|
||||
let failed = false
|
||||
let usage: SessionUsage.Recorded | undefined
|
||||
const recordUsage = Effect.suspend(() =>
|
||||
usage
|
||||
? bus.publish(SessionEvent.UsageRecorded, {
|
||||
sessionID: input.session.id,
|
||||
source: "title",
|
||||
...usage,
|
||||
})
|
||||
: Effect.void,
|
||||
)
|
||||
const prepared = yield* context.prepare({
|
||||
scope: { session: input.session, agentID: input.agent.id, model: input.model },
|
||||
transcript: {
|
||||
system: input.agent.system ? [SystemPart.make(input.agent.system)] : [],
|
||||
messages: [Message.user(input.text)],
|
||||
},
|
||||
contextHooks: false,
|
||||
})
|
||||
yield* llm.stream(prepared.request, prepared.options).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event)) failed = true
|
||||
if (LLMEvent.is.textDelta(event)) chunks.push(event.text)
|
||||
if (LLMEvent.is.stepFinish(event)) {
|
||||
const step = SessionUsage.record(event.usage, input.model.cost)
|
||||
usage = usage ? SessionUsage.add(usage, step) : step
|
||||
}
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.catchTag("AI.Error", () =>
|
||||
Effect.sync(() => {
|
||||
failed = true
|
||||
}),
|
||||
),
|
||||
Effect.onInterrupt(() => recordUsage.pipe(Effect.asVoid)),
|
||||
)
|
||||
yield* recordUsage
|
||||
if (failed) return
|
||||
return chunks
|
||||
.join("")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0)
|
||||
})
|
||||
|
||||
const generate = Effect.fn("SessionTitle.generate")(function* (sessionID: SessionSchema.ID) {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return
|
||||
const firstUser = yield* SessionHistory.firstUserMessage(db, session.id)
|
||||
if (!firstUser) return
|
||||
const text = !isUntitled(session)
|
||||
? yield* store.context(session.id).pipe(
|
||||
Effect.map((messages) => {
|
||||
const original = `Original request:\n${firstUser.text.slice(0, MAX_FIRST_MESSAGE_LENGTH)}`
|
||||
const recent = messages
|
||||
.flatMap((message) => {
|
||||
if (message.type === "user" && message.id !== firstUser.id) return [`User: ${message.text.trim()}`]
|
||||
if (message.type !== "assistant") return []
|
||||
const text = message.content
|
||||
.flatMap((part) => (part.type === "text" ? [part.text.trim()] : []))
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
return text ? [`Assistant: ${text}`] : []
|
||||
})
|
||||
.join("\n\n")
|
||||
if (!recent) return original
|
||||
const prefix = `${original}\n\nRecent conversation:\n`
|
||||
return `${prefix}${recent.slice(-(MAX_CONTEXT_LENGTH - prefix.length))}`
|
||||
}),
|
||||
Effect.orElseSucceed(() => firstUser.text),
|
||||
)
|
||||
: firstUser.text
|
||||
const selection = yield* context.selectTitle(session)
|
||||
if (!selection) return
|
||||
const title =
|
||||
(yield* attempt({ session, agent: selection.agent, text, model: selection.selected })) ??
|
||||
(selection.primary && !isDeepStrictEqual(selection.selected.ref, selection.primary.ref)
|
||||
? yield* attempt({ session, agent: selection.agent, text, model: selection.primary })
|
||||
: undefined)
|
||||
if (!title) return
|
||||
const expectedSequence = (yield* Bus.latestSequence(db, sessionID)) + 1
|
||||
const current = yield* store.get(sessionID)
|
||||
if (!current || current.title !== session.title || current.title === truncate(title)) return
|
||||
yield* bus
|
||||
.publish(
|
||||
SessionEvent.Renamed,
|
||||
{
|
||||
sessionID: session.id,
|
||||
title: truncate(title),
|
||||
},
|
||||
{ commit: (sequence) => (sequence === expectedSequence ? Effect.void : Effect.die(titleChanged)) },
|
||||
)
|
||||
.pipe(Effect.catchDefect((defect) => (defect === titleChanged ? Effect.void : Effect.die(defect))))
|
||||
})
|
||||
return Service.of({ generate })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { DateTime, Effect, Layer } from "effect"
|
||||
import { CommandInvocation } from "@opencode-ai/core/command/invocation"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ShellSelect } from "@opencode-ai/core/shell/select"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { ConfigCommand } from "@opencode-ai/schema/config/command"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { tempLocationLayer } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { host } from "../plugin/host"
|
||||
|
||||
const shell = ShellSelect.Service.of({
|
||||
resolve: (input) =>
|
||||
Effect.sync(() => {
|
||||
expect(input).toEqual({ priority: "config" })
|
||||
return "sh"
|
||||
}),
|
||||
transform: () => Effect.die("unused shell.transform"),
|
||||
reload: () => Effect.die("unused shell.reload"),
|
||||
})
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(AppNodeBuilder.build(AppProcess.node), tempLocationLayer, Layer.succeed(ShellSelect.Service, shell)),
|
||||
)
|
||||
const sessionID = Session.ID.make("ses_command_invocation")
|
||||
|
||||
describe("CommandInvocation", () => {
|
||||
it.effect("expands arguments without changing unconfigured session defaults or prompt attachments", () =>
|
||||
Effect.gen(function* () {
|
||||
const prompts: unknown[] = []
|
||||
const invoke = yield* CommandInvocation.make(promptHost(prompts))
|
||||
const files = [{ uri: "file:///context.md", name: "context" }]
|
||||
for (const [template, text, expected] of [
|
||||
[
|
||||
"$2 / $1 / $2",
|
||||
`"alpha beta" 'gamma delta' [Image 3] tail`,
|
||||
"gamma delta [Image 3] tail / alpha beta / gamma delta [Image 3] tail",
|
||||
],
|
||||
["[$1][$3]", "one two", "[one][]"],
|
||||
["raw [$ARGUMENTS]", `"alpha beta" 'gamma delta'`, `raw ["alpha beta" 'gamma delta']`],
|
||||
[" Review ", " details ", "Review \n\n details"],
|
||||
[" Review ", " ", "Review"],
|
||||
]) {
|
||||
expect(
|
||||
yield* invoke(new ConfigCommand.Info({ template }), {
|
||||
sessionID,
|
||||
prompt: { text, files },
|
||||
delivery: "queue",
|
||||
}),
|
||||
).toBeUndefined()
|
||||
expect(prompts.at(-1)).toEqual({ sessionID, text: expected, files, delivery: "queue" })
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("switches agents before applying command or agent model defaults and admitting the prompt", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: unknown[] = []
|
||||
const ctx = promptHost(calls)
|
||||
const location = yield* Location.Service
|
||||
const reviewer = Agent.ID.make("reviewer")
|
||||
const agentModel = { id: Model.ID.make("agent-model"), providerID: Provider.ID.make("example") }
|
||||
const commandModel = {
|
||||
model: Model.ID.make("command-model"),
|
||||
providerID: Provider.ID.make("example"),
|
||||
variant: Model.VariantID.make("careful"),
|
||||
}
|
||||
const session = Session.Info.make({
|
||||
id: sessionID,
|
||||
projectID: location.project.id,
|
||||
agent: Agent.ID.make("build"),
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||
location: { directory: location.directory },
|
||||
})
|
||||
for (const testCase of [
|
||||
{
|
||||
currentAgent: session.agent,
|
||||
agentModel,
|
||||
command: new ConfigCommand.Info({ template: "Review", agent: reviewer, model: commandModel }),
|
||||
expected: [
|
||||
["session.get", { sessionID }],
|
||||
["switchAgent", { sessionID, agent: reviewer }],
|
||||
["agent.get", { agentID: reviewer }],
|
||||
["switchModel", { sessionID, model: { id: "command-model", providerID: "example", variant: "careful" } }],
|
||||
],
|
||||
},
|
||||
{
|
||||
currentAgent: reviewer,
|
||||
agentModel,
|
||||
command: new ConfigCommand.Info({ template: "Review", agent: reviewer }),
|
||||
expected: [
|
||||
["session.get", { sessionID }],
|
||||
["agent.get", { agentID: reviewer }],
|
||||
["switchModel", { sessionID, model: agentModel }],
|
||||
],
|
||||
},
|
||||
{
|
||||
currentAgent: session.agent,
|
||||
agentModel: undefined,
|
||||
command: new ConfigCommand.Info({ template: "Review", agent: reviewer }),
|
||||
expected: [
|
||||
["session.get", { sessionID }],
|
||||
["switchAgent", { sessionID, agent: reviewer }],
|
||||
["agent.get", { agentID: reviewer }],
|
||||
],
|
||||
},
|
||||
{
|
||||
currentAgent: session.agent,
|
||||
agentModel,
|
||||
command: new ConfigCommand.Info({
|
||||
template: "Review",
|
||||
model: { model: commandModel.model, providerID: commandModel.providerID },
|
||||
}),
|
||||
expected: [["switchModel", { sessionID, model: { id: "command-model", providerID: "example" } }]],
|
||||
},
|
||||
]) {
|
||||
calls.length = 0
|
||||
const invoke = yield* CommandInvocation.make(
|
||||
host({
|
||||
agent: {
|
||||
...ctx.agent,
|
||||
get: (input) =>
|
||||
Effect.sync(() => {
|
||||
calls.push(["agent.get", input])
|
||||
return { location, data: { ...Agent.Info.default(reviewer), model: testCase.agentModel } }
|
||||
}),
|
||||
},
|
||||
session: {
|
||||
...ctx.session,
|
||||
get: (input) =>
|
||||
Effect.sync(() => {
|
||||
calls.push(["session.get", input])
|
||||
return { ...session, agent: testCase.currentAgent }
|
||||
}),
|
||||
switchAgent: (input) => Effect.sync(() => calls.push(["switchAgent", input])),
|
||||
switchModel: (input) => Effect.sync(() => calls.push(["switchModel", input])),
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* invoke(testCase.command, {
|
||||
sessionID,
|
||||
prompt: { text: "" },
|
||||
delivery: "steer",
|
||||
})
|
||||
expect(calls).toEqual([...testCase.expected, { sessionID, text: "Review", delivery: "steer" }])
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("interpolates in source order using the location, closed stdin and nonzero-exit output", () =>
|
||||
Effect.gen(function* () {
|
||||
const prompts: unknown[] = []
|
||||
const location = yield* Location.Service
|
||||
yield* Effect.promise(() => Bun.write(path.join(location.directory, "context.txt"), "context"))
|
||||
const invoke = yield* CommandInvocation.make(promptHost(prompts))
|
||||
yield* invoke(
|
||||
new ConfigCommand.Info({
|
||||
template:
|
||||
'first=!`read value || printf closed-; cat context.txt; sleep 0.05; printf "%s" "-stderr" >&2; exit 7`; second=!`printf "%s" "$1"`',
|
||||
}),
|
||||
{ sessionID, prompt: { text: "argument" }, delivery: "steer" },
|
||||
)
|
||||
expect(prompts).toEqual([{ sessionID, text: "first=closed-context-stderr; second=argument", delivery: "steer" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("wraps process failures with the shell source and does not admit a prompt", () =>
|
||||
Effect.gen(function* () {
|
||||
const prompts: unknown[] = []
|
||||
const location = yield* Location.Service
|
||||
const missing = path.join(location.directory, "missing-shell")
|
||||
const invoke = yield* CommandInvocation.make(promptHost(prompts)).pipe(
|
||||
Effect.provideService(ShellSelect.Service, { ...shell, resolve: () => Effect.succeed(missing) }),
|
||||
)
|
||||
const error = yield* invoke(new ConfigCommand.Info({ template: '!`printf "hello"`' }), {
|
||||
sessionID,
|
||||
prompt: { text: "" },
|
||||
delivery: "steer",
|
||||
}).pipe(Effect.flip)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect(String(error)).toContain('Shell interpolation failed for "printf \\"hello\\"": Command failed:')
|
||||
expect(String(error)).toContain(missing)
|
||||
expect(prompts).toEqual([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
function promptHost(prompts: unknown[]) {
|
||||
return host({
|
||||
session: {
|
||||
prompt: (input) =>
|
||||
Effect.sync(() => {
|
||||
prompts.push(input)
|
||||
return SessionInbox.User.make({
|
||||
id: SessionMessage.ID.make("msg_command_invocation"),
|
||||
sessionID: input.sessionID,
|
||||
timeCreated: DateTime.makeUnsafe(0),
|
||||
type: "user",
|
||||
payload: { text: input.text },
|
||||
delivery: input.delivery ?? "steer",
|
||||
})
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -76,57 +76,6 @@ const provider = {
|
||||
}
|
||||
|
||||
describe("Config", () => {
|
||||
it.live("updates the first file-backed document", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const project = path.join(tmp.path, "project")
|
||||
const globalFile = path.join(global, "opencode.jsonc")
|
||||
const projectFile = path.join(project, "opencode.json")
|
||||
return Effect.promise(async () => {
|
||||
await Promise.all([fs.mkdir(global, { recursive: true }), fs.mkdir(project, { recursive: true })])
|
||||
await Promise.all([
|
||||
fs.writeFile(globalFile, '{\n // Keep this comment.\n "shell": "global"\n}\n'),
|
||||
fs.writeFile(projectFile, JSON.stringify({ shell: "project" })),
|
||||
])
|
||||
}).pipe(
|
||||
Effect.andThen(
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const content = yield* Effect.promise(() => fs.readFile(globalFile, "utf8"))
|
||||
const cause = new Error("Rejected config update")
|
||||
const error = yield* config
|
||||
.update((draft) => {
|
||||
draft.shell = "discarded"
|
||||
throw cause
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(Config.UpdateError)
|
||||
expect(error.message).toBe("Config update failed")
|
||||
expect(error.cause).toBe(cause)
|
||||
expect(yield* Effect.promise(() => fs.readFile(globalFile, "utf8"))).toBe(content)
|
||||
|
||||
const updated = yield* config.update((draft) => {
|
||||
draft.shell = "updated"
|
||||
})
|
||||
|
||||
expect(updated.shell).toBe("updated")
|
||||
expect(yield* Effect.promise(() => fs.readFile(globalFile, "utf8"))).toContain("// Keep this comment.")
|
||||
expect(yield* Effect.promise(() => fs.readFile(globalFile, "utf8"))).toContain('"shell": "updated"')
|
||||
expect(JSON.parse(yield* Effect.promise(() => fs.readFile(projectFile, "utf8")))).toEqual({
|
||||
shell: "project",
|
||||
})
|
||||
}).pipe(Effect.provide(testLayer(project, global))),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("excludes home-level claude and agents directories when global is disabled", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -206,16 +155,6 @@ describe("Config", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("fails updates when no file-backed document exists", () =>
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const error = yield* config.update((draft) => void draft).pipe(Effect.flip)
|
||||
expect(error.message).toBe("No editable config document found")
|
||||
}).pipe(
|
||||
Effect.provide(Config.testLayer([new Document({ type: "document", info: new Info({ shell: "virtual" }) })])),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads explicit file and content overrides in priority order", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -430,38 +369,6 @@ describe("Config", () => {
|
||||
}).pipe(Effect.provide(Config.testLayer())),
|
||||
)
|
||||
|
||||
it.effect("keeps test config unchanged after an update callback fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const test = yield* Config.Test
|
||||
const entry = new Document({
|
||||
type: "document",
|
||||
path: AbsolutePath.make(path.join(import.meta.dir, "opencode.json")),
|
||||
info: new Info({ shell: "initial" }),
|
||||
})
|
||||
yield* test.setEntries([entry])
|
||||
const cause = new Error("Rejected config update")
|
||||
const error = yield* config
|
||||
.update((draft) => {
|
||||
draft.shell = "discarded"
|
||||
throw cause
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(Config.UpdateError)
|
||||
expect(error.message).toBe("Config update failed")
|
||||
expect(error.cause).toBe(cause)
|
||||
expect(yield* config.entries()).toEqual([entry])
|
||||
expect(entry.info.shell).toBe("initial")
|
||||
|
||||
const updated = yield* config.update((draft) => {
|
||||
draft.shell = "recovered"
|
||||
})
|
||||
expect(updated.shell).toBe("recovered")
|
||||
expect(Config.latest(yield* test.entries(), "shell")).toBe("recovered")
|
||||
}).pipe(Effect.provide(Config.testLayer())),
|
||||
)
|
||||
|
||||
it.effect("returns the latest defined scalar from priority-ordered documents", () =>
|
||||
Effect.sync(() => {
|
||||
const entries = [
|
||||
|
||||
@@ -14,7 +14,6 @@ describe("ConfigEntryObserver", () => {
|
||||
const reloaded = yield* Deferred.make<void>()
|
||||
const config = Config.Service.of({
|
||||
entries: () => Ref.get(current),
|
||||
update: () => Effect.die("unused config.update"),
|
||||
changes: () => Stream.empty,
|
||||
})
|
||||
const event = {
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { parse } from "jsonc-parser"
|
||||
import { isRecord } from "@opencode-ai/ai/utils/record"
|
||||
import { ConfigFile } from "@opencode-ai/core/config/file"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { withTempDir } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
// No Config, Location, Watcher, Credential, or WellKnown services are provided.
|
||||
const it = testEffect(LayerNode.compile(FSUtil.node))
|
||||
|
||||
describe("ConfigFile", () => {
|
||||
it.live("edits the explicit target and preserves comments and unrelated fields", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = path.join(tmp.path, "global", "opencode.jsonc")
|
||||
const target = path.join(tmp.path, "project", "custom.jsonc")
|
||||
const text = '{\n // Keep this comment.\n "shell": "project",\n "custom": { "value": 1 },\n}\n'
|
||||
yield* fs.writeWithDirs(global, '{ "shell": "global" }')
|
||||
yield* fs.writeWithDirs(target, text)
|
||||
|
||||
const updated = yield* ConfigFile.update(target, (draft) => {
|
||||
draft.shell = "updated"
|
||||
})
|
||||
|
||||
expect(updated).toEqual({ shell: "updated", custom: { value: 1 } })
|
||||
expect(yield* fs.readFileString(target)).toBe(text.replace('"project"', '"updated"'))
|
||||
expect(yield* fs.readFileString(global)).toBe('{ "shell": "global" }')
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("leaves raw substitutions, model shorthand, and legacy shapes unresolved", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.jsonc")
|
||||
const text = `{
|
||||
"model": "{env:OPENCODE_TEST_CONFIG_MODEL}",
|
||||
"shell": "{file:missing-shell.txt}",
|
||||
"skills": { "paths": ["./skills"] },
|
||||
"agent": { "review": { "model": "acme/reasoner" } },
|
||||
"username": "before"
|
||||
}
|
||||
`
|
||||
yield* fs.writeFileString(target, text)
|
||||
yield* ConfigFile.update(target, (draft) => {
|
||||
expect(draft.model).toBe("{env:OPENCODE_TEST_CONFIG_MODEL}")
|
||||
expect(draft.shell).toBe("{file:missing-shell.txt}")
|
||||
expect(draft.skills).toEqual({ paths: ["./skills"] })
|
||||
draft.username = "after"
|
||||
})
|
||||
|
||||
expect(yield* fs.readFileString(target)).toBe(text.replace('"before"', '"after"'))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("patches nested source fields and deletes legacy keys without migrating them", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.jsonc")
|
||||
yield* fs.writeFileString(
|
||||
target,
|
||||
`{
|
||||
"agent": {
|
||||
"review": { "description": "before", "hidden": true },
|
||||
// Keep the other definition.
|
||||
"build": { "description": "unchanged" }
|
||||
},
|
||||
"snapshot": true
|
||||
}
|
||||
`,
|
||||
)
|
||||
const updated = yield* ConfigFile.update(target, (draft) => {
|
||||
const agent: unknown = draft.agent
|
||||
if (!isRecord(agent) || !isRecord(agent.review)) throw new Error("Missing fixture agent")
|
||||
agent.review.description = "after"
|
||||
agent.review.color = "blue"
|
||||
delete agent.review.hidden
|
||||
delete draft.snapshot
|
||||
})
|
||||
|
||||
expect(updated).toEqual({
|
||||
agent: { review: { description: "after", color: "blue" }, build: { description: "unchanged" } },
|
||||
})
|
||||
expect(parse(yield* fs.readFileString(target))).toEqual(updated)
|
||||
expect(yield* fs.readFileString(target)).toContain("// Keep the other definition.")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("patches array elements without rewriting untouched comments", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.jsonc")
|
||||
const text = `{
|
||||
"plugins": [
|
||||
// Keep the first plugin.
|
||||
"first",
|
||||
"second",
|
||||
// Keep the third plugin.
|
||||
"third",
|
||||
"fourth"
|
||||
]
|
||||
}
|
||||
`
|
||||
yield* fs.writeFileString(target, text)
|
||||
yield* ConfigFile.update(target, (draft) => {
|
||||
if (!Array.isArray(draft.plugins)) throw new Error("Missing fixture plugins")
|
||||
draft.plugins[1] = "updated"
|
||||
})
|
||||
expect(yield* fs.readFileString(target)).toBe(text.replace('"second"', '"updated"'))
|
||||
|
||||
const shortened = yield* ConfigFile.update(target, (draft) => {
|
||||
if (!Array.isArray(draft.plugins)) throw new Error("Missing fixture plugins")
|
||||
draft.plugins.splice(1, 3)
|
||||
})
|
||||
expect(shortened.plugins).toEqual(["first"])
|
||||
expect(parse(yield* fs.readFileString(target))).toEqual(shortened)
|
||||
|
||||
const extended = yield* ConfigFile.update(target, (draft) => {
|
||||
if (!Array.isArray(draft.plugins)) throw new Error("Missing fixture plugins")
|
||||
draft.plugins.push("added", "last")
|
||||
})
|
||||
expect(extended.plugins).toEqual(["first", "added", "last"])
|
||||
expect(parse(yield* fs.readFileString(target))).toEqual(extended)
|
||||
expect(yield* fs.readFileString(target)).toContain("// Keep the first plugin.")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("preserves adjacent comments when deleting properties and array elements", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.jsonc")
|
||||
yield* fs.writeFileString(
|
||||
target,
|
||||
`{
|
||||
"shell": "remove",
|
||||
// Keep the model explanation.
|
||||
"model": "acme/reasoner",
|
||||
"plugins": ["first", "second", /* Keep the plugin explanation. */ "third"],
|
||||
"skills": [/* Keep the source explanation. */ "remove",],
|
||||
}
|
||||
`,
|
||||
)
|
||||
const updated = yield* ConfigFile.update(target, (draft) => {
|
||||
delete draft.shell
|
||||
if (!Array.isArray(draft.plugins)) throw new Error("Missing fixture plugins")
|
||||
draft.plugins.splice(1, 1)
|
||||
draft.skills = []
|
||||
})
|
||||
|
||||
expect(parse(yield* fs.readFileString(target))).toEqual(updated)
|
||||
expect(updated).toEqual({ model: "acme/reasoner", plugins: ["first", "third"], skills: [] })
|
||||
expect(yield* fs.readFileString(target)).toContain("// Keep the model explanation.")
|
||||
expect(yield* fs.readFileString(target)).toContain("/* Keep the plugin explanation. */")
|
||||
expect(yield* fs.readFileString(target)).toContain("/* Keep the source explanation. */")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("deletes own JSON keys that also exist on Object.prototype", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
yield* fs.writeFileString(
|
||||
target,
|
||||
'{ "\\u005f_proto__": "remove", "constructor": "remove", "toString": "remove", "shell": "keep" }',
|
||||
)
|
||||
const updated = yield* ConfigFile.update(target, (draft) => {
|
||||
;["__proto__", "constructor", "toString"].forEach((key) => {
|
||||
delete draft[key]
|
||||
})
|
||||
})
|
||||
|
||||
expect(updated).toEqual({ shell: "keep" })
|
||||
expect(yield* fs.readJson(target)).toEqual(updated)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("preserves and edits object-valued __proto__ source keys", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
yield* fs.writeFileString(target, '{ "__proto__": { "value": "before" }, "shell": "keep" }')
|
||||
const updated = yield* ConfigFile.update(target, (draft) => {
|
||||
expect(Object.hasOwn(draft, "__proto__")).toBe(true)
|
||||
const entry: unknown = draft["__proto__"]
|
||||
if (!isRecord(entry)) throw new Error("Missing fixture entry")
|
||||
entry.value = "after"
|
||||
})
|
||||
|
||||
expect(updated).toEqual({ ["__proto__"]: { value: "after" }, shell: "keep" })
|
||||
expect(yield* fs.readJson(target)).toEqual(updated)
|
||||
expect(Object.getPrototypeOf(updated)).toBe(Object.prototype)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects a duplicate-key patch that would not change the effective value", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
const text = '{ "shell": "first", "shell": "second" }'
|
||||
yield* fs.writeFileString(target, text)
|
||||
const error = yield* ConfigFile.update(target, (draft) => {
|
||||
draft.shell = "after"
|
||||
}).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
|
||||
expect(error.message).toBe(`Config patch does not match the requested update: ${target}`)
|
||||
expect(yield* fs.readFileString(target)).toBe(text)
|
||||
expect(yield* fs.exists(target + ".tmp")).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rereads the selected file for consecutive edits without a watcher", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
yield* fs.writeFileString(target, '{ "shell": "first" }')
|
||||
yield* ConfigFile.update(target, (draft) => {
|
||||
draft.shell = "second"
|
||||
})
|
||||
yield* ConfigFile.update(target, (draft) => {
|
||||
expect(draft.shell).toBe("second")
|
||||
draft.username = "added"
|
||||
})
|
||||
expect(yield* fs.readJson(target)).toEqual({ shell: "second", username: "added" })
|
||||
|
||||
yield* fs.writeFileString(target, '{ "shell": "external", "username": "added" }')
|
||||
const updated = yield* ConfigFile.update(target, (draft) => {
|
||||
expect(draft.shell).toBe("external")
|
||||
draft.snapshots = false
|
||||
})
|
||||
expect(yield* fs.readJson(target)).toEqual(updated)
|
||||
expect(updated).toEqual({ shell: "external", username: "added", snapshots: false })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("serializes concurrent read-modify-write calls", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
yield* fs.writeFileString(target, '{ "count": 0 }')
|
||||
const increment = ConfigFile.update(target, (draft) => {
|
||||
if (typeof draft.count !== "number") throw new Error("Missing fixture count")
|
||||
draft.count++
|
||||
})
|
||||
yield* Effect.all([increment, increment, increment], { concurrency: "unbounded" })
|
||||
|
||||
expect(yield* fs.readJson(target)).toEqual({ count: 3 })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not rewrite no-op or structurally equal edits", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
const text = '{\r\n "plugins": ["first"]\r\n}'
|
||||
yield* fs.writeFileString(target, text)
|
||||
const before = yield* fs.stat(target)
|
||||
yield* ConfigFile.update(target, () => {})
|
||||
yield* ConfigFile.update(target, (draft) => {
|
||||
draft.plugins = ["first"]
|
||||
})
|
||||
|
||||
expect(yield* fs.readFileString(target)).toBe(text)
|
||||
expect((yield* fs.stat(target)).ino).toEqual(before.ino)
|
||||
expect((yield* fs.stat(target)).mtime).toEqual(before.mtime)
|
||||
expect(yield* fs.exists(target + ".tmp")).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("leaves the file unchanged when a callback throws and permits a later edit", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
const text = '{ "shell": "before" }'
|
||||
yield* fs.writeFileString(target, text)
|
||||
const cause = new Error("Rejected config update")
|
||||
const error = yield* ConfigFile.update(target, (draft) => {
|
||||
draft.shell = "discarded"
|
||||
throw cause
|
||||
}).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
|
||||
expect(error.message).toBe("Config update failed")
|
||||
expect(error.cause).toBe(cause)
|
||||
expect(yield* fs.readFileString(target)).toBe(text)
|
||||
expect(yield* fs.exists(target + ".tmp")).toBe(false)
|
||||
|
||||
yield* ConfigFile.update(target, (draft) => {
|
||||
draft.shell = "recovered"
|
||||
})
|
||||
expect(yield* fs.readJson(target)).toEqual({ shell: "recovered" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("ignores callback return values instead of replacing the document", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
yield* fs.writeFileString(target, "{}")
|
||||
|
||||
expect(yield* ConfigFile.update(target, () => new Date(0))).toEqual({})
|
||||
expect(yield* fs.readFileString(target)).toBe("{}")
|
||||
|
||||
const updated = yield* ConfigFile.update(target, (draft) => (draft.shell = "updated"))
|
||||
expect(updated).toEqual({ shell: "updated" })
|
||||
expect(yield* fs.readJson(target)).toEqual(updated)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects non-JSON mutations before writing", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
const text = '{ "shell": "before" }'
|
||||
yield* fs.writeFileString(target, text)
|
||||
const error = yield* ConfigFile.update(target, (draft) => {
|
||||
draft.invalid = Number.NaN
|
||||
}).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
|
||||
expect(error.message).toBe(`Config update must produce a JSON object: ${target}`)
|
||||
expect(yield* fs.readFileString(target)).toBe(text)
|
||||
expect(yield* fs.exists(target + ".tmp")).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
;["", "{", "[]", "null"].forEach((text) => {
|
||||
it.live(`rejects invalid or non-object source ${JSON.stringify(text)}`, () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
yield* fs.writeFileString(target, text)
|
||||
const error = yield* ConfigFile.update(target, () => {
|
||||
throw new Error("Callback must not run")
|
||||
}).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
|
||||
expect(error.message).toBe(`Invalid config file: ${target}`)
|
||||
expect(yield* fs.readFileString(target)).toBe(text)
|
||||
expect(yield* fs.exists(target + ".tmp")).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("reports a missing target without creating it", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "missing.json")
|
||||
const error = yield* ConfigFile.update(target, () => {}).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
|
||||
expect(error.message).toBe(`Failed to read config: ${target}`)
|
||||
expect(error.cause).toBeDefined()
|
||||
expect(yield* fs.exists(target)).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reports write failures without replacing the target", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
const text = '{ "shell": "before" }'
|
||||
yield* fs.writeFileString(target, text)
|
||||
yield* fs.makeDirectory(target + ".tmp")
|
||||
const error = yield* ConfigFile.update(target, (draft) => {
|
||||
draft.shell = "discarded"
|
||||
}).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
|
||||
expect(error.message).toBe(`Failed to write config: ${target}`)
|
||||
expect(error.cause).toBeDefined()
|
||||
expect(yield* fs.readFileString(target)).toBe(text)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -55,7 +55,6 @@ describe("ConfigImagePlugin.Plugin", () => {
|
||||
let reads = 0
|
||||
const config = Config.Service.of({
|
||||
entries: () => Effect.sync(() => [document({ max_width: reads++ === 0 ? 1_200 : 700, max_base64_bytes: 1 })]),
|
||||
update: () => Effect.die(new Error("Config update is unavailable")),
|
||||
changes: () => Stream.empty,
|
||||
})
|
||||
yield* ConfigImagePlugin.Plugin.effect(yield* PluginHost.make(plugins)).pipe(
|
||||
|
||||
@@ -255,7 +255,6 @@ describe("LocationWatcher subscriptions", () => {
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
entries: () => Effect.sync(() => entries.current),
|
||||
update: () => Effect.die("unused config.update"),
|
||||
changes: () => Stream.never,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -65,7 +65,7 @@ const aisdk = Layer.mock(AISDK.Service, {
|
||||
},
|
||||
model: () => Effect.succeed(runtime),
|
||||
})
|
||||
const client = TestLLM.clientLayer.pipe(Layer.provide(TestLLM.layer({ fallback: TestLLM.text("OK", "generate") })))
|
||||
const client = TestLLM.testLayer({ fallback: TestLLM.text("OK", "generate") })
|
||||
|
||||
const resolver = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk)))
|
||||
const it = testEffect(Generate.layer.pipe(Layer.provide(Layer.merge(resolver, client))))
|
||||
|
||||
@@ -205,7 +205,6 @@ function resourceMcpLayer(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
entries: overrides.entries,
|
||||
update: () => Effect.die("unused config update"),
|
||||
changes: () => Stream.never,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -9,12 +9,14 @@ import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { PersistentPty } from "@opencode-ai/core/persistent-pty"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { Vcs } from "@opencode-ai/core/vcs"
|
||||
import { Pty } from "@opencode-ai/schema/pty"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { PluginTestLayer } from "./plugin/fixture"
|
||||
|
||||
@@ -25,6 +27,54 @@ class Secret extends Context.Service<Secret, string>()("@opencode/test/PluginSec
|
||||
const versioned = <R>(plugin: EffectPlugin.Plugin<R>, version = "1") => ({ ...plugin, version })
|
||||
|
||||
describe("Plugin", () => {
|
||||
it.effect("routes experimental terminal reads through the runtime cell without wrapping results", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const cell = PluginRuntime.makeCell()
|
||||
const host = yield* PluginHost.make(plugins).pipe(Effect.provide(PluginRuntime.layerWithCell(cell)))
|
||||
const sessionID = Session.ID.make("ses_terminal")
|
||||
const pending = host.experimental.terminal.read({ sessionID, lines: 3 })
|
||||
const seen: unknown[] = []
|
||||
const terminal = {
|
||||
ptyID: Pty.ID.make("pty_terminal"),
|
||||
title: "Build",
|
||||
cwd: "/workspace",
|
||||
foregroundProcess: null,
|
||||
screen: { text: "one\ntwo\nthree", cols: 80, rows: 2, cursor: { x: 3, y: 1 } },
|
||||
}
|
||||
const error = new PersistentPty.UnavailableError({ message: "terminal daemon unavailable" })
|
||||
cell.runtime = {
|
||||
...runtime,
|
||||
persistentPty: {
|
||||
read: (id, lines) => {
|
||||
seen.push({ sessionID: id, lines })
|
||||
if (id === Session.ID.make("ses_failure")) return Effect.fail(error)
|
||||
return Effect.succeed(id === sessionID ? terminal : null)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
expect(Object.keys(host.experimental)).toEqual(["terminal"])
|
||||
expect(Object.keys(host.experimental.terminal)).toEqual(["read"])
|
||||
expect(yield* pending).toBe(terminal)
|
||||
expect(yield* host.experimental.terminal.read({ sessionID })).toBe(terminal)
|
||||
expect(yield* host.experimental.terminal.read({ sessionID: Session.ID.make("ses_empty") })).toBeNull()
|
||||
expect(
|
||||
yield* host.experimental.terminal.read({ sessionID: Session.ID.make("ses_failure") }).pipe(Effect.flip),
|
||||
).toBe(error)
|
||||
expect(seen).toEqual([
|
||||
{ sessionID, lines: 3 },
|
||||
{ sessionID, lines: undefined },
|
||||
{ sessionID: Session.ID.make("ses_empty"), lines: undefined },
|
||||
{ sessionID: Session.ID.make("ses_failure"), lines: undefined },
|
||||
])
|
||||
|
||||
cell.runtime = undefined
|
||||
expect(Exit.isFailure(yield* pending.pipe(Effect.exit))).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("exposes the current location to activated plugins", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { PersistentPty } from "@opencode-ai/core/persistent-pty"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { tempGlobalLayer } from "../fixture/global"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const cell = PluginRuntime.makeCell()
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Global.node,
|
||||
Bus.node,
|
||||
PersistentPty.node,
|
||||
PluginRuntime.node,
|
||||
PluginRuntime.providerNodeWithCell(cell),
|
||||
]),
|
||||
[
|
||||
[Global.node, tempGlobalLayer],
|
||||
[Watcher.node, Watcher.configured({ enabled: false })],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
[PluginRuntime.node, PluginRuntime.layerWithCell(cell)],
|
||||
[PersistentPty.node, PersistentPty.configured()],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
describe("Plugin runtime terminal reads", () => {
|
||||
it.live("shares the configured global PTY service and validates lines before an empty selection", () =>
|
||||
Effect.gen(function* () {
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const persistentPty = yield* PersistentPty.Service
|
||||
const sessionID = Session.ID.make("ses_no_terminal")
|
||||
|
||||
expect(cell.runtime?.persistentPty).toBe(persistentPty)
|
||||
expect(yield* runtime.persistentPty.read(sessionID)).toBeNull()
|
||||
expect(yield* runtime.persistentPty.read(sessionID, 1)).toBeNull()
|
||||
expect(yield* runtime.persistentPty.read(sessionID, 65535)).toBeNull()
|
||||
yield* Effect.forEach([0, -1, 1.5, 65536, NaN, Infinity], (lines) =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* runtime.persistentPty.read(sessionID, lines).pipe(Effect.flip)
|
||||
expect(error).toBeInstanceOf(PersistentPty.UnavailableError)
|
||||
expect(error.message).toContain("lines")
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -58,6 +58,11 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
event: overrides.event ?? {
|
||||
subscribe: () => Stream.empty,
|
||||
},
|
||||
experimental: overrides.experimental ?? {
|
||||
terminal: {
|
||||
read: () => Effect.die("unused experimental.terminal.read"),
|
||||
},
|
||||
},
|
||||
generate: overrides.generate ?? {
|
||||
text: () => Effect.die("unused generate.text"),
|
||||
},
|
||||
|
||||
@@ -22,6 +22,8 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { define } from "@opencode-ai/plugin/promise/plugin"
|
||||
import type { Info } from "@opencode-ai/plugin/promise/tool"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
|
||||
import { Pty } from "@opencode-ai/schema/pty"
|
||||
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
@@ -30,6 +32,84 @@ import { host as testHost } from "./host"
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
describe("fromPromise", () => {
|
||||
it.effect("validates and forwards experimental terminal reads through the protocol schema", () =>
|
||||
Effect.gen(function* () {
|
||||
const seen: unknown[] = []
|
||||
const terminal = PersistentPty.ReadResult.make({
|
||||
ptyID: Pty.ID.make("pty_terminal"),
|
||||
title: "Build",
|
||||
cwd: "/workspace",
|
||||
foregroundProcess: "bun",
|
||||
screen: { text: "one\ntwo\nthree", cols: 80, rows: 2, cursor: { x: 3, y: 1 } },
|
||||
})
|
||||
const host = testHost({
|
||||
experimental: {
|
||||
terminal: {
|
||||
read: (input) => {
|
||||
seen.push(input)
|
||||
return Effect.succeed(terminal)
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
yield* PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-terminal-read",
|
||||
setup: async (ctx) => {
|
||||
expect(Object.keys(ctx.experimental)).toEqual(["terminal"])
|
||||
expect(Object.keys(ctx.experimental.terminal)).toEqual(["read"])
|
||||
for (const lines of [0, -1, 1.5, 65536, NaN, Infinity, "3"]) {
|
||||
await expect(
|
||||
Reflect.apply(ctx.experimental.terminal.read, undefined, [{ sessionID: "ses_terminal", lines }]),
|
||||
).rejects.toBeDefined()
|
||||
}
|
||||
await expect(Reflect.apply(ctx.experimental.terminal.read, undefined, [{ lines: 3 }])).rejects.toBeDefined()
|
||||
expect(seen).toEqual([])
|
||||
expect(await ctx.experimental.terminal.read({ sessionID: "ses_terminal" })).toEqual(terminal)
|
||||
expect(await ctx.experimental.terminal.read({ sessionID: "ses_terminal", lines: 3 })).toEqual(terminal)
|
||||
await ctx.experimental.terminal.read({ sessionID: "ses_terminal", lines: 1 })
|
||||
await ctx.experimental.terminal.read({ sessionID: "ses_terminal", lines: 65535 })
|
||||
},
|
||||
}),
|
||||
).effect(host)
|
||||
|
||||
expect(seen).toEqual([
|
||||
{ sessionID: Session.ID.make("ses_terminal") },
|
||||
{ sessionID: Session.ID.make("ses_terminal"), lines: 3 },
|
||||
{ sessionID: Session.ID.make("ses_terminal"), lines: 1 },
|
||||
{ sessionID: Session.ID.make("ses_terminal"), lines: 65535 },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves null terminal reads and rejects daemon failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const host = testHost({
|
||||
experimental: {
|
||||
terminal: {
|
||||
read: (input) =>
|
||||
input.sessionID === Session.ID.make("ses_failure")
|
||||
? Effect.fail(new Error("terminal daemon unavailable"))
|
||||
: Effect.succeed(null),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
yield* PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-terminal-null",
|
||||
setup: async (ctx) => {
|
||||
expect(await ctx.experimental.terminal.read({ sessionID: "ses_empty" })).toBeNull()
|
||||
await expect(ctx.experimental.terminal.read({ sessionID: "ses_failure" })).rejects.toThrow(
|
||||
"terminal daemon unavailable",
|
||||
)
|
||||
},
|
||||
}),
|
||||
).effect(host)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("exposes the host location including workspace and project metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { GenerationOptions, LLM, LLMRequest, Message, LanguageModel, ToolDefinition } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import { PromptCacheDiagnostics } from "@opencode-ai/core/session/prompt-cache-diagnostics"
|
||||
|
||||
const model = LanguageModel.make({ id: "test", provider: "test", route: OpenAIChat.route })
|
||||
const tool = ToolDefinition.make({
|
||||
name: "read",
|
||||
description: "Read a file",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
})
|
||||
|
||||
const request = LLM.request({
|
||||
model,
|
||||
system: "System",
|
||||
prompt: "First",
|
||||
tools: [tool],
|
||||
})
|
||||
const compare = (current: LLMRequest) =>
|
||||
PromptCacheDiagnostics.compare(PromptCacheDiagnostics.snapshot(request), PromptCacheDiagnostics.snapshot(current))
|
||||
|
||||
describe("PromptCacheDiagnostics", () => {
|
||||
test("distinguishes initial and stable requests", () => {
|
||||
const snapshot = PromptCacheDiagnostics.snapshot(request)
|
||||
expect(PromptCacheDiagnostics.compare(undefined, snapshot)).toEqual({ status: "initial" })
|
||||
expect(PromptCacheDiagnostics.compare(snapshot, snapshot)).toEqual({ status: "stable", messages: 1 })
|
||||
})
|
||||
|
||||
test("recognizes append-only history", () => {
|
||||
const current = LLMRequest.update(request, { messages: [...request.messages, Message.assistant("Second")] })
|
||||
expect(compare(current)).toEqual({ status: "append-only", previousMessages: 1, currentMessages: 2 })
|
||||
})
|
||||
|
||||
test("detects cache-sensitive setting changes", () => {
|
||||
const current = LLMRequest.update(request, { generation: GenerationOptions.make({ temperature: 0.5 }) })
|
||||
expect(compare(current)).toEqual({ status: "changed", component: "settings", index: 0, label: "model settings" })
|
||||
})
|
||||
|
||||
test("finds the first changed prefix component", () => {
|
||||
const changedTool = ToolDefinition.make({ ...tool, description: "Read one file" })
|
||||
const current = LLMRequest.update(request, { tools: [changedTool] })
|
||||
expect(compare(current)).toEqual({ status: "changed", component: "tools", index: 0, label: "read" })
|
||||
})
|
||||
|
||||
test("treats appended tools as a prefix change", () => {
|
||||
const write = ToolDefinition.make({
|
||||
name: "write",
|
||||
description: "Write a file",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
})
|
||||
const current = LLMRequest.update(request, { tools: [...request.tools, write] })
|
||||
expect(compare(current)).toEqual({ status: "changed", component: "tools", index: 1, label: "write" })
|
||||
})
|
||||
})
|
||||
@@ -45,7 +45,6 @@ import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator
|
||||
import { SessionRunner } from "@opencode-ai/core/session/runner/index"
|
||||
import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { PromptCacheDiagnostics } from "@opencode-ai/core/session/prompt-cache-diagnostics"
|
||||
import { SessionUsage } from "@opencode-ai/core/session/usage"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
@@ -1642,12 +1641,19 @@ describe("SessionRunnerLLM", () => {
|
||||
s.systemBaseline = "Changed context"
|
||||
yield* s.runPrompt("Second")
|
||||
|
||||
expect(
|
||||
PromptCacheDiagnostics.compare(
|
||||
PromptCacheDiagnostics.snapshot(s.requests[0]),
|
||||
PromptCacheDiagnostics.snapshot(s.requests[1]),
|
||||
),
|
||||
).toEqual({ status: "append-only", previousMessages: 1, currentMessages: 3 })
|
||||
for (const field of [
|
||||
"model",
|
||||
"generation",
|
||||
"providerOptions",
|
||||
"http",
|
||||
"toolChoice",
|
||||
"cache",
|
||||
"tools",
|
||||
"system",
|
||||
] as const)
|
||||
expect(s.requests[1][field]).toEqual(s.requests[0][field])
|
||||
expect(s.requests[0].messages).toHaveLength(1)
|
||||
expect(s.requests[1].messages.slice(0, 1)).toEqual([...s.requests[0].messages])
|
||||
expect(s.requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||
[defaultSystem, "Initial context"],
|
||||
[defaultSystem, "Initial context"],
|
||||
@@ -3805,11 +3811,18 @@ describe("SessionRunnerLLM", () => {
|
||||
])
|
||||
})
|
||||
|
||||
scenario("interrupts runner continuation when permission approval is declined", function* (s) {
|
||||
scenario("interrupts runner continuation on a decline after settling an ordinary tool error", function* (s) {
|
||||
const registry = yield* Tool.Service
|
||||
yield* transformTools(
|
||||
registry,
|
||||
{
|
||||
failed: {
|
||||
name: "failed",
|
||||
description: "Fail normally before the declined call",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({}),
|
||||
execute: () => Effect.fail(new Tool.Error({ message: "Ordinary tool failure" })),
|
||||
},
|
||||
declined: {
|
||||
name: "declined",
|
||||
description: "Fail because the user declined approval",
|
||||
@@ -3822,7 +3835,12 @@ describe("SessionRunnerLLM", () => {
|
||||
)
|
||||
yield* s.admit("Call declined")
|
||||
|
||||
yield* s.llm.push(TestLLM.tool("call-declined", "declined", {}))
|
||||
yield* s.llm.push(
|
||||
TestLLM.toolCalls(
|
||||
LLMEvent.toolCall({ id: "call-failed", name: "failed", input: {} }),
|
||||
LLMEvent.toolCall({ id: "call-declined", name: "declined", input: {} }),
|
||||
),
|
||||
)
|
||||
|
||||
const exit = yield* s.resume.pipe(Effect.exit)
|
||||
|
||||
@@ -3832,6 +3850,7 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(yield* s.context).toMatchObject([
|
||||
Expected.user("Call declined"),
|
||||
Expected.assistant({}, [
|
||||
Expected.failedTool({ id: "call-failed" }, { error: { message: "Ordinary tool failure" } }),
|
||||
Expected.failedTool(
|
||||
{ id: "call-declined" },
|
||||
{ error: { type: "aborted", message: "The user declined this tool call" } },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect } from "bun:test"
|
||||
import { LanguageModel, LLM, LLMClient, LLMEvent } from "@opencode-ai/ai"
|
||||
import { LanguageModel, LLM, LLMEvent } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols/openai-chat"
|
||||
import { TestLLM } from "@opencode-ai/ai/testing"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
@@ -29,23 +29,27 @@ const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node, ToolOutput.node]), [
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
]),
|
||||
TestLLM.layer(),
|
||||
TestLLM.testLayer(),
|
||||
),
|
||||
)
|
||||
|
||||
for (const finish of ["stop", "content-filter"] as const) {
|
||||
it.effect(`settles ${finish} with snapshot files and nonzero usage after its tool`, () =>
|
||||
for (const fixture of [
|
||||
{ finish: "stop", toolChoice: undefined },
|
||||
{ finish: "content-filter", toolChoice: undefined },
|
||||
{ finish: "stop", toolChoice: "none" },
|
||||
] as const) {
|
||||
it.effect(`settles ${fixture.finish} with tool choice ${fixture.toolChoice ?? "default"}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
const llm = yield* TestLLM.Service
|
||||
const llm = yield* TestLLM.Test
|
||||
const sessionID = Session.ID.create()
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
const start = Snapshot.ID.make("before")
|
||||
const end = Snapshot.ID.make("after")
|
||||
const files = [RelativePath.make("changed.ts")]
|
||||
let captures = 0
|
||||
let executions = 0
|
||||
const steps = yield* SessionStep.make.pipe(
|
||||
Effect.provideService(LLMClient.Service, llm.client),
|
||||
Effect.provide(
|
||||
Layer.mock(Snapshot.Service)({
|
||||
capture: () => Effect.sync(() => (captures++ === 0 ? start : end)),
|
||||
@@ -81,7 +85,7 @@ for (const finish of ["stop", "content-filter"] as const) {
|
||||
yield* llm.push(
|
||||
TestLLM.complete(
|
||||
{
|
||||
reason: { normalized: finish },
|
||||
reason: { normalized: fixture.finish },
|
||||
usage: {
|
||||
inputTokens: 15,
|
||||
outputTokens: 6,
|
||||
@@ -101,17 +105,25 @@ for (const finish of ["stop", "content-filter"] as const) {
|
||||
agent: Agent.defaultID,
|
||||
model,
|
||||
prepared: {
|
||||
request: LLM.request({ model: model.model, prompt: "Run one tool" }),
|
||||
request: LLM.request({ model: model.model, prompt: "Run one tool", toolChoice: fixture.toolChoice }),
|
||||
options: {},
|
||||
executeTool: () => Effect.succeed({ content: "Completed tool" }),
|
||||
executeTool: () =>
|
||||
Effect.sync(() => {
|
||||
executions++
|
||||
return { content: "Completed tool" }
|
||||
}),
|
||||
},
|
||||
toolsDisabled: false,
|
||||
recoverContinuation: true,
|
||||
recoverOverflow: Effect.succeed(false),
|
||||
})
|
||||
.pipe(Effect.exit)
|
||||
expect(Exit.isSuccess(result)).toBe(finish === "stop")
|
||||
expect(llm.requests).toHaveLength(1)
|
||||
expect(Exit.isSuccess(result)).toBe(fixture.finish === "stop")
|
||||
expect(executions).toBe(fixture.toolChoice === "none" ? 0 : 1)
|
||||
if (Exit.isSuccess(result))
|
||||
expect(result.value).toEqual(
|
||||
SessionStep.Outcome.Completed({ needsContinuation: fixture.toolChoice !== "none" }),
|
||||
)
|
||||
expect(yield* llm.requests()).toHaveLength(1)
|
||||
expect(captures).toBe(2)
|
||||
const message = yield* db
|
||||
.select()
|
||||
@@ -119,10 +131,10 @@ for (const finish of ["stop", "content-filter"] as const) {
|
||||
.where(eq(SessionMessageTable.id, assistantMessageID))
|
||||
.get()
|
||||
expect(message?.data).toMatchObject({
|
||||
finish,
|
||||
finish: fixture.finish,
|
||||
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 2 } },
|
||||
snapshot: { start, end, files },
|
||||
content: [{ type: "tool", state: { status: "completed" } }],
|
||||
content: [{ type: "tool", state: { status: fixture.toolChoice === "none" ? "error" : "completed" } }],
|
||||
})
|
||||
expect(message?.data).toHaveProperty("cost", expect.closeTo(0.0000233, 10))
|
||||
const events = yield* db
|
||||
@@ -132,9 +144,11 @@ for (const finish of ["stop", "content-filter"] as const) {
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all()
|
||||
const types = events.map((event) => event.type)
|
||||
const terminal = finish === "stop" ? "session.step.ended.1" : "session.step.failed.1"
|
||||
const terminal = fixture.finish === "stop" ? "session.step.ended.1" : "session.step.failed.1"
|
||||
expect(types.filter((type) => type === terminal)).toHaveLength(1)
|
||||
expect(types.indexOf("session.tool.success.2")).toBeLessThan(types.indexOf(terminal))
|
||||
expect(
|
||||
types.indexOf(fixture.toolChoice === "none" ? "session.tool.failed.2" : "session.tool.success.2"),
|
||||
).toBeLessThan(types.indexOf(terminal))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -669,8 +669,8 @@ describe("HttpApiCodegen.generate", () => {
|
||||
)
|
||||
const types = output.files.find((file) => file.path === "types.ts")?.content
|
||||
|
||||
expect(types).toContain('readonly "first": ({ readonly "value": string })')
|
||||
expect(types).toContain('readonly "second": ({ readonly "value": string })')
|
||||
expect(types).toContain('readonly "first": { readonly "value": string }')
|
||||
expect(types).toContain('readonly "second": { readonly "value": string }')
|
||||
expect(types).not.toContain("export type Objects")
|
||||
})
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export { Integration } from "@opencode-ai/schema/integration"
|
||||
export { Location } from "@opencode-ai/schema/location"
|
||||
export { Mcp } from "@opencode-ai/schema/mcp"
|
||||
export { Model } from "@opencode-ai/schema/model"
|
||||
export { PersistentPty } from "@opencode-ai/schema/persistent-pty"
|
||||
export { Provider } from "@opencode-ai/schema/provider"
|
||||
export { Reference } from "@opencode-ai/schema/reference"
|
||||
export { Skill } from "@opencode-ai/schema/skill"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GenerateApi, PluginApi } from "@opencode-ai/client/effect/api"
|
||||
import type { ExperimentalApi, GenerateApi, PluginApi } from "@opencode-ai/client/effect/api"
|
||||
import type { Location } from "@opencode-ai/schema/location"
|
||||
import type { Effect, Scope } from "effect"
|
||||
import type { PluginOptions } from "../options.js"
|
||||
@@ -30,6 +30,9 @@ export interface Context {
|
||||
readonly catalog: CatalogDomain
|
||||
readonly command: CommandDomain
|
||||
readonly event: EventDomain
|
||||
readonly experimental: {
|
||||
readonly terminal: Pick<ExperimentalApi<unknown>["persistentPty"], "read">
|
||||
}
|
||||
readonly integration: IntegrationDomain
|
||||
readonly mcp: MCPDomain
|
||||
readonly generate: GenerateApi<unknown>
|
||||
|
||||
@@ -77,6 +77,7 @@ export function fromPromise(plugin: Plugin) {
|
||||
)
|
||||
const AgentEndpoints = ClientApi.groups["server.agent"].endpoints
|
||||
const CommandEndpoints = ClientApi.groups["server.command"].endpoints
|
||||
const ExperimentalEndpoints = ClientApi.groups["server.experimental"].endpoints
|
||||
const GenerateEndpoints = ClientApi.groups["server.generate"].endpoints
|
||||
const IntegrationEndpoints = ClientApi.groups["server.integration"].endpoints
|
||||
const McpEndpoints = ClientApi.groups["server.mcp"].endpoints
|
||||
@@ -188,6 +189,11 @@ export function fromPromise(plugin: Plugin) {
|
||||
),
|
||||
),
|
||||
},
|
||||
experimental: {
|
||||
terminal: {
|
||||
read: adaptApiMethod(ExperimentalEndpoints["persistentPty.read"], host.experimental.terminal.read),
|
||||
},
|
||||
},
|
||||
generate: {
|
||||
text: adaptApiMethod(GenerateEndpoints["generate.text"], host.generate.text),
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ export { Integration } from "@opencode-ai/schema/integration"
|
||||
export { Location } from "@opencode-ai/schema/location"
|
||||
export { Mcp } from "@opencode-ai/schema/mcp"
|
||||
export { Model } from "@opencode-ai/schema/model"
|
||||
export { PersistentPty } from "@opencode-ai/schema/persistent-pty"
|
||||
export { Provider } from "@opencode-ai/schema/provider"
|
||||
export { Reference } from "@opencode-ai/schema/reference"
|
||||
export { Skill } from "@opencode-ai/schema/skill"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { OpenCodeClient } from "@opencode-ai/client"
|
||||
import type { GenerateApi, PluginApi } from "@opencode-ai/client/promise/api"
|
||||
import type { Location } from "@opencode-ai/schema/location"
|
||||
import type { PluginOptions } from "../options.js"
|
||||
@@ -29,6 +30,9 @@ export interface Context {
|
||||
readonly catalog: CatalogDomain
|
||||
readonly command: CommandDomain
|
||||
readonly event: EventDomain
|
||||
readonly experimental: {
|
||||
readonly terminal: Pick<OpenCodeClient["experimental"]["persistentPty"], "read">
|
||||
}
|
||||
readonly integration: IntegrationDomain
|
||||
readonly mcp: MCPDomain
|
||||
readonly generate: GenerateApi
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Reference } from "@opencode-ai/schema/reference"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
@@ -30,6 +31,7 @@ test.each([
|
||||
expect(entrypoint.Location).toBe(Location)
|
||||
expect(entrypoint.Mcp).toBe(Mcp)
|
||||
expect(entrypoint.Model).toBe(Model)
|
||||
expect(entrypoint.PersistentPty).toBe(PersistentPty)
|
||||
expect(entrypoint.Provider).toBe(Provider)
|
||||
expect(entrypoint.Reference).toBe(Reference)
|
||||
expect(entrypoint.Skill).toBe(Skill)
|
||||
@@ -44,6 +46,7 @@ test.each([
|
||||
"Location",
|
||||
"Mcp",
|
||||
"Model",
|
||||
"PersistentPty",
|
||||
"Plugin",
|
||||
"Provider",
|
||||
"Reference",
|
||||
|
||||
@@ -19,6 +19,22 @@ const errors = [InvalidRequestError, ServiceUnavailableError] as const
|
||||
const terminalErrors = [PtyNotFoundError, ServiceUnavailableError] as const
|
||||
|
||||
export const PersistentPtyGroup = HttpApiGroup.make("server.experimental")
|
||||
.add(
|
||||
HttpApiEndpoint.get("persistentPty.read", "/api/experimental/session/:sessionID/terminal/read", {
|
||||
params: { sessionID: Session.ID },
|
||||
query: {
|
||||
lines: Schema.NumberFromString.pipe(Schema.decodeTo(PersistentPty.ReadLines), Schema.optional),
|
||||
},
|
||||
success: Schema.Struct({ data: Schema.NullOr(PersistentPty.ReadResult) }),
|
||||
error: [ServiceUnavailableError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
summary: "Read the session's most recently controlled terminal",
|
||||
description:
|
||||
"Read the last physical rows without changing selection or taking control. Omitted lines uses the live terminal height; larger counts include retained history. Blank rows are preserved. Screen dimensions and cursor remain relative to the live screen. Returns null when no current terminal exists. Selection is server-local and resets on restart. Experimental: may change without compatibility guarantees.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("persistentPty.list", "/api/experimental/session/:sessionID/terminal", {
|
||||
params: { sessionID: Session.ID },
|
||||
|
||||
@@ -47,6 +47,24 @@ export const Snapshot = Schema.Struct({
|
||||
}).annotate({ identifier: "PersistentPty.Snapshot" })
|
||||
export interface Snapshot extends Schema.Schema.Type<typeof Snapshot> {}
|
||||
|
||||
export const ReadLines = PositiveInt.check(Schema.isLessThanOrEqualTo(65535)).annotate({
|
||||
identifier: "PersistentPty.ReadLines",
|
||||
})
|
||||
|
||||
export const ReadResult = Schema.Struct({
|
||||
ptyID: Pty.ID,
|
||||
title: Schema.String,
|
||||
cwd: Schema.String,
|
||||
foregroundProcess: Schema.NullOr(Schema.String),
|
||||
screen: Schema.Struct({
|
||||
text: Schema.String,
|
||||
cols: PositiveInt,
|
||||
rows: PositiveInt,
|
||||
cursor: Snapshot.fields.cursor,
|
||||
}),
|
||||
}).annotate({ identifier: "PersistentPty.ReadResult" })
|
||||
export interface ReadResult extends Schema.Schema.Type<typeof ReadResult> {}
|
||||
|
||||
export const Added = ephemeral({ type: "persistent-pty.added", schema: { sessionID: Session.ID, terminal: Info } })
|
||||
export const Removed = ephemeral({ type: "persistent-pty.removed", schema: { sessionID: Session.ID, ptyID: Pty.ID } })
|
||||
export const Event = { Added, Removed, Definitions: inventory(Added, Removed) }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { expect } from "bun:test"
|
||||
import { LanguageModel, LLMClient, LLMResponse, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { LanguageModel, LLMClient } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import { TestLLM } from "@opencode-ai/ai/testing"
|
||||
import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
|
||||
@@ -40,8 +40,8 @@ for (const selection of ["explicit", "default"] as const) {
|
||||
withEmbedded("opencode-embedded-generate-", (fixture) =>
|
||||
Effect.gen(function* () {
|
||||
const release = yield* Latch.make()
|
||||
const llm = yield* TestLLM.Service.pipe(
|
||||
Effect.provide(TestLLM.layer({ fallback: TestLLM.text("ready", "answer") })),
|
||||
const llm = yield* TestLLM.Test.pipe(
|
||||
Effect.provide(TestLLM.testLayer({ fallback: TestLLM.text("ready", "answer") })),
|
||||
)
|
||||
const supervisor = Layer.effect(
|
||||
PluginSupervisor.Service,
|
||||
@@ -71,7 +71,7 @@ for (const selection of ["explicit", "default"] as const) {
|
||||
},
|
||||
{
|
||||
overrides: [
|
||||
[llmClient, Layer.succeed(LLMClient.Service, llm.client)],
|
||||
[llmClient, Layer.succeed(LLMClient.Service, llm)],
|
||||
[PluginSupervisor.node, { ...PluginSupervisor.node, implementation: supervisor }],
|
||||
],
|
||||
},
|
||||
@@ -92,8 +92,9 @@ for (const selection of ["explicit", "default"] as const) {
|
||||
})
|
||||
|
||||
expect(result.text).toBe("ready")
|
||||
expect(llm.requests).toHaveLength(1)
|
||||
expect(llm.requests[0]?.model).toMatchObject({ provider: "custom", id: "fictional-chat" })
|
||||
const requests = yield* llm.requests()
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.model).toMatchObject({ provider: "custom", id: "fictional-chat" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -644,17 +645,13 @@ const workspaceModelScenario = (fixture: Fixture, policy: "eager" | "lazy") =>
|
||||
const modelStarted = yield* Deferred.make<void>()
|
||||
yield* Effect.addFinalizer(() => Deferred.succeed(createRelease, undefined).pipe(Effect.asVoid))
|
||||
const model = LanguageModel.make({ id: "workspace-test", provider: "test", route: OpenAIChat.route })
|
||||
const client = TestLLM.clientLayer.pipe(
|
||||
Layer.provide(
|
||||
TestLLM.layer({
|
||||
fallback: TestLLM.text("ready", "answer"),
|
||||
transformRequest: (request) => {
|
||||
Deferred.doneUnsafe(modelStarted, Effect.void)
|
||||
return request
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
const client = TestLLM.testLayer({
|
||||
fallback: TestLLM.text("ready", "answer"),
|
||||
transformRequest: (request) => {
|
||||
Deferred.doneUnsafe(modelStarted, Effect.void)
|
||||
return request
|
||||
},
|
||||
})
|
||||
const models = Layer.mock(SessionRunnerModel.Service, {
|
||||
resolve: () =>
|
||||
Effect.succeed(
|
||||
@@ -752,27 +749,13 @@ it.live(
|
||||
// The first tool-advertising request selects the shell tool; everything else
|
||||
// (including title generation, which carries no tools) answers with text.
|
||||
let toolIssued = false
|
||||
const respond = (request: LLMRequest) => {
|
||||
const llm = yield* TestLLM.Test.pipe(Effect.provide(TestLLM.testLayer()))
|
||||
yield* llm.serve((request) => {
|
||||
const wantsTool = !toolIssued && request.tools.some((tool) => tool.name === "shell")
|
||||
if (!wantsTool) return TestLLM.text("done", "answer")
|
||||
toolIssued = true
|
||||
return TestLLM.tool("call-shell", "shell", { command: "echo hi" })
|
||||
}
|
||||
const client = Layer.succeed(
|
||||
LLMClient.Service,
|
||||
LLMClient.Service.of({
|
||||
stream: (request) => Stream.fromIterable(respond(request)),
|
||||
generate: (request) =>
|
||||
Stream.fromIterable(respond(request)).pipe(
|
||||
Stream.runFold(LLMResponse.empty, LLMResponse.reduce),
|
||||
Effect.flatMap((state) => {
|
||||
const response = LLMResponse.complete(state)
|
||||
if (response) return Effect.succeed(response)
|
||||
return Effect.die("test response ended without a terminal finish event")
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
})
|
||||
const models = Layer.mock(SessionRunnerModel.Service, {
|
||||
resolve: () =>
|
||||
Effect.succeed(
|
||||
@@ -807,7 +790,7 @@ it.live(
|
||||
},
|
||||
{
|
||||
overrides: [
|
||||
[llmClient, client],
|
||||
[llmClient, Layer.succeed(LLMClient.Service, llm)],
|
||||
[SessionRunnerModel.node, models],
|
||||
],
|
||||
},
|
||||
|
||||
@@ -20,6 +20,12 @@ export const PersistentPtyHandler = HttpApiBuilder.group(Api, "server.experiment
|
||||
const pty = yield* PersistentPty.Service
|
||||
|
||||
return handlers
|
||||
.handle(
|
||||
"persistentPty.read",
|
||||
Effect.fn(function* (ctx) {
|
||||
return { data: yield* pty.read(ctx.params.sessionID, ctx.query.lines).pipe(mapUnavailable) }
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"persistentPty.list",
|
||||
Effect.fn(function* (ctx) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect } from "bun:test"
|
||||
import { createServer, type Server } from "node:http"
|
||||
import { createServer } from "node:http"
|
||||
import { makeMemoryDriver } from "@opencode-ai/core/environment/index"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
|
||||
@@ -10,15 +10,44 @@ import { ServerFetch } from "../src/fetch"
|
||||
const options = {
|
||||
app: { version: "test-version" },
|
||||
database: { path: ":memory:" },
|
||||
models: { fetch: false },
|
||||
fs: { filewatcher: false },
|
||||
} as const
|
||||
|
||||
type Handler = (request: Request) => Promise<Response>
|
||||
|
||||
function occupy(server: Server, port: number) {
|
||||
return Effect.callback<void, Error>((resume) => {
|
||||
server.once("error", (error) => resume(Effect.fail(error)))
|
||||
server.listen(port, "localhost", () => resume(Effect.void))
|
||||
function occupy(port: number, cancel = false) {
|
||||
return Effect.gen(function* () {
|
||||
const requests: string[] = []
|
||||
// A localhost listener occupies only one family; Bun can bind the other.
|
||||
const servers = ["127.0.0.1", "::1"].map((host) => ({
|
||||
host,
|
||||
server: createServer((request, response) => {
|
||||
requests.push(request.url ?? "")
|
||||
response.end(cancel ? "cancelled" : "still running", () => {
|
||||
if (cancel) servers.forEach((item) => item.server.close())
|
||||
})
|
||||
}),
|
||||
}))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.forEach(servers, (item) =>
|
||||
Effect.callback<void>((resume) => {
|
||||
item.server.close(() => resume(Effect.void))
|
||||
item.server.closeAllConnections()
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* Effect.forEach(servers, (item) =>
|
||||
Effect.callback<void, Error>((resume) => {
|
||||
const onError = (error: Error) => resume(Effect.fail(error))
|
||||
item.server.once("error", onError)
|
||||
item.server.listen(port, item.host, () => {
|
||||
item.server.off("error", onError)
|
||||
resume(Effect.void)
|
||||
})
|
||||
}),
|
||||
)
|
||||
return requests
|
||||
})
|
||||
}
|
||||
|
||||
@@ -98,13 +127,7 @@ it.live("serves unauthenticated and answers CORS preflight when no password is c
|
||||
|
||||
it.live("cancels a stale OpenAI OAuth callback server before falling back", () =>
|
||||
Effect.gen(function* () {
|
||||
const requests: string[] = []
|
||||
const blocker = createServer((request, response) => {
|
||||
requests.push(request.url ?? "")
|
||||
response.end("cancelled", () => blocker.close())
|
||||
})
|
||||
yield* occupy(blocker, 1455)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => blocker.close()))
|
||||
const requests = yield* occupy(1455, true)
|
||||
const handler = yield* ServerFetch.make(options)
|
||||
yield* ready(handler)
|
||||
const response = yield* connectOpenAI(handler)
|
||||
@@ -118,13 +141,7 @@ it.live("cancels a stale OpenAI OAuth callback server before falling back", () =
|
||||
|
||||
it.live("falls back to port 1457 when OpenAI OAuth port 1455 remains busy", () =>
|
||||
Effect.gen(function* () {
|
||||
const requests: string[] = []
|
||||
const blocker = createServer((request, response) => {
|
||||
requests.push(request.url ?? "")
|
||||
response.end("still running")
|
||||
})
|
||||
yield* occupy(blocker, 1455)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => blocker.close()))
|
||||
const requests = yield* occupy(1455)
|
||||
const handler = yield* ServerFetch.make(options)
|
||||
yield* ready(handler)
|
||||
const response = yield* connectOpenAI(handler)
|
||||
@@ -138,12 +155,8 @@ it.live("falls back to port 1457 when OpenAI OAuth port 1455 remains busy", () =
|
||||
|
||||
it.live("explains how to recover when both OpenAI OAuth callback ports are busy", () =>
|
||||
Effect.gen(function* () {
|
||||
const preferred = createServer((_request, response) => response.end("still running"))
|
||||
const fallback = createServer()
|
||||
yield* occupy(preferred, 1455)
|
||||
yield* occupy(fallback, 1457)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => preferred.close()))
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => fallback.close()))
|
||||
yield* occupy(1455)
|
||||
yield* occupy(1457)
|
||||
const handler = yield* ServerFetch.make(options)
|
||||
yield* ready(handler)
|
||||
const response = yield* connectOpenAI(handler)
|
||||
|
||||
@@ -7,12 +7,137 @@ import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Effect, Exit, Schema, Scope } from "effect"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { OpenCode } from "../../client/src/promise/index"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerProcess } from "../src/process"
|
||||
|
||||
const binary = process.env.OPENCODE_PTY_BIN ?? "/root/projects/opencode-pty/target/debug/opencode-pty"
|
||||
const smoke = existsSync(binary) ? it.live : it.live.skip
|
||||
|
||||
smoke(
|
||||
"reads the latest controlled terminal with optional physical line counts through the SDK",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* testDirectory("xdg")
|
||||
const server = yield* ServerProcess.start<never, never>({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
password: "secret",
|
||||
app: { version: "test-version" },
|
||||
database: { path: fixture.database },
|
||||
fs: { filewatcher: false },
|
||||
})
|
||||
const base = HttpServer.formatAddress(server.address)
|
||||
const client = OpenCode.make({ baseUrl: base, headers: { authorization: `Basic ${btoa("opencode:secret")}` } })
|
||||
const sessionID = "ses_terminal_read"
|
||||
expect(yield* Effect.promise(() => client.experimental.persistentPty.read({ sessionID }))).toBeNull()
|
||||
expect(existsSync(fixture.directory)).toBeFalse()
|
||||
yield* Effect.promise(async () => {
|
||||
for (const lines of ["0", "-1", "1.5", "65536", "nope"]) {
|
||||
const response = await fetch(`${base}/api/experimental/session/${sessionID}/terminal/read?lines=${lines}`, {
|
||||
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
|
||||
})
|
||||
expect(response.status).toBe(400)
|
||||
await response.arrayBuffer()
|
||||
}
|
||||
})
|
||||
expect(existsSync(fixture.directory)).toBeFalse()
|
||||
const first = yield* Effect.promise(() =>
|
||||
client.experimental.persistentPty.create({
|
||||
sessionID,
|
||||
command: "/bin/sh",
|
||||
args: ["-c", "stty -echo; seq 1 20; exec cat"],
|
||||
cwd: fixture.root,
|
||||
title: "first",
|
||||
env: {},
|
||||
size: { cols: 40, rows: 4 },
|
||||
}),
|
||||
)
|
||||
const second = yield* Effect.promise(() =>
|
||||
client.experimental.persistentPty.create({
|
||||
sessionID,
|
||||
command: "/bin/sh",
|
||||
args: ["-c", "printf second; exec cat"],
|
||||
cwd: fixture.root,
|
||||
title: "second",
|
||||
env: {},
|
||||
}),
|
||||
)
|
||||
expect(yield* waitForText(base, first.id, "20")).toContain("1\n2\n")
|
||||
expect(yield* Effect.promise(() => client.experimental.persistentPty.read({ sessionID }))).toBeNull()
|
||||
const controller = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => openTerminalSocket(base, first.id, "read-first")),
|
||||
(connection) => Effect.sync(() => connection.socket.close()),
|
||||
)
|
||||
const observer = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => openTerminalSocket(base, second.id, "read-second", "observer")),
|
||||
(connection) => Effect.sync(() => connection.socket.close()),
|
||||
)
|
||||
const current = yield* Effect.promise(() => client.experimental.persistentPty.read({ sessionID }))
|
||||
if (!current) throw new Error("Expected the controller's terminal to be selected")
|
||||
expect(current).toEqual({
|
||||
ptyID: first.id,
|
||||
title: "first",
|
||||
cwd: fixture.root,
|
||||
foregroundProcess: current.foregroundProcess,
|
||||
screen: { text: "18\n19\n20\n", cols: 40, rows: 4, cursor: { x: 0, y: 3 } },
|
||||
})
|
||||
expect(current.foregroundProcess === null || typeof current.foregroundProcess === "string").toBeTrue()
|
||||
expect(yield* Effect.promise(() => client.experimental.persistentPty.read({ sessionID: "ses_other" }))).toBeNull()
|
||||
yield* Effect.promise(() => client.experimental.persistentPty.snapshot({ ptyID: second.id }))
|
||||
for (const lines of [2, 6, 65535]) {
|
||||
const value = yield* Effect.promise(() => client.experimental.persistentPty.read({ sessionID, lines }))
|
||||
expect(value?.ptyID).toBe(first.id)
|
||||
expect(value?.screen.rows).toBe(4)
|
||||
const expected = Array.from({ length: 20 }, (_, index) => String(index + 1))
|
||||
.concat("")
|
||||
.slice(-lines)
|
||||
expect(value?.screen.text.split("\n")).toEqual(expected)
|
||||
}
|
||||
observer.socket.send(controlFrame(30, 3))
|
||||
expect((yield* Effect.promise(() => waitForRead(client, sessionID, second.id))).screen.rows).toBe(3)
|
||||
yield* Effect.promise(() =>
|
||||
client.experimental.persistentPty.update({
|
||||
ptyID: first.id,
|
||||
attachmentID: "read-first",
|
||||
size: { cols: 42, rows: 5 },
|
||||
}),
|
||||
)
|
||||
expect((yield* Effect.promise(() => client.experimental.persistentPty.read({ sessionID })))?.ptyID).toBe(first.id)
|
||||
observer.socket.send(controlFrame(30, 3))
|
||||
yield* Effect.promise(() => waitForRead(client, sessionID, second.id))
|
||||
controller.socket.send(inputFrame(40, 4, "typed\n"))
|
||||
yield* Effect.promise(() => waitForSocketOutput([controller], "typed"))
|
||||
expect((yield* Effect.promise(() => waitForRead(client, sessionID, first.id))).screen.text).toContain("typed")
|
||||
const takeover = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => openTerminalSocket(base, second.id, "read-takeover")),
|
||||
(connection) => Effect.sync(() => connection.socket.close()),
|
||||
)
|
||||
expect((yield* Effect.promise(() => client.experimental.persistentPty.read({ sessionID })))?.ptyID).toBe(
|
||||
second.id,
|
||||
)
|
||||
takeover.socket.close()
|
||||
yield* Effect.promise(() => client.experimental.persistentPty.remove({ ptyID: first.id }))
|
||||
expect((yield* Effect.promise(() => client.experimental.persistentPty.read({ sessionID })))?.ptyID).toBe(
|
||||
second.id,
|
||||
)
|
||||
yield* Effect.promise(() => client.experimental.persistentPty.remove({ ptyID: second.id }))
|
||||
expect(yield* Effect.promise(() => client.experimental.persistentPty.read({ sessionID }))).toBeNull()
|
||||
yield* Effect.promise(() => client.experimental.persistentPty.shutdown())
|
||||
expect(yield* Effect.promise(() => client.experimental.persistentPty.read({ sessionID }))).toBeNull()
|
||||
}),
|
||||
20_000,
|
||||
)
|
||||
|
||||
async function waitForRead(client: ReturnType<typeof OpenCode.make>, sessionID: string, ptyID: string) {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
const result = await client.experimental.persistentPty.read({ sessionID })
|
||||
if (result?.ptyID === ptyID) return result
|
||||
await Bun.sleep(20)
|
||||
}
|
||||
throw new Error(`Terminal ${ptyID} did not become current for ${sessionID}`)
|
||||
}
|
||||
|
||||
smoke(
|
||||
"creates two persistent terminals for one session through the client API",
|
||||
() =>
|
||||
@@ -216,10 +341,18 @@ smoke(
|
||||
})).data,
|
||||
)
|
||||
expect(yield* waitForText(base, first.id, "before-restart")).toContain("before-restart")
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.promise(() => openTerminalSocket(base, first.id, "before-restart")),
|
||||
(connection) => Effect.sync(() => connection.socket.close()),
|
||||
)
|
||||
expect((yield* request(base, "GET", `/api/experimental/session/${sessionID}/terminal/read`)).data).toMatchObject({
|
||||
ptyID: first.id,
|
||||
})
|
||||
|
||||
const independent = yield* ServerProcess.start<never, never>(options)
|
||||
const otherBase = HttpServer.formatAddress(independent.address)
|
||||
expect((yield* request(otherBase, "GET", `/api/experimental/session/${sessionID}/terminal`)).data).toEqual([])
|
||||
expect((yield* request(otherBase, "GET", `/api/experimental/session/${sessionID}/terminal/read`)).data).toBeNull()
|
||||
|
||||
const handoff = Schema.decodeUnknownSync(PersistentPty.Handoff)(
|
||||
(yield* request(base, "POST", "/api/experimental/persistent-pty/handoff")).handoff,
|
||||
@@ -240,6 +373,9 @@ smoke(
|
||||
(yield* request(replacementBase, "GET", `/api/experimental/session/${sessionID}/terminal`)).data,
|
||||
).toMatchObject([{ id: first.id, pid: first.pid }])
|
||||
expect(yield* waitForText(replacementBase, first.id, "before-restart")).toContain("before-restart")
|
||||
expect(
|
||||
(yield* request(replacementBase, "GET", `/api/experimental/session/${sessionID}/terminal/read`)).data,
|
||||
).toBeNull()
|
||||
|
||||
yield* Scope.close(replacementScope, Exit.void)
|
||||
yield* waitForExit(registration.pid)
|
||||
|
||||
+20
-80
@@ -15,7 +15,6 @@ import {
|
||||
MouseButton,
|
||||
type CliRenderer,
|
||||
type CliRendererConfig,
|
||||
type MouseEvent,
|
||||
type ThemeMode,
|
||||
} from "@opentui/core"
|
||||
import { RouteProvider, useRoute } from "./context/route"
|
||||
@@ -72,6 +71,8 @@ import { DialogSessionList } from "./component/dialog-session-list"
|
||||
import { DialogOpen, DialogOpenKey, loadDialogOpen } from "./component/dialog-open"
|
||||
import { SessionTabs } from "./component/session-tabs"
|
||||
import { clampSessionTabsWidth, sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "./ui/layout"
|
||||
import { createPaneResize } from "./ui/pane-resize"
|
||||
import { PaneResizeHandle } from "./ui/pane-resize-handle"
|
||||
import { ThemeErrorToast } from "./component/theme-error-toast"
|
||||
import { createThemeSource, ThemeProvider, useTheme, useThemes } from "./context/theme"
|
||||
import { Home } from "./routes/home"
|
||||
@@ -472,7 +473,6 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const theme = useTheme()
|
||||
const tabsTheme = useTheme("elevated")
|
||||
const { mode, supports, setMode, locked, lock, unlock } = useThemes()
|
||||
const data = useData()
|
||||
const location = useLocation()
|
||||
@@ -495,38 +495,18 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
const [layout, updateLayout] = useStorage().store<{ verticalTabsWidth?: number }>("layout", {
|
||||
initial: { verticalTabsWidth: SESSION_SIDEBAR_WIDTH },
|
||||
})
|
||||
const [preferredTabsWidth, setPreferredTabsWidth] = createSignal(layout.verticalTabsWidth ?? SESSION_SIDEBAR_WIDTH)
|
||||
const [tabsResizeHovered, setTabsResizeHovered] = createSignal(false)
|
||||
const [tabsResizing, setTabsResizing] = createSignal(false)
|
||||
let requestedTabsWidth = layout.verticalTabsWidth ?? SESSION_SIDEBAR_WIDTH
|
||||
createEffect(() => {
|
||||
if (tabsResizing()) return
|
||||
requestedTabsWidth = layout.verticalTabsWidth ?? SESSION_SIDEBAR_WIDTH
|
||||
setPreferredTabsWidth(requestedTabsWidth)
|
||||
const tabsResize = createPaneResize({
|
||||
value: () => layout.verticalTabsWidth ?? SESSION_SIDEBAR_WIDTH,
|
||||
defaultValue: () => SESSION_SIDEBAR_WIDTH,
|
||||
clamp: (width) => clampSessionTabsWidth(width, dimensions().width),
|
||||
fromMouse: (event) => event.x + 1,
|
||||
contains: (event, width) => event.x >= width - 1 && event.x <= width,
|
||||
onCommit: (width) => {
|
||||
void updateLayout((draft) => {
|
||||
draft.verticalTabsWidth = width
|
||||
}).catch((error) => console.error("Failed to persist TUI layout", error))
|
||||
},
|
||||
})
|
||||
const verticalTabsWidth = () => clampSessionTabsWidth(preferredTabsWidth(), dimensions().width)
|
||||
const resizeVerticalTabs = (width: number) => setPreferredTabsWidth(clampSessionTabsWidth(width, dimensions().width))
|
||||
const commitVerticalTabsWidth = (width: number) => {
|
||||
const next = clampSessionTabsWidth(width, dimensions().width)
|
||||
setPreferredTabsWidth(next)
|
||||
if (requestedTabsWidth === next) return
|
||||
requestedTabsWidth = next
|
||||
void updateLayout((draft) => {
|
||||
draft.verticalTabsWidth = next
|
||||
}).catch((error) => console.error("Failed to persist TUI layout", error))
|
||||
}
|
||||
let tabsResizeMoved = false
|
||||
let lastTabsBoundaryClick = 0
|
||||
const finishTabsResize = (event: MouseEvent) => {
|
||||
if (!tabsResizing()) return
|
||||
const next = tabsResizeMoved ? event.x + 1 : verticalTabsWidth()
|
||||
setTabsResizing(false)
|
||||
lastTabsBoundaryClick = tabsResizeMoved ? 0 : Date.now()
|
||||
commitVerticalTabsWidth(next)
|
||||
const width = clampSessionTabsWidth(next, dimensions().width)
|
||||
setTabsResizeHovered(event.x >= width - 1 && event.x <= width)
|
||||
event.stopPropagation()
|
||||
}
|
||||
let openingOpen: Promise<SessionInfo[]> | undefined
|
||||
// Toast once when an MCP server enters a failed or needs-auth state so the user knows to act,
|
||||
// without having to open the status panel. Tracking the last alerted status avoids re-toasting
|
||||
@@ -587,7 +567,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
const terminalTitleEnabled = () => config.data.terminal?.title ?? true
|
||||
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
|
||||
const tabsVertical = () =>
|
||||
config.data.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width, preferredTabsWidth())
|
||||
config.data.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width, tabsResize.preferredSize())
|
||||
const tabsVisible = () => sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"
|
||||
const verticalTabsVisible = () => tabsVisible() && tabsVertical()
|
||||
|
||||
@@ -1293,18 +1273,12 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
minHeight={0}
|
||||
flexDirection="row"
|
||||
position="relative"
|
||||
onMouseDrag={(event) => {
|
||||
if (!tabsResizing()) return
|
||||
tabsResizeMoved = true
|
||||
lastTabsBoundaryClick = 0
|
||||
resizeVerticalTabs(event.x + 1)
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onMouseDragEnd={finishTabsResize}
|
||||
onMouseUp={finishTabsResize}
|
||||
onMouseDrag={tabsResize.onMouseDrag}
|
||||
onMouseDragEnd={tabsResize.onMouseDragEnd}
|
||||
onMouseUp={tabsResize.onMouseUp}
|
||||
>
|
||||
<Show when={verticalTabsVisible()}>
|
||||
<SessionTabs orientation="vertical" width={verticalTabsWidth()} />
|
||||
<SessionTabs orientation="vertical" width={tabsResize.size()} />
|
||||
</Show>
|
||||
<box flexGrow={1} minWidth={0} flexDirection="column">
|
||||
<Show when={plugins.ready()}>
|
||||
@@ -1321,7 +1295,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
{(sessionID) => (
|
||||
<SessionFrame
|
||||
sessionID={sessionID}
|
||||
verticalTabsWidth={verticalTabsVisible() ? verticalTabsWidth() : 0}
|
||||
verticalTabsWidth={verticalTabsVisible() ? tabsResize.size() : 0}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
@@ -1339,41 +1313,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={verticalTabsVisible()}>
|
||||
<box
|
||||
position="absolute"
|
||||
left={verticalTabsWidth() - 1}
|
||||
top={0}
|
||||
zIndex={10}
|
||||
width={2}
|
||||
height="100%"
|
||||
onMouseOver={() => setTabsResizeHovered(true)}
|
||||
onMouseOut={() => setTabsResizeHovered(false)}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== MouseButton.LEFT) return
|
||||
const now = Date.now()
|
||||
if (now - lastTabsBoundaryClick < 300) {
|
||||
lastTabsBoundaryClick = 0
|
||||
setTabsResizing(false)
|
||||
setTabsResizeHovered(false)
|
||||
commitVerticalTabsWidth(SESSION_SIDEBAR_WIDTH)
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
tabsResizeMoved = false
|
||||
setTabsResizing(true)
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<box
|
||||
width={1}
|
||||
height="100%"
|
||||
backgroundColor={
|
||||
tabsResizeHovered() || tabsResizing() ? tabsTheme.background.action.primary.hovered : undefined
|
||||
}
|
||||
/>
|
||||
</box>
|
||||
<PaneResizeHandle resize={tabsResize} left={tabsResize.size() - 1} />
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={devtools()}>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useTheme } from "../context/theme"
|
||||
import type { createPaneResize } from "./pane-resize"
|
||||
|
||||
export function PaneResizeHandle(props: { resize: ReturnType<typeof createPaneResize>; left: number }) {
|
||||
const theme = useTheme("elevated")
|
||||
|
||||
return (
|
||||
<box
|
||||
position="absolute"
|
||||
left={props.left}
|
||||
top={0}
|
||||
zIndex={10}
|
||||
width={2}
|
||||
height="100%"
|
||||
onMouseOver={props.resize.onMouseOver}
|
||||
onMouseOut={props.resize.onMouseOut}
|
||||
onMouseDown={props.resize.onMouseDown}
|
||||
>
|
||||
<box
|
||||
width={1}
|
||||
height="100%"
|
||||
backgroundColor={
|
||||
props.resize.hovered() || props.resize.resizing() ? theme.background.action.primary.hovered : undefined
|
||||
}
|
||||
/>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { MouseButton, type MouseEvent } from "@opentui/core"
|
||||
import { createEffect, createSignal } from "solid-js"
|
||||
|
||||
export function createPaneResize(options: {
|
||||
value: () => number
|
||||
defaultValue: () => number
|
||||
clamp: (size: number) => number
|
||||
fromMouse: (event: MouseEvent) => number
|
||||
contains: (event: MouseEvent, size: number) => boolean
|
||||
onCommit: (size: number) => void
|
||||
}) {
|
||||
const [preferredSize, setPreferredSize] = createSignal(options.value())
|
||||
const [hovered, setHovered] = createSignal(false)
|
||||
const [resizing, setResizing] = createSignal(false)
|
||||
let requestedSize = options.value()
|
||||
createEffect(() => {
|
||||
if (resizing()) return
|
||||
requestedSize = options.value()
|
||||
setPreferredSize(requestedSize)
|
||||
})
|
||||
const size = () => options.clamp(preferredSize())
|
||||
const commit = (value: number) => {
|
||||
const next = options.clamp(value)
|
||||
setPreferredSize(next)
|
||||
if (requestedSize === next) return
|
||||
requestedSize = next
|
||||
options.onCommit(next)
|
||||
}
|
||||
let moved = false
|
||||
let lastBoundaryClick = 0
|
||||
const finish = (event: MouseEvent) => {
|
||||
if (!resizing()) return
|
||||
const next = moved ? options.fromMouse(event) : size()
|
||||
setResizing(false)
|
||||
lastBoundaryClick = moved ? 0 : Date.now()
|
||||
commit(next)
|
||||
setHovered(options.contains(event, options.clamp(next)))
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
// Bind drag/release on the parent so resizing continues outside the handle.
|
||||
return {
|
||||
preferredSize,
|
||||
size,
|
||||
hovered,
|
||||
resizing,
|
||||
onMouseOver: () => setHovered(true),
|
||||
onMouseOut: () => setHovered(false),
|
||||
onMouseDown: (event: MouseEvent) => {
|
||||
if (event.button !== MouseButton.LEFT) return
|
||||
const now = Date.now()
|
||||
if (now - lastBoundaryClick < 300) {
|
||||
lastBoundaryClick = 0
|
||||
setResizing(false)
|
||||
setHovered(false)
|
||||
commit(options.defaultValue())
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
moved = false
|
||||
setResizing(true)
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
},
|
||||
onMouseDrag: (event: MouseEvent) => {
|
||||
if (!resizing()) return
|
||||
moved = true
|
||||
lastBoundaryClick = 0
|
||||
setPreferredSize(options.clamp(options.fromMouse(event)))
|
||||
event.stopPropagation()
|
||||
},
|
||||
onMouseDragEnd: finish,
|
||||
onMouseUp: finish,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { BoxRenderable } from "@opentui/core"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { createSignal } from "solid-js"
|
||||
import { ConfigProvider } from "../../src/config"
|
||||
import { ThemeProvider, useTheme } from "../../src/context/theme"
|
||||
import { createPaneResize } from "../../src/ui/pane-resize"
|
||||
import { PaneResizeHandle } from "../../src/ui/pane-resize-handle"
|
||||
import { emptyThemeSource } from "../fixture/fixture"
|
||||
import { TestTuiContexts } from "../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
|
||||
|
||||
for (const mode of ["dark", "light"] as const) {
|
||||
test(`${mode} pane resize handle renders themed hover and keeps parent-owned dragging and reset`, async () => {
|
||||
const [value, setValue] = createSignal(20)
|
||||
const commits: number[] = []
|
||||
let resize!: ReturnType<typeof createPaneResize>
|
||||
let theme!: ReturnType<typeof useTheme>
|
||||
let parent!: BoxRenderable
|
||||
function Pane() {
|
||||
theme = useTheme("elevated")
|
||||
resize = createPaneResize({
|
||||
value,
|
||||
defaultValue: () => 16,
|
||||
clamp: (size) => Math.max(10, Math.min(40, size)),
|
||||
fromMouse: (event) => event.x + 1,
|
||||
contains: (event, size) => event.x >= size - 1 && event.x <= size,
|
||||
onCommit: (size) => {
|
||||
commits.push(size)
|
||||
setValue(size)
|
||||
},
|
||||
})
|
||||
return (
|
||||
<box
|
||||
ref={(element) => (parent = element)}
|
||||
width="100%"
|
||||
height="100%"
|
||||
onMouseDrag={resize.onMouseDrag}
|
||||
onMouseDragEnd={resize.onMouseDragEnd}
|
||||
onMouseUp={resize.onMouseUp}
|
||||
>
|
||||
<PaneResizeHandle resize={resize} left={resize.size() - 1} />
|
||||
</box>
|
||||
)
|
||||
}
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<TestTuiContexts>
|
||||
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "opencode", mode } })}>
|
||||
<ThemeProvider mode={mode} source={emptyThemeSource}>
|
||||
<Pane />
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
</TestTuiContexts>
|
||||
),
|
||||
{ width: 60, height: 5 },
|
||||
)
|
||||
|
||||
try {
|
||||
app.renderer.start()
|
||||
await app.waitForFrame(() => parent?.height === 5)
|
||||
const handle = parent.getChildren()[0] as BoxRenderable
|
||||
const line = handle.getChildren()[0] as BoxRenderable
|
||||
expect(handle).toMatchObject({ x: 19, y: 0, width: 2, height: 5, zIndex: 10 })
|
||||
expect(line).toMatchObject({ x: 19, y: 0, width: 1, height: 5 })
|
||||
expect(line.backgroundColor.a).toBe(0)
|
||||
expect(handle.backgroundColor.a).toBe(0)
|
||||
|
||||
setValue(24)
|
||||
await app.renderOnce()
|
||||
expect(handle.x).toBe(23)
|
||||
expect(line.x).toBe(23)
|
||||
expect(commits).toEqual([])
|
||||
|
||||
// The transparent second column is still part of the hitbox.
|
||||
await app.mockMouse.moveTo(24, 2)
|
||||
await app.renderOnce()
|
||||
expect(resize.hovered()).toBe(true)
|
||||
expect(resize.resizing()).toBe(false)
|
||||
expect(line.backgroundColor.toInts()).toEqual(theme.background.action.primary.hovered.toInts())
|
||||
expect(handle.backgroundColor.a).toBe(0)
|
||||
for (const row of app.captureSpans().lines) {
|
||||
const colors = row.spans.flatMap((span) => Array.from({ length: span.width }, () => span.bg.toInts()))
|
||||
expect(colors[23]).toEqual(theme.background.action.primary.hovered.toInts())
|
||||
expect(colors[24]).not.toEqual(colors[23])
|
||||
}
|
||||
|
||||
await app.mockMouse.moveTo(5, 2)
|
||||
await app.renderOnce()
|
||||
expect(resize.hovered()).toBe(false)
|
||||
expect(line.backgroundColor.a).toBe(0)
|
||||
|
||||
await app.mockMouse.pressDown(24, 2)
|
||||
expect(resize.resizing()).toBe(true)
|
||||
await app.mockMouse.moveTo(30, 2)
|
||||
await app.renderOnce()
|
||||
expect(resize.size()).toBe(31)
|
||||
expect(handle.x).toBe(30)
|
||||
expect(line.x).toBe(30)
|
||||
expect(commits).toEqual([])
|
||||
|
||||
// Clamping leaves the pointer outside the handle while the parent keeps dragging.
|
||||
await app.mockMouse.moveTo(50, 2)
|
||||
await app.renderOnce()
|
||||
expect(resize.size()).toBe(40)
|
||||
expect(handle.x).toBe(39)
|
||||
expect(resize.hovered()).toBe(false)
|
||||
expect(resize.resizing()).toBe(true)
|
||||
expect(line.backgroundColor.toInts()).toEqual(theme.background.action.primary.hovered.toInts())
|
||||
expect(commits).toEqual([])
|
||||
|
||||
await app.mockMouse.release(55, 2)
|
||||
await app.renderOnce()
|
||||
expect(resize.resizing()).toBe(false)
|
||||
expect(resize.hovered()).toBe(false)
|
||||
expect(line.backgroundColor.a).toBe(0)
|
||||
expect(value()).toBe(40)
|
||||
expect(commits).toEqual([40])
|
||||
|
||||
await app.mockMouse.doubleClick(40, 2)
|
||||
await app.renderOnce()
|
||||
expect(value()).toBe(16)
|
||||
expect(handle.x).toBe(15)
|
||||
expect(line.x).toBe(15)
|
||||
expect(resize.resizing()).toBe(false)
|
||||
expect(resize.hovered()).toBe(false)
|
||||
expect(line.backgroundColor.a).toBe(0)
|
||||
expect(commits).toEqual([40, 16])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { afterEach, beforeEach, expect, setSystemTime, test } from "bun:test"
|
||||
import { MouseButton, MouseEvent } from "@opentui/core"
|
||||
import { createRoot, createSignal } from "solid-js"
|
||||
import { createPaneResize } from "../../src/ui/pane-resize"
|
||||
|
||||
const disposals: Array<() => void> = []
|
||||
|
||||
beforeEach(() => setSystemTime(new Date(1_000)))
|
||||
afterEach(() => {
|
||||
disposals.splice(0).forEach((dispose) => dispose())
|
||||
setSystemTime()
|
||||
})
|
||||
|
||||
function setup() {
|
||||
return createRoot((dispose) => {
|
||||
disposals.push(dispose)
|
||||
const [value, setValue] = createSignal(40)
|
||||
const [maximum, setMaximum] = createSignal(80)
|
||||
const [defaultValue, setDefaultValue] = createSignal(30)
|
||||
const commits: number[] = []
|
||||
const resize = createPaneResize({
|
||||
value,
|
||||
defaultValue,
|
||||
clamp: (size) => Math.max(10, Math.min(maximum(), size)),
|
||||
fromMouse: (event) => event.x + 1,
|
||||
contains: (event, size) => event.x >= size - 1 && event.x <= size,
|
||||
onCommit: (size) => {
|
||||
commits.push(size)
|
||||
setValue(size)
|
||||
},
|
||||
})
|
||||
return { resize, commits, setValue, setMaximum, setDefaultValue }
|
||||
})
|
||||
}
|
||||
|
||||
function mouse(type: MouseEvent["type"], x = 39, button = MouseButton.LEFT, y = 0) {
|
||||
return new MouseEvent(null, { type, x, y, button, modifiers: { shift: false, alt: false, ctrl: false } })
|
||||
}
|
||||
|
||||
test("syncs external preferences and clamps responsively without persisting or shrinking the preference", () => {
|
||||
const scope = setup()
|
||||
expect(scope.resize.preferredSize()).toBe(40)
|
||||
expect(scope.resize.size()).toBe(40)
|
||||
expect(scope.resize.resizing()).toBe(false)
|
||||
expect(scope.resize.hovered()).toBe(false)
|
||||
|
||||
scope.setValue(60)
|
||||
expect(scope.resize.preferredSize()).toBe(60)
|
||||
expect(scope.resize.size()).toBe(60)
|
||||
scope.setMaximum(25)
|
||||
expect(scope.resize.preferredSize()).toBe(60)
|
||||
expect(scope.resize.size()).toBe(25)
|
||||
scope.setMaximum(80)
|
||||
expect(scope.resize.size()).toBe(60)
|
||||
|
||||
scope.setValue(100)
|
||||
expect(scope.resize.preferredSize()).toBe(100)
|
||||
expect(scope.resize.size()).toBe(80)
|
||||
scope.setMaximum(110)
|
||||
expect(scope.resize.size()).toBe(100)
|
||||
expect(scope.commits).toEqual([])
|
||||
})
|
||||
|
||||
test("handles only left-button starts, commits the final drag coordinate, and ignores duplicate releases", () => {
|
||||
const scope = setup()
|
||||
const idleDrag = mouse("drag", 70)
|
||||
scope.resize.onMouseDrag(idleDrag)
|
||||
const idleRelease = mouse("up", 70)
|
||||
scope.resize.onMouseUp(idleRelease)
|
||||
expect(idleDrag.propagationStopped).toBe(false)
|
||||
expect(idleRelease.propagationStopped).toBe(false)
|
||||
;[MouseButton.MIDDLE, MouseButton.RIGHT].forEach((button) => {
|
||||
const event = mouse("down", 39, button)
|
||||
scope.resize.onMouseDown(event)
|
||||
expect(scope.resize.resizing()).toBe(false)
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
expect(event.propagationStopped).toBe(false)
|
||||
})
|
||||
|
||||
const down = mouse("down")
|
||||
scope.resize.onMouseDown(down)
|
||||
expect(scope.resize.resizing()).toBe(true)
|
||||
expect(down.defaultPrevented).toBe(true)
|
||||
expect(down.propagationStopped).toBe(true)
|
||||
|
||||
const drag = mouse("drag", 47)
|
||||
scope.resize.onMouseDrag(drag)
|
||||
expect(scope.resize.preferredSize()).toBe(48)
|
||||
expect(scope.commits).toEqual([])
|
||||
expect(drag.propagationStopped).toBe(true)
|
||||
expect(drag.defaultPrevented).toBe(false)
|
||||
|
||||
const release = mouse("drag-end", 54)
|
||||
scope.resize.onMouseDragEnd(release)
|
||||
expect(scope.resize.resizing()).toBe(false)
|
||||
expect(scope.resize.size()).toBe(55)
|
||||
expect(scope.commits).toEqual([55])
|
||||
expect(release.propagationStopped).toBe(true)
|
||||
expect(release.defaultPrevented).toBe(false)
|
||||
|
||||
const duplicate = mouse("up", 70)
|
||||
scope.resize.onMouseUp(duplicate)
|
||||
expect(duplicate.propagationStopped).toBe(false)
|
||||
expect(scope.commits).toEqual([55])
|
||||
|
||||
scope.resize.onMouseDown(mouse("down", 54))
|
||||
scope.resize.onMouseUp(mouse("up", 70))
|
||||
expect(scope.resize.size()).toBe(55)
|
||||
expect(scope.commits).toEqual([55])
|
||||
|
||||
scope.setMaximum(35)
|
||||
setSystemTime(new Date(1_300))
|
||||
scope.resize.onMouseDown(mouse("down", 34))
|
||||
scope.resize.onMouseUp(mouse("up", 70))
|
||||
expect(scope.resize.preferredSize()).toBe(35)
|
||||
expect(scope.commits).toEqual([55, 35])
|
||||
})
|
||||
|
||||
test("resets only within 300ms of a clean release and reads the current clamped default", () => {
|
||||
const scope = setup()
|
||||
scope.resize.onMouseDown(mouse("down"))
|
||||
setSystemTime(new Date(2_000))
|
||||
scope.resize.onMouseUp(mouse("up"))
|
||||
|
||||
setSystemTime(new Date(2_300))
|
||||
scope.resize.onMouseDown(mouse("down"))
|
||||
expect(scope.resize.resizing()).toBe(true)
|
||||
expect(scope.resize.size()).toBe(40)
|
||||
setSystemTime(new Date(3_000))
|
||||
scope.resize.onMouseUp(mouse("up"))
|
||||
|
||||
scope.setDefaultValue(70)
|
||||
scope.setMaximum(60)
|
||||
scope.resize.onMouseOver()
|
||||
setSystemTime(new Date(3_299))
|
||||
const reset = mouse("down")
|
||||
scope.resize.onMouseDown(reset)
|
||||
expect(scope.resize.resizing()).toBe(false)
|
||||
expect(scope.resize.hovered()).toBe(false)
|
||||
expect(scope.resize.preferredSize()).toBe(60)
|
||||
expect(scope.commits).toEqual([60])
|
||||
expect(reset.defaultPrevented).toBe(true)
|
||||
expect(reset.propagationStopped).toBe(true)
|
||||
|
||||
const release = mouse("up", 59)
|
||||
scope.resize.onMouseUp(release)
|
||||
expect(release.propagationStopped).toBe(false)
|
||||
expect(scope.commits).toEqual([60])
|
||||
setSystemTime(new Date(3_300))
|
||||
scope.resize.onMouseDown(mouse("down", 59))
|
||||
expect(scope.resize.resizing()).toBe(true)
|
||||
})
|
||||
|
||||
test("even a drag with no size change clears the clean-click timer", () => {
|
||||
const scope = setup()
|
||||
scope.resize.onMouseDown(mouse("down"))
|
||||
scope.resize.onMouseUp(mouse("up"))
|
||||
|
||||
setSystemTime(new Date(1_300))
|
||||
scope.resize.onMouseDown(mouse("down"))
|
||||
setSystemTime(new Date(1_400))
|
||||
scope.resize.onMouseDrag(mouse("drag"))
|
||||
setSystemTime(new Date(1_450))
|
||||
scope.resize.onMouseUp(mouse("up"))
|
||||
expect(scope.commits).toEqual([])
|
||||
|
||||
setSystemTime(new Date(1_500))
|
||||
scope.resize.onMouseDown(mouse("down"))
|
||||
expect(scope.resize.resizing()).toBe(true)
|
||||
expect(scope.resize.size()).toBe(40)
|
||||
expect(scope.commits).toEqual([])
|
||||
})
|
||||
|
||||
test("updates hover without consuming events and checks release against the clamped boundary", () => {
|
||||
const scope = setup()
|
||||
const onMouseOver: (event: MouseEvent) => void = scope.resize.onMouseOver
|
||||
const onMouseOut: (event: MouseEvent) => void = scope.resize.onMouseOut
|
||||
const over = mouse("over")
|
||||
onMouseOver(over)
|
||||
expect(scope.resize.hovered()).toBe(true)
|
||||
expect(over.propagationStopped).toBe(false)
|
||||
expect(over.defaultPrevented).toBe(false)
|
||||
const out = mouse("out")
|
||||
onMouseOut(out)
|
||||
expect(scope.resize.hovered()).toBe(false)
|
||||
expect(out.propagationStopped).toBe(false)
|
||||
expect(out.defaultPrevented).toBe(false)
|
||||
|
||||
scope.setMaximum(50)
|
||||
scope.resize.onMouseDown(mouse("down"))
|
||||
scope.resize.onMouseDrag(mouse("drag", 100))
|
||||
scope.resize.onMouseUp(mouse("up", 100))
|
||||
expect(scope.resize.size()).toBe(50)
|
||||
expect(scope.resize.hovered()).toBe(false)
|
||||
|
||||
scope.resize.onMouseDown(mouse("down", 49))
|
||||
scope.resize.onMouseUp(mouse("up", 50))
|
||||
expect(scope.resize.hovered()).toBe(true)
|
||||
setSystemTime(new Date(1_300))
|
||||
scope.resize.onMouseDown(mouse("down", 49))
|
||||
scope.resize.onMouseUp(mouse("up", 49))
|
||||
expect(scope.resize.hovered()).toBe(true)
|
||||
setSystemTime(new Date(1_600))
|
||||
scope.resize.onMouseDown(mouse("down", 49))
|
||||
scope.resize.onMouseUp(mouse("up", 48))
|
||||
expect(scope.resize.hovered()).toBe(false)
|
||||
expect(scope.commits).toEqual([50])
|
||||
})
|
||||
|
||||
test("ignores storage changes during a drag and resumes external synchronization after release", () => {
|
||||
const scope = setup()
|
||||
scope.resize.onMouseDown(mouse("down"))
|
||||
scope.resize.onMouseDrag(mouse("drag", 49))
|
||||
scope.setValue(70)
|
||||
expect(scope.resize.resizing()).toBe(true)
|
||||
expect(scope.resize.preferredSize()).toBe(50)
|
||||
expect(scope.resize.size()).toBe(50)
|
||||
expect(scope.commits).toEqual([])
|
||||
|
||||
scope.resize.onMouseUp(mouse("up", 59))
|
||||
expect(scope.resize.preferredSize()).toBe(60)
|
||||
expect(scope.commits).toEqual([60])
|
||||
scope.setValue(35)
|
||||
expect(scope.resize.preferredSize()).toBe(35)
|
||||
expect(scope.commits).toEqual([60])
|
||||
})
|
||||
|
||||
test("supports right-anchored coordinates and live constraints through caller callbacks", () => {
|
||||
const scope = createRoot((dispose) => {
|
||||
disposals.push(dispose)
|
||||
const [value, setValue] = createSignal(30)
|
||||
const [width, setWidth] = createSignal(100)
|
||||
const commits: number[] = []
|
||||
const resize = createPaneResize({
|
||||
value,
|
||||
defaultValue: () => 20,
|
||||
clamp: (size) => Math.max(10, Math.min(width() - 20, size)),
|
||||
fromMouse: (event) => width() - event.x,
|
||||
contains: (event, size) => event.x === width() - size && event.y >= 2,
|
||||
onCommit: (size) => {
|
||||
commits.push(size)
|
||||
setValue(size)
|
||||
},
|
||||
})
|
||||
return { resize, setWidth, commits }
|
||||
})
|
||||
|
||||
scope.resize.onMouseDown(mouse("down", 70))
|
||||
scope.resize.onMouseDrag(mouse("drag", 50))
|
||||
expect(scope.resize.size()).toBe(50)
|
||||
scope.setWidth(60)
|
||||
expect(scope.resize.preferredSize()).toBe(50)
|
||||
expect(scope.resize.size()).toBe(40)
|
||||
scope.resize.onMouseUp(mouse("up", 25, MouseButton.LEFT, 2))
|
||||
expect(scope.resize.size()).toBe(35)
|
||||
expect(scope.resize.hovered()).toBe(true)
|
||||
expect(scope.commits).toEqual([35])
|
||||
})
|
||||
Reference in New Issue
Block a user