mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-26 03:26:12 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbbd1cf7da |
@@ -22,36 +22,6 @@ env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
affected:
|
||||
name: affected packages
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
outputs:
|
||||
app: ${{ steps.packages.outputs.app }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version-file: package.json
|
||||
|
||||
- name: Find affected packages
|
||||
id: packages
|
||||
env:
|
||||
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
TURBO_SCM_HEAD: ${{ github.sha }}
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "app=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
bun x turbo@2.10.2 ls --affected --filter=@opencode-ai/app --output=json > affected.json
|
||||
bun -e 'const result = await Bun.file("affected.json").json(); console.log(`app=${result.packages.count > 0}`)' >> "$GITHUB_OUTPUT"
|
||||
|
||||
unit:
|
||||
name: unit (${{ matrix.settings.name }})
|
||||
strategy:
|
||||
@@ -163,7 +133,7 @@ jobs:
|
||||
|
||||
e2e:
|
||||
name: e2e (${{ matrix.settings.name }})
|
||||
needs: affected
|
||||
if: github.ref_name != 'v2' && github.head_ref != 'v2'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -174,38 +144,32 @@ jobs:
|
||||
host: blacksmith-4vcpu-windows-2025
|
||||
runs-on: ${{ matrix.settings.host }}
|
||||
env:
|
||||
E2E_ENABLED: ${{ needs.affected.outputs.app == 'true' && github.ref_name != 'v2' && github.head_ref != 'v2' }}
|
||||
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
if: env.E2E_ENABLED == 'true'
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node
|
||||
if: env.E2E_ENABLED == 'true'
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
# Playwright 1.59 hangs while extracting Chromium with Node 24.16.
|
||||
node-version: "24.15"
|
||||
|
||||
- name: Setup Bun
|
||||
if: env.E2E_ENABLED == 'true'
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Read Playwright version
|
||||
if: env.E2E_ENABLED == 'true'
|
||||
id: playwright-version
|
||||
run: |
|
||||
version=$(node -e 'console.log(require("./package.json").workspaces.catalog["@playwright/test"])')
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
if: env.E2E_ENABLED == 'true'
|
||||
id: playwright-cache
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
@@ -213,24 +177,23 @@ jobs:
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium
|
||||
|
||||
- name: Install Playwright system dependencies
|
||||
if: env.E2E_ENABLED == 'true' && runner.os == 'Linux'
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: packages/app
|
||||
run: bunx playwright install-deps chromium
|
||||
|
||||
- name: Install Playwright browsers
|
||||
if: env.E2E_ENABLED == 'true' && steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
working-directory: packages/app
|
||||
run: bunx playwright install chromium
|
||||
|
||||
- name: Run app e2e tests
|
||||
if: env.E2E_ENABLED == 'true'
|
||||
run: bun --cwd packages/app test:e2e:local
|
||||
env:
|
||||
CI: true
|
||||
timeout-minutes: 30
|
||||
|
||||
- name: Upload Playwright artifacts
|
||||
if: always() && env.E2E_ENABLED == 'true'
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }}
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-3Jx1Q7hl+Y0Log/k2vd5y6dzBpzFKWlhShPESxn1Rm4=",
|
||||
"aarch64-linux": "sha256-EiiI6g01oBIrExCMAUgT3w82P0fvu4FAJhI32C+ze0I=",
|
||||
"aarch64-darwin": "sha256-s+w49HRp1+ewtiTaU65tPWjUiO1NQw3kzfemMEEQZb0=",
|
||||
"x86_64-darwin": "sha256-/Ee5V7pnL/qm3c4ZHeWEjH7FhGVXArXryOugbG5vsz8="
|
||||
"x86_64-linux": "sha256-rIg+QSwF0kmPRnlPrpV7IlPTrHAxvoBOjdtoFOcUnKc=",
|
||||
"aarch64-linux": "sha256-74rMnqiGy7gAUDWr9HWBc6Sn3kxh/hDfm5kp+I4fjok=",
|
||||
"aarch64-darwin": "sha256-ImASKYxUQDHzH/UuwXUBsMNkitAH5wDYESlevEy9qeA=",
|
||||
"x86_64-darwin": "sha256-txPhXfZOSoQdNXPfddKSdKqdjEWrb9GkBb38h+pZIuo="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,7 +288,6 @@ export const Event = Schema.StructWithRest(
|
||||
arguments: Schema.optional(Schema.String),
|
||||
text: Schema.optional(Schema.String),
|
||||
item_id: Schema.optional(Schema.String),
|
||||
output_index: Schema.optional(Schema.Number),
|
||||
summary_index: Schema.optional(Schema.Number),
|
||||
item: Schema.optional(StreamItem),
|
||||
response: Schema.optional(
|
||||
@@ -342,7 +341,6 @@ export interface ParserState {
|
||||
readonly tools: ToolStream.State<string>
|
||||
readonly hasFunctionCall: boolean
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly outputItems: Readonly<Record<number, string>>
|
||||
readonly messageItems: ReadonlySet<string>
|
||||
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
|
||||
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
||||
@@ -657,7 +655,14 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
}
|
||||
}
|
||||
|
||||
return input
|
||||
// With store:false, Responses APIs only accept previous reasoning items when the
|
||||
// complete item has encrypted state. Summary blocks for one item may carry
|
||||
// that state only on the last block, so filter after they have been joined.
|
||||
return store === false
|
||||
? input.filter(
|
||||
(item) => !("type" in item) || item.type !== "reasoning" || typeof item.encrypted_content === "string",
|
||||
)
|
||||
: input
|
||||
})
|
||||
|
||||
const lowerOptions = (request: LLMRequest) => {
|
||||
@@ -813,9 +818,6 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes
|
||||
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events]
|
||||
}
|
||||
|
||||
export const outputItemID = (state: ParserState, event: Event) =>
|
||||
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
|
||||
|
||||
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
|
||||
const item = state.reasoningItems[itemID]
|
||||
if (!event.delta || !item) return [state, NO_EVENTS]
|
||||
@@ -1199,11 +1201,7 @@ export const providerFailure = (id: string, event: Event, fallback: string) => {
|
||||
|
||||
const providerError = (state: ParserState, event: Event, fallback: string) => providerFailure(state.id, event, fallback)
|
||||
|
||||
export const step = (state: ParserState, input: Event) => {
|
||||
const event =
|
||||
input.item_id && outputItemID(state, input) !== input.item_id
|
||||
? { ...input, item_id: outputItemID(state, input) }
|
||||
: input
|
||||
export const step = (state: ParserState, event: Event) => {
|
||||
if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") {
|
||||
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
return Effect.succeed(
|
||||
@@ -1245,14 +1243,7 @@ export const step = (state: ParserState, input: Event) => {
|
||||
if (event.type === "response.output_item.added") {
|
||||
if (event.item?.type === "message" && !event.item.id)
|
||||
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
|
||||
return Effect.succeed(
|
||||
onOutputItemAdded(
|
||||
event.output_index !== undefined && event.item?.id
|
||||
? { ...state, outputItems: { ...state.outputItems, [event.output_index]: event.item.id } }
|
||||
: state,
|
||||
event,
|
||||
),
|
||||
)
|
||||
return Effect.succeed(onOutputItemAdded(state, event))
|
||||
}
|
||||
if (event.type === "response.function_call_arguments.delta" || event.type === "response.function_call_arguments.done")
|
||||
return event.item_id
|
||||
@@ -1287,7 +1278,6 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
|
||||
hasFunctionCall: false,
|
||||
tools: ToolStream.empty<string>(),
|
||||
lifecycle: Lifecycle.initial(),
|
||||
outputItems: {},
|
||||
messageItems: new Set<string>(),
|
||||
messagePhases: {},
|
||||
reasoningItems: {},
|
||||
|
||||
@@ -278,7 +278,6 @@ interface LoweringOptions {
|
||||
readonly cacheControl?: (
|
||||
cache: CacheHint | undefined,
|
||||
) => Schema.Schema.Type<typeof OpenAIChatCacheControl> | undefined
|
||||
readonly toolCallID?: (id: string) => string
|
||||
}
|
||||
|
||||
const lowerTool = (
|
||||
@@ -305,8 +304,8 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
||||
tool: (name) => ({ type: "function" as const, function: { name } }),
|
||||
})
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart, options: LoweringOptions): OpenAIChatAssistantToolCall => ({
|
||||
id: options.toolCallID?.(part.id) ?? part.id,
|
||||
const lowerToolCall = (part: ToolCallPart): OpenAIChatAssistantToolCall => ({
|
||||
id: part.id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: part.name,
|
||||
@@ -382,7 +381,7 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-call") {
|
||||
toolCalls.push(lowerToolCall(part, options))
|
||||
toolCalls.push(lowerToolCall(part))
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -428,7 +427,7 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
|
||||
if (part.result.type !== "content") {
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: options.toolCallID?.(part.id) ?? part.id,
|
||||
tool_call_id: part.id,
|
||||
content: ProviderShared.toolResultText(part),
|
||||
cache_control: options.cacheControl?.(part.cache),
|
||||
})
|
||||
@@ -438,7 +437,7 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
|
||||
const text = content.filter((item) => item.type === "text").map((item) => item.text)
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: options.toolCallID?.(part.id) ?? part.id,
|
||||
tool_call_id: part.id,
|
||||
content: text.join("\n"),
|
||||
cache_control: options.cacheControl?.(part.cache),
|
||||
})
|
||||
@@ -479,30 +478,12 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
||||
]
|
||||
: [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
||||
const messages = [...system]
|
||||
const modelID = request.model.id.toLowerCase()
|
||||
const mistral = ["mistral", "devstral", "codestral", "pixtral", "mixtral"].some((family) => modelID.includes(family))
|
||||
const lowering = {
|
||||
...options,
|
||||
toolCallID: (id: string) => {
|
||||
if (mistral) return id.replace(/[^a-zA-Z0-9]/g, "").slice(0, 9).padEnd(9, "0")
|
||||
if (modelID.includes("claude")) return id.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
if (request.model.provider === "openai" || request.model.provider === "azure" || modelID.startsWith("openai/"))
|
||||
return id.slice(0, 40)
|
||||
return id
|
||||
},
|
||||
}
|
||||
const requireAssistantAfterTool = request.model.compatibility?.requireAssistantAfterTool ?? mistral
|
||||
const bridgeTools = () => {
|
||||
if (requireAssistantAfterTool && messages.at(-1)?.role === "tool") messages.push({ role: "assistant", content: "Done." })
|
||||
}
|
||||
const pendingImages: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
|
||||
const flushImages = () => {
|
||||
if (pendingImages.length === 0) return
|
||||
bridgeTools()
|
||||
messages.push({ role: "user", content: pendingImages.splice(0) })
|
||||
}
|
||||
for (const message of request.messages) {
|
||||
if (message.role === "user") bridgeTools()
|
||||
if (message.role === "system") {
|
||||
const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Chat", message)
|
||||
if (pendingImages.length > 0) {
|
||||
@@ -545,16 +526,14 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (message.role === "assistant" && message.content.every((part) => part.type === "text" && part.text.trim() === ""))
|
||||
continue
|
||||
if (message.role === "tool") {
|
||||
const lowered = yield* lowerToolMessages(message, lowering)
|
||||
const lowered = yield* lowerToolMessages(message, options)
|
||||
messages.push(...lowered.messages)
|
||||
pendingImages.push(...lowered.images)
|
||||
continue
|
||||
}
|
||||
flushImages()
|
||||
messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField, lowering)))
|
||||
messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField, options)))
|
||||
}
|
||||
flushImages()
|
||||
return messages
|
||||
|
||||
@@ -166,9 +166,7 @@ const HOSTED_TOOLS = {
|
||||
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
|
||||
if (event.type === "response.reasoning_text.delta")
|
||||
return event.item_id
|
||||
? Effect.succeed(
|
||||
OpenResponses.onReasoningDelta(state, event, OpenResponses.outputItemID(state, event) ?? event.item_id),
|
||||
)
|
||||
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
|
||||
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
|
||||
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
|
||||
|
||||
@@ -155,7 +155,6 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
|
||||
reasoningField: Schema.optional(Schema.String),
|
||||
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
|
||||
requireFinishReason: Schema.optional(Schema.Boolean),
|
||||
requireAssistantAfterTool: Schema.optional(Schema.Boolean),
|
||||
supportsStore: Schema.optional(Schema.Boolean),
|
||||
supportsUsageInStreaming: Schema.optional(Schema.Boolean),
|
||||
supportsStrictMode: Schema.optional(Schema.Boolean),
|
||||
|
||||
@@ -85,28 +85,6 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits empty and whitespace-only assistant messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user("Before."),
|
||||
Message.assistant([]),
|
||||
Message.assistant(""),
|
||||
Message.assistant(" \n\t "),
|
||||
Message.assistant("After."),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "user", content: "Before." },
|
||||
{ role: "assistant", content: "After." },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays canonical reasoning as OpenAI-compatible reasoning_content", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -388,35 +366,6 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("limits OpenAI and Azure Chat tool call IDs to 40 characters", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = `call_${"a".repeat(48)}`
|
||||
const models = [
|
||||
model,
|
||||
Azure.configure({ baseURL: "https://opencode-test.openai.azure.com/openai/", apiKey: "test" }).chat("gpt-4o"),
|
||||
]
|
||||
|
||||
yield* Effect.forEach(models, (selected) =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: selected,
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id, name: "lookup", input: {} })]),
|
||||
Message.tool({ id, name: "lookup", result: "Sunny" }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toMatchObject([
|
||||
{ role: "assistant", tool_calls: [{ id: id.slice(0, 40) }] },
|
||||
{ role: "tool", tool_call_id: id.slice(0, 40) },
|
||||
])
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves structured tool errors for the model", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = { error: { type: "unknown", message: "Tool execution interrupted" } }
|
||||
@@ -482,30 +431,6 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("bridges image tool results before their synthetic user message when required", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: LanguageModel.update(model, { compatibility: { requireAssistantAfterTool: true } }),
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_image", name: "read", input: {} })]),
|
||||
Message.tool({
|
||||
id: "call_image",
|
||||
name: "read",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages.map((message) => message.role)).toEqual(["assistant", "tool", "assistant", "user"])
|
||||
expect(prepared.body.messages[2]).toEqual({ role: "assistant", content: "Done." })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("orders parallel tool responses before one aggregated vision message", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
|
||||
@@ -238,85 +238,6 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("normalizes tool call IDs for the selected model family", () =>
|
||||
Effect.gen(function* () {
|
||||
const longID = `call_${"a".repeat(48)}`
|
||||
const cases = [
|
||||
{ provider: "custom", model: "mistral-small", id: "toolu_01CBhTTz95qkd9LJMdC9sf8t", expected: "toolu01CB" },
|
||||
{ provider: "custom", model: "devstral-small", id: "abc", expected: "abc000000" },
|
||||
{ provider: "custom", model: "codestral-latest", id: "toolu_01CBhTTz95", expected: "toolu01CB" },
|
||||
{ provider: "custom", model: "pixtral-large", id: "toolu_01CBhTTz95", expected: "toolu01CB" },
|
||||
{ provider: "custom", model: "open-mixtral-8x22b", id: "toolu_01CBhTTz95", expected: "toolu01CB" },
|
||||
{ provider: "gateway", model: "anthropic/claude-sonnet-4", id: "call|item/+", expected: "call_item__" },
|
||||
{ provider: "gateway", model: "openai/gpt-4o", id: longID, expected: longID.slice(0, 40) },
|
||||
{ provider: "custom", model: "ordinary-model", id: "call|item/+", expected: "call|item/+" },
|
||||
{ provider: "mistral", model: "zai-glm-5-2", id: "call_long_identifier", expected: "call_long_identifier" },
|
||||
]
|
||||
|
||||
yield* Effect.forEach(cases, (item) =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenAICompatibleChat.route
|
||||
.with({ provider: item.provider, endpoint: { baseURL: "https://api.custom.test/v1" } })
|
||||
.model({ id: item.model }),
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: item.id, name: "lookup", input: {} })]),
|
||||
Message.tool({ id: item.id, name: "lookup", result: { type: "content", value: [] } }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toMatchObject([
|
||||
{ role: "assistant", tool_calls: [{ id: item.expected }] },
|
||||
{ role: "tool", tool_call_id: item.expected },
|
||||
])
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("bridges tool results for Mistral-family models and honors compatibility overrides", () =>
|
||||
Effect.gen(function* () {
|
||||
const cases = [
|
||||
{ id: "mistral-small", bridge: true },
|
||||
{ id: "devstral-small", bridge: true },
|
||||
{ id: "codestral-latest", bridge: true },
|
||||
{ id: "pixtral-large", bridge: true },
|
||||
{ id: "open-mixtral-8x22b", bridge: true },
|
||||
{ id: "ordinary-model", bridge: false },
|
||||
{ id: "ordinary-model", override: true, bridge: true },
|
||||
{ id: "mistral-small", override: false, bridge: false },
|
||||
] as const
|
||||
|
||||
yield* Effect.forEach(cases, (item) =>
|
||||
Effect.gen(function* () {
|
||||
const selected = OpenAICompatibleChat.route
|
||||
.with({ provider: "custom", endpoint: { baseURL: "https://api.custom.test/v1" } })
|
||||
.model({
|
||||
id: item.id,
|
||||
compatibility: "override" in item ? { requireAssistantAfterTool: item.override } : undefined,
|
||||
})
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: selected,
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "Sunny" }),
|
||||
Message.user("What next?"),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages.map((message) => message.role)).toEqual(
|
||||
item.bridge ? ["assistant", "tool", "assistant", "user"] : ["assistant", "tool", "user"],
|
||||
)
|
||||
if (item.bridge) expect(prepared.body.messages[2]).toEqual({ role: "assistant", content: "Done." })
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("posts to the configured compatible endpoint and parses text usage", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
|
||||
@@ -225,31 +225,6 @@ describe("Open Responses-compatible route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes response deltas by output index", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
}).model("example-model")
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", output_index: 2, item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.output_text.delta", output_index: 2, item_id: "wrong_message", delta: "Indexed" },
|
||||
{ type: "response.output_item.done", output_index: 2, item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content).toEqual([
|
||||
{ type: "text", text: "Indexed", providerMetadata: { openresponses: { itemId: "msg_1" } } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("finalizes pending function calls from completed response output", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
|
||||
@@ -830,9 +830,7 @@ describe("OpenAI Responses route", () => {
|
||||
webSocket: {
|
||||
execute: (exchange) =>
|
||||
Effect.gen(function* () {
|
||||
yield* exchange.driver
|
||||
.create(undefined)
|
||||
.pipe(Effect.flatMap((create) => Ref.set(message, create.message)))
|
||||
yield* exchange.driver.create(undefined).pipe(Effect.flatMap((create) => Ref.set(message, create.message)))
|
||||
return { frames: exchange.fallback(), complete: Effect.void }
|
||||
}),
|
||||
},
|
||||
@@ -2053,163 +2051,6 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes assistant text by output index when its item id disagrees", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", output_index: 2, item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.output_text.delta", output_index: 2, item_id: "wrong_message", delta: "Indexed" },
|
||||
{ type: "response.output_item.done", output_index: 2, item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("Indexed")
|
||||
expect(response.message.content).toEqual([
|
||||
{ type: "text", text: "Indexed", providerMetadata: { openai: { itemId: "msg_1" } } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes interleaved function calls by output index", () =>
|
||||
Effect.gen(function* () {
|
||||
const first = { type: "function_call", id: "fc_1", call_id: "call_1", name: "first", arguments: "" }
|
||||
const second = { type: "function_call", id: "fc_2", call_id: "call_2", name: "second", arguments: "" }
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", output_index: 1, item: first },
|
||||
{ type: "response.output_item.added", output_index: 3, item: second },
|
||||
{ type: "response.function_call_arguments.delta", output_index: 1, item_id: "fc_2", delta: '{"a":' },
|
||||
{ type: "response.function_call_arguments.delta", output_index: 3, item_id: "fc_1", delta: '{"b":' },
|
||||
{
|
||||
type: "response.function_call_arguments.done",
|
||||
output_index: 3,
|
||||
item_id: "fc_1",
|
||||
arguments: '{"b":2}',
|
||||
},
|
||||
{
|
||||
type: "response.function_call_arguments.done",
|
||||
output_index: 1,
|
||||
item_id: "fc_2",
|
||||
arguments: '{"a":1}',
|
||||
},
|
||||
{ type: "response.output_item.done", output_index: 1, item: { ...first, arguments: '{"a":1}' } },
|
||||
{ type: "response.output_item.done", output_index: 3, item: { ...second, arguments: '{"b":2}' } },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.filter((event) => event.type === "tool-input-delta")).toMatchObject([
|
||||
{ id: "call_1", text: '{"a":' },
|
||||
{ id: "call_2", text: '{"b":' },
|
||||
{ id: "call_2", text: "2}" },
|
||||
{ id: "call_1", text: "1}" },
|
||||
])
|
||||
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
|
||||
expect.objectContaining({ id: "call_1", name: "first", input: { a: 1 } }),
|
||||
expect.objectContaining({ id: "call_2", name: "second", input: { b: 2 } }),
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes reasoning summary events by output index", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
output_index: 4,
|
||||
item: { type: "reasoning", id: "rs_1" },
|
||||
},
|
||||
{
|
||||
type: "response.reasoning_summary_part.added",
|
||||
output_index: 4,
|
||||
item_id: "wrong_reasoning",
|
||||
summary_index: 0,
|
||||
},
|
||||
{
|
||||
type: "response.reasoning_summary_text.delta",
|
||||
output_index: 4,
|
||||
item_id: "wrong_reasoning",
|
||||
summary_index: 0,
|
||||
delta: "Thinking",
|
||||
},
|
||||
{
|
||||
type: "response.reasoning_summary_part.done",
|
||||
output_index: 4,
|
||||
item_id: "wrong_reasoning",
|
||||
summary_index: 0,
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
output_index: 4,
|
||||
item: { type: "reasoning", id: "rs_1", encrypted_content: "state" },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("Thinking")
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Thinking",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "state" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes native reasoning text deltas by output index", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", output_index: 1, item: { type: "reasoning", id: "rs_1" } },
|
||||
{ type: "response.reasoning_text.delta", output_index: 1, item_id: "wrong_reasoning", delta: "Raw" },
|
||||
{ type: "response.output_item.done", output_index: 1, item: { type: "reasoning", id: "rs_1" } },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("Raw")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to item ids when an output index was not registered", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.output_text.delta", output_index: 9, item_id: "msg_1", delta: "Fallback" },
|
||||
{ type: "response.output_item.done", item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("Fallback")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects output text events without the spec-required item id", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
@@ -2229,25 +2070,6 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("requires item ids even when their output index is known", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", output_index: 0, item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.output_text.delta", output_index: 0, delta: "Missing item ID" },
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.message).toContain("response.output_text.delta is missing item_id")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores deltas without a matching output item", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
@@ -3157,7 +2979,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays stateless reasoning without encrypted state", () =>
|
||||
it.effect("skips non-persisted reasoning ids without encrypted state", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
@@ -3187,12 +3009,6 @@ describe("OpenAI Responses route", () => {
|
||||
expect(prepared.body).toMatchObject({
|
||||
input: [
|
||||
{ role: "user", content: [{ type: "input_text", text: "What changed?" }] },
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
|
||||
encrypted_content: null,
|
||||
},
|
||||
{ role: "assistant", content: [{ type: "output_text", text: "The parser changed." }] },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
|
||||
],
|
||||
|
||||
@@ -70,42 +70,6 @@ describe("xAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes xAI reasoning summaries by output index", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think" })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
output_index: 3,
|
||||
item: { type: "reasoning", id: "reasoning_1" },
|
||||
},
|
||||
{
|
||||
type: "response.reasoning_summary_text.delta",
|
||||
output_index: 3,
|
||||
item_id: "wrong_reasoning",
|
||||
summary_index: 0,
|
||||
delta: "Considering.",
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
output_index: 3,
|
||||
item: { type: "reasoning", id: "reasoning_1", encrypted_content: "opaque" },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "response_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("Considering.")
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")).toMatchObject({
|
||||
providerMetadata: { xai: { itemId: "reasoning_1", reasoningEncryptedContent: "opaque" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses xAI hosted tool items", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Search X" })).pipe(
|
||||
|
||||
@@ -500,7 +500,7 @@ export const dict = {
|
||||
"context.stats.lastActivity": "Last Activity",
|
||||
|
||||
"context.usage.tokens": "Tokens",
|
||||
"context.usage.usage": "Context Usage",
|
||||
"context.usage.usage": "Usage",
|
||||
"context.usage.cost": "Cost",
|
||||
"context.usage.clickToView": "Click to view context",
|
||||
"context.usage.view": "View context usage",
|
||||
|
||||
@@ -9,7 +9,6 @@ import type { BunPlugin } from "bun"
|
||||
import pkg from "../package.json"
|
||||
import { buildAppArchive } from "./app-assets"
|
||||
import { verifyArtifact, verifySimulationGraph } from "./verify-artifact"
|
||||
import { resolveOpencodePty } from "./opencode-pty"
|
||||
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
const binary = "opencode2"
|
||||
@@ -79,23 +78,6 @@ const appAssetsPlugin: BunPlugin = {
|
||||
}
|
||||
|
||||
for (const item of targets) {
|
||||
const opencodePty = await resolveOpencodePty({
|
||||
platform: item.os,
|
||||
arch: item.arch,
|
||||
...(item.os === "linux" ? { libc: item.abi ?? "glibc" } : {}),
|
||||
})
|
||||
const opencodePtyPlugin: BunPlugin = {
|
||||
name: "opencode-pty-binary",
|
||||
setup(build) {
|
||||
build.onLoad({ filter: /persistent-pty[/\\]pty-binding\.ts$/ }, () => ({
|
||||
loader: "js",
|
||||
contents: opencodePty
|
||||
? `import file from ${JSON.stringify(opencodePty.source)} with { type: "file" }
|
||||
export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sha256: ${JSON.stringify(opencodePty.sha256)} }`
|
||||
: "export default undefined",
|
||||
}))
|
||||
},
|
||||
}
|
||||
const simulationInputs = new Set<string>()
|
||||
const simulationGraphPlugin: BunPlugin = {
|
||||
name: "opencode-simulation-graph",
|
||||
@@ -123,7 +105,7 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
|
||||
const result = await Bun.build({
|
||||
entrypoints: ["./src/index.ts"],
|
||||
tsconfig: "./tsconfig.json",
|
||||
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, opencodePtyPlugin, simulationGraphPlugin],
|
||||
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, simulationGraphPlugin],
|
||||
external: ["node-gyp"],
|
||||
format: "esm",
|
||||
minify: true,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { fileURLToPath } from "node:url"
|
||||
import { getNodeAssets } from "@opentui/core/node-assets"
|
||||
import { attentionSoundAssets, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "../src/node/target"
|
||||
import { collectFiles } from "./files"
|
||||
import { resolveOpencodePty } from "./opencode-pty"
|
||||
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
|
||||
@@ -19,11 +18,6 @@ export type NodeAsset = {
|
||||
}
|
||||
|
||||
export async function collectNodeAssets(target: NodeTarget) {
|
||||
const opencodePty = await resolveOpencodePty({
|
||||
platform: target.platform,
|
||||
arch: target.arch,
|
||||
...(target.platform === "linux" ? { libc: "glibc" as const } : {}),
|
||||
})
|
||||
const ptyEntry = fileURLToPath(import.meta.resolve(target.nodePtyPackage))
|
||||
const ptyRoot = path.resolve(path.dirname(ptyEntry), "..")
|
||||
const assets: NodeAsset[] = [
|
||||
@@ -47,7 +41,6 @@ export async function collectNodeAssets(target: NodeTarget) {
|
||||
key,
|
||||
source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)),
|
||||
})),
|
||||
...(opencodePty && target.opencodePtyAsset ? [{ key: target.opencodePtyAsset, source: opencodePty.source }] : []),
|
||||
...(await collectFiles(ptyRoot))
|
||||
.filter((relative) => !relative.endsWith(".map") && !relative.endsWith(".pdb"))
|
||||
.map((relative) => ({
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { spawnSync } from "node:child_process"
|
||||
import { createHash } from "node:crypto"
|
||||
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
const VERSION = "0.1.5"
|
||||
const RELEASE = `https://github.com/anomalyco/opencode-pty/releases/download/v${VERSION}`
|
||||
const SHA256 = {
|
||||
"aarch64-apple-darwin": "d5156e44a6783381aadbd968dbd27c1d83e7e0f1b6042c7c934e6d33541d334f",
|
||||
"aarch64-unknown-linux-gnu": "075d99ffb269cbd0846d3d404fdee93965a53cd6eaf046dbd1064785a7ce9351",
|
||||
"aarch64-unknown-linux-musl": "22fb55c944ff05fbe03e84de67333e9fd037ad4e04ffc93d8a3f0b2193c29421",
|
||||
"x86_64-apple-darwin": "773e363b5385c1bd56021e69ada95132efd615ed5b9c3734f878ad644ae22b01",
|
||||
"x86_64-unknown-linux-gnu": "d9cac2a7c09d013188f696c45ded5eb5764d308e52dd31cb2de68bf4fc675624",
|
||||
"x86_64-unknown-linux-musl": "2a176302de3d24f8ae3fbacf0b4afce7b4af3e00abd619906187a487b5e50bd6",
|
||||
} as const
|
||||
|
||||
export type OpencodePtyAsset = {
|
||||
readonly source: string
|
||||
readonly version: string
|
||||
readonly sha256: string
|
||||
}
|
||||
|
||||
type Target = {
|
||||
readonly platform: string
|
||||
readonly arch: string
|
||||
readonly libc?: "glibc" | "musl"
|
||||
}
|
||||
|
||||
const pending = new Map<string, Promise<OpencodePtyAsset | undefined>>()
|
||||
|
||||
export function resolveOpencodePty(target: Target) {
|
||||
const rustTarget = targetName(target)
|
||||
if (!rustTarget) return Promise.resolve(undefined)
|
||||
const existing = pending.get(rustTarget)
|
||||
if (existing) return existing
|
||||
const result = acquire(rustTarget).catch((error) => {
|
||||
pending.delete(rustTarget)
|
||||
throw error
|
||||
})
|
||||
pending.set(rustTarget, result)
|
||||
return result
|
||||
}
|
||||
|
||||
async function acquire(target: keyof typeof SHA256): Promise<OpencodePtyAsset> {
|
||||
const root = path.resolve(import.meta.dirname, "../.cache/opencode-pty", VERSION, target)
|
||||
const executable = path.join(root, "opencode-pty")
|
||||
const cached = await readFile(executable).catch(() => undefined)
|
||||
if (cached)
|
||||
return {
|
||||
source: executable,
|
||||
version: VERSION,
|
||||
sha256: createHash("sha256").update(cached).digest("hex"),
|
||||
}
|
||||
|
||||
await mkdir(root, { recursive: true })
|
||||
const archiveName = `opencode-pty-${VERSION}-${target}.tar.gz`
|
||||
const response = await fetch(`${RELEASE}/${archiveName}`)
|
||||
if (!response.ok) throw new Error(`Failed to download ${archiveName}: ${response.status}`)
|
||||
const archive = new Uint8Array(await response.arrayBuffer())
|
||||
const actual = createHash("sha256").update(archive).digest("hex")
|
||||
if (actual !== SHA256[target]) throw new Error(`Checksum mismatch for ${archiveName}`)
|
||||
|
||||
const temporary = await mkdtemp(path.join(os.tmpdir(), "opencode-pty-build-"))
|
||||
try {
|
||||
const archivePath = path.join(temporary, archiveName)
|
||||
await writeFile(archivePath, archive)
|
||||
run("tar", ["-xzf", archivePath, "-C", temporary])
|
||||
const source = path.join(temporary, `opencode-pty-${VERSION}-${target}`, "opencode-pty")
|
||||
const bytes = await readFile(source)
|
||||
const staged = path.join(root, `opencode-pty.${process.pid}.${crypto.randomUUID()}.tmp`)
|
||||
await writeFile(staged, bytes, { flag: "wx", mode: 0o755 })
|
||||
await rename(staged, executable).catch(async (error) => {
|
||||
await rm(staged, { force: true })
|
||||
if (!(await readFile(executable).catch(() => undefined))) throw error
|
||||
})
|
||||
const installed = await readFile(executable)
|
||||
return {
|
||||
source: executable,
|
||||
version: VERSION,
|
||||
sha256: createHash("sha256").update(installed).digest("hex"),
|
||||
}
|
||||
} finally {
|
||||
await rm(temporary, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function targetName(target: Target): keyof typeof SHA256 | undefined {
|
||||
const arch = target.arch === "arm64" ? "aarch64" : target.arch === "x64" ? "x86_64" : undefined
|
||||
if (!arch) return undefined
|
||||
if (target.platform === "darwin") return arch === "aarch64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin"
|
||||
if (target.platform === "linux" && target.libc === "musl")
|
||||
return arch === "aarch64" ? "aarch64-unknown-linux-musl" : "x86_64-unknown-linux-musl"
|
||||
if (target.platform === "linux") return arch === "aarch64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu"
|
||||
return undefined
|
||||
}
|
||||
|
||||
function run(command: string, args: readonly string[]) {
|
||||
const result = spawnSync(command, args, { stdio: "inherit" })
|
||||
if (result.error) throw result.error
|
||||
if (result.status !== 0) throw new Error(`${command} exited with status ${result.status ?? "unknown"}`)
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Schema } from "effect"
|
||||
import { format } from "prettier"
|
||||
import { Info, SchemaURL } from "../src/config/schema"
|
||||
|
||||
const target = process.argv[2]
|
||||
if (!target) throw new Error("A schema output path is required")
|
||||
|
||||
const document = Schema.toJsonSchemaDocument(Info)
|
||||
const content = await format(
|
||||
JSON.stringify({
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||
$id: SchemaURL,
|
||||
...document.schema,
|
||||
...(Object.keys(document.definitions).length ? { $defs: document.definitions } : {}),
|
||||
}),
|
||||
{ parser: "json", printWidth: 120 },
|
||||
)
|
||||
|
||||
if (process.argv.includes("--check")) {
|
||||
if ((await Bun.file(target).text()) !== content) {
|
||||
console.error("Generated CLI config schema is stale. Run `bun run generate` from packages/www.")
|
||||
process.exit(1)
|
||||
}
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
await Bun.write(target, content)
|
||||
@@ -79,7 +79,6 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
commands: [
|
||||
Spec.make("agents", { description: "List all agents" }),
|
||||
Spec.make("config", { description: "List configuration sources" }),
|
||||
Spec.make("paths", { description: "Show global paths (data, config, cache, state)" }),
|
||||
],
|
||||
}),
|
||||
Spec.make("console", {
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.debug.commands.paths,
|
||||
Effect.fn("cli.debug.paths")(function* () {
|
||||
const global = yield* Global.Service
|
||||
process.stdout.write(
|
||||
Object.entries(global)
|
||||
.map(([key, value]) => `${key.padEnd(10)} ${value}${EOL}`)
|
||||
.join(""),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -7,7 +7,7 @@ import { produce, type Draft } from "immer"
|
||||
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
|
||||
import path from "path"
|
||||
import { ConfigMigration } from "./migrate"
|
||||
import { Info, SchemaURL } from "./schema"
|
||||
import { Info } from "./schema"
|
||||
|
||||
export * from "./schema"
|
||||
|
||||
@@ -87,9 +87,7 @@ export const layer = Layer.effect(
|
||||
const next = produce(current, update)
|
||||
const edits = changes(current, next)
|
||||
if (!edits.length) return current
|
||||
const text = yield* fs
|
||||
.readFileString(file)
|
||||
.pipe(Effect.orElseSucceed(() => JSON.stringify({ $schema: SchemaURL }, null, 2)))
|
||||
const text = yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => "{}"))
|
||||
const updated = edits.reduce(
|
||||
(text, edit) =>
|
||||
applyEdits(
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Effect, FileSystem, Option, Schema } from "effect"
|
||||
import { randomUUID } from "crypto"
|
||||
import { applyEdits, createScanner, modify, parse, parseTree, type Node, type ParseError } from "jsonc-parser"
|
||||
import path from "path"
|
||||
import { Info, SchemaURL } from "./schema"
|
||||
import { Info } from "./schema"
|
||||
|
||||
const decodeV1 = Schema.decodeUnknownOption(TuiConfigV1.Info)
|
||||
const decodeInfo = Schema.decodeUnknownOption(Info)
|
||||
@@ -100,9 +100,8 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: {
|
||||
const legacyValue = yield* readJson(path.join(input.config, "tui.json"))
|
||||
const legacy = Option.getOrUndefined(decodeV1(legacyValue))
|
||||
const kv = yield* readJson(path.join(input.state, "kv.json"))
|
||||
const values = migrateV1(legacy, kv ?? {})
|
||||
if (!Object.keys(values).length) return
|
||||
const migrated = { $schema: SchemaURL, ...values }
|
||||
const migrated = migrateV1(legacy, kv ?? {})
|
||||
if (!Object.keys(migrated).length) return
|
||||
|
||||
const result = yield* persist(JSON.stringify(migrated, null, 2) + "\n", migrated)
|
||||
if (result.cause === undefined)
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { Config } from "@opencode-ai/tui/config"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const SchemaURL = "https://opencode.ai/v2/cli.json"
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
$schema: Schema.optional(Schema.String).annotate({ description: "JSON Schema for CLI configuration" }),
|
||||
...Config.Info.fields,
|
||||
})
|
||||
export const Info = Schema.Struct({ ...Config.Info.fields })
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
@@ -27,7 +27,6 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
debug: {
|
||||
agents: () => import("./commands/handlers/debug/agents"),
|
||||
config: () => import("./commands/handlers/debug/config"),
|
||||
paths: () => import("./commands/handlers/debug/paths"),
|
||||
},
|
||||
console: {
|
||||
login: () => import("./commands/handlers/console/login"),
|
||||
|
||||
@@ -13,7 +13,6 @@ export function nodeTarget(platform: string, arch: string) {
|
||||
const parcelWatcherPackage = `@parcel/watcher-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-glibc" : ""}`
|
||||
const fffPackage = `@ff-labs/fff-bin-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : ""}`
|
||||
const fffFfiPackage = `@yuuang/ffi-rs-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : targetPlatform === "win32" ? "-msvc" : ""}`
|
||||
const opencodePtyAsset = targetPlatform === "win32" ? undefined : "opencode-pty/opencode-pty"
|
||||
|
||||
return {
|
||||
platform: targetPlatform,
|
||||
@@ -26,7 +25,6 @@ export function nodeTarget(platform: string, arch: string) {
|
||||
fffAsset: `${fffPackage}/${targetPlatform === "darwin" ? "libfff_c.dylib" : targetPlatform === "win32" ? "fff_c.dll" : "libfff_c.so"}`,
|
||||
fffFfiPackage,
|
||||
fffFfiAsset: `${fffFfiPackage}/ffi-rs.${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : targetPlatform === "win32" ? "-msvc" : ""}.node`,
|
||||
opencodePtyAsset,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Flock } from "@opencode-ai/util/flock"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Effect, FileSystem, Option, Schema } from "effect"
|
||||
import { Effect, FileSystem, Option } from "effect"
|
||||
import { expect, test } from "bun:test"
|
||||
import { parse } from "jsonc-parser"
|
||||
import path from "path"
|
||||
@@ -17,67 +17,6 @@ function run<A, E>(directory: string, effect: Effect.Effect<A, E, Config.Service
|
||||
)
|
||||
}
|
||||
|
||||
test("generates reusable keybind schemas and preserves descriptions and numeric constraints", () => {
|
||||
const document = Schema.toJsonSchemaDocument(Config.Info)
|
||||
expect(document).toHaveProperty(["definitions", "TuiKeybind.BindingValue"])
|
||||
expect(document).toHaveProperty(["schema", "properties", "keybinds", "anyOf", 0, "properties", "app.exit"], {
|
||||
anyOf: [{ $ref: "#/$defs/TuiKeybind.BindingValue" }, { type: "null" }],
|
||||
description: "Exit the application",
|
||||
})
|
||||
expect(document).toHaveProperty(["schema", "properties", "scroll", "anyOf", 0, "properties", "speed", "anyOf", 0], {
|
||||
type: "number",
|
||||
minimum: 0.001,
|
||||
})
|
||||
expect(document).toHaveProperty(
|
||||
["schema", "properties", "attention", "anyOf", 0, "properties", "volume", "anyOf", 0],
|
||||
{ type: "number", minimum: 0, maximum: 1 },
|
||||
)
|
||||
})
|
||||
|
||||
test("includes the published schema when creating cli.json", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
|
||||
try {
|
||||
const config = await run(
|
||||
directory,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.update((draft) => {
|
||||
draft.animations = false
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
expect(config).toEqual({ $schema: "https://opencode.ai/v2/cli.json", animations: false })
|
||||
expect(await Bun.file(path.join(directory, "cli.json")).json()).toEqual(config)
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves the schema in an existing cli.json", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
const file = path.join(directory, "cli.json")
|
||||
await Bun.write(file, JSON.stringify({ $schema: "https://opencode.ai/v2/cli.json", animations: true }))
|
||||
|
||||
try {
|
||||
const config = await run(
|
||||
directory,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.update((draft) => {
|
||||
draft.animations = false
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
expect(config).toEqual({ $schema: "https://opencode.ai/v2/cli.json", animations: false })
|
||||
expect(await Bun.file(file).json()).toEqual(config)
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
})
|
||||
|
||||
test("migrates tui and kv config into cli.json", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
await Bun.write(
|
||||
@@ -134,7 +73,6 @@ test("migrates tui and kv config into cli.json", async () => {
|
||||
)
|
||||
|
||||
expect(config).toMatchObject({
|
||||
$schema: "https://opencode.ai/v2/cli.json",
|
||||
theme: { name: "legacy", mode: "light" },
|
||||
keybinds: {
|
||||
leader: "ctrl+o",
|
||||
@@ -190,14 +128,8 @@ test("migrates before the first update and does not remigrate afterward", async
|
||||
}),
|
||||
)
|
||||
|
||||
expect(config).toEqual({
|
||||
$schema: "https://opencode.ai/v2/cli.json",
|
||||
theme: { name: "legacy" },
|
||||
animations: false,
|
||||
mouse: false,
|
||||
})
|
||||
expect(config).toEqual({ theme: { name: "legacy" }, animations: false, mouse: false })
|
||||
expect(await Bun.file(path.join(directory, "cli.json")).json()).toEqual({
|
||||
$schema: "https://opencode.ai/v2/cli.json",
|
||||
theme: { name: "legacy" },
|
||||
animations: false,
|
||||
mouse: false,
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
describe("debug paths command", () => {
|
||||
test("is included in troubleshooting help", async () => {
|
||||
const [debug, paths] = await Promise.all([cli(["debug", "--help"]), cli(["debug", "paths", "--help"])])
|
||||
|
||||
expect(debug.exitCode).toBe(0)
|
||||
expect(debug.stdout).toContain("paths")
|
||||
expect(debug.stdout).toContain("Show global paths (data, config, cache, state)")
|
||||
expect(paths.exitCode).toBe(0)
|
||||
expect(paths.stdout).toContain("opencode debug paths [flags]")
|
||||
})
|
||||
|
||||
test("prints resolved global paths without starting a server", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-debug-paths-"))
|
||||
|
||||
try {
|
||||
const result = await cli(["debug", "paths"], {
|
||||
XDG_DATA_HOME: path.join(root, "data"),
|
||||
XDG_CONFIG_HOME: path.join(root, "config"),
|
||||
XDG_CACHE_HOME: path.join(root, "cache"),
|
||||
XDG_STATE_HOME: path.join(root, "state"),
|
||||
})
|
||||
const paths = Object.fromEntries(
|
||||
result.stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => line.trim().split(/\s+/, 2)),
|
||||
)
|
||||
|
||||
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
|
||||
expect(paths).toMatchObject({
|
||||
home: os.homedir(),
|
||||
data: path.join(root, "data", "opencode"),
|
||||
config: path.join(root, "config", "opencode"),
|
||||
cache: path.join(root, "cache", "opencode"),
|
||||
state: path.join(root, "state", "opencode"),
|
||||
bin: path.join(root, "cache", "opencode", "bin"),
|
||||
log: path.join(root, "data", "opencode", "log"),
|
||||
repos: path.join(root, "data", "opencode", "repos"),
|
||||
})
|
||||
expect(paths.tmp).toBeTruthy()
|
||||
expect(await Bun.file(path.join(root, "state", "opencode", "service-local.json")).exists()).toBe(false)
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
async function cli(args: string[], env?: Record<string, string>) {
|
||||
const child = Bun.spawn([process.execPath, "run", path.join(import.meta.dir, "../src/index.ts"), ...args], {
|
||||
cwd: path.join(import.meta.dir, ".."),
|
||||
env: { ...process.env, ...env },
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
child.exited,
|
||||
])
|
||||
return { stdout, stderr, exitCode }
|
||||
}
|
||||
@@ -8,7 +8,6 @@ test("collects each SEA asset key once", async () => {
|
||||
const keys = assets.map((asset) => asset.key)
|
||||
|
||||
expect(new Set(keys).size).toBe(keys.length)
|
||||
if (process.platform !== "win32") expect(keys.filter((key) => key === "opencode-pty/opencode-pty")).toHaveLength(1)
|
||||
expect(assets.filter((asset) => asset.key === shellParserWasmAssets.runtime)).toEqual([
|
||||
{
|
||||
key: shellParserWasmAssets.runtime,
|
||||
|
||||
@@ -120,7 +120,6 @@ function nodePrelude(input: NodeBuildInput) {
|
||||
input.target.platform === "darwin"
|
||||
? `${input.target.nodePtyPackage}/prebuilds/darwin-${input.target.arch}/spawn-helper`
|
||||
: undefined
|
||||
const opencodePtyAsset = input.target.opencodePtyAsset
|
||||
const promiseModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")]
|
||||
if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable")
|
||||
export const Agent = sdk.Agent
|
||||
@@ -201,17 +200,13 @@ if (__ocIsSea()) {
|
||||
const __ocAssetRoot = __ocIsSea()
|
||||
? __ocPath.join(__ocCacheRoot, ${JSON.stringify(`${input.assetHash}-${input.target.platform}-${input.target.arch}`)})
|
||||
: __ocFileURLToPath(new URL("./assets/", import.meta.url))
|
||||
const __ocPersistentPty = ${JSON.stringify(opencodePtyAsset)}
|
||||
if (__ocIsSea()) {
|
||||
const __ocPtySpawnHelper = ${JSON.stringify(nodePtySpawnHelper)}
|
||||
for (const __ocKey of __ocAssetKeys()) {
|
||||
const __ocTarget = __ocPath.join(__ocAssetRoot, __ocKey)
|
||||
if (__ocExists(__ocTarget)) continue
|
||||
__ocMkdir(__ocPath.dirname(__ocTarget), { recursive: true })
|
||||
const __ocTemporary = \`${"${__ocTarget}"}.${"${process.pid}"}.${"${crypto.randomUUID()}"}.tmp\`
|
||||
__ocWrite(__ocTemporary, new Uint8Array(__ocRawAsset(__ocKey)))
|
||||
if ((__ocKey === __ocPtySpawnHelper || __ocKey === __ocPersistentPty) && process.platform !== "win32")
|
||||
__ocChmod(__ocTemporary, 0o755)
|
||||
try {
|
||||
__ocRename(__ocTemporary, __ocTarget)
|
||||
} catch (__ocError) {
|
||||
@@ -219,6 +214,8 @@ if (__ocIsSea()) {
|
||||
if (!__ocExists(__ocTarget)) throw __ocError
|
||||
}
|
||||
}
|
||||
const __ocPtySpawnHelper = ${JSON.stringify(nodePtySpawnHelper)}
|
||||
if (__ocPtySpawnHelper) __ocChmod(__ocPath.join(__ocAssetRoot, __ocPtySpawnHelper), 0o755)
|
||||
}
|
||||
process.env.OPENCODE_NODE_ASSETS_DIR = __ocAssetRoot
|
||||
process.env.OTUI_ASSET_ROOT = __ocAssetRoot
|
||||
@@ -230,7 +227,6 @@ process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH = __ocPath.join(__ocAssetRoot, $
|
||||
process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.powershell)})
|
||||
process.env.FFF_BINARY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffAsset)})
|
||||
process.env.OPENCODE_FFF_FFI_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffFfiAsset)})
|
||||
if (__ocPersistentPty && !process.env.OPENCODE_PTY_BIN) process.env.OPENCODE_PTY_BIN = __ocPath.join(__ocAssetRoot, __ocPersistentPty)
|
||||
try {
|
||||
globalThis.__OPENCODE_FFF_FFI = require(process.env.OPENCODE_FFF_FFI_PATH)
|
||||
} catch {}
|
||||
|
||||
@@ -1333,15 +1333,6 @@ export type CredentialUpdateOperation<E = never> = (
|
||||
input: CredentialUpdateInput,
|
||||
) => Effect.Effect<CredentialUpdateOutput, E>
|
||||
|
||||
export type CredentialActivateInput = {
|
||||
readonly credentialID: Credential.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type CredentialActivateOutput = void
|
||||
export type CredentialActivateOperation<E = never> = (
|
||||
input: CredentialActivateInput,
|
||||
) => Effect.Effect<CredentialActivateOutput, E>
|
||||
|
||||
export type CredentialRemoveInput = {
|
||||
readonly credentialID: Credential.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
@@ -1353,7 +1344,6 @@ export type CredentialRemoveOperation<E = never> = (
|
||||
|
||||
export interface CredentialApi<E = never> {
|
||||
readonly update: CredentialUpdateOperation<E>
|
||||
readonly activate: CredentialActivateOperation<E>
|
||||
readonly remove: CredentialRemoveOperation<E>
|
||||
}
|
||||
|
||||
|
||||
@@ -138,8 +138,6 @@ import type {
|
||||
McpResourceCatalogOutput,
|
||||
CredentialUpdateInput,
|
||||
CredentialUpdateOutput,
|
||||
CredentialActivateInput,
|
||||
CredentialActivateOutput,
|
||||
CredentialRemoveInput,
|
||||
CredentialRemoveOutput,
|
||||
ProjectListOutput,
|
||||
@@ -937,14 +935,6 @@ const EndpointCredentialUpdate = (raw: RawClient["server.credential"]) => (input
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointCredentialActivate = (raw: RawClient["server.credential"]) => (input: CredentialActivateInput) =>
|
||||
preserveEffect<CredentialActivateOutput>()(
|
||||
raw["credential.activate"]({
|
||||
params: { credentialID: input["credentialID"] },
|
||||
query: { location: input["location"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointCredentialRemove = (raw: RawClient["server.credential"]) => (input: CredentialRemoveInput) =>
|
||||
preserveEffect<CredentialRemoveOutput>()(
|
||||
raw["credential.remove"]({
|
||||
@@ -955,7 +945,6 @@ const EndpointCredentialRemove = (raw: RawClient["server.credential"]) => (input
|
||||
|
||||
const adaptGroupCredential = (raw: RawClient["server.credential"]) => ({
|
||||
update: EndpointCredentialUpdate(raw),
|
||||
activate: EndpointCredentialActivate(raw),
|
||||
remove: EndpointCredentialRemove(raw),
|
||||
})
|
||||
|
||||
|
||||
@@ -4,12 +4,10 @@ export type AgentApi = Client["agent"]
|
||||
export type CommandApi = Client["command"]
|
||||
export type ConfigApi = Client["config"]
|
||||
export type EventApi = Client["event"]
|
||||
export type GenerateApi = Client["generate"]
|
||||
export type IntegrationApi = Client["integration"]
|
||||
export type McpApi = Client["mcp"]
|
||||
export type ModelApi = Client["model"]
|
||||
export type PluginApi = Client["plugin"]
|
||||
export type PermissionApi = Client["permission"]
|
||||
export type ProviderApi = Client["provider"]
|
||||
export type ReferenceApi = Client["reference"]
|
||||
export type WebSearchApi = Client["websearch"]
|
||||
|
||||
@@ -132,8 +132,6 @@ import type {
|
||||
McpResourceCatalogOutput,
|
||||
CredentialUpdateInput,
|
||||
CredentialUpdateOutput,
|
||||
CredentialActivateInput,
|
||||
CredentialActivateOutput,
|
||||
CredentialRemoveInput,
|
||||
CredentialRemoveOutput,
|
||||
ProjectListOutput,
|
||||
@@ -1272,18 +1270,6 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
activate: (input: CredentialActivateInput, requestOptions?: RequestOptions) =>
|
||||
request<CredentialActivateOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/credential/${encodeURIComponent(input.credentialID)}/activate`,
|
||||
query: { location: input["location"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
remove: (input: CredentialRemoveInput, requestOptions?: RequestOptions) =>
|
||||
request<CredentialRemoveOutput>(
|
||||
{
|
||||
|
||||
@@ -823,24 +823,6 @@ export type ModelsDevRefreshed = {
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type CredentialUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "credential.updated"
|
||||
location?: LocationRef
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type CredentialSwitched = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "credential.switched"
|
||||
location?: LocationRef
|
||||
data: { integrationID: string; credentialID: string | null }
|
||||
}
|
||||
|
||||
export type IntegrationUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -850,6 +832,15 @@ export type IntegrationUpdated = {
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type IntegrationConnectionUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "integration.connection.updated"
|
||||
location?: LocationRef
|
||||
data: { integrationID: string }
|
||||
}
|
||||
|
||||
export type CatalogUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1321,7 +1312,6 @@ export type ModelCompatibility = {
|
||||
reasoningField?: ModelReasoningField
|
||||
maxTokensField?: ModelMaxTokensField
|
||||
requireFinishReason?: boolean
|
||||
requireAssistantAfterTool?: boolean
|
||||
}
|
||||
|
||||
export type ModelCost = {
|
||||
@@ -1417,24 +1407,6 @@ export type Project = {
|
||||
sandboxes: Array<string>
|
||||
}
|
||||
|
||||
export type ProjectUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "project.updated"
|
||||
location?: LocationRef
|
||||
data: {
|
||||
id: string
|
||||
canonical: string
|
||||
vcs?: ProjectVcs
|
||||
name?: string
|
||||
icon?: ProjectIcon
|
||||
commands?: ProjectCommands
|
||||
time: ProjectTime
|
||||
sandboxes: Array<string>
|
||||
}
|
||||
}
|
||||
|
||||
export type FormAnswer = { [x: string]: FormValue }
|
||||
|
||||
export type PermissionRequest = {
|
||||
@@ -1445,7 +1417,6 @@ export type PermissionRequest = {
|
||||
save?: Array<string>
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
source?: PermissionSource
|
||||
message?: string
|
||||
}
|
||||
|
||||
export type PermissionAsked = {
|
||||
@@ -1462,7 +1433,6 @@ export type PermissionAsked = {
|
||||
save?: Array<string>
|
||||
metadata?: { [x: string]: any }
|
||||
source?: PermissionSource
|
||||
message?: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2138,9 +2108,8 @@ export type IntegrationInfo = {
|
||||
|
||||
export type V2Event =
|
||||
| ModelsDevRefreshed
|
||||
| CredentialUpdated
|
||||
| CredentialSwitched
|
||||
| IntegrationUpdated
|
||||
| IntegrationConnectionUpdated
|
||||
| CatalogUpdated
|
||||
| AgentUpdated
|
||||
| SessionCreated
|
||||
@@ -2195,7 +2164,6 @@ export type V2Event =
|
||||
| PermissionReplied
|
||||
| PluginAdded
|
||||
| PluginUpdated
|
||||
| ProjectUpdated
|
||||
| WorktreeUpdated
|
||||
| WorktreeResolved
|
||||
| CommandUpdated
|
||||
@@ -4381,15 +4349,6 @@ export type CredentialUpdateInput = {
|
||||
|
||||
export type CredentialUpdateOutput = void
|
||||
|
||||
export type CredentialActivateInput = {
|
||||
readonly credentialID: { readonly credentialID: string }["credentialID"]
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type CredentialActivateOutput = void
|
||||
|
||||
export type CredentialRemoveInput = {
|
||||
readonly credentialID: { readonly credentialID: string }["credentialID"]
|
||||
readonly location?: {
|
||||
|
||||
@@ -521,9 +521,6 @@ 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
|
||||
case "session.created":
|
||||
sessionOutbox.delete(event.data.sessionID)
|
||||
result.session.invalidate(event.data.sessionID)
|
||||
@@ -1048,36 +1045,6 @@ export function createData(config: CreateDataInput) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "credential.updated" || event.type === "credential.switched") {
|
||||
Object.keys(store.location).forEach((key) => {
|
||||
const ref = JSON.parse(key) as [string, string | null]
|
||||
const location = { directory: ref[0], workspaceID: ref[1] ?? undefined }
|
||||
if (event.type === "credential.updated") {
|
||||
result.location.integration.invalidate(location)
|
||||
void result.location.integration.sync(location)
|
||||
return
|
||||
}
|
||||
setStore("location", key, (data) => ({
|
||||
...data,
|
||||
integration: data?.integration?.map((integration) => {
|
||||
if (integration.id !== event.data.integrationID) return integration
|
||||
const active = integration.connections.find(
|
||||
(connection) => connection.type === "credential" && connection.id === event.data.credentialID,
|
||||
)
|
||||
if (!active) return integration
|
||||
return {
|
||||
...integration,
|
||||
connections: [active, ...integration.connections.filter((connection) => connection !== active)],
|
||||
}
|
||||
}),
|
||||
}))
|
||||
result.location.model.invalidate(location)
|
||||
result.location.provider.invalidate(location)
|
||||
void Promise.all([result.location.model.sync(location), result.location.provider.sync(location)])
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!event.location) return
|
||||
const location = event.location
|
||||
switch (event.type) {
|
||||
|
||||
@@ -26,7 +26,6 @@ test("exposes every standard HTTP API group", () => {
|
||||
"skill",
|
||||
"event",
|
||||
"pty",
|
||||
"experimental",
|
||||
"shell",
|
||||
"reference",
|
||||
"worktree",
|
||||
@@ -188,24 +187,6 @@ test("experimental wellknown integration add uses the public HTTP contract", asy
|
||||
expect(await request?.json()).toEqual({ url: "https://example.com" })
|
||||
})
|
||||
|
||||
test("credential.activate uses the public HTTP contract", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
request = input instanceof Request ? input : new Request(input, init)
|
||||
return new Response(null, { status: 204 })
|
||||
},
|
||||
})
|
||||
|
||||
await client.credential.activate({ credentialID: "cred_work", location: { directory: "/tmp/project" } })
|
||||
|
||||
expect(request?.method).toBe("POST")
|
||||
expect(request?.url).toBe(
|
||||
"http://localhost:3000/api/credential/cred_work/activate?location%5Bdirectory%5D=%2Ftmp%2Fproject",
|
||||
)
|
||||
})
|
||||
|
||||
test("integration connections optionally submit a form answer", async () => {
|
||||
const requests: Request[] = []
|
||||
const client = OpenCode.make({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createData, type CreateDataInput } from "../src/solid"
|
||||
import { OpenCode, type OpenCodeEvent, type Project, type SessionInfo } from "../src/promise"
|
||||
import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise"
|
||||
|
||||
const session = (viewed: number): SessionInfo => ({
|
||||
id: "ses_refresh",
|
||||
@@ -72,185 +72,6 @@ test("revalidates after an event overtakes an active session read", async () =>
|
||||
}
|
||||
})
|
||||
|
||||
test("updates authoritative cached project metadata from live events", async () => {
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
const original: Project = {
|
||||
id: "project_renamed",
|
||||
canonical: "/projects/original",
|
||||
name: "Original custom name",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
}
|
||||
const unrelated: Project = {
|
||||
id: "project_unrelated",
|
||||
canonical: "/projects/unrelated",
|
||||
name: "Unrelated project",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
}
|
||||
let requests = 0
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
if (!request.url.endsWith("/api/project")) throw new Error(`Unexpected request: ${request.url}`)
|
||||
requests++
|
||||
return Response.json([original, unrelated])
|
||||
},
|
||||
})
|
||||
const event: CreateDataInput["event"] = {
|
||||
on: () => () => {},
|
||||
listen(handler) {
|
||||
listeners.add(handler)
|
||||
return () => listeners.delete(handler)
|
||||
},
|
||||
}
|
||||
const setup = createRoot((dispose) => ({
|
||||
data: createData({ api: () => api, directory: "/projects/original", event }),
|
||||
dispose,
|
||||
}))
|
||||
|
||||
try {
|
||||
await setup.data.project.sync()
|
||||
expect(setup.data.project.get(original.id)).toEqual(original)
|
||||
|
||||
const updated: OpenCodeEvent = {
|
||||
id: "evt_project_renamed",
|
||||
created: 2,
|
||||
type: "project.updated",
|
||||
data: {
|
||||
...original,
|
||||
canonical: "/projects/renamed",
|
||||
name: "Updated custom name",
|
||||
time: { ...original.time, updated: 2 },
|
||||
},
|
||||
}
|
||||
listeners.forEach((listener) => listener({ name: updated.type, details: updated }))
|
||||
|
||||
expect(setup.data.project.get(original.id)?.canonical).toBe("/projects/renamed")
|
||||
expect(setup.data.project.get(original.id)?.name).toBe("Updated custom name")
|
||||
expect(setup.data.project.get(unrelated.id)).toEqual(unrelated)
|
||||
expect(requests).toBe(1)
|
||||
|
||||
const reset: OpenCodeEvent = {
|
||||
id: "evt_project_name_reset",
|
||||
created: 3,
|
||||
type: "project.updated",
|
||||
data: {
|
||||
id: original.id,
|
||||
canonical: "/projects/renamed-again",
|
||||
time: { ...original.time, updated: 3 },
|
||||
sandboxes: [],
|
||||
},
|
||||
}
|
||||
listeners.forEach((listener) => listener({ name: reset.type, details: reset }))
|
||||
|
||||
expect(setup.data.project.get(original.id)?.canonical).toBe("/projects/renamed-again")
|
||||
expect(setup.data.project.get(original.id)?.name).toBeUndefined()
|
||||
expect(setup.data.project.get(unrelated.id)).toEqual(unrelated)
|
||||
expect(requests).toBe(1)
|
||||
} finally {
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("refreshes global credential events across every loaded location and workspace", async () => {
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
const requests: URL[] = []
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
const url = new URL(request.url)
|
||||
requests.push(url)
|
||||
const directory = url.searchParams.get("location[directory]") ?? "/project"
|
||||
return Response.json({
|
||||
location: {
|
||||
directory,
|
||||
workspaceID: url.searchParams.get("location[workspace]") ?? undefined,
|
||||
project: { id: "project", directory, canonical: directory },
|
||||
},
|
||||
data: [],
|
||||
})
|
||||
},
|
||||
})
|
||||
const setup = createRoot((dispose) => ({
|
||||
data: createData({
|
||||
api: () => api,
|
||||
directory: "/project",
|
||||
event: {
|
||||
on: () => () => {},
|
||||
listen(handler) {
|
||||
listeners.add(handler)
|
||||
return () => listeners.delete(handler)
|
||||
},
|
||||
},
|
||||
connection: { status: () => "connected" },
|
||||
}),
|
||||
dispose,
|
||||
}))
|
||||
const locations = [{ directory: "/project" }, { directory: "/other", workspaceID: "workspace-other" }]
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
locations.flatMap((location) => [
|
||||
setup.data.location.integration.sync(location),
|
||||
setup.data.location.model.sync(location),
|
||||
setup.data.location.provider.sync(location),
|
||||
]),
|
||||
)
|
||||
requests.length = 0
|
||||
|
||||
const updated: OpenCodeEvent = {
|
||||
id: "evt_credential.updated",
|
||||
created: 1,
|
||||
type: "credential.updated",
|
||||
data: {},
|
||||
}
|
||||
listeners.forEach((listener) => listener({ name: updated.type, details: updated }))
|
||||
await wait(() => requests.length === 2)
|
||||
expect(
|
||||
requests.map((url) => [
|
||||
url.pathname,
|
||||
url.searchParams.get("location[directory]"),
|
||||
url.searchParams.get("location[workspace]"),
|
||||
]),
|
||||
).toEqual([
|
||||
["/api/integration", "/project", null],
|
||||
["/api/integration", "/other", "workspace-other"],
|
||||
])
|
||||
requests.length = 0
|
||||
|
||||
for (const credentialID of ["credential", null]) {
|
||||
const switched: OpenCodeEvent = {
|
||||
id: `evt_credential.switched.${credentialID}`,
|
||||
created: 2,
|
||||
type: "credential.switched",
|
||||
data: { credentialID, integrationID: "integration" },
|
||||
}
|
||||
listeners.forEach((listener) => listener({ name: switched.type, details: switched }))
|
||||
await wait(() => requests.length === 4)
|
||||
expect(
|
||||
requests.map((url) => [
|
||||
url.pathname,
|
||||
url.searchParams.get("location[directory]"),
|
||||
url.searchParams.get("location[workspace]"),
|
||||
]),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
["/api/model", "/project", null],
|
||||
["/api/provider", "/project", null],
|
||||
["/api/model", "/other", "workspace-other"],
|
||||
["/api/provider", "/other", "workspace-other"],
|
||||
]),
|
||||
)
|
||||
requests.length = 0
|
||||
}
|
||||
} finally {
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("reports optimistic sessions as creating until the request settles", async () => {
|
||||
const release = Promise.withResolvers<void>()
|
||||
const api = OpenCode.make({
|
||||
|
||||
@@ -41,12 +41,6 @@
|
||||
"node": "./src/pty/pty.node.ts",
|
||||
"default": "./src/pty/pty.bun.ts"
|
||||
},
|
||||
"#persistent-pty-binary": {
|
||||
"workerd": "./src/persistent-pty/binary.workerd.ts",
|
||||
"bun": "./src/persistent-pty/binary.bun.ts",
|
||||
"node": "./src/persistent-pty/binary.node.ts",
|
||||
"default": "./src/persistent-pty/binary.bun.ts"
|
||||
},
|
||||
"#fff": {
|
||||
"workerd": "./src/filesystem/fff.workerd.ts",
|
||||
"bun": "./src/filesystem/fff.bun.ts",
|
||||
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
import { Auth, Endpoint, RequestExecutor, type AnyRoute } from "@opencode-ai/ai/route"
|
||||
import { ProviderShared } from "@opencode-ai/ai/protocols/shared"
|
||||
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
|
||||
import { makeParser } from "effect/unstable/encoding/Sse"
|
||||
import type { ID, Info } from "./model.js"
|
||||
import { Provider } from "./provider.js"
|
||||
import { State } from "./state.js"
|
||||
@@ -66,23 +65,15 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
|
||||
if (!res.headers.get("content-type")?.includes("text/event-stream")) return res
|
||||
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let deadline: number | undefined
|
||||
const parser = makeParser((event) => {
|
||||
if (event._tag === "Event") deadline = Date.now() + ms
|
||||
})
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
async pull(ctrl) {
|
||||
const expires = deadline ?? Date.now() + ms
|
||||
deadline = expires
|
||||
const part = await new Promise<Awaited<ReturnType<typeof reader.read>>>((resolve, reject) => {
|
||||
const remaining = Math.max(0, expires - Date.now())
|
||||
const id = setTimeout(() => {
|
||||
const err = new Error("SSE read timed out")
|
||||
ctl.abort(err)
|
||||
void reader.cancel(err)
|
||||
reject(err)
|
||||
}, remaining)
|
||||
}, ms)
|
||||
|
||||
reader.read().then(
|
||||
(part) => {
|
||||
@@ -101,7 +92,6 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
|
||||
return
|
||||
}
|
||||
|
||||
parser.feed(decoder.decode(part.value, { stream: true }))
|
||||
ctrl.enqueue(part.value)
|
||||
},
|
||||
async cancel(reason) {
|
||||
|
||||
+10
-13
@@ -89,7 +89,6 @@ export interface PublishOptions {
|
||||
readonly id?: Event.ID
|
||||
readonly metadata?: Record<string, unknown>
|
||||
readonly location?: Location.Ref
|
||||
readonly global?: boolean
|
||||
/** Local operational projection committed atomically with a new durable event. Not replayed or serialized. */
|
||||
readonly commit?: (seq: number) => Effect.Effect<void>
|
||||
}
|
||||
@@ -447,12 +446,11 @@ export function configured(options?: Options) {
|
||||
function publish<D extends Event.Definition>(definition: D, data: Event.Data<D>, options?: PublishOptions) {
|
||||
return Effect.gen(function* () {
|
||||
const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
|
||||
const location = options?.global
|
||||
? undefined
|
||||
: (options?.location ??
|
||||
(serviceLocation
|
||||
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
|
||||
: undefined))
|
||||
const location =
|
||||
options?.location ??
|
||||
(serviceLocation
|
||||
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
|
||||
: undefined)
|
||||
return yield* publishEvent(
|
||||
definition,
|
||||
{
|
||||
@@ -482,12 +480,11 @@ export function configured(options?: Options) {
|
||||
}),
|
||||
)
|
||||
}
|
||||
const location = options?.global
|
||||
? undefined
|
||||
: (options?.location ??
|
||||
(serviceLocation
|
||||
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
|
||||
: undefined))
|
||||
const location =
|
||||
options?.location ??
|
||||
(serviceLocation
|
||||
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
|
||||
: undefined)
|
||||
return {
|
||||
definition,
|
||||
aggregateID,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type Entry,
|
||||
Event,
|
||||
} from "@opencode-ai/schema/config"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { isRecord } from "@opencode-ai/ai/utils/record"
|
||||
import { Credential } from "./credential.js"
|
||||
import { Bus } from "./bus.js"
|
||||
@@ -158,7 +159,9 @@ export const layer = (options?: Options) =>
|
||||
const loadWellknownEntry = Effect.fnUntraced(function* (entry: WellKnown.Entry) {
|
||||
const auth = entry.manifest.auth
|
||||
if (!auth) return []
|
||||
const credential = (yield* credentials.list(entry.integrationID)).at(-1)
|
||||
const credential = (yield* credentials.list(entry.integrationID)).findLast(
|
||||
(credential) => credential.value.type === "key",
|
||||
)
|
||||
if (!credential || credential.value.type !== "key") return []
|
||||
const variables = { [auth.env]: credential.value.key }
|
||||
const configs = yield* wellknown
|
||||
@@ -340,7 +343,7 @@ export const layer = (options?: Options) =>
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* bus.subscribe(Credential.Event.Switched).pipe(
|
||||
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||
Stream.filterEffect((event) =>
|
||||
wellknown.entries().pipe(
|
||||
Effect.map((entries) => entries.some((entry) => entry.integrationID === event.data.integrationID)),
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
export * as Credential from "./credential.js"
|
||||
|
||||
import { asc, desc, eq } from "drizzle-orm"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Database } from "./database/database.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { CredentialTable } from "./credential/sql.js"
|
||||
|
||||
@@ -21,8 +20,6 @@ export type Key = Credential.Key
|
||||
export const Value = Credential.Value
|
||||
export type Value = Credential.Value
|
||||
|
||||
export const Event = Credential.Event
|
||||
|
||||
export class Info extends Schema.Class<Info>("Credential.Info")({
|
||||
id: ID,
|
||||
integrationID: Integration.ID,
|
||||
@@ -37,14 +34,12 @@ export interface Interface {
|
||||
readonly list: (integrationID: Integration.ID) => Effect.Effect<Info[]>
|
||||
/** Returns one stored credential by ID. */
|
||||
readonly get: (id: ID) => Effect.Effect<Info | undefined>
|
||||
/** Creates a credential for an integration and returns the new record. */
|
||||
/** Replaces any credential for an integration and returns the new record. */
|
||||
readonly create: (input: {
|
||||
readonly integrationID: Integration.ID
|
||||
readonly value: Value
|
||||
readonly label?: string
|
||||
}) => Effect.Effect<Info>
|
||||
/** Selects a stored credential for its integration. */
|
||||
readonly activate: (id: ID) => Effect.Effect<void>
|
||||
/** Updates the label or secret value of a stored credential. */
|
||||
readonly update: (id: ID, updates: Partial<Pick<Info, "label" | "value">>) => Effect.Effect<void>
|
||||
/** Removes a stored credential. */
|
||||
@@ -57,7 +52,6 @@ const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
const bus = yield* Bus.Service
|
||||
const decode = Schema.decodeUnknownSync(Value)
|
||||
const stored = (row: typeof CredentialTable.$inferSelect) => {
|
||||
if (!row.integration_id) return
|
||||
@@ -79,7 +73,7 @@ const layer = Layer.effect(
|
||||
db
|
||||
.select()
|
||||
.from(CredentialTable)
|
||||
.orderBy(asc(CredentialTable.active), asc(CredentialTable.time_created), asc(CredentialTable.id))
|
||||
.orderBy(asc(CredentialTable.time_created))
|
||||
.all()
|
||||
.pipe(Effect.orDie, Effect.map(storedRows)),
|
||||
),
|
||||
@@ -88,7 +82,7 @@ const layer = Layer.effect(
|
||||
.select()
|
||||
.from(CredentialTable)
|
||||
.where(eq(CredentialTable.integration_id, integrationID))
|
||||
.orderBy(asc(CredentialTable.active), asc(CredentialTable.time_created), asc(CredentialTable.id))
|
||||
.orderBy(asc(CredentialTable.time_created))
|
||||
.all()
|
||||
.pipe(Effect.orDie, Effect.map(storedRows)),
|
||||
),
|
||||
@@ -107,8 +101,7 @@ const layer = Layer.effect(
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* tx
|
||||
.update(CredentialTable)
|
||||
.set({ active: false })
|
||||
.delete(CredentialTable)
|
||||
.where(eq(CredentialTable.integration_id, credential.integrationID))
|
||||
.run()
|
||||
yield* tx
|
||||
@@ -118,117 +111,27 @@ const layer = Layer.effect(
|
||||
integration_id: credential.integrationID,
|
||||
label: credential.label,
|
||||
value: credential.value,
|
||||
active: true,
|
||||
})
|
||||
.run()
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
yield* bus.publish(Event.Updated, {}, { global: true })
|
||||
yield* bus.publish(
|
||||
Event.Switched,
|
||||
{ integrationID: credential.integrationID, credentialID: credential.id },
|
||||
{ global: true },
|
||||
)
|
||||
return credential
|
||||
}),
|
||||
activate: Effect.fn("Credential.activate")(function* (id) {
|
||||
const integrationID = yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
const credential = yield* tx.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get()
|
||||
if (!credential?.integration_id) return
|
||||
const active = yield* tx
|
||||
.select({ id: CredentialTable.id })
|
||||
.from(CredentialTable)
|
||||
.where(eq(CredentialTable.integration_id, credential.integration_id))
|
||||
.orderBy(desc(CredentialTable.active), desc(CredentialTable.time_created), desc(CredentialTable.id))
|
||||
.get()
|
||||
if (active?.id === id) return
|
||||
yield* tx
|
||||
.update(CredentialTable)
|
||||
.set({ active: false })
|
||||
.where(eq(CredentialTable.integration_id, credential.integration_id))
|
||||
.run()
|
||||
yield* tx.update(CredentialTable).set({ active: true }).where(eq(CredentialTable.id, id)).run()
|
||||
return credential.integration_id
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
if (integrationID) yield* bus.publish(Event.Switched, { integrationID, credentialID: id }, { global: true })
|
||||
}),
|
||||
update: Effect.fn("Credential.update")(function* (id, updates) {
|
||||
if (updates.label === undefined && updates.value === undefined) return
|
||||
const credential = yield* db
|
||||
.select({ integrationID: CredentialTable.integration_id, label: CredentialTable.label })
|
||||
.from(CredentialTable)
|
||||
.where(eq(CredentialTable.id, id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!credential?.integrationID) return
|
||||
if (updates.label === credential.label && updates.value === undefined) return
|
||||
if (!updates.label && !updates.value) return
|
||||
yield* db
|
||||
.update(CredentialTable)
|
||||
.set({ label: updates.label, value: updates.value })
|
||||
.where(eq(CredentialTable.id, id))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
if (updates.label !== undefined && updates.label !== credential.label)
|
||||
yield* bus.publish(Event.Updated, {}, { global: true })
|
||||
}),
|
||||
remove: Effect.fn("Credential.remove")(function* (id) {
|
||||
const removed = yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
const credential = yield* tx.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get()
|
||||
if (!credential) return
|
||||
const active = credential.integration_id
|
||||
? yield* tx
|
||||
.select({ id: CredentialTable.id })
|
||||
.from(CredentialTable)
|
||||
.where(eq(CredentialTable.integration_id, credential.integration_id))
|
||||
.orderBy(desc(CredentialTable.active), desc(CredentialTable.time_created), desc(CredentialTable.id))
|
||||
.get()
|
||||
: undefined
|
||||
yield* tx.delete(CredentialTable).where(eq(CredentialTable.id, id)).run()
|
||||
if (!credential.integration_id || active?.id !== id) return { switched: false as const }
|
||||
const replacement = yield* tx
|
||||
.select({ id: CredentialTable.id })
|
||||
.from(CredentialTable)
|
||||
.where(eq(CredentialTable.integration_id, credential.integration_id))
|
||||
.orderBy(desc(CredentialTable.time_created), desc(CredentialTable.id))
|
||||
.get()
|
||||
if (replacement) {
|
||||
yield* tx
|
||||
.update(CredentialTable)
|
||||
.set({ active: false })
|
||||
.where(eq(CredentialTable.integration_id, credential.integration_id))
|
||||
.run()
|
||||
yield* tx
|
||||
.update(CredentialTable)
|
||||
.set({ active: true })
|
||||
.where(eq(CredentialTable.id, replacement.id))
|
||||
.run()
|
||||
}
|
||||
return {
|
||||
switched: true as const,
|
||||
integrationID: credential.integration_id,
|
||||
credentialID: replacement?.id ?? null,
|
||||
}
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
if (!removed) return
|
||||
yield* bus.publish(Event.Updated, {}, { global: true })
|
||||
if (removed.switched)
|
||||
yield* bus.publish(
|
||||
Event.Switched,
|
||||
{ integrationID: removed.integrationID, credentialID: removed.credentialID },
|
||||
{ global: true },
|
||||
)
|
||||
yield* db.delete(CredentialTable).where(eq(CredentialTable.id, id)).run().pipe(Effect.orDie)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node, Bus.node] })
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] })
|
||||
|
||||
@@ -66,39 +66,12 @@ export function applyOnly(db: Database, input: Migration[]) {
|
||||
if (
|
||||
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${"__drizzle_migrations"}`)
|
||||
) {
|
||||
const named = (yield* db.all<{ name: string }>(
|
||||
sql`SELECT name FROM pragma_table_info('__drizzle_migrations')`,
|
||||
)).some((column) => column.name === "name")
|
||||
|
||||
if (named) {
|
||||
yield* db.run(sql`
|
||||
INSERT OR IGNORE INTO ${sql.identifier("migration")} (id, time_completed)
|
||||
SELECT name, ${Date.now()}
|
||||
FROM ${sql.identifier("__drizzle_migrations")}
|
||||
WHERE name IS NOT NULL
|
||||
`)
|
||||
}
|
||||
|
||||
if (!named) {
|
||||
const entries = yield* db.all<{ created_at: number; prefix: string | null }>(sql`
|
||||
SELECT created_at, strftime('%Y%m%d%H%M%S', created_at / 1000, 'unixepoch') AS prefix
|
||||
FROM ${sql.identifier("__drizzle_migrations")}
|
||||
WHERE created_at IS NOT NULL
|
||||
`)
|
||||
|
||||
for (const entry of entries) {
|
||||
const migration = input.find((item) => item.id.startsWith(`${entry.prefix}_`))
|
||||
if (!migration) {
|
||||
return yield* Effect.die(
|
||||
new Error(`Legacy migration timestamp ${entry.created_at} does not match any known migration`),
|
||||
)
|
||||
}
|
||||
yield* db.run(sql`
|
||||
INSERT OR IGNORE INTO ${sql.identifier("migration")} (id, time_completed)
|
||||
VALUES (${migration.id}, ${Date.now()})
|
||||
`)
|
||||
}
|
||||
}
|
||||
yield* db.run(sql`
|
||||
INSERT OR IGNORE INTO ${sql.identifier("migration")} (id, time_completed)
|
||||
SELECT name, ${Date.now()}
|
||||
FROM ${sql.identifier("__drizzle_migrations")}
|
||||
WHERE name IS NOT NULL
|
||||
`)
|
||||
completed = new Set(
|
||||
(yield* db.all<{ id: string }>(sql`SELECT id FROM ${sql.identifier("migration")}`)).map((row) => row.id),
|
||||
)
|
||||
|
||||
@@ -5,9 +5,6 @@ const migration: DatabaseMigration.Migration = {
|
||||
id: "20260410174513_workspace-name",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
const columns = yield* tx.all<{ name: string }>(`PRAGMA table_info(\`workspace\`)`)
|
||||
const name = columns.some((column) => column.name === "name") ? "`name`" : "''"
|
||||
|
||||
yield* tx.run(`PRAGMA foreign_keys=OFF;`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`__new_workspace\` (
|
||||
@@ -22,7 +19,7 @@ const migration: DatabaseMigration.Migration = {
|
||||
);
|
||||
`)
|
||||
yield* tx.run(
|
||||
`INSERT INTO \`__new_workspace\`(\`id\`, \`type\`, \`branch\`, \`name\`, \`directory\`, \`extra\`, \`project_id\`) SELECT \`id\`, \`type\`, \`branch\`, ${name}, \`directory\`, \`extra\`, \`project_id\` FROM \`workspace\`;`,
|
||||
`INSERT INTO \`__new_workspace\`(\`id\`, \`type\`, \`branch\`, \`name\`, \`directory\`, \`extra\`, \`project_id\`) SELECT \`id\`, \`type\`, \`branch\`, \`name\`, \`directory\`, \`extra\`, \`project_id\` FROM \`workspace\`;`,
|
||||
)
|
||||
yield* tx.run(`DROP TABLE \`workspace\`;`)
|
||||
yield* tx.run(`ALTER TABLE \`__new_workspace\` RENAME TO \`workspace\`;`)
|
||||
|
||||
@@ -166,8 +166,6 @@ export interface Interface extends State.Transformable<Draft> {
|
||||
/** User-facing label for the stored credential. */
|
||||
readonly label?: string
|
||||
}) => Effect.Effect<void, AuthorizationError>
|
||||
/** Selects a stored credential as the active integration connection. */
|
||||
readonly activate: (credentialID: Credential.ID) => Effect.Effect<void>
|
||||
/** Updates a stored credential exposed as a connection. */
|
||||
readonly update: (
|
||||
credentialID: Credential.ID,
|
||||
@@ -331,17 +329,6 @@ const layer = Layer.effect(
|
||||
finalize: () => bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const createCredential = Effect.fnUntraced(function* (input: Parameters<Credential.Interface["create"]>[0]) {
|
||||
if (input.label !== undefined) return yield* credentials.create(input)
|
||||
const name = state.get().integrations.get(input.integrationID)?.ref.name ?? input.integrationID
|
||||
const labels = new Set((yield* credentials.list(input.integrationID)).map((credential) => credential.label))
|
||||
const label =
|
||||
Array.from({ length: labels.size + 1 }, (_, index) => (index === 0 ? name : `${name} ${index + 1}`)).find(
|
||||
(candidate) => !labels.has(candidate),
|
||||
) ?? name
|
||||
return yield* credentials.create({ ...input, label })
|
||||
})
|
||||
|
||||
const resolveConnections = (entry: Entry | undefined, saved: readonly Credential.Info[]) => {
|
||||
const credentials = saved
|
||||
.map((credential) => ({
|
||||
@@ -407,7 +394,7 @@ const layer = Layer.effect(
|
||||
?.implementations.get(attempt.methodID)
|
||||
const persistence = yield* Effect.sync(() => attempt.label ?? implementation?.label?.(exit.value)).pipe(
|
||||
Effect.flatMap((label) =>
|
||||
createCredential({
|
||||
credentials.create({
|
||||
integrationID: attempt.integrationID,
|
||||
label,
|
||||
value: exit.value,
|
||||
@@ -434,6 +421,8 @@ const layer = Layer.effect(
|
||||
// Persisting attempts cannot be cancelled, expired, or claimed again.
|
||||
yield* SynchronizedRef.update(attempts, (current) => new Map(current).set(attemptID, terminal))
|
||||
if (Exit.isFailure(persistence)) yield* Effect.failCause(persistence.cause)
|
||||
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: attempt.integrationID })
|
||||
yield* bus.publish(Integration.Event.Updated, {})
|
||||
}).pipe(Effect.ensuring(close(attempt.scope)))
|
||||
}),
|
||||
)
|
||||
@@ -463,11 +452,13 @@ const layer = Layer.effect(
|
||||
return
|
||||
}
|
||||
|
||||
const persistence = yield* createCredential({
|
||||
integrationID: attempt.integrationID,
|
||||
label: attempt.label,
|
||||
value: Credential.Key.make({ type: "key", key: exit.value }),
|
||||
}).pipe(Effect.asVoid, Effect.exit)
|
||||
const persistence = yield* credentials
|
||||
.create({
|
||||
integrationID: attempt.integrationID,
|
||||
label: attempt.label,
|
||||
value: Credential.Key.make({ type: "key", key: exit.value }),
|
||||
})
|
||||
.pipe(Effect.asVoid, Effect.exit)
|
||||
const settledAt = yield* Clock.currentTimeMillis
|
||||
const terminal: TerminalCommandAttempt = Exit.isSuccess(persistence)
|
||||
? {
|
||||
@@ -485,6 +476,9 @@ const layer = Layer.effect(
|
||||
}
|
||||
yield* SynchronizedRef.update(commandAttempts, (current) => new Map(current).set(attemptID, terminal))
|
||||
yield* close(attempt.scope)
|
||||
if (Exit.isFailure(persistence)) return
|
||||
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: attempt.integrationID })
|
||||
yield* bus.publish(Integration.Event.Updated, {})
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -708,7 +702,7 @@ const layer = Layer.effect(
|
||||
if (!method.form && Object.keys(answer).length > 0) {
|
||||
return yield* new AuthorizationError({ cause: new Error("Key method does not accept a form answer") })
|
||||
}
|
||||
yield* createCredential({
|
||||
yield* credentials.create({
|
||||
integrationID: input.integrationID,
|
||||
label: input.label,
|
||||
value: Credential.Key.make({
|
||||
@@ -717,21 +711,25 @@ const layer = Layer.effect(
|
||||
...(Object.keys(answer).length > 0 ? { configuration: answer } : {}),
|
||||
}),
|
||||
})
|
||||
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: input.integrationID })
|
||||
yield* bus.publish(Integration.Event.Updated, {})
|
||||
}),
|
||||
activate: Effect.fn("Integration.connection.activate")(function* (credentialID) {
|
||||
update: Effect.fn("Integration.connection.update")(function* (credentialID, updates) {
|
||||
const credential = yield* credentials.get(credentialID)
|
||||
if (!credential) return
|
||||
const active = resolveConnections(
|
||||
state.get().integrations.get(credential.integrationID),
|
||||
yield* credentials.list(credential.integrationID),
|
||||
)[0]
|
||||
if (active?.type === "credential" && active.id === credentialID) return
|
||||
yield* credentials.activate(credentialID)
|
||||
yield* credentials.update(credentialID, updates)
|
||||
if (credential) {
|
||||
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: credential.integrationID })
|
||||
}
|
||||
yield* bus.publish(Integration.Event.Updated, {})
|
||||
}),
|
||||
remove: Effect.fn("Integration.connection.remove")(function* (credentialID) {
|
||||
const credential = yield* credentials.get(credentialID)
|
||||
yield* credentials.remove(credentialID)
|
||||
if (credential) {
|
||||
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: credential.integrationID })
|
||||
}
|
||||
yield* bus.publish(Integration.Event.Updated, {})
|
||||
}),
|
||||
update: Effect.fn("Integration.connection.update")((credentialID, updates) =>
|
||||
credentials.update(credentialID, updates),
|
||||
),
|
||||
remove: Effect.fn("Integration.connection.remove")((credentialID) => credentials.remove(credentialID)),
|
||||
},
|
||||
oauth: {
|
||||
connect: connectOAuth,
|
||||
|
||||
@@ -140,7 +140,8 @@ export type Draft = {
|
||||
remove: (server: ServerName | string) => void
|
||||
}
|
||||
|
||||
const cloneConfig = (config: Mcp.ServerConfig) => structuredClone(config) as Types.DeepMutable<Mcp.ServerConfig>
|
||||
const cloneConfig = (config: Mcp.ServerConfig) =>
|
||||
structuredClone(config) as Types.DeepMutable<Mcp.ServerConfig>
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly servers: () => Effect.Effect<ServerInfo[]>
|
||||
@@ -263,7 +264,8 @@ export const layer = (options?: Options) =>
|
||||
// No browser during connect: an auth-gated server surfaces needs_auth instead of opening a browser.
|
||||
onRedirect: () => {},
|
||||
}
|
||||
const found = (yield* credentials.list(entry.integrationID)).at(-1)
|
||||
const stored = yield* credentials.list(entry.integrationID)
|
||||
const found = stored.find((credential) => credential.value.type === "oauth")
|
||||
if (!found || found.value.type !== "oauth")
|
||||
// No stored credential yet: an empty in-memory store still lets the SDK run the auth handshake, which
|
||||
// ends in UnauthorizedError -> needs_auth. Returning no provider instead would let the transport throw
|
||||
@@ -286,8 +288,8 @@ export const layer = (options?: Options) =>
|
||||
// Drop a credential the SDK rejected so the next connect cleanly reports needs_auth — but only if it is
|
||||
// still the stored one. Rotating servers hand out a fresh refresh token per use, so a concurrent
|
||||
// connection may have already replaced ours; deleting then would discard the newer valid credential and
|
||||
// strand every connection in needs_auth until a manual re-auth. Credential deletion notifies all locations;
|
||||
// reconnects remain serialized by the server lock.
|
||||
// strand every connection in needs_auth until a manual re-auth. Uses the raw credential service (no
|
||||
// integration event) to avoid re-triggering the reconnect subscriber mid-connect.
|
||||
invalidate: async (scope) => {
|
||||
if (scope === "verifier" || scope === "discovery") return
|
||||
const oauth = await readOAuthCredential()
|
||||
@@ -671,7 +673,7 @@ export const layer = (options?: Options) =>
|
||||
}).pipe(locks.withLock(name))
|
||||
})
|
||||
fork(
|
||||
bus.subscribe(Credential.Event.Switched).pipe(
|
||||
bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||
Stream.filter((event) => owned.has(event.data.integrationID)),
|
||||
Stream.runForEach((event) => Effect.sync(() => fork(reconnect(event.data.integrationID)))),
|
||||
Effect.ignore,
|
||||
@@ -685,7 +687,9 @@ export const layer = (options?: Options) =>
|
||||
config === false ? [] : [[name, cloneConfig(config)] as const],
|
||||
),
|
||||
),
|
||||
removed: new Set(Array.from(overrides).flatMap(([name, config]) => (config === false ? [name] : []))),
|
||||
removed: new Set(
|
||||
Array.from(overrides).flatMap(([name, config]) => (config === false ? [name] : [])),
|
||||
),
|
||||
}),
|
||||
draft: (draft) => ({
|
||||
list: () => Array.from(draft.servers),
|
||||
|
||||
@@ -11,7 +11,6 @@ import { SessionSchema } from "./session/schema.js"
|
||||
import { SessionStore } from "./session/store.js"
|
||||
import { Wildcard } from "./util/wildcard.js"
|
||||
import { PermissionSaved } from "./permission/saved.js"
|
||||
import { PluginHooks } from "./plugin/hooks.js"
|
||||
|
||||
const PermissionEffect = Permission.Effect
|
||||
export { PermissionEffect as Effect }
|
||||
@@ -71,10 +70,9 @@ export class BlockedError extends Schema.TaggedError<BlockedError>()("Permission
|
||||
rules: Permission.Ruleset,
|
||||
permission: Schema.String,
|
||||
resources: Schema.Array(Schema.String),
|
||||
reason: Schema.String.pipe(Schema.optional),
|
||||
}) {
|
||||
override get message() {
|
||||
return this.reason ?? `Permission denied: ${this.permission}`
|
||||
return `Permission denied: ${this.permission}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +99,11 @@ export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly allowsAll: (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly action: string
|
||||
readonly agent?: Agent.ID
|
||||
}) => Effect.Effect<boolean, SessionErrors.NotFoundError>
|
||||
readonly ask: (input: AssertInput) => Effect.Effect<AskResult, SessionErrors.NotFoundError>
|
||||
readonly assert: (input: AssertInput) => Effect.Effect<void, Error | SessionErrors.NotFoundError>
|
||||
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
|
||||
@@ -125,7 +128,6 @@ const layer = Layer.effect(
|
||||
const agents = yield* Agent.Service
|
||||
const sessions = yield* SessionStore.Service
|
||||
const saved = yield* PermissionSaved.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const pending = new Map<ID, Pending>()
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
@@ -157,6 +159,24 @@ const layer = Layer.effect(
|
||||
return agent?.permissions ?? missingAgentPermissions
|
||||
})
|
||||
|
||||
const allowsAll = Effect.fnUntraced(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly action: string
|
||||
readonly agent?: Agent.ID
|
||||
}) {
|
||||
const rules = yield* configured(input.sessionID, input.agent)
|
||||
const relevant = rules.filter((rule) => Wildcard.match(input.action, rule.action))
|
||||
for (let index = relevant.length - 1; index >= 0; index--) {
|
||||
const rule = relevant[index]
|
||||
if (rule.resource !== "*") {
|
||||
if (rule.effect !== "allow") return false
|
||||
continue
|
||||
}
|
||||
return rule.effect === "allow"
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
function denied(input: Pick<Request, "action" | "resources">, rules: Permission.Ruleset) {
|
||||
return input.resources.some((resource) => evaluate(input.action, resource, rules).effect === "deny")
|
||||
}
|
||||
@@ -171,19 +191,10 @@ const layer = Layer.effect(
|
||||
const all = [...rules, ...(yield* savedRules())]
|
||||
const effects = input.resources.map((resource) => evaluate(input.action, resource, all).effect)
|
||||
const effect: Permission.Effect = effects.includes("deny") ? "deny" : effects.includes("ask") ? "ask" : "allow"
|
||||
const event = yield* hooks.trigger("permission", "evaluate", {
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
action: input.action,
|
||||
resources: input.resources,
|
||||
metadata: input.metadata,
|
||||
source: input.source,
|
||||
effect,
|
||||
})
|
||||
return { effect: event.effect, message: event.message, rules: all }
|
||||
return { effect, rules: all }
|
||||
})
|
||||
|
||||
function request(input: AssertInput, message?: string): Request {
|
||||
function request(input: AssertInput): Request {
|
||||
return {
|
||||
id: input.id ?? ID.create(),
|
||||
sessionID: input.sessionID,
|
||||
@@ -192,7 +203,6 @@ const layer = Layer.effect(
|
||||
save: input.save,
|
||||
metadata: input.metadata,
|
||||
source: input.source,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,42 +223,39 @@ const layer = Layer.effect(
|
||||
|
||||
const ask = Effect.fn("Permission.ask")(function* (input: AssertInput) {
|
||||
const result = yield* evaluateInput(input)
|
||||
const value = request(input, result.message)
|
||||
const value = request(input)
|
||||
if (result.effect === "ask") yield* create(value, input.agent)
|
||||
return { id: value.id, effect: result.effect }
|
||||
})
|
||||
|
||||
const assert = Effect.fn("Permission.assert")((input: AssertInput) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* evaluateInput(input)
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
if (result.effect === "deny") {
|
||||
return yield* new BlockedError({
|
||||
rules: relevant(input, result.rules),
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
reason: result.message,
|
||||
})
|
||||
}
|
||||
if (result.effect === "allow") return
|
||||
const item = yield* create(request(input, result.message), input.agent)
|
||||
return yield* restore(Deferred.await(item.deferred)).pipe(
|
||||
// Deliberate defect tunnel: leaves wrap execution in blanket `mapError`, which
|
||||
// must not convert a user's decline into model-facing tool output. The decline
|
||||
// resurfaces as a typed failure at SessionModelRequest.executeTool. A decline
|
||||
// WITH feedback (CorrectedError) intentionally stays typed so the leaf can turn
|
||||
// it into ToolFailure and the model continues.
|
||||
Effect.catchTag("Permission.DeclinedError", (error) => Effect.die(error)),
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
pending.delete(item.request.id)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* evaluateInput(input)
|
||||
if (result.effect === "deny") {
|
||||
return yield* new BlockedError({
|
||||
rules: relevant(input, result.rules),
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
})
|
||||
}
|
||||
if (result.effect === "allow") return
|
||||
const item = yield* create(request(input), input.agent)
|
||||
return yield* restore(Deferred.await(item.deferred)).pipe(
|
||||
// Deliberate defect tunnel: leaves wrap execution in blanket `mapError`, which
|
||||
// must not convert a user's decline into model-facing tool output. The decline
|
||||
// resurfaces as a typed failure at SessionModelRequest.executeTool. A decline
|
||||
// WITH feedback (CorrectedError) intentionally stays typed so the leaf can turn
|
||||
// it into ToolFailure and the model continues.
|
||||
Effect.catchTag("Permission.DeclinedError", (error) => Effect.die(error)),
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
pending.delete(item.request.id)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const reply = Effect.fn("Permission.reply")((input: ReplyInput) =>
|
||||
@@ -330,12 +337,12 @@ const layer = Layer.effect(
|
||||
return Array.from(pending.values(), (item) => item.request).filter((request) => request.sessionID === sessionID)
|
||||
})
|
||||
|
||||
return Service.of({ ask, assert, reply, get, forSession, list })
|
||||
return Service.of({ allowsAll, ask, assert, reply, get, forSession, list })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Bus.node, Location.node, Agent.node, SessionStore.node, PermissionSaved.node, PluginHooks.node],
|
||||
deps: [Bus.node, Location.node, Agent.node, SessionStore.node, PermissionSaved.node],
|
||||
})
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { createHash } from "node:crypto"
|
||||
import { chmod, lstat, mkdir, open, readFile, rename, rm } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import asset from "./pty-binding.js"
|
||||
|
||||
export async function resolveBinary(bin: string) {
|
||||
if (process.env.OPENCODE_PTY_BIN) return process.env.OPENCODE_PTY_BIN
|
||||
if (!asset) return "opencode-pty"
|
||||
return install(bin, asset)
|
||||
}
|
||||
|
||||
async function install(
|
||||
bin: string,
|
||||
input: { readonly path: string; readonly version: string; readonly sha256: string },
|
||||
) {
|
||||
const root = path.join(bin, "opencode-pty")
|
||||
await privateDirectory(root)
|
||||
const directory = path.join(root, `${input.version}-${input.sha256.slice(0, 16)}`)
|
||||
await privateDirectory(directory)
|
||||
const destination = path.join(directory, "opencode-pty")
|
||||
if (await exists(destination, input.sha256)) return destination
|
||||
|
||||
const bytes = new Uint8Array(await Bun.file(input.path).arrayBuffer())
|
||||
if (sha256(bytes) !== input.sha256) throw new Error("Embedded opencode-pty checksum mismatch")
|
||||
const temporary = path.join(directory, `opencode-pty.${process.pid}.${crypto.randomUUID()}.tmp`)
|
||||
try {
|
||||
const file = await open(temporary, "wx", 0o700)
|
||||
try {
|
||||
await file.writeFile(bytes)
|
||||
await file.sync()
|
||||
} finally {
|
||||
await file.close()
|
||||
}
|
||||
await chmod(temporary, 0o755)
|
||||
await rename(temporary, destination).catch(async (error) => {
|
||||
if (!(await exists(destination, input.sha256))) throw error
|
||||
})
|
||||
} finally {
|
||||
await rm(temporary, { force: true })
|
||||
}
|
||||
return validate(destination, input.sha256)
|
||||
}
|
||||
|
||||
async function privateDirectory(directory: string) {
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 })
|
||||
const info = await lstat(directory)
|
||||
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`Unsafe opencode-pty directory: ${directory}`)
|
||||
const uid = typeof process.getuid === "function" ? process.getuid() : undefined
|
||||
if (uid !== undefined && info.uid !== uid)
|
||||
throw new Error(`opencode-pty directory is owned by another user: ${directory}`)
|
||||
await chmod(directory, 0o700)
|
||||
}
|
||||
|
||||
async function exists(file: string, expected: string) {
|
||||
try {
|
||||
await validate(file, expected)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function validate(file: string, expected?: string) {
|
||||
const info = await lstat(file)
|
||||
if (!info.isFile() || info.isSymbolicLink()) throw new Error(`Unsafe opencode-pty executable: ${file}`)
|
||||
const uid = typeof process.getuid === "function" ? process.getuid() : undefined
|
||||
if (uid !== undefined && info.uid !== uid)
|
||||
throw new Error(`opencode-pty executable is owned by another user: ${file}`)
|
||||
if (expected && sha256(await readFile(file)) !== expected)
|
||||
throw new Error(`Cached opencode-pty checksum mismatch: ${file}`)
|
||||
await chmod(file, 0o755)
|
||||
return file
|
||||
}
|
||||
|
||||
function sha256(bytes: Uint8Array) {
|
||||
return createHash("sha256").update(bytes).digest("hex")
|
||||
}
|
||||
|
||||
function isMissing(error: unknown): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error && "code" in error && error.code === "ENOENT"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export async function resolveBinary() {
|
||||
return process.env.OPENCODE_PTY_BIN || "opencode-pty"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export async function resolveBinary(): Promise<string> {
|
||||
throw new Error("Persistent PTYs are unavailable in this runtime")
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import { Session } from "@opencode-ai/schema/session"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Database } from "../database/database.js"
|
||||
import { Pty } from "@opencode-ai/schema/pty"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import {
|
||||
makeDaemonTransport,
|
||||
type DaemonTransport,
|
||||
@@ -19,7 +18,6 @@ import {
|
||||
type WireResponse,
|
||||
type WireTerminal,
|
||||
} from "./daemon.js"
|
||||
import { resolveBinary } from "#persistent-pty-binary"
|
||||
|
||||
export type { Role, StreamEvent } from "./daemon.js"
|
||||
|
||||
@@ -122,18 +120,9 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const database = yield* Database.Service
|
||||
const global = yield* Global.Service
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
let binary: Promise<string> | undefined
|
||||
const daemon = yield* makeDaemonTransport(
|
||||
runtimeDirectory(databasePath(database.db)),
|
||||
() =>
|
||||
(binary ??= resolveBinary(global.bin).catch((error) => {
|
||||
binary = undefined
|
||||
throw error
|
||||
})),
|
||||
)
|
||||
const daemon = yield* makeDaemonTransport(runtimeDirectory(databasePath(database.db)))
|
||||
const removing = new Set<Pty.ID>()
|
||||
|
||||
const list = Effect.fn("PersistentPty.list")(function* (sessionID?: Session.ID) {
|
||||
@@ -328,7 +317,7 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [Bus.node, Database.node, Global.node] })
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [Bus.node, Database.node] })
|
||||
|
||||
const request = (daemon: DaemonTransport, value: object, start = false) =>
|
||||
daemon.request(value, start).pipe(Effect.mapError(unavailable))
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
const asset: { readonly path: string; readonly version: string; readonly sha256: string } | undefined = undefined
|
||||
|
||||
export default asset
|
||||
@@ -24,8 +24,6 @@ import { State } from "./state.js"
|
||||
import { Tool } from "./tool.js"
|
||||
import { Vcs } from "./vcs.js"
|
||||
import { PluginHooks } from "./plugin/hooks.js"
|
||||
import { Generate } from "./generate.js"
|
||||
import { Permission } from "./permission.js"
|
||||
|
||||
export interface Interface {
|
||||
readonly activate: (
|
||||
@@ -206,7 +204,5 @@ export const node = makeLocationNode({
|
||||
PluginHooks.node,
|
||||
PluginRuntime.node,
|
||||
WebSearch.node,
|
||||
Generate.node,
|
||||
Permission.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
|
||||
import type { ShellHooks } from "@opencode-ai/plugin/effect/shell"
|
||||
import type { ToolFailures, ToolHooks } from "@opencode-ai/plugin/effect/tool"
|
||||
import type { ModelHookOptions } from "@opencode-ai/plugin/effect/registration"
|
||||
import type { PermissionHooks } from "@opencode-ai/plugin/effect/permission"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { State } from "../state.js"
|
||||
@@ -13,7 +12,6 @@ import { State } from "../state.js"
|
||||
export interface Domains {
|
||||
readonly aisdk: AISDKHooks
|
||||
readonly session: SessionHooks
|
||||
readonly permission: PermissionHooks
|
||||
readonly shell: ShellHooks
|
||||
readonly tool: ToolHooks
|
||||
}
|
||||
@@ -24,7 +22,6 @@ type NoFailures<Spec> = { readonly [Name in keyof Spec]: never }
|
||||
interface Failures extends Record<keyof Domains, unknown> {
|
||||
readonly aisdk: NoFailures<AISDKHooks>
|
||||
readonly session: NoFailures<SessionHooks>
|
||||
readonly permission: NoFailures<PermissionHooks>
|
||||
readonly shell: NoFailures<ShellHooks>
|
||||
readonly tool: ToolFailures
|
||||
}
|
||||
|
||||
@@ -26,8 +26,6 @@ import { Tool } from "../tool.js"
|
||||
import { Workspace } from "../workspace.js"
|
||||
import { Vcs } from "../vcs.js"
|
||||
import { WebSearch } from "../websearch.js"
|
||||
import { Generate } from "../generate.js"
|
||||
import { Permission } from "../permission.js"
|
||||
import { PluginHooks } from "./hooks.js"
|
||||
import type { Interface } from "../plugin.js"
|
||||
|
||||
@@ -48,8 +46,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
const tools = yield* Tool.Service
|
||||
const vcs = yield* Vcs.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
const generate = yield* Generate.Service
|
||||
const permission = yield* Permission.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const locationInfo = () =>
|
||||
@@ -192,9 +188,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
event: {
|
||||
subscribe: () => bus.subscribe().pipe(Stream.filter(EventManifest.isServer)),
|
||||
},
|
||||
generate: {
|
||||
text: (input) => generate.text(input).pipe(Effect.map((text) => ({ text }))),
|
||||
},
|
||||
integration: {
|
||||
list: () => response(integration.list()),
|
||||
get: (input) => response(integration.get(Integration.ID.make(input.integrationID))),
|
||||
@@ -320,30 +313,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
})
|
||||
}),
|
||||
},
|
||||
permission: {
|
||||
hook: (name, callback) => hooks.register("permission", name, callback),
|
||||
list: (input) => permission.forSession(input.sessionID),
|
||||
get: (input) =>
|
||||
permission
|
||||
.get(input.requestID)
|
||||
.pipe(
|
||||
Effect.flatMap((request) =>
|
||||
request?.sessionID === input.sessionID
|
||||
? Effect.succeed(request)
|
||||
: Effect.fail(new Error(`Permission request not found: ${input.requestID}`)),
|
||||
),
|
||||
),
|
||||
reply: (input) =>
|
||||
permission
|
||||
.get(input.requestID)
|
||||
.pipe(
|
||||
Effect.flatMap((request) =>
|
||||
request?.sessionID === input.sessionID
|
||||
? permission.reply({ requestID: input.requestID, reply: input.reply, message: input.message })
|
||||
: Effect.fail(new Error(`Permission request not found: ${input.requestID}`)),
|
||||
),
|
||||
),
|
||||
},
|
||||
plugin: {
|
||||
list: () => response(plugin.list()),
|
||||
},
|
||||
@@ -448,7 +417,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
.interrupt(input.sessionID, { continue: input.continue })
|
||||
.pipe(Effect.map((interrupted) => ({ interrupted }))),
|
||||
wait: (input) => runtime.session.wait(input.sessionID),
|
||||
context: (input) => runtime.session.context(input.sessionID),
|
||||
},
|
||||
} satisfies Plugin.Context
|
||||
})
|
||||
|
||||
@@ -221,7 +221,7 @@ export const GithubCopilotPlugin = define({
|
||||
}
|
||||
})
|
||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
||||
yield* bus.subscribe(Credential.Event.Switched).pipe(
|
||||
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||
Stream.filter((event) => event.data.integrationID === Integration.ID.make("github-copilot")),
|
||||
Stream.runForEach(refresh),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
|
||||
@@ -301,7 +301,7 @@ export const OpenAIPlugin = define({
|
||||
{ providerID: Provider.ID.openai },
|
||||
)
|
||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
||||
yield* bus.subscribe(Credential.Event.Switched).pipe(
|
||||
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||
Stream.filter((event) => event.data.integrationID === Integration.ID.make("openai")),
|
||||
Stream.runForEach(refresh),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
|
||||
@@ -193,7 +193,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
||||
})
|
||||
|
||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
||||
yield* bus.subscribe(Credential.Event.Switched).pipe(
|
||||
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||
Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")),
|
||||
Stream.runForEach(refresh),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
|
||||
@@ -26,7 +26,6 @@ export interface Interface {
|
||||
| "interrupt"
|
||||
| "synthetic"
|
||||
| "wait"
|
||||
| "context"
|
||||
>
|
||||
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel">
|
||||
readonly location: {
|
||||
@@ -82,7 +81,6 @@ export const layerWithCell = (cell: Cell) =>
|
||||
interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)),
|
||||
synthetic: (input) => require(cell, (runtime) => runtime.session.synthetic(input)),
|
||||
wait: (sessionID) => require(cell, (runtime) => runtime.session.wait(sessionID)),
|
||||
context: (sessionID) => require(cell, (runtime) => runtime.session.context(sessionID)),
|
||||
},
|
||||
job: {
|
||||
start: (input) => require(cell, (runtime) => runtime.job.start(input)),
|
||||
|
||||
@@ -96,17 +96,7 @@ const layer = Layer.effect(
|
||||
|
||||
const announcing = new Set<string>()
|
||||
const persist = Effect.fnUntraced(function* (project: Resolved) {
|
||||
const previous = yield* db
|
||||
.select({ canonical: ProjectTable.worktree })
|
||||
.from(ProjectTable)
|
||||
.where(eq(ProjectTable.id, project.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
yield* upsertProject(db, project).pipe(Effect.orDie)
|
||||
if (previous && previous.canonical !== project.canonical) {
|
||||
const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, project.id)).get().pipe(Effect.orDie)
|
||||
if (row) yield* bus.publish(ProjectSchema.Event.Updated, fromRow(row))
|
||||
}
|
||||
if (!project.vcs) return project
|
||||
const directories: Array<{ projectID: ID; directory: AbsolutePath; strategy?: string }> = [
|
||||
{ projectID: project.id, directory: project.canonical },
|
||||
|
||||
@@ -50,7 +50,6 @@ interface Channel {
|
||||
interface State {
|
||||
readonly lock: Semaphore.Semaphore
|
||||
closed: boolean
|
||||
httpFallback: boolean
|
||||
channel?: Channel
|
||||
}
|
||||
|
||||
@@ -120,7 +119,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
const state = (sessionID: SessionSchema.ID) => {
|
||||
const current = states.get(sessionID)
|
||||
if (current) return current
|
||||
const created = { lock: Semaphore.makeUnsafe(1), closed: false, httpFallback: false }
|
||||
const created = { lock: Semaphore.makeUnsafe(1), closed: false }
|
||||
states.set(sessionID, created)
|
||||
return created
|
||||
}
|
||||
@@ -242,9 +241,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
channel.active?.lifecycle.delivery === "terminal" ||
|
||||
(error.reason._tag === "Transport" && error.reason.code === "queue-overflow")
|
||||
? "accepted"
|
||||
: error.reason._tag === "Transport" && error.reason.code === "1009"
|
||||
? "rejected"
|
||||
: "ambiguous",
|
||||
: "ambiguous",
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -277,7 +274,6 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
phase: "queue",
|
||||
delivery: "not-sent",
|
||||
})
|
||||
if (owner.httpFallback) return fallback(exchange)
|
||||
const key = affinity(exchange)
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const current = owner.channel
|
||||
@@ -424,26 +420,6 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
yield* poison(owner, channel, error)
|
||||
}),
|
||||
),
|
||||
Stream.catch((error) => {
|
||||
if (
|
||||
error.reason._tag !== "Transport" ||
|
||||
error.reason.code !== "1009" ||
|
||||
error.reason.delivery !== "rejected"
|
||||
)
|
||||
return Stream.fail(error)
|
||||
owner.httpFallback = true
|
||||
return Stream.unwrap(
|
||||
Effect.logWarning("session websocket request too large; using http", {
|
||||
sessionTransport: "websocket",
|
||||
phase: "close",
|
||||
delivery: "rejected",
|
||||
code: error.reason.code,
|
||||
}).pipe(
|
||||
Effect.andThen(metric("fallback", { reason: "message_too_large" })),
|
||||
Effect.as(exchange.fallback()),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
const complete = Effect.sync(() => {
|
||||
if (owner.channel !== channel || channel.pending?.token !== token) return
|
||||
|
||||
@@ -332,8 +332,8 @@ const layer = Layer.effect(
|
||||
// a blocked first step leaves pending inputs untouched.
|
||||
yield* InstructionState.prepare(db, bus, selected.instructions, selected.session.id)
|
||||
const promoted = promotable ? yield* SessionInbox.promote(db, bus, selected.session.id, promotable) : 0
|
||||
if (promoted > 0 && !selected.session.parentID && SessionTitle.isUntitled(selected.session))
|
||||
yield* FiberMap.run(titles, sessionID, title.generate(sessionID).pipe(Effect.ignore), {
|
||||
if (promoted > 0)
|
||||
yield* FiberMap.run(titles, sessionID, title.generateForFirstPrompt(sessionID).pipe(Effect.ignore), {
|
||||
onlyIfMissing: true,
|
||||
})
|
||||
// Promoted input opens a fresh step allowance.
|
||||
|
||||
@@ -21,8 +21,6 @@ import { SessionUsage } from "./usage.js"
|
||||
import { SessionStore } from "./store.js"
|
||||
|
||||
const MAX_LENGTH = 100
|
||||
const MAX_CONTEXT_LENGTH = 8_000
|
||||
const MAX_FIRST_MESSAGE_LENGTH = 2_000
|
||||
const titleChanged = Symbol("Session title changed")
|
||||
|
||||
type Dependencies = {
|
||||
@@ -38,14 +36,14 @@ type Dependencies = {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
/** Generates an initial title or regenerates one from bounded conversation history. */
|
||||
readonly generate: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
/** Generates a title from the session's first user message when the session remains untitled. */
|
||||
readonly generateForFirstPrompt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionTitle") {}
|
||||
|
||||
const truncate = (value: string) => (value.length <= MAX_LENGTH ? value : `${value.slice(0, MAX_LENGTH - 3)}...`)
|
||||
export const isUntitled = (session: SessionSchema.Info) =>
|
||||
const isUntitled = (session: SessionSchema.Info) =>
|
||||
isExactRootFallback({
|
||||
title: session.title,
|
||||
time: { created: DateTime.toEpochMillis(session.time.created) },
|
||||
@@ -110,36 +108,16 @@ const attempt = Effect.fn("SessionTitle.attempt")(function* (
|
||||
const MINIMAL_REASONING_VARIANTS = ["none", "minimal", "low"].map((id) => Model.VariantID.make(id))
|
||||
|
||||
const make = (dependencies: Dependencies) => {
|
||||
const generate = Effect.fn("SessionTitle.generate")(function* (
|
||||
const generateForFirstPrompt = Effect.fn("SessionTitle.generateForFirstPrompt")(function* (
|
||||
db: Database.Interface["db"],
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
const session = yield* dependencies.store.get(sessionID)
|
||||
if (!session) return
|
||||
if (session.parentID) return
|
||||
if (!isUntitled(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 agent = yield* dependencies.agents.get(Agent.ID.make("title"))
|
||||
if (!agent) return
|
||||
const primary = yield* dependencies.models.resolve(session).pipe(Effect.orElseSucceed(() => undefined))
|
||||
@@ -165,14 +143,14 @@ const make = (dependencies: Dependencies) => {
|
||||
const selected = preferred ?? primary
|
||||
if (!selected) return
|
||||
const title =
|
||||
(yield* attempt(dependencies, { session, agent, text, model: selected })) ??
|
||||
(yield* attempt(dependencies, { session, agent, text: firstUser.text, model: selected })) ??
|
||||
(primary && !isDeepStrictEqual(selected.ref, primary.ref)
|
||||
? yield* attempt(dependencies, { session, agent, text, model: primary })
|
||||
? yield* attempt(dependencies, { session, agent, text: firstUser.text, model: 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
|
||||
if (!current || !isUntitled(current)) return
|
||||
yield* dependencies.bus
|
||||
.publish(
|
||||
SessionEvent.Renamed,
|
||||
@@ -184,7 +162,7 @@ const make = (dependencies: Dependencies) => {
|
||||
)
|
||||
.pipe(Effect.catchDefect((defect) => (defect === titleChanged ? Effect.void : Effect.die(defect))))
|
||||
})
|
||||
return { generate }
|
||||
return { generateForFirstPrompt }
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
@@ -200,7 +178,7 @@ export const layer = Layer.effect(
|
||||
const database = yield* Database.Service
|
||||
const title = make({ bus, llm, agents, catalog, models, modelRequests, store })
|
||||
return Service.of({
|
||||
generate: (sessionID) => title.generate(database.db, sessionID),
|
||||
generateForFirstPrompt: (sessionID) => title.generateForFirstPrompt(database.db, sessionID),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -194,40 +194,54 @@ export const Plugin = {
|
||||
(invocation) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" })
|
||||
const unrestricted =
|
||||
(yield* permission.allowsAll({
|
||||
sessionID: context.sessionID,
|
||||
action: name,
|
||||
agent: context.agent,
|
||||
})) &&
|
||||
(yield* permission.allowsAll({
|
||||
sessionID: context.sessionID,
|
||||
action: "external_directory",
|
||||
agent: context.agent,
|
||||
}))
|
||||
invocation.cwd = target.absolute
|
||||
finalTimeout = invocation.timeout
|
||||
const portable =
|
||||
Config.latest(yield* config.entries(), "experimental")?.portable_shell_scanner === true
|
||||
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute, {
|
||||
portable,
|
||||
})
|
||||
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
|
||||
mutation.resolve({ path: path.resolve(target.absolute, directory), kind: "directory" }),
|
||||
)
|
||||
const external = [target, ...directories]
|
||||
.map((item) => item.externalDirectory)
|
||||
.filter((item) => item !== undefined)
|
||||
.filter(
|
||||
(item, index, items) => items.findIndex((other) => other.resource === item.resource) === index,
|
||||
if (!unrestricted) {
|
||||
const portable =
|
||||
Config.latest(yield* config.entries(), "experimental")?.portable_shell_scanner === true
|
||||
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute, {
|
||||
portable,
|
||||
})
|
||||
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
|
||||
mutation.resolve({ path: path.resolve(target.absolute, directory), kind: "directory" }),
|
||||
)
|
||||
if (external.length > 0)
|
||||
yield* permission.assert({
|
||||
action: "external_directory",
|
||||
resources: external.map((item) => item.resource),
|
||||
save: external.map((item) => item.save),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
if (parsed.commands.length > 0)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: parsed.commands.map((command) => command.resource),
|
||||
save: parsed.commands.map((command) => command.save),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const external = [target, ...directories]
|
||||
.map((item) => item.externalDirectory)
|
||||
.filter((item) => item !== undefined)
|
||||
.filter(
|
||||
(item, index, items) =>
|
||||
items.findIndex((other) => other.resource === item.resource) === index,
|
||||
)
|
||||
if (external.length > 0)
|
||||
yield* permission.assert({
|
||||
action: "external_directory",
|
||||
resources: external.map((item) => item.resource),
|
||||
save: external.map((item) => item.save),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
if (parsed.commands.length > 0)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: parsed.commands.map((command) => command.resource),
|
||||
save: parsed.commands.map((command) => command.save),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
}
|
||||
const workdir = yield* Environment.typeFollowing(environment.files, target.absolute).pipe(
|
||||
Effect.catchTag("Environment.NotFound", () =>
|
||||
Effect.fail(new Error(`Working directory does not exist: ${target.absolute}`)),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { APICallError } from "@ai-sdk/provider"
|
||||
import type { LanguageModelV3, LanguageModelV3StreamPart } from "@ai-sdk/provider"
|
||||
import { createMistral } from "@ai-sdk/mistral"
|
||||
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
|
||||
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
|
||||
@@ -441,63 +440,6 @@ it.effect("moves a tool image through the real Mistral provider as a user messag
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not treat SSE comment heartbeats as model progress", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
const encoder = new TextEncoder()
|
||||
let heartbeat: ReturnType<typeof setInterval> | undefined
|
||||
const customFetch = Object.assign(
|
||||
async () =>
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'data: {"id":"response-1","object":"chat.completion.chunk","created":0,"model":"api-model","choices":[{"index":0,"delta":{"content":"partial"},"finish_reason":null}]}\n\n',
|
||||
),
|
||||
)
|
||||
heartbeat = setInterval(() => controller.enqueue(encoder.encode(": keepalive\n\n")), 5)
|
||||
},
|
||||
cancel() {
|
||||
if (heartbeat) clearInterval(heartbeat)
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
),
|
||||
{ preconnect: fetch.preconnect },
|
||||
)
|
||||
yield* aisdk.hook.sdk((event) => {
|
||||
event.sdk = createOpenAICompatible({
|
||||
...event.options,
|
||||
name: String(event.options.name),
|
||||
baseURL: String(event.options.baseURL),
|
||||
})
|
||||
})
|
||||
const resolved = yield* aisdk.model(
|
||||
model("@ai-sdk/openai-compatible", {
|
||||
apiKey: "test",
|
||||
baseURL: "https://example.test/v1",
|
||||
chunkTimeout: 25,
|
||||
fetch: customFetch,
|
||||
}),
|
||||
)
|
||||
const result = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Hello" })).pipe(
|
||||
Effect.provide(client),
|
||||
Effect.result,
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
if (heartbeat) clearInterval(heartbeat)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
_tag: "Failure",
|
||||
failure: { reason: { message: expect.stringContaining("SSE read timed out") } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits malformed AI SDK tool input without executing it", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
|
||||
@@ -149,23 +149,6 @@ describe("Bus", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits ambient and explicit locations for global events", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const event = yield* bus.publish(
|
||||
GlobalMessage,
|
||||
{ text: "hello" },
|
||||
{
|
||||
global: true,
|
||||
location: { directory: AbsolutePath.make("explicit"), workspaceID: Workspace.ID.make("wrk_explicit") },
|
||||
},
|
||||
)
|
||||
|
||||
expect(event).not.toHaveProperty("location")
|
||||
expect(event.type).toBe("test.global")
|
||||
}),
|
||||
)
|
||||
|
||||
itWithoutLocation.effect("omits location when no location is available", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
@@ -393,7 +393,6 @@ describe("Config", () => {
|
||||
]),
|
||||
get: () => Effect.die("unused Credential.get"),
|
||||
create: () => Effect.die("unused Credential.create"),
|
||||
activate: () => Effect.die("unused Credential.activate"),
|
||||
update: () => Effect.die("unused Credential.update"),
|
||||
remove: () => Effect.die("unused Credential.remove"),
|
||||
}),
|
||||
@@ -438,11 +437,7 @@ describe("Config", () => {
|
||||
yield* Effect.yieldNow
|
||||
available = true
|
||||
key = "next"
|
||||
yield* bus.publish(
|
||||
Credential.Event.Switched,
|
||||
{ credentialID: Credential.ID.create(), integrationID },
|
||||
{ global: true },
|
||||
)
|
||||
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID })
|
||||
expect(yield* Fiber.join(updated)).toHaveLength(1)
|
||||
const refreshed = yield* config.entries()
|
||||
expect(Config.latest(refreshed, "shell")).toBe("project")
|
||||
@@ -501,7 +496,6 @@ describe("Config", () => {
|
||||
]),
|
||||
get: () => Effect.die("unused Credential.get"),
|
||||
create: () => Effect.die("unused Credential.create"),
|
||||
activate: () => Effect.die("unused Credential.activate"),
|
||||
update: () => Effect.die("unused Credential.update"),
|
||||
remove: () => Effect.die("unused Credential.remove"),
|
||||
}),
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect } from "effect"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { CredentialTable } from "@opencode-ai/core/credential/sql"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(LayerNode.compile(LayerNode.group([Credential.node, Bus.node, Database.node])))
|
||||
const it = testEffect(LayerNode.compile(Credential.node))
|
||||
|
||||
describe("Credential", () => {
|
||||
it.effect("stores, updates, lists, and removes credentials", () =>
|
||||
@@ -31,172 +22,15 @@ describe("Credential", () => {
|
||||
yield* credentials.update(created.id, { label: "Personal" })
|
||||
expect((yield* credentials.list(integrationID))[0]?.label).toBe("Personal")
|
||||
|
||||
const additional = yield* credentials.create({
|
||||
integrationID,
|
||||
label: "Additional",
|
||||
value: Credential.Key.make({ type: "key", key: "additional" }),
|
||||
})
|
||||
expect(yield* credentials.list(integrationID)).toEqual([
|
||||
expect.objectContaining({ id: created.id, label: "Personal" }),
|
||||
additional,
|
||||
])
|
||||
|
||||
yield* credentials.remove(additional.id)
|
||||
expect(yield* credentials.list(integrationID)).toEqual([
|
||||
expect.objectContaining({ id: created.id, label: "Personal" }),
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("publishes global events only for observable credential mutations", () =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const bus = yield* Bus.Service
|
||||
const integrationID = Integration.ID.make("openai")
|
||||
const events = new Array<Event.Payload>()
|
||||
yield* bus.listen((event) => Effect.sync(() => events.push(event)))
|
||||
|
||||
const older = yield* credentials
|
||||
.create({ integrationID, value: Credential.Key.make({ type: "key", key: "older" }) })
|
||||
.pipe(
|
||||
Effect.provideService(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory: AbsolutePath.make("project"), workspaceID: Workspace.ID.make("wrk_test") }),
|
||||
),
|
||||
),
|
||||
)
|
||||
const newer = yield* credentials.create({
|
||||
integrationID,
|
||||
value: Credential.Key.make({ type: "key", key: "newer" }),
|
||||
})
|
||||
|
||||
yield* credentials.activate(newer.id)
|
||||
yield* credentials.activate(Credential.ID.create())
|
||||
yield* credentials.activate(older.id)
|
||||
yield* credentials.activate(older.id)
|
||||
yield* credentials.update(older.id, {})
|
||||
yield* credentials.update(Credential.ID.create(), { label: "Missing" })
|
||||
yield* credentials.update(older.id, { label: "default" })
|
||||
yield* credentials.update(older.id, { value: Credential.Key.make({ type: "key", key: "refreshed" }) })
|
||||
yield* credentials.update(older.id, { label: "Renamed" })
|
||||
yield* credentials.remove(Credential.ID.create())
|
||||
yield* credentials.remove(newer.id)
|
||||
yield* credentials.remove(newer.id)
|
||||
expect((yield* credentials.list(integrationID)).at(-1)?.id).toBe(older.id)
|
||||
|
||||
const replacement = yield* credentials.create({
|
||||
integrationID,
|
||||
label: "Replacement",
|
||||
value: Credential.Key.make({ type: "key", key: "replacement" }),
|
||||
})
|
||||
expect(yield* credentials.list(integrationID)).toEqual([replacement])
|
||||
|
||||
yield* credentials.remove(replacement.id)
|
||||
expect((yield* credentials.list(integrationID)).at(-1)?.id).toBe(older.id)
|
||||
yield* credentials.remove(older.id)
|
||||
expect(yield* credentials.list(integrationID)).toEqual([])
|
||||
|
||||
expect(events.map((event) => ({ type: event.type, data: event.data }))).toEqual([
|
||||
{ type: Credential.Event.Updated.type, data: {} },
|
||||
{ type: Credential.Event.Switched.type, data: { integrationID, credentialID: older.id } },
|
||||
{ type: Credential.Event.Updated.type, data: {} },
|
||||
{ type: Credential.Event.Switched.type, data: { integrationID, credentialID: newer.id } },
|
||||
{ type: Credential.Event.Switched.type, data: { integrationID, credentialID: older.id } },
|
||||
{ type: Credential.Event.Updated.type, data: {} },
|
||||
{ type: Credential.Event.Updated.type, data: {} },
|
||||
{ type: Credential.Event.Updated.type, data: {} },
|
||||
{ type: Credential.Event.Switched.type, data: { integrationID, credentialID: replacement.id } },
|
||||
{ type: Credential.Event.Updated.type, data: {} },
|
||||
{ type: Credential.Event.Switched.type, data: { integrationID, credentialID: older.id } },
|
||||
{ type: Credential.Event.Updated.type, data: {} },
|
||||
{ type: Credential.Event.Switched.type, data: { integrationID, credentialID: null } },
|
||||
])
|
||||
expect(events.every((event) => !("location" in event))).toBeTrue()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("promotes the newest remaining legacy credential when the effective selection is removed", () =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const bus = yield* Bus.Service
|
||||
const database = yield* Database.Service
|
||||
const integrationID = Integration.ID.make("openai")
|
||||
const oldest = yield* credentials.create({
|
||||
integrationID,
|
||||
value: Credential.Key.make({ type: "key", key: "oldest" }),
|
||||
})
|
||||
const newer = yield* credentials.create({
|
||||
integrationID,
|
||||
value: Credential.Key.make({ type: "key", key: "newer" }),
|
||||
})
|
||||
const newest = yield* credentials.create({
|
||||
integrationID,
|
||||
value: Credential.Key.make({ type: "key", key: "newest" }),
|
||||
})
|
||||
yield* database.db
|
||||
.update(CredentialTable)
|
||||
.set({ active: null })
|
||||
.where(eq(CredentialTable.integration_id, integrationID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const events = new Array<Event.Payload>()
|
||||
yield* bus.listen((event) => Effect.sync(() => events.push(event)))
|
||||
yield* credentials.activate(newest.id)
|
||||
yield* credentials.remove(oldest.id)
|
||||
yield* credentials.remove(newest.id)
|
||||
|
||||
expect(yield* credentials.list(integrationID)).toEqual([newer])
|
||||
expect(
|
||||
yield* database.db
|
||||
.select({ active: CredentialTable.active })
|
||||
.from(CredentialTable)
|
||||
.where(eq(CredentialTable.id, newer.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie),
|
||||
).toEqual({ active: true })
|
||||
expect(events.map((event) => ({ type: event.type, data: event.data }))).toEqual([
|
||||
{ type: Credential.Event.Updated.type, data: {} },
|
||||
{ type: Credential.Event.Updated.type, data: {} },
|
||||
{ type: Credential.Event.Switched.type, data: { integrationID, credentialID: newer.id } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("activates older credentials without affecting other integrations", () =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const integrationID = Integration.ID.make("openai")
|
||||
const otherIntegrationID = Integration.ID.make("anthropic")
|
||||
const older = yield* credentials.create({
|
||||
integrationID,
|
||||
value: Credential.Key.make({ type: "key", key: "older" }),
|
||||
})
|
||||
const newer = yield* credentials.create({
|
||||
integrationID,
|
||||
value: Credential.Key.make({ type: "key", key: "newer" }),
|
||||
})
|
||||
const otherOlder = yield* credentials.create({
|
||||
integrationID: otherIntegrationID,
|
||||
value: Credential.Key.make({ type: "key", key: "other-older" }),
|
||||
})
|
||||
const otherNewer = yield* credentials.create({
|
||||
integrationID: otherIntegrationID,
|
||||
value: Credential.Key.make({ type: "key", key: "other-newer" }),
|
||||
})
|
||||
|
||||
yield* credentials.activate(older.id)
|
||||
expect(yield* credentials.list(integrationID)).toEqual([newer, older])
|
||||
expect(yield* credentials.list(otherIntegrationID)).toEqual([otherOlder, otherNewer])
|
||||
|
||||
yield* credentials.activate(Credential.ID.create())
|
||||
expect((yield* credentials.list(integrationID)).at(-1)).toEqual(older)
|
||||
|
||||
yield* credentials.activate(otherOlder.id)
|
||||
expect(yield* credentials.list(otherIntegrationID)).toEqual([otherNewer, otherOlder])
|
||||
expect((yield* credentials.list(integrationID)).at(-1)).toEqual(older)
|
||||
|
||||
yield* credentials.remove(older.id)
|
||||
expect((yield* credentials.list(integrationID)).at(-1)).toEqual(newer)
|
||||
expect((yield* credentials.list(otherIntegrationID)).at(-1)).toEqual(otherOlder)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Effect, Layer } from "effect"
|
||||
import { sql } from "drizzle-orm"
|
||||
import { DatabaseMigration } from "@opencode-ai/core/database/migration"
|
||||
import { migrations } from "@opencode-ai/core/database/migration.gen"
|
||||
import workspaceNameMigration from "@opencode-ai/core/database/migration/20260410174513_workspace-name"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import type { SqlClient } from "effect/unstable/sql/SqlClient"
|
||||
@@ -36,68 +35,6 @@ const run = <A, E>(
|
||||
const makeDb = EffectDrizzleSqlite.makeWithDefaults()
|
||||
|
||||
describe("DatabaseMigration", () => {
|
||||
test("defaults missing workspace names while preserving legacy workspace data", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`
|
||||
CREATE TABLE workspace (
|
||||
id text PRIMARY KEY,
|
||||
type text NOT NULL,
|
||||
branch text,
|
||||
directory text,
|
||||
extra text,
|
||||
project_id text NOT NULL
|
||||
)
|
||||
`)
|
||||
yield* db.run(sql`
|
||||
INSERT INTO workspace (id, type, branch, directory, extra, project_id)
|
||||
VALUES ('wrk_legacy', 'remote', 'main', '/repo', '{}', 'proj_legacy')
|
||||
`)
|
||||
|
||||
yield* DatabaseMigration.applyOnly(db, [workspaceNameMigration])
|
||||
|
||||
expect(yield* db.get(sql`SELECT id, name, branch, directory, extra FROM workspace`)).toEqual({
|
||||
id: "wrk_legacy",
|
||||
name: "",
|
||||
branch: "main",
|
||||
directory: "/repo",
|
||||
extra: "{}",
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("imports unnamed legacy Drizzle journal entries by their actual migration timestamps", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`CREATE TABLE __drizzle_migrations (id integer PRIMARY KEY, hash text, created_at integer)`)
|
||||
yield* db.run(sql`
|
||||
INSERT INTO __drizzle_migrations (hash, created_at)
|
||||
VALUES ('', ${Date.UTC(2026, 3, 10, 17, 45, 13)})
|
||||
`)
|
||||
|
||||
yield* DatabaseMigration.applyOnly(db, [workspaceNameMigration])
|
||||
|
||||
expect(yield* db.all(sql`SELECT id FROM migration`)).toEqual([{ id: "20260410174513_workspace-name" }])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("rejects unknown legacy Drizzle journal timestamps instead of guessing completed migrations", async () => {
|
||||
await expect(
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`CREATE TABLE __drizzle_migrations (id integer PRIMARY KEY, hash text, created_at integer)`)
|
||||
yield* db.run(sql`INSERT INTO __drizzle_migrations (hash, created_at) VALUES ('', 1234567890000)`)
|
||||
yield* DatabaseMigration.applyOnly(db, [workspaceNameMigration])
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow("does not match any known migration")
|
||||
})
|
||||
|
||||
test("serializes concurrent embedded initialization for one database path", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filename = path.join(tmp.path, "embedded.sqlite")
|
||||
|
||||
@@ -12,7 +12,6 @@ export const emptyCredentialNode = makeGlobalNode({
|
||||
list: () => Effect.succeed([]),
|
||||
get: () => Effect.undefined,
|
||||
create: () => Effect.die("unused Credential.create"),
|
||||
activate: () => Effect.die("unused Credential.activate"),
|
||||
update: () => Effect.die("unused Credential.update"),
|
||||
remove: () => Effect.die("unused Credential.remove"),
|
||||
}),
|
||||
|
||||
@@ -38,7 +38,6 @@ const integrations = Layer.mock(Integration.Service, {
|
||||
active: () => Effect.undefined,
|
||||
resolve: () => Effect.die("unused"),
|
||||
key: () => Effect.die("unused"),
|
||||
activate: () => Effect.die("unused"),
|
||||
update: () => Effect.die("unused"),
|
||||
remove: () => Effect.die("unused"),
|
||||
},
|
||||
|
||||
@@ -19,7 +19,6 @@ const failingCredentialNode = makeGlobalNode({
|
||||
list: () => Effect.succeed([]),
|
||||
get: () => Effect.undefined,
|
||||
create: () => Effect.die(new Error("credential persistence failed")),
|
||||
activate: () => Effect.void,
|
||||
update: () => Effect.void,
|
||||
remove: () => Effect.void,
|
||||
}),
|
||||
@@ -148,9 +147,9 @@ describe("Integration", () => {
|
||||
},
|
||||
}),
|
||||
)
|
||||
const created = yield* bus
|
||||
.subscribe([Credential.Event.Updated, Credential.Event.Switched])
|
||||
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
|
||||
const updated = yield* bus
|
||||
.subscribe(Integration.Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
expect(
|
||||
@@ -167,42 +166,14 @@ describe("Integration", () => {
|
||||
label: "Work",
|
||||
})
|
||||
|
||||
const stored = yield* credentials.list(integrationID)
|
||||
expect(stored).toEqual([
|
||||
expect(yield* credentials.list(integrationID)).toEqual([
|
||||
expect.objectContaining({
|
||||
integrationID,
|
||||
label: "Work",
|
||||
value: Credential.Key.make({ type: "key", key: "secret", configuration: { accountId: "account" } }),
|
||||
}),
|
||||
])
|
||||
expect(Array.from(yield* Fiber.join(created), (event) => ({ type: event.type, data: event.data }))).toEqual([
|
||||
{ type: Credential.Event.Updated.type, data: {} },
|
||||
{ type: Credential.Event.Switched.type, data: { credentialID: stored[0]?.id, integrationID } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("names unlabeled credentials after the integration", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const integrationID = Integration.ID.make("openai")
|
||||
yield* integrations.transform((editor) => {
|
||||
editor.update(integrationID, (integration) => (integration.name = "OpenAI"))
|
||||
editor.method.update({ integrationID, method: { type: "key", label: "API key" } })
|
||||
})
|
||||
|
||||
yield* integrations.connection.key({ integrationID, key: "first" })
|
||||
yield* integrations.connection.key({ integrationID, key: "second" })
|
||||
yield* integrations.connection.key({ integrationID, key: "work", label: "Work" })
|
||||
yield* integrations.connection.key({ integrationID, key: "third" })
|
||||
|
||||
expect((yield* credentials.list(integrationID)).map((credential) => credential.label)).toEqual([
|
||||
"OpenAI",
|
||||
"OpenAI 2",
|
||||
"Work",
|
||||
"OpenAI 3",
|
||||
])
|
||||
expect((yield* Fiber.join(updated)).length).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -511,11 +482,6 @@ describe("Integration", () => {
|
||||
},
|
||||
}),
|
||||
)
|
||||
const archived = yield* credentials.create({
|
||||
integrationID,
|
||||
label: "Archived",
|
||||
value: Credential.Key.make({ type: "key", key: "c" }),
|
||||
})
|
||||
const work = yield* credentials.create({
|
||||
integrationID,
|
||||
label: "Work",
|
||||
@@ -534,16 +500,6 @@ describe("Integration", () => {
|
||||
id: personal.id,
|
||||
label: "Personal",
|
||||
},
|
||||
{
|
||||
type: "credential",
|
||||
id: work.id,
|
||||
label: "Work",
|
||||
},
|
||||
{
|
||||
type: "credential",
|
||||
id: archived.id,
|
||||
label: "Archived",
|
||||
},
|
||||
{ type: "env", name: "INTEGRATION_TEST_ACME_KEY" },
|
||||
])
|
||||
expect(yield* integrations.connection.active(integrationID)).toEqual({
|
||||
@@ -551,52 +507,7 @@ describe("Integration", () => {
|
||||
id: personal.id,
|
||||
label: "Personal",
|
||||
})
|
||||
|
||||
const bus = yield* Bus.Service
|
||||
const events = new Array<{ type: string; data: unknown }>()
|
||||
yield* bus.listen((event) => Effect.sync(() => events.push({ type: event.type, data: event.data })))
|
||||
yield* integrations.connection.activate(work.id)
|
||||
|
||||
expect(yield* integrations.connection.active(integrationID)).toEqual({
|
||||
type: "credential",
|
||||
id: work.id,
|
||||
label: "Work",
|
||||
})
|
||||
expect((yield* integrations.get(integrationID))?.connections.map((connection) => connection.type)).toEqual([
|
||||
"credential",
|
||||
"credential",
|
||||
"credential",
|
||||
"env",
|
||||
])
|
||||
expect(events).toEqual([
|
||||
{ type: Credential.Event.Switched.type, data: { credentialID: work.id, integrationID } },
|
||||
])
|
||||
|
||||
yield* integrations.connection.remove(archived.id)
|
||||
expect(events).toEqual([
|
||||
{ type: Credential.Event.Switched.type, data: { credentialID: work.id, integrationID } },
|
||||
{ type: Credential.Event.Updated.type, data: {} },
|
||||
])
|
||||
|
||||
yield* integrations.connection.remove(work.id)
|
||||
expect(yield* integrations.connection.active(integrationID)).toEqual({
|
||||
type: "credential",
|
||||
id: personal.id,
|
||||
label: "Personal",
|
||||
})
|
||||
yield* integrations.connection.remove(personal.id)
|
||||
expect(yield* integrations.connection.active(integrationID)).toEqual({
|
||||
type: "env",
|
||||
name: "INTEGRATION_TEST_ACME_KEY",
|
||||
})
|
||||
expect(events).toEqual([
|
||||
{ type: Credential.Event.Switched.type, data: { credentialID: work.id, integrationID } },
|
||||
{ type: Credential.Event.Updated.type, data: {} },
|
||||
{ type: Credential.Event.Updated.type, data: {} },
|
||||
{ type: Credential.Event.Switched.type, data: { credentialID: personal.id, integrationID } },
|
||||
{ type: Credential.Event.Updated.type, data: {} },
|
||||
{ type: Credential.Event.Switched.type, data: { credentialID: null, integrationID } },
|
||||
])
|
||||
expect(work.id).not.toBe(personal.id)
|
||||
}),
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { Layer } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
|
||||
export const permissionLayer = (overrides: Partial<Permission.Interface> = {}) =>
|
||||
Layer.mock(Permission.Service, overrides)
|
||||
Layer.mock(Permission.Service, {
|
||||
allowsAll: () => Effect.succeed(false),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -234,7 +234,6 @@ function resourceMcpLayer(
|
||||
active: unusedIntegration,
|
||||
resolve: unusedIntegration,
|
||||
key: unusedIntegration,
|
||||
activate: unusedIntegration,
|
||||
update: unusedIntegration,
|
||||
remove: unusedIntegration,
|
||||
},
|
||||
@@ -1025,7 +1024,9 @@ test("restores runtime MCP config when a transform is disposed", async () => {
|
||||
yield* transformed.dispose
|
||||
expect(observed).toBe("original")
|
||||
}).pipe(
|
||||
Effect.provide(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true }))),
|
||||
Effect.provide(
|
||||
resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true })),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -312,7 +312,6 @@ describe("ModelResolver", () => {
|
||||
active: () => Effect.undefined,
|
||||
resolve: () => Effect.die("unused"),
|
||||
key: () => Effect.die("unused"),
|
||||
activate: () => Effect.die("unused"),
|
||||
update: () => Effect.die("unused"),
|
||||
remove: () => Effect.die("unused"),
|
||||
},
|
||||
@@ -393,7 +392,6 @@ describe("ModelResolver", () => {
|
||||
reasoningField: "vendor_reasoning",
|
||||
maxTokensField: "max_completion_tokens",
|
||||
requireFinishReason: false,
|
||||
requireAssistantAfterTool: true,
|
||||
},
|
||||
settings: {
|
||||
apiKey: "settings-secret",
|
||||
@@ -419,7 +417,6 @@ describe("ModelResolver", () => {
|
||||
expect(resolved.compatibility?.reasoningField).toBe("vendor_reasoning")
|
||||
expect(resolved.compatibility?.maxTokensField).toBe("max_completion_tokens")
|
||||
expect(resolved.compatibility?.requireFinishReason).toBe(false)
|
||||
expect(resolved.compatibility?.requireAssistantAfterTool).toBe(true)
|
||||
expect(prepared.body).toMatchObject({ max_completion_tokens: 10 })
|
||||
expect(prepared.body).not.toHaveProperty("max_tokens")
|
||||
expect(resolved.route.endpoint.baseURL).toBe("https://compatible.example/v1")
|
||||
@@ -886,7 +883,12 @@ describe("ModelResolver", () => {
|
||||
{ reasoning: { effort: "high" } },
|
||||
{ reasoning: { effort: "high" } },
|
||||
],
|
||||
["@ai-sdk/xai", "@opencode-ai/ai/providers/xai", { reasoningEffort: "high" }, { reasoningEffort: "high" }],
|
||||
[
|
||||
"@ai-sdk/xai",
|
||||
"@opencode-ai/ai/providers/xai",
|
||||
{ reasoningEffort: "high" },
|
||||
{ reasoningEffort: "high" },
|
||||
],
|
||||
] as const
|
||||
|
||||
yield* Effect.forEach(packages, ([catalogPackage, nativePackage, sourceOptions, providerOptions]) =>
|
||||
@@ -935,7 +937,11 @@ describe("ModelResolver", () => {
|
||||
["@ai-sdk/azure", "@opencode-ai/ai/providers/azure/responses", "api-model"],
|
||||
["@ai-sdk/google", "@opencode-ai/ai/providers/google", "api-model"],
|
||||
["@ai-sdk/google-vertex", "@opencode-ai/ai/providers/google-vertex", "api-model"],
|
||||
["@ai-sdk/google-vertex/anthropic", "@opencode-ai/ai/providers/google-vertex/messages", "claude-sonnet-4-6"],
|
||||
[
|
||||
"@ai-sdk/google-vertex/anthropic",
|
||||
"@opencode-ai/ai/providers/google-vertex/messages",
|
||||
"claude-sonnet-4-6",
|
||||
],
|
||||
["@ai-sdk/openai", "@opencode-ai/ai/providers/openai", "api-model"],
|
||||
["@ai-sdk/openai-compatible", "@opencode-ai/ai/providers/openai-compatible", "api-model"],
|
||||
["@openrouter/ai-sdk-provider", "@opencode-ai/ai/providers/openrouter", "api-model"],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
|
||||
import { Cause, Deferred, Effect, Fiber, Layer } from "effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -10,7 +10,6 @@ import { Location } from "@opencode-ai/core/location"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { PermissionTable } from "@opencode-ai/core/permission/sql"
|
||||
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -27,15 +26,7 @@ const current = Layer.succeed(
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
Bus.node,
|
||||
SessionStore.node,
|
||||
PermissionSaved.node,
|
||||
Agent.node,
|
||||
PluginHooks.node,
|
||||
Permission.node,
|
||||
]),
|
||||
LayerNode.group([Database.node, Bus.node, SessionStore.node, PermissionSaved.node, Agent.node, Permission.node]),
|
||||
[[Location.node, current]],
|
||||
),
|
||||
)
|
||||
@@ -121,6 +112,31 @@ describe("Permission", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("proves only unconditional configured allows", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Permission.Service
|
||||
const input = { sessionID: Session.ID.make("ses_test"), action: "shell" }
|
||||
|
||||
yield* setup([{ action: "shell", resource: "*", effect: "allow" }])
|
||||
expect(yield* service.allowsAll(input)).toBe(true)
|
||||
|
||||
yield* setRules([
|
||||
{ action: "shell", resource: "*", effect: "allow" },
|
||||
{ action: "shell", resource: "rm *", effect: "deny" },
|
||||
])
|
||||
expect(yield* service.allowsAll(input)).toBe(false)
|
||||
|
||||
yield* setRules([{ action: "shell", resource: "git *", effect: "allow" }])
|
||||
expect(yield* service.allowsAll(input)).toBe(false)
|
||||
|
||||
yield* setRules([
|
||||
{ action: "shell", resource: "rm *", effect: "deny" },
|
||||
{ action: "shell", resource: "*", effect: "allow" },
|
||||
])
|
||||
expect(yield* service.allowsAll(input)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("evaluates against an explicit provider-turn agent", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
|
||||
@@ -156,74 +172,6 @@ describe("Permission", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lets plugins review allow and ask decisions without overriding configured denies", () =>
|
||||
Effect.gen(function* () {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const seen: string[] = []
|
||||
yield* hooks.register("permission", "evaluate", (event) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(event.effect)
|
||||
event.effect = event.action === "write" ? "deny" : "allow"
|
||||
event.message = "Reviewed by policy"
|
||||
}),
|
||||
)
|
||||
const service = yield* Permission.Service
|
||||
|
||||
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
|
||||
expect(yield* service.ask(assertion())).toMatchObject({ effect: "allow" })
|
||||
|
||||
yield* setRules([])
|
||||
expect(yield* service.ask(assertion({ id: Permission.ID.create("per_ask") }))).toMatchObject({ effect: "allow" })
|
||||
expect(yield* service.list()).toEqual([])
|
||||
|
||||
const blocked = yield* service
|
||||
.assert(assertion({ id: Permission.ID.create("per_write"), action: "write" }))
|
||||
.pipe(Effect.flip)
|
||||
expect(blocked).toBeInstanceOf(Permission.BlockedError)
|
||||
expect(blocked.message).toBe("Reviewed by policy")
|
||||
|
||||
yield* setRules([{ action: "read", resource: "*", effect: "deny" }])
|
||||
expect(yield* service.ask(assertion({ id: Permission.ID.create("per_deny") }))).toMatchObject({ effect: "deny" })
|
||||
expect(seen).toEqual(["allow", "ask", "ask"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("publishes the reviewer message when a plugin escalates to ask", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* hooks.register("permission", "evaluate", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.effect = "ask"
|
||||
event.message = "Confirm production access"
|
||||
}),
|
||||
)
|
||||
const service = yield* Permission.Service
|
||||
const result = yield* service.ask(assertion())
|
||||
|
||||
expect(result.effect).toBe("ask")
|
||||
expect(yield* service.get(result.id)).toMatchObject({ message: "Confirm production access" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("allows cancellation while a permission reviewer is running", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const started = yield* Deferred.make<void>()
|
||||
yield* hooks.register("permission", "evaluate", () =>
|
||||
Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
|
||||
)
|
||||
const service = yield* Permission.Service
|
||||
const fiber = yield* service.assert(assertion()).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Deferred.await(started)
|
||||
yield* Fiber.interrupt(fiber)
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("allows managed output reads without granting external directory access", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup([
|
||||
|
||||
@@ -10,7 +10,6 @@ import { Bus } from "@opencode-ai/core/bus"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { Generate } from "@opencode-ai/core/generate"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
@@ -19,7 +18,6 @@ import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { Reference } from "@opencode-ai/core/reference"
|
||||
import { Skill } from "@opencode-ai/core/skill"
|
||||
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
|
||||
@@ -40,20 +38,6 @@ const npmLayer = Layer.succeed(
|
||||
}),
|
||||
)
|
||||
|
||||
const generateLayer = Layer.succeed(Generate.Service, Generate.Service.of({ text: () => Effect.succeed("") }))
|
||||
|
||||
const permissionLayer = Layer.succeed(
|
||||
Permission.Service,
|
||||
Permission.Service.of({
|
||||
ask: (input) => Effect.succeed({ id: input.id ?? Permission.ID.create(), effect: "ask" }),
|
||||
assert: () => Effect.void,
|
||||
reply: () => Effect.void,
|
||||
get: () => Effect.succeed(undefined),
|
||||
forSession: () => Effect.succeed([]),
|
||||
list: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
|
||||
export const PluginTestLayer = LayerNode.compile(
|
||||
LayerNode.group([
|
||||
FileSystem.node,
|
||||
@@ -63,7 +47,6 @@ export const PluginTestLayer = LayerNode.compile(
|
||||
Credential.node,
|
||||
Bus.node,
|
||||
Form.node,
|
||||
Generate.node,
|
||||
LayerNodePlatform.httpClient,
|
||||
Plugin.node,
|
||||
Agent.node,
|
||||
@@ -74,7 +57,6 @@ export const PluginTestLayer = LayerNode.compile(
|
||||
KV.node,
|
||||
MCP.node,
|
||||
PluginRuntime.node,
|
||||
Permission.node,
|
||||
PluginHooks.node,
|
||||
Reference.node,
|
||||
Skill.node,
|
||||
@@ -90,7 +72,5 @@ export const PluginTestLayer = LayerNode.compile(
|
||||
[Npm.node, npmLayer],
|
||||
[Config.node, Config.testLayer()],
|
||||
[MCP.node, emptyMcpLayer],
|
||||
[Generate.node, generateLayer],
|
||||
[Permission.node, permissionLayer],
|
||||
],
|
||||
) as unknown as Layer.Layer<unknown, never>
|
||||
|
||||
@@ -48,9 +48,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
event: overrides.event ?? {
|
||||
subscribe: () => Stream.empty,
|
||||
},
|
||||
generate: overrides.generate ?? {
|
||||
text: () => Effect.die("unused generate.text"),
|
||||
},
|
||||
integration: overrides.integration ?? {
|
||||
list: () => Effect.die("unused integration.list"),
|
||||
get: () => Effect.die("unused integration.get"),
|
||||
@@ -84,12 +81,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
transform: () => Effect.die("unused mcp.transform"),
|
||||
reload: () => Effect.die("unused mcp.reload"),
|
||||
},
|
||||
permission: overrides.permission ?? {
|
||||
hook: () => Effect.die("unused permission.hook"),
|
||||
list: () => Effect.die("unused permission.list"),
|
||||
get: () => Effect.die("unused permission.get"),
|
||||
reply: () => Effect.die("unused permission.reply"),
|
||||
},
|
||||
plugin: overrides.plugin ?? {
|
||||
list: () => Effect.die("unused plugin.list"),
|
||||
},
|
||||
@@ -143,7 +134,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
synthetic: overrides.session?.synthetic ?? (() => Effect.die("unused session.synthetic")),
|
||||
interrupt: overrides.session?.interrupt ?? (() => Effect.die("unused session.interrupt")),
|
||||
wait: overrides.session?.wait ?? (() => Effect.die("unused session.wait")),
|
||||
context: overrides.session?.context ?? (() => Effect.die("unused session.context")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,7 +279,7 @@ describe("OpencodePlugin", () => {
|
||||
})
|
||||
draft.model.update(Provider.ID.make("remote"), Model.ID.make("stale"), () => {})
|
||||
})
|
||||
const initial = yield* credentials.create({
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: Credential.Key.make({
|
||||
type: "key",
|
||||
@@ -332,29 +332,6 @@ describe("OpencodePlugin", () => {
|
||||
required(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("disabled"))).enabled,
|
||||
).toBe(false)
|
||||
expect(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("stale"))).toBeDefined()
|
||||
|
||||
yield* credentials.update(initial.id, { label: "Renamed" })
|
||||
yield* Effect.yieldNow
|
||||
expect(authorization).toEqual(["Bearer secret"])
|
||||
|
||||
const replacement = yield* credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: Credential.Key.make({
|
||||
type: "key",
|
||||
key: "replacement",
|
||||
metadata: { server: server.url.origin },
|
||||
}),
|
||||
})
|
||||
yield* eventually(
|
||||
Effect.sync(() => authorization.length),
|
||||
(count) => count === 2,
|
||||
)
|
||||
expect(authorization).toEqual(["Bearer secret", "Bearer replacement"])
|
||||
|
||||
yield* credentials.remove(initial.id)
|
||||
yield* Effect.yieldNow
|
||||
expect(authorization).toEqual(["Bearer secret", "Bearer replacement"])
|
||||
expect((yield* credentials.list(Integration.ID.make("opencode"))).at(-1)?.id).toBe(replacement.id)
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
|
||||
@@ -2,20 +2,17 @@ import { describe, expect } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectSchema } from "@opencode-ai/core/project/schema"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Project.node, Database.node, Bus.node])))
|
||||
const it = testEffect(Layer.merge(AppNodeBuilder.build(Project.node), AppNodeBuilder.build(Database.node)))
|
||||
|
||||
describe("Project.list", () => {
|
||||
it.effect("returns complete projects ordered by recent update", () =>
|
||||
@@ -163,65 +160,6 @@ describe("Project.resolve", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("does not publish project updates for first or repeated resolutions", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
|
||||
const project = yield* Project.Service
|
||||
const bus = yield* Bus.Service
|
||||
const updates: Project.Info[] = []
|
||||
yield* bus.subscribe(ProjectSchema.Event.Updated).pipe(
|
||||
Stream.runForEach((event) => Effect.sync(() => updates.push(event.data))),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
|
||||
yield* project.resolve(abs(tmp.path))
|
||||
yield* project.resolve(abs(tmp.path))
|
||||
yield* Effect.yieldNow
|
||||
|
||||
expect(updates).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("publishes preserved project metadata when its canonical directory is renamed", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const before = path.join(tmp.path, "before")
|
||||
const after = path.join(tmp.path, "after")
|
||||
yield* Effect.promise(() => fs.mkdir(before))
|
||||
yield* Effect.promise(() => initRepo(before, { commit: true, remote: "git@github.com:owner/repo.git" }))
|
||||
const project = yield* Project.Service
|
||||
const bus = yield* Bus.Service
|
||||
const initial = yield* project.resolve(abs(before))
|
||||
yield* project.update({ projectID: initial.id, name: "Preserved name" })
|
||||
const updates: Project.Info[] = []
|
||||
yield* bus.subscribe(ProjectSchema.Event.Updated).pipe(
|
||||
Stream.runForEach((event) => Effect.sync(() => updates.push(event.data))),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
|
||||
yield* Effect.promise(() => fs.rename(before, after))
|
||||
const renamed = yield* project.resolve(abs(after))
|
||||
yield* Effect.yieldNow
|
||||
|
||||
expect(renamed.id).toBe(initial.id)
|
||||
expect(renamed.canonical).toBe(yield* real(after))
|
||||
expect(updates).toHaveLength(1)
|
||||
expect(updates).toEqual((yield* project.list()).filter((item) => item.id === initial.id))
|
||||
expect(updates[0]).toMatchObject({
|
||||
id: initial.id,
|
||||
canonical: yield* real(after),
|
||||
name: "Preserved name",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns git global for repo with no commits and no remote", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
} from "@opencode-ai/ai/route"
|
||||
import { SessionModelTransport } from "@opencode-ai/core/session/model-transport"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Cause, Deferred, Effect, Fiber, Metric, Queue, Stream } from "effect"
|
||||
import { Deferred, Effect, Fiber, Metric, Queue, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
|
||||
@@ -546,63 +546,6 @@ describe("SessionModelTransport", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("falls back to HTTP after close code 1009 and keeps the Session on HTTP", async () => {
|
||||
const messages = queue<string | Uint8Array, AIError>()
|
||||
let opened = 0
|
||||
let fallbacks = 0
|
||||
let closed = 0
|
||||
const connector: WebSocketConnector = {
|
||||
open: () =>
|
||||
Effect.sync(() => {
|
||||
opened++
|
||||
return {
|
||||
sendText: () =>
|
||||
Effect.sync(() => {
|
||||
Queue.failCauseUnsafe(
|
||||
messages,
|
||||
Cause.fail(
|
||||
new AIError({
|
||||
module: "test",
|
||||
method: "websocket",
|
||||
reason: new TransportReason({
|
||||
message: "message too big",
|
||||
transport: "websocket",
|
||||
operation: "read",
|
||||
code: "1009",
|
||||
phase: "close",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
messages: Stream.fromQueue(messages),
|
||||
close: Effect.sync(() => closed++).pipe(Effect.andThen(Queue.shutdown(messages)), Effect.asVoid),
|
||||
}
|
||||
}),
|
||||
}
|
||||
const item = (id: string) =>
|
||||
exchange(id, {
|
||||
fallback: () => {
|
||||
fallbacks++
|
||||
return Stream.make(`http:${id}`)
|
||||
},
|
||||
})
|
||||
|
||||
await run(
|
||||
connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const executor = transport.bind(session)
|
||||
|
||||
expect(yield* collect(executor, item("first"))).toEqual(["http:first"])
|
||||
expect(yield* collect(executor, item("second"))).toEqual(["http:second"])
|
||||
expect(opened).toBe(1)
|
||||
expect(fallbacks).toBe(2)
|
||||
expect(closed).toBe(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("does not fall back after an ambiguous send failure", async () => {
|
||||
const messages = queue<string | Uint8Array, AIError>()
|
||||
let fallbacks = 0
|
||||
|
||||
@@ -938,21 +938,6 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not automatically replace an existing session title", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* prepareTitleGeneration
|
||||
yield* session.rename({ sessionID, title: "Manual title" })
|
||||
yield* admit(session, "Follow-up prompt")
|
||||
yield* TestLLM.push(TestLLM.text("Assistant response", "text-response"))
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect((yield* session.get(sessionID)).title).toBe("Manual title")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("coalesces title generation while a request is active", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
|
||||
@@ -212,7 +212,7 @@ it.effect("generates a title from the sole user message and renames the session"
|
||||
|
||||
const store = yield* SessionStore.Service
|
||||
const title = yield* SessionTitle.Service
|
||||
yield* title.generate(sessionID)
|
||||
yield* title.generateForFirstPrompt(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.http?.headers).toEqual({
|
||||
@@ -252,7 +252,7 @@ it.effect("uses a small model from the primary provider", () =>
|
||||
yield* prompt(sessionID, "Use a small model for this title")
|
||||
|
||||
const title = yield* SessionTitle.Service
|
||||
yield* title.generate(sessionID)
|
||||
yield* title.generateForFirstPrompt(sessionID)
|
||||
|
||||
expect(requests.map((request) => String(request.model.id))).toEqual(["title-small"])
|
||||
expect(selections[1]?.variant).toBe(Model.VariantID.make("none"))
|
||||
@@ -299,7 +299,7 @@ it.effect("falls back to the primary model when the small model fails", () =>
|
||||
)
|
||||
|
||||
const title = yield* SessionTitle.Service
|
||||
yield* title.generate(sessionID)
|
||||
yield* title.generateForFirstPrompt(sessionID)
|
||||
|
||||
expect(requests.map((request) => String(request.model.id))).toEqual(["title-small", "title-model"])
|
||||
expect(attempted.map((model) => String(model.variant))).toEqual(["low", "high"])
|
||||
@@ -327,7 +327,7 @@ it.effect("generates from the first user message after later messages exist", ()
|
||||
|
||||
const store = yield* SessionStore.Service
|
||||
const title = yield* SessionTitle.Service
|
||||
yield* title.generate(sessionID)
|
||||
yield* title.generateForFirstPrompt(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(JSON.stringify(requests[0]?.messages)).toContain("First message")
|
||||
@@ -354,7 +354,7 @@ it.effect("retries a legacy persisted fallback title", () =>
|
||||
yield* prompt(sessionID, "Retry the legacy title")
|
||||
|
||||
const title = yield* SessionTitle.Service
|
||||
yield* title.generate(sessionID)
|
||||
yield* title.generateForFirstPrompt(sessionID)
|
||||
|
||||
const store = yield* SessionStore.Service
|
||||
expect(requests).toHaveLength(1)
|
||||
@@ -362,7 +362,7 @@ it.effect("retries a legacy persisted fallback title", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("generates a title for an explicitly requested child session", () =>
|
||||
it.effect("does not generate for a child session", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
titleStream = successfulTitle
|
||||
@@ -398,10 +398,9 @@ it.effect("generates a title for an explicitly requested child session", () =>
|
||||
yield* prompt(sessionID, "Do this subtask")
|
||||
|
||||
const title = yield* SessionTitle.Service
|
||||
yield* title.generate(sessionID)
|
||||
const store = yield* SessionStore.Service
|
||||
expect(requests).toHaveLength(1)
|
||||
expect((yield* store.get(sessionID))?.title).toBe("Generated Title")
|
||||
yield* title.generateForFirstPrompt(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(0)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -415,7 +414,7 @@ it.effect("does not generate when the title agent is removed", () =>
|
||||
|
||||
const store = yield* SessionStore.Service
|
||||
const title = yield* SessionTitle.Service
|
||||
yield* title.generate(sessionID)
|
||||
yield* title.generateForFirstPrompt(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(0)
|
||||
const untouched = yield* store.get(sessionID)
|
||||
@@ -423,107 +422,22 @@ it.effect("does not generate when the title agent is removed", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("regenerates an existing title using the title agent", () =>
|
||||
it.effect("does not overwrite an explicit title", () =>
|
||||
Effect.gen(function* () {
|
||||
const agentService = yield* Agent.Service
|
||||
yield* agentService.transform((editor) => {
|
||||
editor.update(Agent.ID.make("title"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.hidden = true
|
||||
agent.system = "You are a title generator."
|
||||
})
|
||||
})
|
||||
const sessionID = Session.ID.make("ses_title_regenerate")
|
||||
yield* insertSession(sessionID, "Original title")
|
||||
yield* prompt(sessionID, "Investigate the login failure")
|
||||
requests = []
|
||||
titleStream = successfulTitle
|
||||
const sessionID = Session.ID.make("ses_title_explicit")
|
||||
yield* insertSession(sessionID)
|
||||
yield* prompt(sessionID, "Help me debug the failing build")
|
||||
const events = yield* Bus.Service
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ id: Model.ID.make("title-model"), providerID: Provider.ID.make("test") }),
|
||||
})
|
||||
yield* events.publish(SessionEvent.Reasoning.Started, { sessionID, assistantMessageID, ordinal: 0 })
|
||||
yield* events.publish(SessionEvent.Reasoning.Ended, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
ordinal: 0,
|
||||
text: "Private reasoning that should not appear",
|
||||
})
|
||||
yield* events.publish(SessionEvent.Text.Started, { sessionID, assistantMessageID, ordinal: 1 })
|
||||
yield* events.publish(SessionEvent.Text.Ended, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
ordinal: 1,
|
||||
text: "The actual issue is expired OAuth credentials.",
|
||||
})
|
||||
yield* prompt(sessionID, "Switch to fixing OAuth token refresh")
|
||||
yield* events.publish(SessionEvent.Renamed, { sessionID, title: "New session - 2099-01-01T00:00:00.000Z" })
|
||||
|
||||
const title = yield* SessionTitle.Service
|
||||
yield* title.generate(sessionID)
|
||||
yield* title.generateForFirstPrompt(sessionID)
|
||||
|
||||
const store = yield* SessionStore.Service
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.system.map((part) => part.text)).toEqual(["You are a title generator."])
|
||||
expect(JSON.stringify(requests[0]?.messages)).toContain("Investigate the login failure")
|
||||
expect(JSON.stringify(requests[0]?.messages)).toContain("The actual issue is expired OAuth credentials.")
|
||||
expect(JSON.stringify(requests[0]?.messages)).toContain("Switch to fixing OAuth token refresh")
|
||||
expect(JSON.stringify(requests[0]?.messages)).not.toContain("Private reasoning that should not appear")
|
||||
expect((yield* store.get(sessionID))?.title).toBe("Generated Title")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("bounds regeneration context while preserving the original request and recent conversation", () =>
|
||||
Effect.gen(function* () {
|
||||
const agentService = yield* Agent.Service
|
||||
yield* agentService.transform((editor) => {
|
||||
editor.update(Agent.ID.make("title"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.hidden = true
|
||||
agent.system = "You are a title generator."
|
||||
})
|
||||
})
|
||||
const sessionID = Session.ID.make("ses_title_regenerate_bounded")
|
||||
yield* insertSession(sessionID, "Original title")
|
||||
yield* prompt(sessionID, `ORIGINAL_GOAL ${"a".repeat(3_000)} OMITTED_ORIGINAL_END`)
|
||||
yield* prompt(sessionID, `OMITTED_OLD_CONTEXT ${"b".repeat(9_000)} RECENT_GOAL`)
|
||||
|
||||
const title = yield* SessionTitle.Service
|
||||
yield* title.generate(sessionID)
|
||||
|
||||
const content = requests[0]?.messages[0]?.content[0]
|
||||
expect(content?.type).toBe("text")
|
||||
if (content?.type !== "text") return
|
||||
expect(content.text.length).toBeLessThanOrEqual(8_000)
|
||||
expect(content.text).toContain("ORIGINAL_GOAL")
|
||||
expect(content.text).toContain("RECENT_GOAL")
|
||||
expect(content.text).not.toContain("OMITTED_ORIGINAL_END")
|
||||
expect(content.text).not.toContain("OMITTED_OLD_CONTEXT")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves the existing title when regeneration fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const agentService = yield* Agent.Service
|
||||
yield* agentService.transform((editor) => {
|
||||
editor.update(Agent.ID.make("title"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.hidden = true
|
||||
agent.system = "You are a title generator."
|
||||
})
|
||||
})
|
||||
const sessionID = Session.ID.make("ses_title_regenerate_failure")
|
||||
yield* insertSession(sessionID, "Original title")
|
||||
yield* prompt(sessionID, "Fail to regenerate this title")
|
||||
titleStream = () => Stream.make(LLMEvent.providerError({ message: "Provider unavailable" }))
|
||||
|
||||
const title = yield* SessionTitle.Service
|
||||
yield* title.generate(sessionID)
|
||||
|
||||
const store = yield* SessionStore.Service
|
||||
expect(requests).toHaveLength(1)
|
||||
expect((yield* store.get(sessionID))?.title).toBe("Original title")
|
||||
expect(requests).toHaveLength(0)
|
||||
expect((yield* store.get(sessionID))?.title).toBe("New session - 2099-01-01T00:00:00.000Z")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -544,9 +458,9 @@ it.effect("retries after a failed title request", () =>
|
||||
const title = yield* SessionTitle.Service
|
||||
titleStream = () => Stream.make(LLMEvent.providerError({ message: "Provider unavailable" }))
|
||||
|
||||
yield* title.generate(sessionID)
|
||||
yield* title.generateForFirstPrompt(sessionID)
|
||||
titleStream = successfulTitle
|
||||
yield* title.generate(sessionID)
|
||||
yield* title.generateForFirstPrompt(sessionID)
|
||||
|
||||
const store = yield* SessionStore.Service
|
||||
expect(requests).toHaveLength(2)
|
||||
@@ -577,7 +491,7 @@ it.effect("does not rename after a failed title stream", () =>
|
||||
)
|
||||
|
||||
const title = yield* SessionTitle.Service
|
||||
yield* title.generate(sessionID)
|
||||
yield* title.generateForFirstPrompt(sessionID)
|
||||
|
||||
const store = yield* SessionStore.Service
|
||||
expect(requests).toHaveLength(1)
|
||||
@@ -610,7 +524,7 @@ it.effect("keeps session context hooks away from title requests", () =>
|
||||
yield* prompt(sessionID, "Hook this title request")
|
||||
|
||||
const title = yield* SessionTitle.Service
|
||||
yield* title.generate(sessionID)
|
||||
yield* title.generateForFirstPrompt(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.system.map((part) => part.text)).toEqual(["You are a title generator."])
|
||||
@@ -641,7 +555,7 @@ it.effect("preserves a manual rename completed while generation is in flight", (
|
||||
),
|
||||
)
|
||||
const title = yield* SessionTitle.Service
|
||||
const fiber = yield* title.generate(sessionID).pipe(Effect.forkScoped)
|
||||
const fiber = yield* title.generateForFirstPrompt(sessionID).pipe(Effect.forkScoped)
|
||||
yield* Deferred.await(started)
|
||||
const events = yield* Bus.Service
|
||||
yield* events.publish(SessionEvent.Renamed, { sessionID, title: "Manual title" })
|
||||
|
||||
@@ -46,10 +46,12 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
|
||||
const sessionID = Session.ID.make("ses_shell_tool_test")
|
||||
const sessionModel = Model.Ref.make({ id: Model.ID.make("test"), providerID: Provider.ID.make("test") })
|
||||
const assertions: Permission.AssertInput[] = []
|
||||
const allowedActions = new Set<string>()
|
||||
let denyAction: string | undefined
|
||||
let afterPermission = (_input: Permission.AssertInput): Effect.Effect<void> => Effect.void
|
||||
|
||||
const permission = permissionLayer({
|
||||
allowsAll: (input) => Effect.succeed(allowedActions.has(input.action)),
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(Effect.suspend(() => afterPermission(input))),
|
||||
@@ -69,6 +71,7 @@ const permission = permissionLayer({
|
||||
|
||||
const reset = () => {
|
||||
assertions.length = 0
|
||||
allowedActions.clear()
|
||||
denyAction = undefined
|
||||
afterPermission = () => Effect.void
|
||||
}
|
||||
@@ -362,6 +365,30 @@ describe("ShellTool", () => {
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
it.live(
|
||||
"skips command decomposition when shell and external directories are unrestricted",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
allowedActions.add("shell")
|
||||
allowedActions.add("external_directory")
|
||||
return withSession(tmp.path, (registry) =>
|
||||
executeTool(registry, call({ command: "printf one && printf two" }, "call-unrestricted")),
|
||||
).pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
expect(assertions).toEqual([])
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
it.live(
|
||||
"captures stderr-only and mixed stdout/stderr output",
|
||||
() =>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { PermissionApi } from "@opencode-ai/client/effect/api"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Permission } from "@opencode-ai/schema/permission"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { Hooks } from "./registration.js"
|
||||
|
||||
export interface PermissionEvaluation {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent?: Agent.ID
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly metadata?: Record<string, unknown>
|
||||
readonly source?: Permission.Source
|
||||
effect: Permission.Effect
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface PermissionHooks {
|
||||
readonly evaluate: PermissionEvaluation
|
||||
}
|
||||
|
||||
export type PermissionDomain = Pick<PermissionApi<unknown>, "list" | "get" | "reply"> & {
|
||||
readonly hook: Hooks<PermissionHooks>
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GenerateApi, PluginApi } from "@opencode-ai/client/effect/api"
|
||||
import type { PluginApi } from "@opencode-ai/client/effect/api"
|
||||
import type { Effect, Scope } from "effect"
|
||||
import type { PluginOptions } from "../options.js"
|
||||
import type { App } from "../app.js"
|
||||
@@ -9,7 +9,6 @@ import type { CommandDomain } from "./command.js"
|
||||
import type { EventDomain } from "./event.js"
|
||||
import type { IntegrationDomain } from "./integration.js"
|
||||
import type { MCPDomain } from "./mcp.js"
|
||||
import type { PermissionDomain } from "./permission.js"
|
||||
import type { ReferenceDomain } from "./reference.js"
|
||||
import type { SessionDomain } from "./session.js"
|
||||
import type { ShellDomain } from "./shell.js"
|
||||
@@ -29,8 +28,6 @@ export interface Context {
|
||||
readonly event: EventDomain
|
||||
readonly integration: IntegrationDomain
|
||||
readonly mcp: MCPDomain
|
||||
readonly generate: GenerateApi<unknown>
|
||||
readonly permission: PermissionDomain
|
||||
readonly plugin: PluginApi<unknown>
|
||||
readonly reference: ReferenceDomain
|
||||
readonly session: SessionDomain
|
||||
|
||||
@@ -58,7 +58,6 @@ export type SessionDomain = Pick<
|
||||
| "interrupt"
|
||||
| "rename"
|
||||
| "wait"
|
||||
| "context"
|
||||
> & {
|
||||
readonly hook: ModelHooks<SessionHooks>
|
||||
}
|
||||
|
||||
@@ -76,12 +76,10 @@ export function fromPromise(plugin: Plugin) {
|
||||
)
|
||||
const AgentEndpoints = ClientApi.groups["server.agent"].endpoints
|
||||
const CommandEndpoints = ClientApi.groups["server.command"].endpoints
|
||||
const GenerateEndpoints = ClientApi.groups["server.generate"].endpoints
|
||||
const IntegrationEndpoints = ClientApi.groups["server.integration"].endpoints
|
||||
const McpEndpoints = ClientApi.groups["server.mcp"].endpoints
|
||||
const ModelEndpoints = ClientApi.groups["server.model"].endpoints
|
||||
const PluginEndpoints = ClientApi.groups["server.plugin"].endpoints
|
||||
const PermissionEndpoints = ClientApi.groups["server.permission"].endpoints
|
||||
const ProviderEndpoints = ClientApi.groups["server.provider"].endpoints
|
||||
const ReferenceEndpoints = ClientApi.groups["server.reference"].endpoints
|
||||
const SessionEndpoints = ClientApi.groups["server.session"].endpoints
|
||||
@@ -176,9 +174,6 @@ export function fromPromise(plugin: Plugin) {
|
||||
),
|
||||
),
|
||||
},
|
||||
generate: {
|
||||
text: adaptApiMethod(GenerateEndpoints["generate.text"], host.generate.text),
|
||||
},
|
||||
integration: {
|
||||
list: adaptApiMethod(IntegrationEndpoints["integration.list"], host.integration.list),
|
||||
get: adaptApiMethod(IntegrationEndpoints["integration.get"], host.integration.get),
|
||||
@@ -266,13 +261,6 @@ export function fromPromise(plugin: Plugin) {
|
||||
transform: transform(host.mcp),
|
||||
reload: () => run(host.mcp.reload()),
|
||||
},
|
||||
permission: {
|
||||
hook: (name, callback) =>
|
||||
register(host.permission.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
list: adaptApiMethod(PermissionEndpoints["session.permission.list"], host.permission.list),
|
||||
get: adaptApiMethod(PermissionEndpoints["session.permission.get"], host.permission.get),
|
||||
reply: adaptApiMethod(PermissionEndpoints["session.permission.reply"], host.permission.reply),
|
||||
},
|
||||
plugin: {
|
||||
list: adaptApiMethod(PluginEndpoints["plugin.list"], host.plugin.list),
|
||||
},
|
||||
@@ -367,7 +355,6 @@ export function fromPromise(plugin: Plugin) {
|
||||
interrupt: adaptApiMethod(SessionEndpoints["session.interrupt"], host.session.interrupt),
|
||||
rename: adaptApiMethod(SessionEndpoints["session.rename"], host.session.rename),
|
||||
wait: adaptApiMethod(SessionEndpoints["session.wait"], host.session.wait),
|
||||
context: adaptApiMethod(SessionEndpoints["session.context"], host.session.context),
|
||||
},
|
||||
shell: {
|
||||
hook: (name, callback) =>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { PermissionApi } from "@opencode-ai/client/promise/api"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Permission } from "@opencode-ai/schema/permission"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { Hooks } from "./registration.js"
|
||||
|
||||
export interface PermissionEvaluation {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent?: Agent.ID
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly metadata?: Record<string, unknown>
|
||||
readonly source?: Permission.Source
|
||||
effect: Permission.Effect
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface PermissionHooks {
|
||||
readonly evaluate: PermissionEvaluation
|
||||
}
|
||||
|
||||
export type PermissionDomain = Pick<PermissionApi, "list" | "get" | "reply"> & {
|
||||
readonly hook: Hooks<PermissionHooks>
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GenerateApi, PluginApi } from "@opencode-ai/client/promise/api"
|
||||
import type { PluginApi } from "@opencode-ai/client/promise/api"
|
||||
import type { PluginOptions } from "../options.js"
|
||||
import type { App } from "../app.js"
|
||||
import type { AgentDomain } from "./agent.js"
|
||||
@@ -8,7 +8,6 @@ import type { CommandDomain } from "./command.js"
|
||||
import type { EventDomain } from "./event.js"
|
||||
import type { IntegrationDomain } from "./integration.js"
|
||||
import type { MCPDomain } from "./mcp.js"
|
||||
import type { PermissionDomain } from "./permission.js"
|
||||
import type { ReferenceDomain } from "./reference.js"
|
||||
import type { SessionDomain } from "./session.js"
|
||||
import type { ShellDomain } from "./shell.js"
|
||||
@@ -28,8 +27,6 @@ export interface Context {
|
||||
readonly event: EventDomain
|
||||
readonly integration: IntegrationDomain
|
||||
readonly mcp: MCPDomain
|
||||
readonly generate: GenerateApi
|
||||
readonly permission: PermissionDomain
|
||||
readonly plugin: PluginApi
|
||||
readonly reference: ReferenceDomain
|
||||
readonly session: SessionDomain
|
||||
|
||||
@@ -58,7 +58,6 @@ export type SessionDomain = Pick<
|
||||
| "interrupt"
|
||||
| "rename"
|
||||
| "wait"
|
||||
| "context"
|
||||
> & {
|
||||
readonly hook: ModelHooks<SessionHooks>
|
||||
}
|
||||
|
||||
@@ -6749,90 +6749,6 @@
|
||||
"summary": "Remove credential"
|
||||
}
|
||||
},
|
||||
"/api/credential/{credentialID}/activate": {
|
||||
"post": {
|
||||
"tags": ["credential"],
|
||||
"operationId": "v2.credential.activate",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "credentialID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false,
|
||||
"style": "deepObject",
|
||||
"explode": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "<No Content>"
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Activate a stored integration credential.",
|
||||
"summary": "Activate credential"
|
||||
}
|
||||
},
|
||||
"/api/project": {
|
||||
"get": {
|
||||
"tags": ["project"],
|
||||
@@ -6878,89 +6794,6 @@
|
||||
"summary": "List projects"
|
||||
}
|
||||
},
|
||||
"/api/project/{projectID}": {
|
||||
"patch": {
|
||||
"tags": ["project"],
|
||||
"operationId": "v2.project.update",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "projectID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Project",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Project"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "ProjectNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProjectNotFoundErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Update project display metadata and workspace commands.",
|
||||
"summary": "Update project",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"icon": {
|
||||
"$ref": "#/components/schemas/Project.Icon"
|
||||
},
|
||||
"commands": {
|
||||
"$ref": "#/components/schemas/Project.Commands"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/project/current": {
|
||||
"get": {
|
||||
"tags": ["project"],
|
||||
@@ -11360,7 +11193,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Create a worktree for a project and run its configured setup script.",
|
||||
"description": "Create a worktree for a project.",
|
||||
"summary": "Create worktree",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -15361,9 +15194,6 @@
|
||||
},
|
||||
"requireFinishReason": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"requireAssistantAfterTool": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -15606,9 +15436,6 @@
|
||||
},
|
||||
"source": {
|
||||
"$ref": "#/components/schemas/Permission.Source"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "sessionID", "action", "resources"],
|
||||
@@ -16087,23 +15914,6 @@
|
||||
"type": "string",
|
||||
"enum": ["git", "hg"]
|
||||
},
|
||||
"ProjectNotFoundErrorEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"_tag": {
|
||||
"type": "string",
|
||||
"enum": ["ProjectNotFoundError"]
|
||||
},
|
||||
"projectID": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["_tag", "projectID", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Prompt.AgentAttachment": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -21,21 +21,6 @@ export const CredentialGroup = HttpApiGroup.make("server.credential")
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "credential" }))
|
||||
.add(
|
||||
HttpApiEndpoint.post("credential.activate", "/api/credential/:credentialID/activate", {
|
||||
params: { credentialID: Credential.ID },
|
||||
query: LocationQuery,
|
||||
success: HttpApiSchema.NoContent,
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.credential.activate",
|
||||
summary: "Activate credential",
|
||||
description: "Activate a stored integration credential.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.delete("credential.remove", "/api/credential/:credentialID", {
|
||||
params: { credentialID: Credential.ID },
|
||||
|
||||
@@ -2,8 +2,7 @@ export * as Credential from "./credential.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { optional } from "./schema.js"
|
||||
import { ephemeral, inventory } from "./event.js"
|
||||
import { IntegrationID, IntegrationMethodID } from "./integration-id.js"
|
||||
import { IntegrationMethodID } from "./integration-id.js"
|
||||
import { ascending } from "./identifier.js"
|
||||
import { NonNegativeInt, statics } from "./schema.js"
|
||||
import { Form } from "./form.js"
|
||||
@@ -14,20 +13,6 @@ export const ID = Schema.String.pipe(
|
||||
)
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
const Updated = ephemeral({
|
||||
type: "credential.updated",
|
||||
schema: {},
|
||||
})
|
||||
const Switched = ephemeral({
|
||||
type: "credential.switched",
|
||||
schema: { integrationID: IntegrationID, credentialID: Schema.NullOr(ID) },
|
||||
})
|
||||
export const Event = {
|
||||
Updated,
|
||||
Switched,
|
||||
Definitions: inventory(Updated, Switched),
|
||||
}
|
||||
|
||||
export interface OAuth extends Schema.Schema.Type<typeof OAuth> {}
|
||||
export const OAuth = Schema.Struct({
|
||||
type: Schema.Literal("oauth"),
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Agent } from "./agent.js"
|
||||
import { Catalog } from "./catalog.js"
|
||||
import { Command } from "./command.js"
|
||||
import { Config } from "./config.js"
|
||||
import { Credential } from "./credential.js"
|
||||
import { Durable } from "./durable-event-manifest.js"
|
||||
import { Event } from "./event.js"
|
||||
import { FileSystem } from "./filesystem.js"
|
||||
@@ -40,7 +39,6 @@ const coreDefinitions = Event.inventory(...SessionEvent.Definitions)
|
||||
|
||||
const foundationDefinitions = Event.inventory(
|
||||
...ModelsDev.Event.Definitions,
|
||||
...Credential.Event.Definitions,
|
||||
...Integration.Event.Definitions,
|
||||
...Catalog.Event.Definitions,
|
||||
...Agent.Event.Definitions,
|
||||
@@ -52,7 +50,6 @@ const featureDefinitions = Event.inventory(
|
||||
...Reference.Event.Definitions,
|
||||
...Permission.Event.Definitions,
|
||||
...Plugin.Event.Definitions,
|
||||
...Project.Event.Definitions,
|
||||
...Worktree.Event.Definitions,
|
||||
...Command.Event.Definitions,
|
||||
...Config.Event.Definitions,
|
||||
@@ -88,6 +85,7 @@ export const Definitions = Event.inventory(
|
||||
...McpEvent.Definitions,
|
||||
...LegacyEventV1.Definitions,
|
||||
...FileSystemV1.Event.Definitions,
|
||||
...Project.Event.Definitions,
|
||||
...SessionStatusEvent.Definitions,
|
||||
...SessionCompactionEvent.Definitions,
|
||||
...VcsEvent.Definitions,
|
||||
|
||||
@@ -53,7 +53,11 @@ const Updated = ephemeral({
|
||||
type: "integration.updated",
|
||||
schema: {},
|
||||
})
|
||||
export const Event = { Updated, Definitions: inventory(Updated) }
|
||||
const ConnectionUpdated = ephemeral({
|
||||
type: "integration.connection.updated",
|
||||
schema: { integrationID: ID },
|
||||
})
|
||||
export const Event = { Updated, ConnectionUpdated, Definitions: inventory(Updated, ConnectionUpdated) }
|
||||
|
||||
export interface Ref extends Schema.Schema.Type<typeof Ref> {}
|
||||
export const Ref = Schema.Struct({
|
||||
|
||||
@@ -57,7 +57,6 @@ export const Compatibility = Schema.Struct({
|
||||
reasoningField: ReasoningField.pipe(optional),
|
||||
maxTokensField: MaxTokensField.pipe(optional),
|
||||
requireFinishReason: Schema.Boolean.pipe(optional),
|
||||
requireAssistantAfterTool: Schema.Boolean.pipe(optional),
|
||||
}).annotate({ identifier: "Model.Compatibility" })
|
||||
|
||||
export interface Capabilities extends Schema.Schema.Type<typeof Capabilities> {}
|
||||
|
||||
@@ -29,7 +29,6 @@ const RequestFields = {
|
||||
save: Schema.Array(Schema.String).pipe(optional),
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
|
||||
source: Source.pipe(optional),
|
||||
message: Schema.String.pipe(optional),
|
||||
}
|
||||
|
||||
export const Request = Schema.Struct({
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
Agent,
|
||||
Config,
|
||||
Credential,
|
||||
FileSystem,
|
||||
Form,
|
||||
Integration,
|
||||
@@ -42,7 +41,6 @@ describe("public event manifest", () => {
|
||||
expect(EventManifest.Server.get("mcp.resources.changed")).toBe(McpEvent.ResourcesChanged)
|
||||
expect(EventManifest.Server.get("session.created")).toBe(SessionEvent.Created)
|
||||
expect(EventManifest.Server.get("session.deleted")).toBe(SessionEvent.Deleted)
|
||||
expect(EventManifest.Server.get("project.updated")).toBe(Project.Event.Updated)
|
||||
expect(EventManifest.Server.has("mcp.tools.changed")).toBe(false)
|
||||
expect(EventManifest.Server.has("question.asked")).toBe(false)
|
||||
expect(EventManifest.Server.has("question.replied")).toBe(false)
|
||||
@@ -60,12 +58,11 @@ describe("public event manifest", () => {
|
||||
expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated)
|
||||
expect(EventManifest.Latest.get("project.updated")).toBe(Project.Event.Updated)
|
||||
expect(Agent.Event.Definitions).toEqual([Agent.Event.Updated])
|
||||
expect(Credential.Event.Definitions).toEqual([Credential.Event.Updated, Credential.Event.Switched])
|
||||
expect(Project.Event.Definitions).toEqual([Project.Event.Updated])
|
||||
expect(Config.Event.Definitions).toEqual([Config.Event.Updated])
|
||||
expect(FileSystem.Event.Definitions).toEqual([FileSystem.Event.Changed])
|
||||
expect(FileSystemV1.Event.Definitions).toEqual([FileSystemV1.Event.Edited])
|
||||
expect(Integration.Event.Definitions).toEqual([Integration.Event.Updated])
|
||||
expect(Integration.Event.Definitions).toEqual([Integration.Event.Updated, Integration.Event.ConnectionUpdated])
|
||||
expect(Permission.Event.Definitions).toEqual([Permission.Event.Asked, Permission.Event.Replied])
|
||||
expect(PersistentPty.Event.Definitions).toEqual([PersistentPty.Event.Added, PersistentPty.Event.Removed])
|
||||
expect(Form.Event.Definitions).toEqual([Form.Event.Created, Form.Event.Replied, Form.Event.Cancelled])
|
||||
@@ -79,35 +76,6 @@ describe("public event manifest", () => {
|
||||
expect(EventManifest.Durable.has("session.step.ended.2")).toBe(false)
|
||||
})
|
||||
|
||||
test("keeps credential events public, canonical, and ephemeral", () => {
|
||||
const credentialID = Credential.ID.make("cred_test")
|
||||
const integrationID = Integration.ID.make("integration_test")
|
||||
|
||||
for (const definition of Credential.Event.Definitions) {
|
||||
expect(EventManifest.ServerDefinitions).toContain(definition)
|
||||
expect(EventManifest.Definitions).toContain(definition)
|
||||
expect(EventManifest.Server.get(definition.type)).toBe(definition)
|
||||
expect(EventManifest.Latest.get(definition.type)).toBe(definition)
|
||||
expect(definition.durability).toBe("ephemeral")
|
||||
expect(EventManifest.Durable.has(definition.type)).toBe(false)
|
||||
}
|
||||
|
||||
expect(Credential.Event.Updated.data.make({})).toEqual({})
|
||||
expect(Credential.Event.Switched.data.make({ integrationID, credentialID })).toEqual({
|
||||
integrationID,
|
||||
credentialID,
|
||||
})
|
||||
expect(Credential.Event.Switched.data.make({ integrationID, credentialID: null })).toEqual({
|
||||
integrationID,
|
||||
credentialID: null,
|
||||
})
|
||||
expect(EventManifest.Server.has("credential.created")).toBe(false)
|
||||
expect(EventManifest.Server.has("credential.activated")).toBe(false)
|
||||
expect(EventManifest.Server.has("credential.deleted")).toBe(false)
|
||||
expect(EventManifest.Server.has("integration.connection.updated")).toBe(false)
|
||||
expect(EventManifest.Latest.has("integration.connection.updated")).toBe(false)
|
||||
})
|
||||
|
||||
test("derives durable definitions from explicit definition durability", () => {
|
||||
expect(Array.from(EventManifest.Durable.keys()).toSorted()).toEqual(
|
||||
[
|
||||
|
||||
@@ -42,13 +42,11 @@ describe("Model.Compatibility", () => {
|
||||
reasoningField: "vendor_reasoning",
|
||||
maxTokensField: "max_completion_tokens",
|
||||
requireFinishReason: false,
|
||||
requireAssistantAfterTool: true,
|
||||
}),
|
||||
).toEqual({
|
||||
reasoningField: "vendor_reasoning",
|
||||
maxTokensField: "max_completion_tokens",
|
||||
requireFinishReason: false,
|
||||
requireAssistantAfterTool: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,14 +13,6 @@ export const CredentialHandler = HttpApiBuilder.group(Api, "server.credential",
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"credential.activate",
|
||||
Effect.fn(function* (ctx) {
|
||||
const integration = yield* Integration.Service
|
||||
yield* integration.connection.activate(ctx.params.credentialID)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"credential.remove",
|
||||
Effect.fn(function* (ctx) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionStats } from "@opencode-ai/core/session/stats"
|
||||
import { SessionTitle } from "@opencode-ai/core/session/title"
|
||||
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
|
||||
import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry"
|
||||
import { DateTime, Effect, Stream } from "effect"
|
||||
@@ -248,14 +247,9 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
.handle(
|
||||
"session.rename",
|
||||
Effect.fn(function* (ctx) {
|
||||
if (ctx.payload.title) {
|
||||
yield* session
|
||||
.rename({ sessionID: ctx.params.sessionID, title: ctx.payload.title })
|
||||
.pipe(Effect.catchTag("Session.NotFoundError", missingSession))
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}
|
||||
const title = yield* SessionTitle.Service
|
||||
yield* title.generate(ctx.params.sessionID)
|
||||
yield* session
|
||||
.rename({ sessionID: ctx.params.sessionID, title: ctx.payload.title })
|
||||
.pipe(Effect.catchTag("Session.NotFoundError", missingSession))
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user