mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-26 02:14:43 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 138339a88d |
@@ -17,6 +17,8 @@ import { Token } from "../util/token"
|
||||
const DEFAULT_BUFFER = 20_000
|
||||
const DEFAULT_KEEP_TOKENS = 8_000
|
||||
const TOOL_OUTPUT_MAX_CHARS = 2_000
|
||||
const COMPACTION_CHUNK_TOKENS = 32_000
|
||||
const SUMMARY_OUTPUT_TOKENS = 4_096
|
||||
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
|
||||
<template>
|
||||
## Objective
|
||||
@@ -80,7 +82,8 @@ type Plan = {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly model: Model
|
||||
readonly reason: SessionMessage.Compaction["reason"]
|
||||
readonly prompt: string
|
||||
readonly previousSummary?: string
|
||||
readonly context: readonly string[]
|
||||
readonly recent: string
|
||||
readonly inputID?: SessionMessage.ID
|
||||
}
|
||||
@@ -199,6 +202,14 @@ export const buildPrompt = (input: { readonly previousSummary?: string; readonly
|
||||
...input.context,
|
||||
].join("\n\n")
|
||||
|
||||
const chunkContext = (context: readonly string[]) => {
|
||||
const value = context.filter(Boolean).join("\n\n")
|
||||
const size = COMPACTION_CHUNK_TOKENS * 4
|
||||
return Array.from({ length: Math.ceil(value.length / size) }, (_, index) =>
|
||||
value.slice(index * size, (index + 1) * size),
|
||||
)
|
||||
}
|
||||
|
||||
const planContent = (messages: readonly SessionMessage.Info[], tokens: number) => {
|
||||
const selected = select(messages, tokens)
|
||||
if (!selected) return
|
||||
@@ -208,10 +219,8 @@ const planContent = (messages: readonly SessionMessage.Info[], tokens: number) =
|
||||
const previousRecent = previousSummary?.type === "compaction" ? previousSummary.recent : ""
|
||||
const summarizeRecent = !previousRecent && !selected.head
|
||||
return {
|
||||
prompt: buildPrompt({
|
||||
previousSummary: previousSummary?.type === "compaction" ? previousSummary.summary : undefined,
|
||||
context: summarizeRecent ? [selected.recent] : [previousRecent, selected.head].filter(Boolean),
|
||||
}),
|
||||
previousSummary: previousSummary?.type === "compaction" ? previousSummary.summary : undefined,
|
||||
context: chunkContext(summarizeRecent ? [selected.recent] : [previousRecent, selected.head]),
|
||||
recent: summarizeRecent ? "" : selected.recent,
|
||||
}
|
||||
}
|
||||
@@ -235,62 +244,67 @@ const make = (dependencies: Dependencies) => {
|
||||
inputID: plan.inputID,
|
||||
})
|
||||
|
||||
const chunks: string[] = []
|
||||
let failure: SessionError.Error | undefined
|
||||
yield* dependencies.llm
|
||||
.stream(
|
||||
LLM.request({
|
||||
model: plan.model,
|
||||
messages: [Message.user(plan.prompt)],
|
||||
tools: [],
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event))
|
||||
failure = {
|
||||
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
|
||||
message: event.message,
|
||||
}
|
||||
if (LLMEvent.is.textDelta(event)) {
|
||||
chunks.push(event.text)
|
||||
return dependencies.events.publish(SessionEvent.Compaction.Delta, {
|
||||
sessionID: plan.sessionID,
|
||||
text: event.text,
|
||||
})
|
||||
}
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.catchTag("LLM.Error", (error) =>
|
||||
Effect.sync(() => {
|
||||
failure = toSessionError(error)
|
||||
let summary = plan.previousSummary
|
||||
for (const [index, context] of plan.context.entries()) {
|
||||
const chunks: string[] = []
|
||||
let failure: SessionError.Error | undefined
|
||||
const publish = index === plan.context.length - 1
|
||||
yield* dependencies.llm
|
||||
.stream(
|
||||
LLM.request({
|
||||
model: plan.model,
|
||||
messages: [Message.user(buildPrompt({ previousSummary: summary, context: [context] }))],
|
||||
tools: [],
|
||||
generation: { maxTokens: SUMMARY_OUTPUT_TOKENS },
|
||||
}),
|
||||
),
|
||||
Effect.onInterrupt(() =>
|
||||
plan.reason === "auto"
|
||||
? failed({
|
||||
sessionID: plan.sessionID,
|
||||
reason: plan.reason,
|
||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||
inputID: plan.inputID,
|
||||
}).pipe(Effect.asVoid)
|
||||
: Effect.void,
|
||||
),
|
||||
)
|
||||
const summary = chunks.join("")
|
||||
if (failure || !summary.trim()) {
|
||||
const error = failure ?? { type: "compaction.failed" as const, message: "Compaction produced no summary" }
|
||||
return yield* failed({
|
||||
sessionID: plan.sessionID,
|
||||
reason: plan.reason,
|
||||
error,
|
||||
inputID: plan.inputID,
|
||||
})
|
||||
)
|
||||
.pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event))
|
||||
failure = {
|
||||
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
|
||||
message: event.message,
|
||||
}
|
||||
if (LLMEvent.is.textDelta(event)) {
|
||||
chunks.push(event.text)
|
||||
if (publish)
|
||||
return dependencies.events.publish(SessionEvent.Compaction.Delta, {
|
||||
sessionID: plan.sessionID,
|
||||
text: event.text,
|
||||
})
|
||||
}
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.catchTag("LLM.Error", (error) =>
|
||||
Effect.sync(() => {
|
||||
failure = toSessionError(error)
|
||||
}),
|
||||
),
|
||||
Effect.onInterrupt(() =>
|
||||
plan.reason === "auto"
|
||||
? failed({
|
||||
sessionID: plan.sessionID,
|
||||
reason: plan.reason,
|
||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||
inputID: plan.inputID,
|
||||
}).pipe(Effect.asVoid)
|
||||
: Effect.void,
|
||||
),
|
||||
)
|
||||
const next = chunks.join("")
|
||||
if (failure || !next.trim())
|
||||
return yield* failed({
|
||||
sessionID: plan.sessionID,
|
||||
reason: plan.reason,
|
||||
error: failure ?? { type: "compaction.failed", message: "Compaction produced no summary" },
|
||||
inputID: plan.inputID,
|
||||
})
|
||||
summary = next
|
||||
}
|
||||
yield* dependencies.events.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID: plan.sessionID,
|
||||
reason: plan.reason,
|
||||
text: summary,
|
||||
text: summary ?? "",
|
||||
recent: plan.recent,
|
||||
})
|
||||
return { status: "completed" as const }
|
||||
|
||||
@@ -151,7 +151,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
expect(Array.from(yield* Fiber.join(delta)).map((event) => event.data.text)).toEqual(["manual summary"])
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.generation).toBeUndefined()
|
||||
expect(requests[0]?.generation?.maxTokens).toBe(4_096)
|
||||
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
|
||||
expect(yield* store.context(sessionID)).toMatchObject([
|
||||
{ type: "compaction", reason: "manual", summary: "manual summary", recent: "" },
|
||||
@@ -170,3 +170,53 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("manual compaction rolls large histories through bounded requests", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
const db = (yield* Database.Service).db
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const sessionID = SessionV2.ID.make("ses_bounded_compaction")
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "bounded-compaction",
|
||||
directory: "/project",
|
||||
title: "Bounded compaction",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const session = yield* store
|
||||
.get(sessionID)
|
||||
.pipe(Effect.flatMap((value) => (value ? Effect.succeed(value) : Effect.die("test session missing"))))
|
||||
|
||||
expect(
|
||||
yield* compaction.compactManual({
|
||||
session,
|
||||
messages: [
|
||||
{
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "a".repeat(300_000),
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
},
|
||||
],
|
||||
inputID: SessionMessage.ID.make("msg_bounded_compaction"),
|
||||
}),
|
||||
).toEqual({ status: "completed" })
|
||||
|
||||
expect(requests.length).toBeGreaterThan(1)
|
||||
expect(requests.every((request) => JSON.stringify(request.messages).length < 150_000)).toBe(true)
|
||||
expect(JSON.stringify(requests[1]?.messages)).toContain("manual summary")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -95,9 +95,17 @@ export function Home() {
|
||||
{(_) => {
|
||||
const form = forms()[0]
|
||||
return form ? (
|
||||
<box position="absolute" zIndex={2000} left={0} right={0} bottom={1} paddingLeft={2} paddingRight={2}>
|
||||
<box
|
||||
position="absolute"
|
||||
zIndex={2000}
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
>
|
||||
<box width="100%">
|
||||
<FormPrompt form={form} onNotFound={() => data.session.form.refresh(form.sessionID, form.location)} />
|
||||
<FormPrompt form={form} />
|
||||
</box>
|
||||
</box>
|
||||
) : null
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core"
|
||||
import open from "open"
|
||||
import { selectedForeground, tint, useTheme } from "../../context/theme"
|
||||
import { isFormNotFoundError, type FormField, type FormValue } from "@opencode-ai/client"
|
||||
import type { FormField, FormValue } from "@opencode-ai/client"
|
||||
import type { FormWithLocation } from "../../context/data"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { useClipboard } from "../../context/clipboard"
|
||||
@@ -144,7 +144,7 @@ function requestOptions(form: FormWithLocation) {
|
||||
}
|
||||
}
|
||||
|
||||
export function FormPrompt(props: { form: FormWithLocation; onNotFound: () => Promise<void> }) {
|
||||
export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
const sdk = useSDK()
|
||||
const { theme } = useTheme()
|
||||
const renderer = useRenderer()
|
||||
@@ -295,19 +295,6 @@ export function FormPrompt(props: { form: FormWithLocation; onNotFound: () => Pr
|
||||
setStore("error", "")
|
||||
}
|
||||
|
||||
function replyFailed(error: unknown) {
|
||||
if (isFormNotFoundError(error)) {
|
||||
void props.onNotFound().catch(() => setStore("error", error.message))
|
||||
return
|
||||
}
|
||||
setStore(
|
||||
"error",
|
||||
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
|
||||
? error.message
|
||||
: "Invalid answer",
|
||||
)
|
||||
}
|
||||
|
||||
function replySingle(field: Field, value: FormValue) {
|
||||
sdk.api.form
|
||||
.reply(
|
||||
@@ -318,7 +305,14 @@ export function FormPrompt(props: { form: FormWithLocation; onNotFound: () => Pr
|
||||
},
|
||||
requestOptions(props.form),
|
||||
)
|
||||
.catch(replyFailed)
|
||||
.catch((error: unknown) => {
|
||||
setStore(
|
||||
"error",
|
||||
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
|
||||
? error.message
|
||||
: "Invalid answer",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function pick(value: FormValue, customValue?: string) {
|
||||
@@ -550,7 +544,14 @@ export function FormPrompt(props: { form: FormWithLocation; onNotFound: () => Pr
|
||||
},
|
||||
requestOptions(props.form),
|
||||
)
|
||||
.catch(replyFailed)
|
||||
.catch((error: unknown) => {
|
||||
setStore(
|
||||
"error",
|
||||
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
|
||||
? error.message
|
||||
: "Invalid answer",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => onCleanup(modeStack.push(FORM_MODE)))
|
||||
|
||||
@@ -918,12 +918,7 @@ export function Session() {
|
||||
<Show when={forms()[0]?.id} keyed>
|
||||
{(_) => {
|
||||
const form = forms()[0]
|
||||
return form ? (
|
||||
<FormPrompt
|
||||
form={form}
|
||||
onNotFound={() => data.session.form.refresh(form.sessionID, form.location)}
|
||||
/>
|
||||
) : null
|
||||
return form ? <FormPrompt form={form} /> : null
|
||||
}}
|
||||
</Show>
|
||||
</Match>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { testRender, useRenderer } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { createSignal, onCleanup, Show } from "solid-js"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { ClipboardProvider } from "../../../src/context/clipboard"
|
||||
import type { FormWithLocation } from "../../../src/context/data"
|
||||
import { KVProvider } from "../../../src/context/kv"
|
||||
@@ -18,13 +18,12 @@ import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { createApi, createClient, createEventStream, createFetch } from "../../fixture/tui-sdk"
|
||||
|
||||
async function mountForm(root: string, width = 80, missing = false) {
|
||||
async function mountForm(root: string, width = 80) {
|
||||
const state = path.join(root, "state")
|
||||
await mkdir(state, { recursive: true })
|
||||
await Bun.write(path.join(state, "kv.json"), "{}")
|
||||
|
||||
const replies: unknown[] = []
|
||||
const notFound: string[] = []
|
||||
const copied: string[] = []
|
||||
const events = createEventStream()
|
||||
const transport = createFetch(
|
||||
@@ -32,11 +31,6 @@ async function mountForm(root: string, width = 80, missing = false) {
|
||||
url.pathname === "/api/session/ses_test/form/frm_test/reply"
|
||||
? request.json().then((answer) => {
|
||||
replies.push(answer)
|
||||
if (missing)
|
||||
return Response.json(
|
||||
{ _tag: "FormNotFoundError", id: "frm_test", message: "Form not found: frm_test" },
|
||||
{ status: 404 },
|
||||
)
|
||||
return new Response(null, { status: 204 })
|
||||
})
|
||||
: undefined,
|
||||
@@ -63,7 +57,6 @@ async function mountForm(root: string, width = 80, missing = false) {
|
||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||
const off = registerOpencodeKeymap(keymap, renderer, config)
|
||||
onCleanup(off)
|
||||
const [visible, setVisible] = createSignal(true)
|
||||
|
||||
return (
|
||||
<TestTuiContexts
|
||||
@@ -88,16 +81,7 @@ async function mountForm(root: string, width = 80, missing = false) {
|
||||
<KVProvider>
|
||||
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
|
||||
<ToastProvider>
|
||||
<Show when={visible()}>
|
||||
<FormPrompt
|
||||
form={form}
|
||||
onNotFound={() => {
|
||||
notFound.push(form.id)
|
||||
setVisible(false)
|
||||
return Promise.resolve()
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
<FormPrompt form={form} />
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</KVProvider>
|
||||
@@ -112,28 +96,9 @@ async function mountForm(root: string, width = 80, missing = false) {
|
||||
const app = await testRender(() => <Harness />, { width, height: 20, kittyKeyboard: true })
|
||||
app.renderer.start()
|
||||
await app.waitForFrame((frame) => frame.includes("Authorization required"))
|
||||
return { app, copied, notFound, replies }
|
||||
return { app, copied, replies }
|
||||
}
|
||||
|
||||
test("dismisses a form that no longer exists when submitting", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const prompt = await mountForm(tmp.path, 80, true)
|
||||
try {
|
||||
prompt.app.mockInput.pressKey("right")
|
||||
prompt.app.mockInput.pressKey("left")
|
||||
prompt.app.mockInput.pressKey("c")
|
||||
await prompt.app.waitForFrame((frame) => frame.includes("press enter to confirm"))
|
||||
prompt.app.mockInput.pressEnter()
|
||||
await prompt.app.waitForFrame((frame) => frame.includes("Acknowledged"))
|
||||
prompt.app.mockInput.pressEnter()
|
||||
|
||||
await prompt.app.waitForFrame((frame) => !frame.includes("Authorization required"))
|
||||
expect(prompt.notFound).toEqual(["frm_test"])
|
||||
} finally {
|
||||
prompt.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("requires explicit acknowledgement before submitting an external field", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const prompt = await mountForm(tmp.path)
|
||||
|
||||
Reference in New Issue
Block a user