mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-11 20:19:53 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e0f414e78 |
@@ -0,0 +1,68 @@
|
||||
---
|
||||
name: ideal-pseudocode
|
||||
description: Function-by-function refactoring loop driven by ideal pseudocode. Use when the user says "ideal pseudocode", asks to make a function read like its pseudocode, or wants a dense module cleaned up one function at a time.
|
||||
---
|
||||
|
||||
# Ideal Pseudocode
|
||||
|
||||
Clean up one function at a time by writing the pseudocode it _should_ read as, naming every delta between that and the real code, and closing only the gaps the user approves.
|
||||
|
||||
## Loop
|
||||
|
||||
One function per round. Never touch code before the user picks a direction.
|
||||
|
||||
1. **Pick the target** with the user — usually the next function up or down the call chain from the last round.
|
||||
2. **Read the current code** fresh from disk. It may have unsaved or parallel edits; ask before overwriting anything unexpected.
|
||||
3. **Distill.** Write the function's ideal pseudocode in a `ts`-fenced code block — TypeScript-flavored for syntax highlighting, but pseudocode: comments over mechanics, one line per idea, every arm of a loop visible as an arm. For a dense or unfamiliar function, first show the _current_ structure as pseudocode, then the ideal.
|
||||
4. **Name the deltas.** A numbered list; each delta is one concrete gap: control flow smuggled through combinators, a flag-typed parameter, colliding names, side-effect plumbing drowning a loop body, twin functions differing only by a filter. For each, give an honest cost/benefit and a decisive recommendation — including "leave it" when the density has reasons (state the reasons).
|
||||
5. **Verify before proposing.** Any API the proposal builds on (a combinator, schedule, matcher) gets checked against the installed version's source, not memory.
|
||||
6. **Get buy-in, then apply.** Minimal edits. Quarantine irreducible ugliness inside a small helper whose name is the pseudocode line it implements. Collapse shallow sibling functions into one deep function only when the distinguishing argument is a domain noun, not a behavior switch.
|
||||
7. **Validate.** Typecheck and the focused test suites from the package directory; rerun timing-sensitive tests repeatedly. Flag any semantic change the restructure introduced, even when tests pass.
|
||||
8. **Offer the next target** and stop.
|
||||
|
||||
## Style
|
||||
|
||||
- Keep each round tight: pseudocode block, deltas, recommendation, one question.
|
||||
- State mutation belongs in one visible place (usually the loop tail), never spread across error channels or callback closures.
|
||||
- Prefer early return and `if` chains for control flow; matchers are for producing values.
|
||||
- Compare names against the question each caller is actually asking; rename toward the domain noun.
|
||||
|
||||
## Example round
|
||||
|
||||
Every round should read like this (abridged from a real one, on an Effect step-runner):
|
||||
|
||||
> `runStep` it is. The ideal pseudocode:
|
||||
>
|
||||
> ```ts
|
||||
> // One logical step: one settled model call, however many attempts it takes.
|
||||
> // An attempt can end without settling in two ways:
|
||||
> // transient provider failure -> retry the same call, same assistant message
|
||||
> // compaction rewrote history -> rebuild the request and call again
|
||||
> function runStep(session, promotable, step) {
|
||||
> while (true) {
|
||||
> const result = callModel(session, promotable, step)
|
||||
>
|
||||
> if (result.completed) return result
|
||||
>
|
||||
> if (result.retryable) {
|
||||
> wait(retrySchedule) // exhausted -> publish Step.Failed, fail drain
|
||||
> promotable = none // never re-promote on a second attempt
|
||||
> continue
|
||||
> }
|
||||
>
|
||||
> // compaction restarted the step: fresh request from rewritten history
|
||||
> promotable = none
|
||||
> step = result.step
|
||||
> }
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> Comparing against the real thing, three deltas:
|
||||
>
|
||||
> 1. **The retry arm doesn't live in the loop.** In the pseudocode, retry is just another loop arm. In the real code it's smuggled through the error channel: `Effect.tapError` mutating loop variables via `Effect.sync` closures, then `Effect.retryOrElse` re-running a suspended call. Four mutable variables mutated from three different places — the state flow is invisible unless you already know the trick.
|
||||
> 2. **`recoverOverflow: typeof compaction.compact | undefined`** — a function-typed parameter used as a one-shot armed/disarmed flag. The callee already closes over `compaction`; a boolean says what it is.
|
||||
> 3. **`assistantMessageID`** exists solely to resume the same durable message across retries — fine, but only legible once the retry arm is visible in the loop.
|
||||
>
|
||||
> The unifying move for (1): a schedule driver, so a retryable failure becomes a caught value handled in the loop body exactly like the compaction arm. One honest trade: it's more manual than the blessed combinator — but the manual version is the loop the combinator was hiding, and the loop already exists for restarts. Two restart mechanisms, one control structure.
|
||||
>
|
||||
> Want me to apply it — unified loop, simplified schedule input, boolean `recoverOverflow`?
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from "@opencode-ai/ai"
|
||||
import { Auth, Endpoint, RequestExecutor, type AnyRoute } from "@opencode-ai/ai/route"
|
||||
import { ProviderShared } from "@opencode-ai/ai/protocols/shared"
|
||||
import type { Content } from "@opencode-ai/schema/tool"
|
||||
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
|
||||
import type { ID, Info } from "./model"
|
||||
import { Provider } from "./provider"
|
||||
@@ -40,9 +41,15 @@ import { State } from "./state"
|
||||
|
||||
type SDK = any
|
||||
type UserContent = Extract<LanguageModelV3Message, { role: "user" }>["content"]
|
||||
type UserFileContent = Extract<UserContent[number], { type: "file" }>
|
||||
type AssistantContent = Extract<LanguageModelV3Message, { role: "assistant" }>["content"]
|
||||
type ToolResultContent = Extract<AssistantContent[number], { type: "tool-result" }>
|
||||
|
||||
const SYNTHETIC_ATTACHMENT_PROMPT = "Attached media from tool result:"
|
||||
const TOOL_RESULT_ATTACHMENT_TEXT = "Media attached in following user message."
|
||||
const isToolResultMedia = (item: Content): item is Extract<Content, { type: "file" }> =>
|
||||
item.type === "file" && (item.mime.toLowerCase().startsWith("image/") || item.mime === "application/pdf")
|
||||
|
||||
export interface SDKEvent {
|
||||
readonly model: Info
|
||||
readonly package: string
|
||||
@@ -435,21 +442,34 @@ function prompt(request: LLMRequest): LanguageModelV3Prompt {
|
||||
.map((part) => part.text)
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
const messages = request.messages.flatMap(message)
|
||||
const messages: LanguageModelV3Message[] = []
|
||||
const media: UserFileContent[] = []
|
||||
const flushMedia = () => {
|
||||
if (media.length === 0) return
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: SYNTHETIC_ATTACHMENT_PROMPT }, ...media.splice(0)],
|
||||
})
|
||||
}
|
||||
for (const input of request.messages) {
|
||||
if (input.role !== "tool") flushMedia()
|
||||
messages.push(...message(input, media))
|
||||
}
|
||||
flushMedia()
|
||||
if (!system.length) return messages
|
||||
return [{ role: "system", content: system }, ...messages]
|
||||
}
|
||||
|
||||
function message(input: LLMRequest["messages"][number]): LanguageModelV3Message[] {
|
||||
function message(input: LLMRequest["messages"][number], media: UserFileContent[]): LanguageModelV3Message[] {
|
||||
switch (input.role) {
|
||||
case "system":
|
||||
return [{ role: "system", content: input.content.flatMap(text).join("\n\n") }]
|
||||
case "user":
|
||||
return [{ role: "user", content: input.content.flatMap(userPart) }]
|
||||
case "assistant":
|
||||
return [{ role: "assistant", content: input.content.flatMap(assistantPart) }]
|
||||
return [{ role: "assistant", content: input.content.flatMap((part) => assistantPart(part, media)) }]
|
||||
case "tool": {
|
||||
const content = input.content.flatMap(toolResultPart)
|
||||
const content = input.content.flatMap((part) => toolResultPart(part, media))
|
||||
return content.length ? [{ role: "tool", content }] : []
|
||||
}
|
||||
}
|
||||
@@ -466,7 +486,7 @@ function userPart(part: ContentPart): UserContent {
|
||||
return []
|
||||
}
|
||||
|
||||
function assistantPart(part: ContentPart): AssistantContent {
|
||||
function assistantPart(part: ContentPart, media: UserFileContent[]): AssistantContent {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
return [{ type: "text", text: part.text }]
|
||||
@@ -486,18 +506,34 @@ function assistantPart(part: ContentPart): AssistantContent {
|
||||
},
|
||||
]
|
||||
case "tool-result":
|
||||
return toolResultPart(part)
|
||||
return toolResultPart(part, media)
|
||||
}
|
||||
}
|
||||
|
||||
function toolResultPart(part: ContentPart): ToolResultContent[] {
|
||||
function toolResultPart(part: ContentPart, media: UserFileContent[]): ToolResultContent[] {
|
||||
if (part.type !== "tool-result") return []
|
||||
const result = (() => {
|
||||
if (part.result.type !== "content") return part.result
|
||||
const extracted = part.result.value.filter(isToolResultMedia)
|
||||
if (extracted.length === 0) return part.result
|
||||
media.push(
|
||||
...extracted.map((item) => ({
|
||||
type: "file" as const,
|
||||
mediaType: item.mime,
|
||||
data: item.uri,
|
||||
filename: item.name,
|
||||
})),
|
||||
)
|
||||
const content = part.result.value.filter((item) => !isToolResultMedia(item))
|
||||
if (content.length === 0) return { type: "text" as const, value: TOOL_RESULT_ATTACHMENT_TEXT }
|
||||
return { type: "content" as const, value: content }
|
||||
})()
|
||||
return [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: part.id,
|
||||
toolName: part.name,
|
||||
output: toolOutput(part.result),
|
||||
output: toolOutput(result),
|
||||
providerOptions: providerOptions(part.providerMetadata),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -126,12 +126,6 @@ function build(id: Model.ID, remote: UsableModel, baseURL: string, previous?: Mo
|
||||
const image =
|
||||
(remote.capabilities.supports.vision ?? false) ||
|
||||
(remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/"))
|
||||
const pdf =
|
||||
(remote.capabilities.supports.vision ?? false) &&
|
||||
(remote.capabilities.limits.vision?.supported_media_types.includes("application/pdf") ?? false)
|
||||
const input = ["text"]
|
||||
if (image) input.push("image")
|
||||
if (pdf) input.push("pdf")
|
||||
const prices = remote.billing?.token_prices
|
||||
// Copilot reports AIC per billing batch; OpenCode stores USD per million tokens.
|
||||
const usdPerMillion = prices && prices.batch_size > 0 ? 10_000 / prices.batch_size : 0
|
||||
@@ -156,7 +150,7 @@ function build(id: Model.ID, remote: UsableModel, baseURL: string, previous?: Mo
|
||||
body: previous?.body,
|
||||
capabilities: {
|
||||
tools: remote.capabilities.supports.tool_calls,
|
||||
input,
|
||||
input: image ? ["text", "image"] : ["text"],
|
||||
output: ["text"],
|
||||
},
|
||||
variants: variants(remote, messages),
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Credential } from "../../credential"
|
||||
import { Bus } from "../../bus"
|
||||
import { CopilotModels } from "../../github-copilot/models"
|
||||
import { App } from "../../app"
|
||||
import { Agent } from "../../agent"
|
||||
import { Integration } from "../../integration"
|
||||
import { Model } from "../../model"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
@@ -243,10 +242,6 @@ export const GithubCopilotPlugin = define({
|
||||
yield* ctx.session.hook("http.request", (evt) =>
|
||||
Effect.gen(function* () {
|
||||
if (evt.model.providerID !== Provider.ID.githubCopilot) return
|
||||
if (evt.agent === Agent.ID.make("title"))
|
||||
evt.request.headers.set("X-Interaction-Type", "conversation-background")
|
||||
if (evt.agent === Agent.ID.make("compaction"))
|
||||
evt.request.headers.set("X-Interaction-Type", "conversation-compaction")
|
||||
const token = evt.request.headers.get("x-api-key")
|
||||
if (!token) return
|
||||
const text = yield* Effect.promise(() => evt.request.clone().text())
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as SessionCompaction from "./compaction"
|
||||
|
||||
import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest, type LanguageModel } from "@opencode-ai/ai"
|
||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Document, type Entry } from "@opencode-ai/schema/config"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
@@ -12,17 +11,14 @@ import { llmClient } from "../effect/app-node-platform"
|
||||
import { SessionEvent } from "./event"
|
||||
import type { SessionMessage } from "./message"
|
||||
import { SessionModelHeaders } from "./model-headers"
|
||||
import { SessionModelHttp } from "./model-http"
|
||||
import { SessionPromptCacheKey } from "./prompt-cache-key"
|
||||
import { App } from "../app"
|
||||
import { SessionRunnerModel } from "./runner/model"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { toSessionError } from "./to-session-error"
|
||||
import { Token } from "../util/token"
|
||||
import type { Info, Ref } from "../model"
|
||||
import type { Info } from "../model"
|
||||
import { SessionUsage } from "./usage"
|
||||
import { PluginHooks } from "../plugin/hooks"
|
||||
import { Agent } from "../agent"
|
||||
|
||||
const DEFAULT_BUFFER = 20_000
|
||||
const DEFAULT_KEEP_TOKENS = 15_000
|
||||
@@ -70,18 +66,16 @@ type Dependencies = {
|
||||
readonly app: App.Info
|
||||
readonly bus: Bus.Interface
|
||||
readonly llm: {
|
||||
readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream<LLMEvent, AIError>
|
||||
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, AIError>
|
||||
}
|
||||
readonly models: SessionRunnerModel.Interface
|
||||
readonly config: Settings
|
||||
readonly hooks: PluginHooks.Interface
|
||||
}
|
||||
|
||||
export type AutoInput = {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly messages: readonly SessionMessage.Info[]
|
||||
readonly model: LanguageModel
|
||||
readonly ref: Ref
|
||||
readonly cost: Info["cost"]
|
||||
}
|
||||
|
||||
@@ -91,12 +85,9 @@ export type ManualInput = {
|
||||
readonly inputID: SessionMessage.ID
|
||||
}
|
||||
|
||||
type RequiredInput = Omit<AutoInput, "ref">
|
||||
|
||||
type Plan = {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly model: LanguageModel
|
||||
readonly ref: Ref
|
||||
readonly cost: Info["cost"]
|
||||
readonly reason: SessionMessage.Compaction["reason"]
|
||||
readonly prompt: string
|
||||
@@ -109,7 +100,7 @@ export type Outcome =
|
||||
| Pick<SessionMessage.CompactionFailed, "status" | "error">
|
||||
|
||||
export interface Interface {
|
||||
readonly required: (input: RequiredInput) => boolean
|
||||
readonly required: (input: AutoInput) => boolean
|
||||
readonly compact: (input: AutoInput) => Effect.Effect<Outcome>
|
||||
readonly compactManual: (input: ManualInput) => Effect.Effect<Outcome>
|
||||
}
|
||||
@@ -274,13 +265,6 @@ const make = (dependencies: Dependencies) => {
|
||||
messages: [Message.user(plan.prompt)],
|
||||
tools: [],
|
||||
}),
|
||||
{
|
||||
http: SessionModelHttp.middleware(dependencies.hooks, {
|
||||
sessionID: plan.session.id,
|
||||
agent: Agent.ID.make("compaction"),
|
||||
model: plan.ref,
|
||||
}),
|
||||
},
|
||||
)
|
||||
.pipe(
|
||||
Stream.runForEach((event) => {
|
||||
@@ -347,7 +331,6 @@ const make = (dependencies: Dependencies) => {
|
||||
return yield* execute({
|
||||
session: input.session,
|
||||
model: input.model,
|
||||
ref: input.ref,
|
||||
cost: input.cost,
|
||||
reason: "auto",
|
||||
...content,
|
||||
@@ -359,7 +342,7 @@ const make = (dependencies: Dependencies) => {
|
||||
error,
|
||||
})
|
||||
})
|
||||
const required = (input: RequiredInput) => {
|
||||
const required = (input: AutoInput) => {
|
||||
if (!config.auto) return false
|
||||
const context = input.model.route.defaults.limits?.context
|
||||
if (context === undefined || context <= 0) return false
|
||||
@@ -402,7 +385,6 @@ const make = (dependencies: Dependencies) => {
|
||||
return yield* execute({
|
||||
session: input.session,
|
||||
model: resolved.model,
|
||||
ref: resolved.ref,
|
||||
cost: resolved.cost,
|
||||
reason: "manual",
|
||||
inputID: input.inputID,
|
||||
@@ -424,13 +406,12 @@ export const layer = Layer.effect(
|
||||
const config = yield* Config.Service
|
||||
const models = yield* SessionRunnerModel.Service
|
||||
const app = yield* App.Metadata
|
||||
const hooks = yield* PluginHooks.Service
|
||||
return make({ bus, llm, models, config: settings(yield* config.entries()), app, hooks })
|
||||
return make({ bus, llm, models, config: settings(yield* config.entries()), app })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Bus.node, llmClient, Config.node, SessionRunnerModel.node, App.node, PluginHooks.node],
|
||||
deps: [Bus.node, llmClient, Config.node, SessionRunnerModel.node, App.node],
|
||||
})
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
export * as SessionModelHttp from "./model-http"
|
||||
|
||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { PluginHooks } from "../plugin/hooks"
|
||||
|
||||
export const middleware =
|
||||
(
|
||||
hooks: PluginHooks.Interface,
|
||||
input: { readonly sessionID: Session.ID; readonly agent: Agent.ID; readonly model: Model.Ref },
|
||||
): NonNullable<StreamOptions["http"]> =>
|
||||
(request, handler) =>
|
||||
Effect.gen(function* () {
|
||||
const before = yield* hooks.trigger("session", "http.request", {
|
||||
...input,
|
||||
request: yield* HttpClientRequest.toWeb(request),
|
||||
})
|
||||
let sent = HttpClientRequest.fromWeb(before.request)
|
||||
if (before.request.body)
|
||||
sent = HttpClientRequest.bodyUint8Array(
|
||||
sent,
|
||||
new Uint8Array(yield* Effect.promise(() => before.request.clone().arrayBuffer())),
|
||||
before.request.headers.get("content-type") ?? undefined,
|
||||
)
|
||||
const response = yield* handler(sent)
|
||||
const after = yield* hooks.trigger("session", "http.response", {
|
||||
...input,
|
||||
request: before.request,
|
||||
response: new Response(
|
||||
[204, 205, 304].includes(response.status) ? null : yield* Stream.toReadableStreamEffect(response.stream),
|
||||
{ status: response.status, headers: response.headers },
|
||||
),
|
||||
})
|
||||
return HttpClientResponse.fromWeb(sent, after.response)
|
||||
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))))
|
||||
@@ -4,7 +4,8 @@ import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||
import type { Content } from "@opencode-ai/schema/tool"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Cause, Config, Context, Effect, Layer, Result } from "effect"
|
||||
import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect"
|
||||
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { App } from "../app"
|
||||
import { Model } from "../model"
|
||||
@@ -14,7 +15,6 @@ import { QuestionTool } from "../tool/plugin/question"
|
||||
import { Tool } from "../tool"
|
||||
import { SessionContext } from "./context"
|
||||
import { SessionModelHeaders } from "./model-headers"
|
||||
import { SessionModelHttp } from "./model-http"
|
||||
import { SessionPromptCacheKey } from "./prompt-cache-key"
|
||||
import { PromptCacheDiagnostics } from "./prompt-cache-diagnostics"
|
||||
import { MAX_STEPS_PROMPT } from "./runner/max-steps"
|
||||
@@ -227,11 +227,36 @@ export const layer = Layer.effect(
|
||||
toolChoice: stepLimitReached ? "none" : undefined,
|
||||
})
|
||||
const options: StreamOptions = {
|
||||
http: SessionModelHttp.middleware(hooks, {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
}),
|
||||
http: (request, handler) =>
|
||||
Effect.gen(function* () {
|
||||
const before = yield* hooks.trigger("session", "http.request", {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
request: yield* HttpClientRequest.toWeb(request),
|
||||
})
|
||||
let sent = HttpClientRequest.fromWeb(before.request)
|
||||
if (before.request.body)
|
||||
sent = HttpClientRequest.bodyUint8Array(
|
||||
sent,
|
||||
new Uint8Array(yield* Effect.promise(() => before.request.clone().arrayBuffer())),
|
||||
before.request.headers.get("content-type") ?? undefined,
|
||||
)
|
||||
const response = yield* handler(sent)
|
||||
const after = yield* hooks.trigger("session", "http.response", {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
request: before.request,
|
||||
response: new Response(
|
||||
[204, 205, 304].includes(response.status)
|
||||
? null
|
||||
: yield* Stream.toReadableStreamEffect(response.stream),
|
||||
{ status: response.status, headers: response.headers },
|
||||
),
|
||||
})
|
||||
return HttpClientResponse.fromWeb(sent, after.response)
|
||||
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
|
||||
}
|
||||
if (promptCacheSnapshots) {
|
||||
const current = PromptCacheDiagnostics.snapshot(request)
|
||||
|
||||
@@ -244,7 +244,7 @@ const layer = Layer.effect(
|
||||
const model = resolved.model
|
||||
// Make room: history must fit the context window before the call. A pending manual
|
||||
// compaction owns this instead; the runner executes it between steps.
|
||||
const compactionInput = { session, messages: loaded.messages, model, ref: resolved.ref, cost: resolved.cost }
|
||||
const compactionInput = { session, messages: loaded.messages, model, cost: resolved.cost }
|
||||
if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) {
|
||||
const compacted = yield* compaction.compact(compactionInput)
|
||||
if (compacted.status === "completed")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as SessionTitle from "./title"
|
||||
|
||||
import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
|
||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||
import { Context, DateTime, Effect, Layer, Stream } from "effect"
|
||||
import { Agent } from "../agent"
|
||||
import { Database } from "../database/database"
|
||||
@@ -10,11 +9,9 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { isExactRootFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { App } from "../app"
|
||||
import { llmClient } from "../effect/app-node-platform"
|
||||
import { PluginHooks } from "../plugin/hooks"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionHistory } from "./history"
|
||||
import { SessionModelHeaders } from "./model-headers"
|
||||
import { SessionModelHttp } from "./model-http"
|
||||
import { SessionRunnerModel } from "./runner/model"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionUsage } from "./usage"
|
||||
@@ -27,12 +24,11 @@ type Dependencies = {
|
||||
readonly app: App.Info
|
||||
readonly bus: Bus.Interface
|
||||
readonly llm: {
|
||||
readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream<LLMEvent, AIError>
|
||||
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, AIError>
|
||||
}
|
||||
readonly agents: Agent.Interface
|
||||
readonly models: SessionRunnerModel.Interface
|
||||
readonly store: SessionStore.Interface
|
||||
readonly hooks: PluginHooks.Interface
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
@@ -89,13 +85,6 @@ const make = (dependencies: Dependencies) => {
|
||||
messages: [Message.user(firstUser.text)],
|
||||
tools: [],
|
||||
}),
|
||||
{
|
||||
http: SessionModelHttp.middleware(dependencies.hooks, {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
}),
|
||||
},
|
||||
)
|
||||
.pipe(
|
||||
Stream.runForEach((event) => {
|
||||
@@ -146,8 +135,7 @@ export const layer = Layer.effect(
|
||||
const store = yield* SessionStore.Service
|
||||
const database = yield* Database.Service
|
||||
const app = yield* App.Metadata
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const title = make({ bus, llm, agents, models, store, app, hooks })
|
||||
const title = make({ bus, llm, agents, models, store, app })
|
||||
return Service.of({
|
||||
generateForFirstPrompt: (sessionID) => title.generateForFirstPrompt(database.db, sessionID),
|
||||
})
|
||||
@@ -157,14 +145,5 @@ export const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [
|
||||
Bus.node,
|
||||
llmClient,
|
||||
Agent.node,
|
||||
SessionRunnerModel.node,
|
||||
SessionStore.node,
|
||||
Database.node,
|
||||
App.node,
|
||||
PluginHooks.node,
|
||||
],
|
||||
deps: [Bus.node, llmClient, Agent.node, SessionRunnerModel.node, SessionStore.node, Database.node, App.node],
|
||||
})
|
||||
|
||||
@@ -275,7 +275,7 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves tool result content in AI SDK prompts", () =>
|
||||
it.effect("moves tool result images and PDFs into an AI SDK user message", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* aisdk.hook.sdk((event) => {
|
||||
@@ -323,21 +323,96 @@ it.effect("preserves tool result content in AI SDK prompts", () =>
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "attachments" },
|
||||
{ type: "image-data", data: "AAAA", mediaType: "image/png" },
|
||||
{
|
||||
type: "file-data",
|
||||
data: "JVBERg==",
|
||||
mediaType: "application/pdf",
|
||||
filename: "document.pdf",
|
||||
},
|
||||
{ type: "file-data", data: "SUQz", mediaType: "audio/mpeg", filename: "clip.mp3" },
|
||||
{ type: "image-url", url: "https://example.com/pixel.png" },
|
||||
{ type: "file-url", url: "https://example.com/document.pdf" },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Attached media from tool result:" },
|
||||
{ type: "file", mediaType: "image/png", data: "data:image/png;base64,AAAA", filename: "pixel.png" },
|
||||
{
|
||||
type: "file",
|
||||
mediaType: "application/pdf",
|
||||
data: "data:application/pdf;charset=utf-8;base64,JVBERg==",
|
||||
filename: "document.pdf",
|
||||
},
|
||||
{ type: "file", mediaType: "image/png", data: "https://example.com/pixel.png" },
|
||||
{ type: "file", mediaType: "application/pdf", data: "https://example.com/document.pdf" },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("groups consecutive AI SDK tool media and keeps file-only results non-empty", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* aisdk.hook.sdk((event) => {
|
||||
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
|
||||
})
|
||||
|
||||
const resolved = yield* aisdk.model(model("test-ai-sdk"))
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: resolved,
|
||||
messages: [
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "read",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "one.png" }],
|
||||
},
|
||||
}),
|
||||
Message.tool({
|
||||
id: "call_2",
|
||||
name: "read",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "data:image/png;base64,BBBB", mime: "image/png", name: "two.png" }],
|
||||
},
|
||||
}),
|
||||
Message.assistant("Images received"),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.prompt).toEqual([
|
||||
{
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "call_1",
|
||||
toolName: "read",
|
||||
output: { type: "text", value: "Media attached in following user message." },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "call_2",
|
||||
toolName: "read",
|
||||
output: { type: "text", value: "Media attached in following user message." },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Attached media from tool result:" },
|
||||
{ type: "file", mediaType: "image/png", data: "data:image/png;base64,AAAA", filename: "one.png" },
|
||||
{ type: "file", mediaType: "image/png", data: "data:image/png;base64,BBBB", filename: "two.png" },
|
||||
],
|
||||
},
|
||||
{ role: "assistant", content: [{ type: "text", text: "Images received" }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import { Effect } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { EffectFlock } from "@opencode-ai/util/effect-flock"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
@@ -30,7 +30,7 @@ const testGlobal = Global.layerWith({
|
||||
log: os.tmpdir(),
|
||||
})
|
||||
|
||||
const testLayer = LayerNode.compile(EffectFlock.node, [[Global.node, testGlobal]])
|
||||
const testLayer = AppNodeBuilder.build(EffectFlock.node, [[Global.node, testGlobal]])
|
||||
|
||||
async function job() {
|
||||
if (msg.ready) await fs.writeFile(msg.ready, String(process.pid))
|
||||
|
||||
@@ -27,32 +27,8 @@ test("defensively syncs advertised Copilot models", async () => {
|
||||
max_context_window_tokens: 200000,
|
||||
max_output_tokens: 16384,
|
||||
max_prompt_tokens: 180000,
|
||||
vision: {
|
||||
max_prompt_image_size: 10000000,
|
||||
max_prompt_images: 10,
|
||||
supported_media_types: ["image/png", "application/pdf"],
|
||||
},
|
||||
},
|
||||
supports: { tool_calls: true, vision: true, reasoning_effort: ["low", "high"] },
|
||||
},
|
||||
},
|
||||
{
|
||||
model_picker_enabled: true,
|
||||
id: "vision-only",
|
||||
name: "Vision only",
|
||||
version: "vision-only-2026-06-01",
|
||||
capabilities: {
|
||||
family: "vision",
|
||||
limits: {
|
||||
max_output_tokens: 16384,
|
||||
max_prompt_tokens: 180000,
|
||||
vision: {
|
||||
max_prompt_image_size: 10000000,
|
||||
max_prompt_images: 10,
|
||||
supported_media_types: ["image/png"],
|
||||
},
|
||||
},
|
||||
supports: { tool_calls: true, vision: true },
|
||||
supports: { tool_calls: true, reasoning_effort: ["low", "high"] },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -91,8 +67,6 @@ test("defensively syncs advertised Copilot models", async () => {
|
||||
Model.VariantID.make("low"),
|
||||
Model.VariantID.make("high"),
|
||||
])
|
||||
expect(model?.capabilities.input).toEqual(["text", "image", "pdf"])
|
||||
expect(models.get(Model.ID.make("vision-only"))?.capabilities.input).toEqual(["text", "image"])
|
||||
expect(models.get(Model.ID.make("utility"))?.enabled).toBe(false)
|
||||
expect(models.has(Model.ID.make("stale"))).toBe(false)
|
||||
expect(models.has(Model.ID.make("incomplete"))).toBe(false)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Command } from "@opencode-ai/core/command"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
@@ -34,7 +35,7 @@ const npmLayer = Layer.succeed(
|
||||
}),
|
||||
)
|
||||
|
||||
export const PluginTestLayer = LayerNode.compile(
|
||||
export const PluginTestLayer = AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
FileSystem.node,
|
||||
FSUtil.node,
|
||||
|
||||
@@ -141,32 +141,6 @@ describe("GithubCopilotPlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies title generation as a background interaction", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
|
||||
sessionID: Session.ID.make("ses_title"),
|
||||
agent: Agent.ID.make("title"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("gpt-5.4-nano") }),
|
||||
request: new Request("https://api.githubcopilot.com/chat/completions"),
|
||||
})
|
||||
expect(event.request.headers.get("x-interaction-type")).toBe("conversation-background")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies compaction requests", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
|
||||
sessionID: Session.ID.make("ses_compaction"),
|
||||
agent: Agent.ID.make("compaction"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("gpt-5.4") }),
|
||||
request: new Request("https://api.githubcopilot.com/responses"),
|
||||
})
|
||||
expect(event.request.headers.get("x-interaction-type")).toBe("conversation-compaction")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from "path"
|
||||
import os from "os"
|
||||
import { Cause, Effect, Exit } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { EffectFlock } from "@opencode-ai/util/effect-flock"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
@@ -109,7 +110,7 @@ const testGlobal = Global.layerWith({
|
||||
log: os.tmpdir(),
|
||||
})
|
||||
|
||||
const testLayer = LayerNode.compile(EffectFlock.node, [[Global.node, testGlobal]])
|
||||
const testLayer = AppNodeBuilder.build(EffectFlock.node, [[Global.node, testGlobal]])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
batch,
|
||||
Show,
|
||||
} from "solid-js"
|
||||
import { createStore, unwrap } from "solid-js/store"
|
||||
import { createStore } from "solid-js/store"
|
||||
import {
|
||||
TuiLifecycleProvider,
|
||||
TuiAppProvider,
|
||||
@@ -62,7 +62,6 @@ import { useConnected } from "./component/use-connected"
|
||||
import { DialogMcp } from "./component/dialog-mcp"
|
||||
import { DialogStatus } from "./component/dialog-status"
|
||||
import { DialogConfig } from "./component/dialog-config"
|
||||
import { DialogExperiments } from "./component/dialog-experiments"
|
||||
import { DialogDebug } from "./component/dialog-debug"
|
||||
import { DialogPair, type DialogPairCredentials } from "./component/dialog-pair"
|
||||
import { DialogThemeList } from "./component/dialog-theme-list"
|
||||
@@ -658,22 +657,8 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
category: "Session",
|
||||
slash: { name: "new", aliases: ["clear"] },
|
||||
run: () => {
|
||||
// With per-tab drafts, a new session is an explicit "this belongs
|
||||
// elsewhere" gesture: move the in-progress draft instead of leaving
|
||||
// a copy behind on the tab it came from.
|
||||
const carried = (() => {
|
||||
if (config.data.experimental?.tab_drafts !== true) return undefined
|
||||
const current = promptRef.current
|
||||
if (!current?.current.text) return undefined
|
||||
// Copy before reset: reset() merges an empty prompt into the same
|
||||
// underlying store object that unwrap exposes.
|
||||
const prompt = { ...unwrap(current.current) }
|
||||
current.reset()
|
||||
return prompt
|
||||
})()
|
||||
route.navigate({
|
||||
type: "home",
|
||||
prompt: carried,
|
||||
location:
|
||||
route.data.type === "session"
|
||||
? (data.session.get(route.data.sessionID)?.location ?? location.ref)
|
||||
@@ -885,18 +870,6 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
// Deliberately absent from the command palette; reachable only by the
|
||||
// secret /baldbeard incantation.
|
||||
name: "opencode.experiments",
|
||||
title: "Experiments",
|
||||
palette: undefined,
|
||||
slash: { name: "baldbeard" },
|
||||
run: () => {
|
||||
dialog.replace(() => <DialogExperiments />)
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
name: "opencode.status",
|
||||
title: "View status",
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { useConfig } from "../config"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
||||
type Experiment = {
|
||||
id: "tab_drafts"
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
// In-flight features anyone can opt into. Each entry is temporary: an
|
||||
// experiment either graduates (delete the entry, make the behavior
|
||||
// unconditional) or dies (delete the entry and the branch it gated).
|
||||
export const experiments: Experiment[] = [
|
||||
{
|
||||
id: "tab_drafts",
|
||||
title: "Per-tab prompt drafts",
|
||||
description: "Keep unsent prompt drafts on the tab where they were written. New session moves the current draft.",
|
||||
},
|
||||
]
|
||||
|
||||
export function DialogExperiments() {
|
||||
const config = useConfig()
|
||||
const toast = useToast()
|
||||
const [saving, setSaving] = createSignal(false)
|
||||
|
||||
const enabled = (experiment: Experiment) => config.data.experimental?.[experiment.id] === true
|
||||
|
||||
const options = createMemo(() =>
|
||||
experiments.map((experiment, index) => ({
|
||||
title: experiment.title,
|
||||
description: experiment.description,
|
||||
category: "Experiments",
|
||||
footer: enabled(experiment) ? "on" : "off",
|
||||
value: index,
|
||||
})),
|
||||
)
|
||||
|
||||
async function toggle(index: number) {
|
||||
if (saving()) return
|
||||
const experiment = experiments[index]
|
||||
if (!experiment) return
|
||||
const next = !enabled(experiment)
|
||||
setSaving(true)
|
||||
await config
|
||||
.update((draft) => {
|
||||
if (!draft.experimental || typeof draft.experimental !== "object") draft.experimental = {}
|
||||
draft.experimental[experiment.id] = next
|
||||
})
|
||||
.catch(toast.error)
|
||||
.finally(() => setSaving(false))
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Experiments"
|
||||
options={options()}
|
||||
onSelect={(option) => void toggle(option.value)}
|
||||
footerHints={[{ title: "enter", label: "toggle" }]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -130,11 +130,7 @@ function formatEditorContext(selection: EditorSelection) {
|
||||
return `<system-reminder>${ranges.join("\n")} This may or may not be relevant to the current task.</system-reminder>\n`
|
||||
}
|
||||
|
||||
// One in-progress draft survives remounts. By default a single slot follows
|
||||
// focus across tabs; the tab_drafts experiment keys drafts to the tab (session
|
||||
// or home) they were written in.
|
||||
let stashed: { prompt: PromptInfo; cursor: number } | undefined
|
||||
const stashedByTab = new Map<string, { prompt: PromptInfo; cursor: number }>()
|
||||
|
||||
function argumentSlash(input: string, commands: readonly KeymapCommand[]) {
|
||||
const head = parseSlashHead(input, /\s/)
|
||||
@@ -651,17 +647,9 @@ export function Prompt(props: PromptProps) {
|
||||
},
|
||||
}
|
||||
|
||||
// Captured once: the session route is keyed by sessionID, so this Prompt
|
||||
// instance belongs to exactly one tab. Reading props.sessionID lazily would
|
||||
// observe the *next* route during onCleanup and stash under the wrong tab.
|
||||
const stashSessionID = props.sessionID
|
||||
const stashKey = () => (config.experimental?.tab_drafts === true ? (stashSessionID ?? "home") : undefined)
|
||||
|
||||
onMount(() => {
|
||||
const key = stashKey()
|
||||
const saved = key === undefined ? stashed : stashedByTab.get(key)
|
||||
if (key === undefined) stashed = undefined
|
||||
else stashedByTab.delete(key)
|
||||
const saved = stashed
|
||||
stashed = undefined
|
||||
if (store.prompt.text) return
|
||||
if (saved && saved.prompt.text) {
|
||||
input.setText(saved.prompt.text)
|
||||
@@ -674,10 +662,7 @@ export function Prompt(props: PromptProps) {
|
||||
onCleanup(() => {
|
||||
disposed = true
|
||||
if (store.prompt.text) {
|
||||
const entry = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
|
||||
const key = stashKey()
|
||||
if (key === undefined) stashed = entry
|
||||
else stashedByTab.set(key, entry)
|
||||
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
|
||||
}
|
||||
setInputTarget(undefined)
|
||||
props.ref?.(undefined)
|
||||
|
||||
@@ -189,13 +189,6 @@ export const Info = Schema.Struct({
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Debugging settings" }),
|
||||
experimental: Schema.optional(
|
||||
Schema.Struct({
|
||||
tab_drafts: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Keep unsent prompt drafts on the tab where they were written",
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Experimental features that may change or be removed at any time" }),
|
||||
animations: Schema.optional(Schema.Boolean).annotate({ description: "Enable interface animations" }),
|
||||
mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable terminal mouse capture" }),
|
||||
cursor: Schema.optional(Cursor),
|
||||
|
||||
@@ -161,8 +161,8 @@ Skills are keyed by ID. If several sources define the same ID, the later source
|
||||
wins. Sources are registered in this order, from lower to higher precedence:
|
||||
|
||||
1. Built-in skills
|
||||
2. `.claude/skills` sources, global first and then from the farthest ancestor toward the current directory
|
||||
3. `.agents/skills` sources, global first and then from the farthest ancestor toward the current directory
|
||||
2. `.claude/skills` sources, global first and then from the current directory upward
|
||||
3. `.agents/skills` sources, global first and then from the current directory upward
|
||||
4. `~/.config/opencode/skills`
|
||||
5. Project `.opencode/skills`, from the project root toward the current directory
|
||||
6. Explicit `skills` config entries, in config priority and array order
|
||||
|
||||
Reference in New Issue
Block a user