mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-29 21:16:10 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
149467b017 | ||
|
|
e70d667a9f | ||
|
|
8ba434b597 | ||
|
|
171947787c | ||
|
|
106629aa11 | ||
|
|
3ee2e482ce | ||
|
|
849824efd2 |
@@ -5,6 +5,7 @@ on:
|
||||
branches:
|
||||
- dev
|
||||
- production
|
||||
- beta
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -15,7 +16,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'production')
|
||||
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'production' || github.ref_name == 'beta')
|
||||
runs-on: ubuntu-latest
|
||||
environment: ${{ github.ref_name }}
|
||||
steps:
|
||||
@@ -28,6 +29,7 @@ jobs:
|
||||
node-version: "24"
|
||||
|
||||
- uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1
|
||||
if: github.ref_name != 'beta'
|
||||
with:
|
||||
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
|
||||
role-session-name: opencode-${{ github.run_id }}
|
||||
|
||||
+2
-9
@@ -1,4 +1,5 @@
|
||||
import { domain } from "./stage"
|
||||
import { createWebApp } from "./webapp"
|
||||
|
||||
const GITHUB_APP_ID = new sst.Secret("GITHUB_APP_ID")
|
||||
const GITHUB_APP_PRIVATE_KEY = new sst.Secret("GITHUB_APP_PRIVATE_KEY")
|
||||
@@ -59,12 +60,4 @@ new sst.cloudflare.x.Astro("Web", {
|
||||
},
|
||||
})
|
||||
|
||||
new sst.cloudflare.StaticSite("WebApp", {
|
||||
domain: "app." + domain,
|
||||
path: "packages/app",
|
||||
build: {
|
||||
// Preserve Sentry credentials and run source-map uploads on every deployment.
|
||||
command: "bun run build",
|
||||
output: "./dist",
|
||||
},
|
||||
})
|
||||
createWebApp("app." + domain)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export function createWebApp(domain: string) {
|
||||
return new sst.cloudflare.StaticSite("WebApp", {
|
||||
domain,
|
||||
path: "packages/app",
|
||||
environment:
|
||||
$app.stage === "beta"
|
||||
? {
|
||||
OPENCODE_CHANNEL: "beta",
|
||||
VITE_SENTRY_ENVIRONMENT: "beta",
|
||||
}
|
||||
: undefined,
|
||||
build: {
|
||||
// Preserve Sentry credentials and run source-map uploads on every deployment.
|
||||
command: "bun run build",
|
||||
output: "./dist",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1343,20 +1343,28 @@ const onMessageDelta = (
|
||||
event: AnthropicEvent & { readonly delta?: AnthropicStreamDelta },
|
||||
): StepResult => {
|
||||
const usage = mergeUsage(state.usage, mapUsage(event.usage, state.providerMetadataKey), state.providerMetadataKey)
|
||||
const pendingFinish = (() => {
|
||||
const stopReason = event.delta?.stop_reason
|
||||
if (stopReason === null || stopReason === undefined) return state.pendingFinish
|
||||
|
||||
const stopSequence = event.delta?.stop_sequence
|
||||
const finishMetadata =
|
||||
stopSequence === null || stopSequence === undefined
|
||||
? state.pendingFinish?.providerMetadata
|
||||
: providerMetadata(state.providerMetadataKey, { stopSequence })
|
||||
return {
|
||||
reason: {
|
||||
normalized: mapFinishReason(stopReason),
|
||||
raw: stopReason,
|
||||
},
|
||||
providerMetadata: finishMetadata,
|
||||
}
|
||||
})()
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
usage,
|
||||
pendingFinish: {
|
||||
reason: {
|
||||
normalized: mapFinishReason(event.delta?.stop_reason),
|
||||
raw: event.delta?.stop_reason ?? undefined,
|
||||
},
|
||||
providerMetadata:
|
||||
event.delta?.stop_sequence === null || event.delta?.stop_sequence === undefined
|
||||
? undefined
|
||||
: providerMetadata(state.providerMetadataKey, { stopSequence: event.delta.stop_sequence }),
|
||||
},
|
||||
pendingFinish,
|
||||
},
|
||||
NO_EVENTS,
|
||||
]
|
||||
|
||||
@@ -469,7 +469,6 @@ const mapFinishReason = (reason: string): FinishReason => {
|
||||
if (reason === "max_tokens" || reason === "model_context_window_exceeded") return "length"
|
||||
if (reason === "tool_use") return "tool-calls"
|
||||
if (reason === "content_filtered" || reason === "guardrail_intervened") return "content-filter"
|
||||
if (reason === "malformed_model_output" || reason === "malformed_tool_use") return "error"
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
@@ -624,6 +623,15 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
}
|
||||
|
||||
if (event.messageStop) {
|
||||
if (
|
||||
event.messageStop.stopReason === "malformed_model_output" ||
|
||||
event.messageStop.stopReason === "malformed_tool_use"
|
||||
)
|
||||
return yield* ProviderShared.eventError(
|
||||
ADAPTER,
|
||||
`Bedrock Converse stopped with ${event.messageStop.stopReason}`,
|
||||
ProviderShared.encodeJson(event),
|
||||
)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
|
||||
@@ -949,6 +949,41 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves terminal state across usage-only message deltas", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "end_turn", stop_sequence: "X" },
|
||||
usage: { output_tokens: 8 },
|
||||
},
|
||||
{ type: "message_delta", delta: {}, usage: { output_tokens: 10 } },
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 10, totalTokens: 15 })
|
||||
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
|
||||
expect(response.events.find((event) => event.type === "step-finish")).toMatchObject({
|
||||
reason: { normalized: "stop", raw: "end_turn" },
|
||||
usage: { inputTokens: 5, outputTokens: 10, totalTokens: 15 },
|
||||
providerMetadata: { anthropic: { stopSequence: "X" } },
|
||||
})
|
||||
expect(response.events.at(-1)).toMatchObject({
|
||||
type: "finish",
|
||||
reason: { normalized: "stop", raw: "end_turn" },
|
||||
usage: { inputTokens: 5, outputTokens: 10, totalTokens: 15 },
|
||||
providerMetadata: { anthropic: { stopSequence: "X" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("requires message_stop before completing a streamed message", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { EventStreamCodec } from "@smithy/eventstream-codec"
|
||||
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Ref, Stream } from "effect"
|
||||
import {
|
||||
CacheHint,
|
||||
GenerationOptions,
|
||||
LLM,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
Message,
|
||||
ToolCallPart,
|
||||
@@ -385,19 +386,39 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps truncation and malformed output stop reasons", () =>
|
||||
it.effect("maps model context window exhaustion to length", () =>
|
||||
Effect.gen(function* () {
|
||||
const reasons = [
|
||||
["model_context_window_exceeded", "length"],
|
||||
["malformed_model_output", "error"],
|
||||
["malformed_tool_use", "error"],
|
||||
] as const
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(
|
||||
Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: "model_context_window_exceeded" }]))),
|
||||
)
|
||||
|
||||
for (const [raw, normalized] of reasons) {
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(
|
||||
Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: raw }]))),
|
||||
expect(response.finishReason).toEqual({
|
||||
normalized: "length",
|
||||
raw: "model_context_window_exceeded",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails malformed output stop reasons", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const reason of ["malformed_model_output", "malformed_tool_use"] as const) {
|
||||
const events = yield* Ref.make<ReadonlyArray<LLMEvent>>([])
|
||||
const error = yield* LLMClient.stream(baseRequest).pipe(
|
||||
Stream.tap((event) => Ref.update(events, (current) => [...current, event])),
|
||||
Stream.runDrain,
|
||||
Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: reason }]))),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(response.finishReason).toEqual({ normalized, raw })
|
||||
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "InvalidProviderOutput" },
|
||||
message: `Bedrock Converse stopped with ${reason}`,
|
||||
})
|
||||
expect(JSON.parse(error.reason.body ?? "")).toMatchObject({
|
||||
headers: { ":event-type": { value: "messageStop" } },
|
||||
body: JSON.stringify({ stopReason: reason }),
|
||||
})
|
||||
expect((yield* Ref.get(events)).some((event) => event.type === "finish")).toBeFalse()
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
+22
-1
@@ -71,4 +71,25 @@ Environment options:
|
||||
|
||||
## Deployment
|
||||
|
||||
You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.)
|
||||
The `deploy` GitHub Actions workflow uses SST to deploy the web app from these branches in `anomalyco/opencode`:
|
||||
|
||||
| Branch | Site |
|
||||
| ------------ | --------------------- |
|
||||
| `dev` | `app.dev.opencode.ai` |
|
||||
| `production` | `app.opencode.ai` |
|
||||
| `beta` | `beta.opencode.ai` |
|
||||
|
||||
Changes merged into `v2` reach the beta site when they are promoted to `beta`. The beta SST stage deploys
|
||||
only the web app, using the same `WebApp` StaticSite definition as production. It sets the build channel
|
||||
and Sentry environment to `beta` without deploying the API, console, database, or billing infrastructure.
|
||||
|
||||
The hosted app defaults to `http://localhost:49374`, matching the managed V2 service. Saved server selections
|
||||
override this default. Connecting still requires the service's credentials.
|
||||
|
||||
The workflow reuses the repository's `CLOUDFLARE_API_TOKEN` and web Sentry settings. The Cloudflare token
|
||||
must cover SST's R2 state storage, KV assets, Workers, and custom-domain management in the account that
|
||||
owns `opencode.ai`. The beta GitHub environment must allow deployments from the `beta` branch; it does not
|
||||
need AWS credentials.
|
||||
|
||||
SST manages the beta site's custom domain. The first deployment creates its DNS record and TLS certificate.
|
||||
Do not create a CNAME for `beta.opencode.ai` first, because it would conflict with the Workers custom domain.
|
||||
|
||||
@@ -53,7 +53,7 @@ export function createWebPlatform(version: string) {
|
||||
}
|
||||
|
||||
function getCurrentServerUrl() {
|
||||
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
||||
if (location.hostname.includes("opencode.ai")) return "http://localhost:49374"
|
||||
if (import.meta.env.DEV)
|
||||
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
||||
return location.origin
|
||||
|
||||
@@ -43,12 +43,12 @@ export default function Layout(props: ParentProps) {
|
||||
style={{
|
||||
"padding-top": "env(safe-area-inset-top, 0px)",
|
||||
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
|
||||
// The native Windows titlebar already includes the gap above the content panels.
|
||||
// Native Windows chrome supplies the gap; retain paint clearance for the panels' outer outlines.
|
||||
"--shell-top-inset":
|
||||
platform.platform === "desktop" &&
|
||||
platform.os === "windows" &&
|
||||
!(mobile() && preferences.general.mobileTitlebarPosition() === "bottom")
|
||||
? "0px"
|
||||
? "1px"
|
||||
: "8px",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -46,12 +46,12 @@ export const register = Effect.fn("ConfigMCPPlugin.register")(function* (
|
||||
const servers = new Map<string, ServerConfig>()
|
||||
for (const document of documents) {
|
||||
for (const [name, server] of Object.entries(document.info.mcp?.servers ?? {})) {
|
||||
servers.set(name, { ...server, timeout: { ...timeout, ...server.timeout } })
|
||||
servers.set(name, server)
|
||||
}
|
||||
}
|
||||
for (const [name, server] of servers) {
|
||||
if (draft.get(name)) continue
|
||||
draft.set(name, server)
|
||||
draft.set(name, { ...server, timeout: { ...timeout, ...server.timeout } })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
+11
-113
@@ -3,9 +3,8 @@ export * from "./session/schema.js"
|
||||
|
||||
import { Cause, Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
|
||||
import { ListAnchor } from "@opencode-ai/schema/session"
|
||||
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
|
||||
import { and, desc, eq } from "drizzle-orm"
|
||||
import { Project } from "./project.js"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Location } from "./location.js"
|
||||
import { SessionMessage } from "./session/message.js"
|
||||
@@ -13,14 +12,13 @@ import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Database } from "./database/database.js"
|
||||
import { SessionProjector } from "./session/projector.js"
|
||||
import { SessionMessageTable, SessionTable } from "./session/sql.js"
|
||||
import { SessionMessageTable } from "./session/sql.js"
|
||||
import { SessionSchema } from "./session/schema.js"
|
||||
import { AbsolutePath, PositiveInt, RelativePath } from "./schema.js"
|
||||
import { AbsolutePath, RelativePath } from "./schema.js"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { App } from "./app.js"
|
||||
import { Slug } from "./util/slug.js"
|
||||
import path from "path"
|
||||
import { fromRow } from "./session/info.js"
|
||||
import { SessionRunner } from "./session/runner/index.js"
|
||||
import { SessionStore } from "./session/store.js"
|
||||
import { SessionExecution } from "./session/execution.js"
|
||||
@@ -58,7 +56,6 @@ import { Job } from "./job.js"
|
||||
import { Command } from "./command.js"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { SessionEnvironment } from "./session/environment.js"
|
||||
import { SessionHistory } from "./session/history.js"
|
||||
import { InstructionEntry } from "./session/instruction-entry.js"
|
||||
|
||||
// get project -> project.locations
|
||||
@@ -72,30 +69,8 @@ import { InstructionEntry } from "./session/instruction-entry.js"
|
||||
|
||||
export { ListAnchor }
|
||||
|
||||
const ListInputBase = {
|
||||
workspaceID: Workspace.ID.pipe(Schema.optional),
|
||||
search: Schema.String.pipe(Schema.optional),
|
||||
limit: PositiveInt.pipe(Schema.optional),
|
||||
order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional),
|
||||
parentID: Schema.NullOr(SessionSchema.ID).pipe(Schema.optional),
|
||||
anchor: ListAnchor.pipe(Schema.optional),
|
||||
}
|
||||
|
||||
const ListDirectoryInput = Schema.Struct({
|
||||
...ListInputBase,
|
||||
directory: AbsolutePath,
|
||||
})
|
||||
|
||||
const ListProjectInput = Schema.Struct({
|
||||
...ListInputBase,
|
||||
project: Project.ID,
|
||||
subpath: RelativePath.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const ListAllInput = Schema.Struct(ListInputBase)
|
||||
|
||||
export const ListInput = Schema.Union([ListDirectoryInput, ListProjectInput, ListAllInput])
|
||||
export type ListInput = typeof ListInput.Type
|
||||
export const ListInput = SessionStore.ListInput
|
||||
export type ListInput = SessionStore.ListInput
|
||||
|
||||
type CreateBaseInput = {
|
||||
id?: SessionSchema.ID
|
||||
@@ -161,15 +136,9 @@ export interface Interface {
|
||||
}) => Effect.Effect<SessionEnvironment.Variables | undefined, NotFoundError>
|
||||
readonly view: (input: { sessionID: SessionSchema.ID; idle: number }) => Effect.Effect<void, NotFoundError>
|
||||
readonly remove: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
|
||||
readonly messages: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
limit?: number
|
||||
order?: "asc" | "desc"
|
||||
cursor?: {
|
||||
id: SessionMessage.ID
|
||||
direction: "previous" | "next"
|
||||
}
|
||||
}) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
|
||||
readonly messages: (
|
||||
input: SessionStore.MessagesInput,
|
||||
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
|
||||
readonly message: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
messageID: SessionMessage.ID
|
||||
@@ -409,83 +378,12 @@ const layer = Layer.effect(
|
||||
yield* bus.publish(SessionEvent.Deleted, { sessionID })
|
||||
yield* bus.remove(sessionID)
|
||||
}),
|
||||
list: Effect.fn("Session.list")(function* (input = {}) {
|
||||
const direction = input.anchor?.direction ?? "next"
|
||||
const requestedOrder = input.order ?? "desc"
|
||||
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
|
||||
const sortColumn = SessionTable.time_updated
|
||||
const conditions: SQL[] = []
|
||||
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
|
||||
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
|
||||
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
|
||||
if ("project" in input && input.subpath !== undefined) conditions.push(eq(SessionTable.path, input.subpath))
|
||||
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
|
||||
if (input.parentID !== undefined)
|
||||
conditions.push(
|
||||
input.parentID === null ? isNull(SessionTable.parent_id) : eq(SessionTable.parent_id, input.parentID),
|
||||
)
|
||||
if (input.anchor) {
|
||||
conditions.push(
|
||||
order === "asc"
|
||||
? or(
|
||||
gt(sortColumn, input.anchor.time),
|
||||
and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)),
|
||||
)!
|
||||
: or(
|
||||
lt(sortColumn, input.anchor.time),
|
||||
and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)),
|
||||
)!,
|
||||
)
|
||||
}
|
||||
const query = db
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(
|
||||
order === "asc" ? asc(sortColumn) : desc(sortColumn),
|
||||
order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
|
||||
)
|
||||
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
||||
Effect.orDie,
|
||||
)
|
||||
return { data: (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row)) }
|
||||
list: Effect.fn("Session.list")(function* (input) {
|
||||
return { data: yield* store.list(input) }
|
||||
}),
|
||||
messages: Effect.fn("Session.messages")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
const direction = input.cursor?.direction ?? "next"
|
||||
const requestedOrder = input.order ?? "desc"
|
||||
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
|
||||
const anchor = input.cursor
|
||||
? yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.cursor.id)),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
: undefined
|
||||
if (input.cursor && !anchor) return []
|
||||
const boundary = anchor
|
||||
? order === "asc"
|
||||
? gt(SessionMessageTable.seq, anchor.seq)
|
||||
: lt(SessionMessageTable.seq, anchor.seq)
|
||||
: undefined
|
||||
const where = boundary
|
||||
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
|
||||
: eq(SessionMessageTable.session_id, input.sessionID)
|
||||
const query = db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(where)
|
||||
.orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
|
||||
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
||||
Effect.orDie,
|
||||
)
|
||||
return yield* Effect.forEach(
|
||||
direction === "previous" ? rows.toReversed() : rows,
|
||||
SessionHistory.decodeMessageRow,
|
||||
)
|
||||
return yield* store.messages(input)
|
||||
}),
|
||||
message: (input) => sessions.forSession(input.sessionID).message(input.messageID),
|
||||
updateMessage: (input) => sessions.forSession(input.sessionID).updateMessage(input),
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
export * as SessionStore from "./store.js"
|
||||
|
||||
import { and, eq, isNotNull, isNull, notInArray, sql } from "drizzle-orm"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { and, asc, desc, eq, gt, isNotNull, isNull, like, lt, notInArray, or, sql, type SQL } from "drizzle-orm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { AbsolutePath, PositiveInt, RelativePath } from "@opencode-ai/schema/schema"
|
||||
import { Database } from "../database/database.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { SessionHistory } from "./history.js"
|
||||
@@ -11,8 +14,45 @@ import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionMessageTable, SessionTable } from "./sql.js"
|
||||
import { fromRow } from "./info.js"
|
||||
|
||||
const ListInputBase = {
|
||||
workspaceID: Workspace.ID.pipe(Schema.optional),
|
||||
search: Schema.String.pipe(Schema.optional),
|
||||
limit: PositiveInt.pipe(Schema.optional),
|
||||
order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional),
|
||||
parentID: Schema.NullOr(Session.ID).pipe(Schema.optional),
|
||||
anchor: Session.ListAnchor.pipe(Schema.optional),
|
||||
}
|
||||
|
||||
const ListDirectoryInput = Schema.Struct({
|
||||
...ListInputBase,
|
||||
directory: AbsolutePath,
|
||||
})
|
||||
|
||||
const ListProjectInput = Schema.Struct({
|
||||
...ListInputBase,
|
||||
project: Project.ID,
|
||||
subpath: RelativePath.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const ListAllInput = Schema.Struct(ListInputBase)
|
||||
|
||||
export const ListInput = Schema.Union([ListDirectoryInput, ListProjectInput, ListAllInput])
|
||||
export type ListInput = typeof ListInput.Type
|
||||
|
||||
export type MessagesInput = {
|
||||
sessionID: Session.ID
|
||||
limit?: number
|
||||
order?: "asc" | "desc"
|
||||
cursor?: {
|
||||
id: SessionMessage.ID
|
||||
direction: "previous" | "next"
|
||||
}
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly get: (sessionID: Session.ID) => Effect.Effect<Session.Info | undefined>
|
||||
readonly list: (input?: ListInput) => Effect.Effect<Session.Info[]>
|
||||
readonly messages: (input: MessagesInput) => Effect.Effect<SessionMessage.Info[], MessageDecodeError>
|
||||
readonly context: (sessionID: Session.ID) => Effect.Effect<SessionMessage.Info[], MessageDecodeError>
|
||||
readonly message: (
|
||||
messageID: SessionMessage.ID,
|
||||
@@ -55,6 +95,83 @@ const layer = Layer.effect(
|
||||
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)
|
||||
return row ? fromRow(row) : undefined
|
||||
}),
|
||||
list: Effect.fn("SessionStore.list")(function* (input = {}) {
|
||||
const direction = input.anchor?.direction ?? "next"
|
||||
const requestedOrder = input.order ?? "desc"
|
||||
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
|
||||
const sortColumn = SessionTable.time_updated
|
||||
const conditions: SQL[] = []
|
||||
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
|
||||
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
|
||||
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
|
||||
if ("project" in input && input.subpath !== undefined) conditions.push(eq(SessionTable.path, input.subpath))
|
||||
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
|
||||
if (input.parentID !== undefined)
|
||||
conditions.push(
|
||||
input.parentID === null ? isNull(SessionTable.parent_id) : eq(SessionTable.parent_id, input.parentID),
|
||||
)
|
||||
if (input.anchor) {
|
||||
conditions.push(
|
||||
order === "asc"
|
||||
? or(
|
||||
gt(sortColumn, input.anchor.time),
|
||||
and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)),
|
||||
)!
|
||||
: or(
|
||||
lt(sortColumn, input.anchor.time),
|
||||
and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)),
|
||||
)!,
|
||||
)
|
||||
}
|
||||
const query = db
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(
|
||||
order === "asc" ? asc(sortColumn) : desc(sortColumn),
|
||||
order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
|
||||
)
|
||||
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
||||
Effect.orDie,
|
||||
)
|
||||
return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row))
|
||||
}),
|
||||
messages: Effect.fn("SessionStore.messages")(function* (input) {
|
||||
const direction = input.cursor?.direction ?? "next"
|
||||
const requestedOrder = input.order ?? "desc"
|
||||
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
|
||||
const anchor = input.cursor
|
||||
? yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.cursor.id)),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
: undefined
|
||||
if (input.cursor && !anchor) return []
|
||||
const boundary = anchor
|
||||
? order === "asc"
|
||||
? gt(SessionMessageTable.seq, anchor.seq)
|
||||
: lt(SessionMessageTable.seq, anchor.seq)
|
||||
: undefined
|
||||
const where = boundary
|
||||
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
|
||||
: eq(SessionMessageTable.session_id, input.sessionID)
|
||||
const query = db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(where)
|
||||
.orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
|
||||
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
||||
Effect.orDie,
|
||||
)
|
||||
return yield* Effect.forEach(
|
||||
direction === "previous" ? rows.toReversed() : rows,
|
||||
SessionHistory.decodeMessageRow,
|
||||
)
|
||||
}),
|
||||
context: Effect.fn("SessionStore.context")((sessionID) => SessionHistory.load(db, sessionID)),
|
||||
message: Effect.fn("SessionStore.message")(function* (messageID) {
|
||||
const row = yield* db
|
||||
|
||||
@@ -1207,6 +1207,68 @@ test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
|
||||
)
|
||||
})
|
||||
|
||||
testEffect(Layer.empty).live(
|
||||
"merges MCP defaults into the winning configured server without changing runtime overrides",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const entries = [
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({
|
||||
mcp: new ConfigMCP.Info({
|
||||
timeout: { startup: 10, catalog: 20, execution: 30 },
|
||||
servers: {
|
||||
resources: { type: "local", command: ["earlier"], disabled: true, timeout: { execution: 90 } },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({
|
||||
mcp: new ConfigMCP.Info({
|
||||
timeout: { catalog: 40 },
|
||||
servers: {
|
||||
resources: { type: "local", command: ["later"], disabled: true, timeout: { startup: 50 } },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]
|
||||
const original = JSON.stringify(entries)
|
||||
yield* Effect.gen(function* () {
|
||||
const service = yield* Mcp.Service
|
||||
const check = yield* service.transform((draft) => {
|
||||
expect(draft.get("resources")).toEqual({
|
||||
type: "local",
|
||||
command: ["later"],
|
||||
disabled: true,
|
||||
timeout: { startup: 50, catalog: 40, execution: 30 },
|
||||
})
|
||||
})
|
||||
yield* check.dispose
|
||||
const runtime = {
|
||||
type: "local",
|
||||
command: ["runtime"],
|
||||
disabled: true,
|
||||
timeout: { catalog: 60 },
|
||||
} satisfies ConfigMCP.Local
|
||||
yield* service.add("resources", runtime)
|
||||
yield* service.reload()
|
||||
yield* service.transform((draft) => {
|
||||
expect(draft.get("resources")).toEqual(runtime)
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
resourceMcpLayer("https://unused.example", undefined, undefined, {
|
||||
entries: () => Effect.succeed(entries),
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(JSON.stringify(entries)).toBe(original)
|
||||
}),
|
||||
)
|
||||
|
||||
testEffect(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true }))).live(
|
||||
"manages live MCP servers entirely through scoped transforms",
|
||||
() =>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { fromRow } from "@opencode-ai/core/session/info"
|
||||
import { SessionInbox } from "@opencode-ai/core/session/inbox"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { Shell } from "@opencode-ai/schema/shell"
|
||||
import {
|
||||
InstructionStateTable,
|
||||
@@ -32,9 +33,10 @@ import { testEffect } from "./lib/effect"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionInbox.node]), [
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
]),
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionInbox.node, SessionStore.node]),
|
||||
[[Bus.node, Bus.configured({ persist: true })]],
|
||||
),
|
||||
)
|
||||
const sessionsLayer = AppNodeBuilder.build(Session.node, [[SessionExecution.node, SessionExecution.noopLayer]])
|
||||
const sessionID = Session.ID.make("ses_projector_test")
|
||||
@@ -278,7 +280,9 @@ describe("SessionProjector", () => {
|
||||
yield* db.run(sql`update session_message set data = '{"time":{"created":0}}' where id = ${messageID}`)
|
||||
|
||||
const sessions = yield* Session.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const expected = { _tag: "Session.MessageDecodeError", sessionID, messageID }
|
||||
expect(yield* store.messages({ sessionID }).pipe(Effect.flip)).toMatchObject(expected)
|
||||
expect(yield* sessions.messages({ sessionID }).pipe(Effect.flip)).toMatchObject(expected)
|
||||
expect(yield* sessions.context(sessionID).pipe(Effect.flip)).toMatchObject(expected)
|
||||
expect(yield* sessions.message({ sessionID, messageID }).pipe(Effect.catchDefect(Effect.succeed))).toMatchObject(
|
||||
@@ -287,6 +291,21 @@ describe("SessionProjector", () => {
|
||||
}).pipe(Effect.provide(sessionsLayer)),
|
||||
)
|
||||
|
||||
it.effect("checks session existence before resolving a missing message cursor", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const missing = Session.ID.make("ses_missing")
|
||||
expect(
|
||||
yield* sessions
|
||||
.messages({
|
||||
sessionID: missing,
|
||||
cursor: { id: SessionMessage.ID.make("msg_missing"), direction: "next" },
|
||||
})
|
||||
.pipe(Effect.flip),
|
||||
).toEqual(new Session.NotFoundError({ sessionID: missing }))
|
||||
}).pipe(Effect.provide(sessionsLayer)),
|
||||
)
|
||||
|
||||
it.effect("consumes the pending row and projects the message at promotion", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = yield* seedSession()
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { AbsolutePath } from "@opencode-ai/schema/schema"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionEvent } from "@opencode-ai/schema/session-event"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]), [
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
]),
|
||||
)
|
||||
|
||||
const seedSessions = (rows: { id: string; updated: number }[]) =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const directory = AbsolutePath.make("/project")
|
||||
yield* database.db.insert(ProjectTable).values({ id: Project.ID.global, worktree: directory, sandboxes: [] }).run()
|
||||
yield* Effect.forEach(rows, (row) =>
|
||||
Effect.gen(function* () {
|
||||
const sessionID = Session.ID.make(row.id)
|
||||
yield* bus.publish(SessionEvent.Created, {
|
||||
sessionID,
|
||||
projectID: Project.ID.global,
|
||||
location: { directory },
|
||||
slug: "store-test",
|
||||
version: "test",
|
||||
})
|
||||
yield* bus.replay({
|
||||
id: Event.ID.create(),
|
||||
created: row.updated,
|
||||
aggregateID: sessionID,
|
||||
seq: 1,
|
||||
type: Bus.versionedType(SessionEvent.Renamed.type, 1),
|
||||
data: { sessionID, title: row.id },
|
||||
})
|
||||
}),
|
||||
)
|
||||
return bus
|
||||
})
|
||||
|
||||
describe("SessionStore", () => {
|
||||
it.effect("lists by updated time and ID with exclusive two-item pages in either direction", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* seedSessions([
|
||||
{ id: "ses_d", updated: 20 },
|
||||
{ id: "ses_z", updated: 10 },
|
||||
{ id: "ses_a", updated: 30 },
|
||||
{ id: "ses_c", updated: 20 },
|
||||
{ id: "ses_y", updated: 10 },
|
||||
{ id: "ses_e", updated: 30 },
|
||||
{ id: "ses_b", updated: 20 },
|
||||
])
|
||||
const store = yield* SessionStore.Service
|
||||
expect((yield* store.list()).map((session) => String(session.id))).toEqual([
|
||||
"ses_e",
|
||||
"ses_a",
|
||||
"ses_d",
|
||||
"ses_c",
|
||||
"ses_b",
|
||||
"ses_z",
|
||||
"ses_y",
|
||||
])
|
||||
expect((yield* store.list({ order: "asc" })).map((session) => String(session.id))).toEqual([
|
||||
"ses_y",
|
||||
"ses_z",
|
||||
"ses_b",
|
||||
"ses_c",
|
||||
"ses_d",
|
||||
"ses_a",
|
||||
"ses_e",
|
||||
])
|
||||
const pages: { order: "asc" | "desc"; direction: "next" | "previous"; ids: string[] }[] = [
|
||||
{ order: "asc", direction: "next", ids: ["ses_d", "ses_a"] },
|
||||
{ order: "asc", direction: "previous", ids: ["ses_z", "ses_b"] },
|
||||
{ order: "desc", direction: "next", ids: ["ses_b", "ses_z"] },
|
||||
{ order: "desc", direction: "previous", ids: ["ses_a", "ses_d"] },
|
||||
]
|
||||
yield* Effect.forEach(pages, (page) =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* store.list({
|
||||
order: page.order,
|
||||
limit: 2,
|
||||
anchor: { id: Session.ID.make("ses_c"), time: 20, direction: page.direction },
|
||||
})
|
||||
expect(sessions.map((session) => String(session.id))).toEqual(page.ids)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("pages messages by durable sequence, not timestamp or ID, and scopes cursor lookup", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessionID = Session.ID.make("ses_messages")
|
||||
const foreignID = Session.ID.make("ses_foreign")
|
||||
const bus = yield* seedSessions([
|
||||
{ id: sessionID, updated: 0 },
|
||||
{ id: foreignID, updated: 0 },
|
||||
])
|
||||
const store = yield* SessionStore.Service
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
{ id: "evt_z", created: 300 },
|
||||
{ id: "evt_b", created: 700 },
|
||||
{ id: "evt_x", created: 100 },
|
||||
{ id: "evt_c", created: 400 },
|
||||
{ id: "evt_w", created: 200 },
|
||||
{ id: "evt_a", created: 600 },
|
||||
{ id: "evt_y", created: 500 },
|
||||
],
|
||||
(event, index) =>
|
||||
bus.replay({
|
||||
id: Event.ID.make(event.id),
|
||||
created: event.created,
|
||||
aggregateID: sessionID,
|
||||
seq: index + 2,
|
||||
type: Bus.versionedType(SessionEvent.Synthetic.type, 1),
|
||||
data: { sessionID, text: event.id },
|
||||
}),
|
||||
)
|
||||
yield* bus.publish(
|
||||
SessionEvent.Synthetic,
|
||||
{ sessionID: foreignID, text: "foreign" },
|
||||
{
|
||||
id: Event.ID.make("evt_foreign"),
|
||||
},
|
||||
)
|
||||
expect((yield* store.messages({ sessionID })).map((message) => String(message.id))).toEqual([
|
||||
"msg_y",
|
||||
"msg_a",
|
||||
"msg_w",
|
||||
"msg_c",
|
||||
"msg_x",
|
||||
"msg_b",
|
||||
"msg_z",
|
||||
])
|
||||
expect((yield* store.messages({ sessionID, order: "asc" })).map((message) => String(message.id))).toEqual([
|
||||
"msg_z",
|
||||
"msg_b",
|
||||
"msg_x",
|
||||
"msg_c",
|
||||
"msg_w",
|
||||
"msg_a",
|
||||
"msg_y",
|
||||
])
|
||||
const pages: { order: "asc" | "desc"; direction: "next" | "previous"; ids: string[] }[] = [
|
||||
{ order: "asc", direction: "next", ids: ["msg_w", "msg_a"] },
|
||||
{ order: "asc", direction: "previous", ids: ["msg_b", "msg_x"] },
|
||||
{ order: "desc", direction: "next", ids: ["msg_x", "msg_b"] },
|
||||
{ order: "desc", direction: "previous", ids: ["msg_a", "msg_w"] },
|
||||
]
|
||||
yield* Effect.forEach(pages, (page) =>
|
||||
Effect.gen(function* () {
|
||||
const messages = yield* store.messages({
|
||||
sessionID,
|
||||
order: page.order,
|
||||
limit: 2,
|
||||
cursor: { id: SessionMessage.ID.make("msg_c"), direction: page.direction },
|
||||
})
|
||||
expect(messages.map((message) => String(message.id))).toEqual(page.ids)
|
||||
}),
|
||||
)
|
||||
expect(yield* store.messages({ sessionID: Session.ID.make("ses_missing") })).toEqual([])
|
||||
expect(
|
||||
yield* store.messages({
|
||||
sessionID,
|
||||
cursor: { id: SessionMessage.ID.make("msg_missing"), direction: "next" },
|
||||
}),
|
||||
).toEqual([])
|
||||
expect(
|
||||
yield* store.messages({
|
||||
sessionID,
|
||||
order: "asc",
|
||||
cursor: { id: SessionMessage.ID.make("msg_foreign"), direction: "next" },
|
||||
}),
|
||||
).toEqual([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
@@ -126,6 +127,7 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||
[Session.ID, schemaSession.Session.ID],
|
||||
[Session.Info, schemaSession.Session.Info],
|
||||
[Session.ListAnchor, schemaSession.Session.ListAnchor],
|
||||
[Session.ListInput, SessionStore.ListInput],
|
||||
[coreSessionInbox.Delivery, SessionInbox.Delivery],
|
||||
[coreSessionInbox.Item, SessionInbox.Item],
|
||||
[coreSessionInbox.User, SessionInbox.User],
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { afterAll, expect, test } from "bun:test"
|
||||
import { once } from "node:events"
|
||||
import { readdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import {
|
||||
BoxRenderable,
|
||||
CliRenderEvents,
|
||||
DiffRenderable,
|
||||
ImageRenderable,
|
||||
InputRenderable,
|
||||
MouseButton,
|
||||
type Renderable,
|
||||
ScrollBoxRenderable,
|
||||
@@ -315,6 +318,9 @@ test.each(["branch", "committed", "working"] as const)(
|
||||
expect(viewer.app.captureCharFrame()).toMatch(/●\s+v2/)
|
||||
expect(viewer.branchesRequests[0].searchParams.get("location[directory]")).toBe("/repo/session")
|
||||
expect(viewer.branchesRequests[0].searchParams.get("limit")).toBe("100")
|
||||
// The picker can paint before its deferred input focus.
|
||||
if (!viewer.app.renderer.currentFocusedEditor) await once(viewer.app.renderer, CliRenderEvents.FOCUSED_EDITOR)
|
||||
expect(viewer.app.renderer.currentFocusedEditor).toBeInstanceOf(InputRenderable)
|
||||
await viewer.app.mockInput.typeText("origin/release")
|
||||
await Bun.sleep(160)
|
||||
await viewer.app.waitFor(() => viewer.branchesRequests.at(-1)?.searchParams.get("search") === "origin/release")
|
||||
|
||||
+26
-18
@@ -7,27 +7,35 @@ export default $config({
|
||||
removal: input?.stage === "production" ? "retain" : "remove",
|
||||
protect: ["production"].includes(input?.stage),
|
||||
home: "cloudflare",
|
||||
providers: {
|
||||
aws: {
|
||||
version: "7.30.0",
|
||||
region: "us-east-1",
|
||||
profile: process.env.GITHUB_ACTIONS
|
||||
? undefined
|
||||
: input.stage === "production"
|
||||
? "opencode-production"
|
||||
: "opencode-dev",
|
||||
},
|
||||
stripe: {
|
||||
version: "0.0.28",
|
||||
apiKey: process.env.STRIPE_SECRET_KEY!,
|
||||
},
|
||||
random: "4.19.2",
|
||||
planetscale: "0.4.1",
|
||||
honeycomb: "0.49.0",
|
||||
},
|
||||
providers:
|
||||
input.stage === "beta"
|
||||
? {}
|
||||
: {
|
||||
aws: {
|
||||
version: "7.30.0",
|
||||
region: "us-east-1",
|
||||
profile: process.env.GITHUB_ACTIONS
|
||||
? undefined
|
||||
: input.stage === "production"
|
||||
? "opencode-production"
|
||||
: "opencode-dev",
|
||||
},
|
||||
stripe: {
|
||||
version: "0.0.28",
|
||||
apiKey: process.env.STRIPE_SECRET_KEY!,
|
||||
},
|
||||
random: "4.19.2",
|
||||
planetscale: "0.4.1",
|
||||
honeycomb: "0.49.0",
|
||||
},
|
||||
}
|
||||
},
|
||||
async run() {
|
||||
if ($app.stage === "beta") {
|
||||
const { createWebApp } = await import("./infra/webapp.js")
|
||||
return { WebAppUrl: createWebApp("beta.opencode.ai").url }
|
||||
}
|
||||
|
||||
const stage = await import("./infra/stage.js")
|
||||
await import("./infra/app.js")
|
||||
const lake = stage.deployAws ? await import("./infra/lake.js") : undefined
|
||||
|
||||
Reference in New Issue
Block a user