diff --git a/packages/core/migration/20260603120017_warm_guardsmen/migration.sql b/packages/core/migration/20260603120017_warm_guardsmen/migration.sql index 971de1308d0..5ed1415ac50 100644 --- a/packages/core/migration/20260603120017_warm_guardsmen/migration.sql +++ b/packages/core/migration/20260603120017_warm_guardsmen/migration.sql @@ -2,7 +2,8 @@ ALTER TABLE `part` ADD `data_model` text; --> statement-breakpoint UPDATE part SET data_model = json_remove(data, '$.state.metadata') -WHERE length(CAST(data AS BLOB)) > 65536 +WHERE json_valid(data) + AND length(CAST(data AS BLOB)) > 65536 AND json_extract(data, '$.type') = 'tool' AND json_extract(data, '$.state.status') = 'completed' AND length(CAST(json_extract(data, '$.state.metadata') AS BLOB)) > 65536; diff --git a/packages/core/src/database/migration/20260603120017_warm_guardsmen.ts b/packages/core/src/database/migration/20260603120017_warm_guardsmen.ts index b87a8b9c5b4..25cca30b943 100644 --- a/packages/core/src/database/migration/20260603120017_warm_guardsmen.ts +++ b/packages/core/src/database/migration/20260603120017_warm_guardsmen.ts @@ -11,7 +11,8 @@ export default { yield* tx.run(` UPDATE part SET data_model = json_remove(data, '$.state.metadata') - WHERE length(CAST(data AS BLOB)) > 65536 + WHERE json_valid(data) + AND length(CAST(data AS BLOB)) > 65536 AND json_extract(data, '$.type') = 'tool' AND json_extract(data, '$.state.status') = 'completed' AND length(CAST(json_extract(data, '$.state.metadata') AS BLOB)) > 65536 diff --git a/packages/core/src/session/model-data.ts b/packages/core/src/session/model-data.ts index d86519a38ec..f1d1c5b8453 100644 --- a/packages/core/src/session/model-data.ts +++ b/packages/core/src/session/model-data.ts @@ -6,10 +6,14 @@ type V1PartData = Data extends Ses ? Omit : never +export type ModelData = Omit, "state"> & { + state: Omit +} + export const THRESHOLD = 64 * 1024 // Strip UI-only metadata only when the stored prompt projection benefits. -export function create(data: unknown): V1PartData | null { +export function create(data: unknown): ModelData | null { if (!data || typeof data !== "object") return null if (!("type" in data) || data.type !== "tool") return null if (!("state" in data) || !data.state || typeof data.state !== "object") return null @@ -18,5 +22,5 @@ export function create(data: unknown): V1PartData | null { const metadata = JSON.stringify(data.state.metadata) if (!metadata || Buffer.byteLength(metadata) <= THRESHOLD) return null const { metadata: _, ...state } = data.state - return { ...data, state } as V1PartData + return { ...data, state } as ModelData } diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index fd80638e070..4a9c74de0fa 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -9,6 +9,7 @@ import type { SessionSchema } from "./schema" import type { MessageID, PartID, SessionV1 } from "../v1/session" import { WorkspaceV2 } from "../workspace" import { Timestamps } from "../database/schema.sql" +import type { ModelData } from "./model-data" type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id"> type V1MessageData = Omit @@ -86,7 +87,7 @@ export const PartTable = sqliteTable( ...Timestamps, data: text({ mode: "json" }).notNull().$type(), // Derived prompt projection; data remains canonical. - data_model: text({ mode: "json" }).$type(), + data_model: text({ mode: "json" }).$type(), }, (table) => [ index("part_message_id_id_idx").on(table.message_id, table.id), diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index aa2c77b78b5..97a94b0f183 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -86,7 +86,8 @@ describe("DatabaseMigration", () => { const large = JSON.stringify({ type: "tool", state: { status: "completed", metadata: { diff: "x".repeat(70_000) } } }) const unicode = JSON.stringify({ type: "tool", state: { status: "completed", metadata: { diff: "😀".repeat(20_000) } } }) const small = JSON.stringify({ type: "tool", state: { status: "completed", metadata: { diff: "small" } } }) - yield* db.run(sql`INSERT INTO part (id, data) VALUES (${"large"}, ${large}), (${"unicode"}, ${unicode}), (${"small"}, ${small})`) + const malformed = "{" + "x".repeat(70_000) + yield* db.run(sql`INSERT INTO part (id, data) VALUES (${"large"}, ${large}), (${"unicode"}, ${unicode}), (${"small"}, ${small}), (${"malformed"}, ${malformed})`) yield* DatabaseMigration.applyOnly(db, [partModelDataMigration]) @@ -98,6 +99,7 @@ describe("DatabaseMigration", () => { expect(yield* db.get(sql`SELECT data_model FROM part WHERE id = ${"unicode"}`)).toEqual({ data_model: JSON.stringify({ type: "tool", state: { status: "completed" } }), }) + expect(yield* db.get(sql`SELECT data_model FROM part WHERE id = ${"malformed"}`)).toEqual({ data_model: null }) }), ) }) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 9c424078126..f287ea25c5c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -319,20 +319,18 @@ export function Prompt(props: PromptProps) { let promptPartTypeId = 0 const event = useEvent() - onCleanup( - event.on(TuiEvent.PromptAppend.type, (evt, { workspace }) => { - if (workspace !== project.workspace.current()) return + event.on(TuiEvent.PromptAppend.type, (evt, { workspace }) => { + if (workspace !== project.workspace.current()) return + if (!input || input.isDestroyed) return + input.insertText(evt.properties.text) + setTimeout(() => { + // setTimeout is a workaround and needs to be addressed properly if (!input || input.isDestroyed) return - input.insertText(evt.properties.text) - setTimeout(() => { - // setTimeout is a workaround and needs to be addressed properly - if (!input || input.isDestroyed) return - input.getLayoutNode().markDirty() - input.gotoBufferEnd() - renderer.requestRender() - }, 0) - }), - ) + input.getLayoutNode().markDirty() + input.gotoBufferEnd() + renderer.requestRender() + }, 0) + }) createEffect(() => { if (!input || input.isDestroyed) return diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 11d39a686c6..350a99ddb1e 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -7,7 +7,6 @@ import { For, Match, on, - onCleanup, onMount, Show, Switch, @@ -292,23 +291,21 @@ export function Session() { }) let lastSwitch: string | undefined = undefined - onCleanup( - event.on("message.part.updated", (evt) => { - const part = evt.properties.part - if (part.type !== "tool") return - if (part.sessionID !== route.sessionID) return - if (part.state.status !== "completed") return - if (part.id === lastSwitch) return + event.on("message.part.updated", (evt) => { + const part = evt.properties.part + if (part.type !== "tool") return + if (part.sessionID !== route.sessionID) return + if (part.state.status !== "completed") return + if (part.id === lastSwitch) return - if (part.tool === "plan_exit") { - local.agent.set("build") - lastSwitch = part.id - } else if (part.tool === "plan_enter") { - local.agent.set("plan") - lastSwitch = part.id - } - }), - ) + if (part.tool === "plan_exit") { + local.agent.set("build") + lastSwitch = part.id + } else if (part.tool === "plan_enter") { + local.agent.set("plan") + lastSwitch = part.id + } + }) let seeded = false let scroll: ScrollBoxRenderable @@ -324,27 +321,25 @@ export function Session() { const dialog = useDialog() const renderer = useRenderer() - onCleanup( - event.on("session.status", (evt) => { - if (evt.properties.sessionID !== route.sessionID) return - if (evt.properties.status.type !== "retry") return - if (!evt.properties.status.action) return - if (dialog.stack.length > 0) return + event.on("session.status", (evt) => { + if (evt.properties.sessionID !== route.sessionID) return + if (evt.properties.status.type !== "retry") return + if (!evt.properties.status.action) return + if (dialog.stack.length > 0) return - const keys = goUpsellKeys(evt.properties.status.action) - if (!keys) return + const keys = goUpsellKeys(evt.properties.status.action) + if (!keys) return - const seen = kv.get(keys.lastSeenAt) - if (typeof seen === "number" && Date.now() - seen < GO_UPSELL_WINDOW) return + const seen = kv.get(keys.lastSeenAt) + if (typeof seen === "number" && Date.now() - seen < GO_UPSELL_WINDOW) return - if (kv.get(keys.dontShow)) return + if (kv.get(keys.dontShow)) return - void DialogRetryAction.show(dialog, evt.properties.status.action).then((dontShowAgain) => { - if (dontShowAgain) kv.set(keys.dontShow, true) - kv.set(keys.lastSeenAt, Date.now()) - }) - }), - ) + void DialogRetryAction.show(dialog, evt.properties.status.action).then((dontShowAgain) => { + if (dontShowAgain) kv.set(keys.dontShow, true) + kv.set(keys.lastSeenAt, Date.now()) + }) + }) const exit = useExit() diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 69b19342cff..665b62898b8 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -170,16 +170,14 @@ export const layer = Layer.effect( def: D, fn: (data: EventV2.Data) => Effect.Effect, ) => - events - .listen((event) => { - if (event.type !== def.type || event.location?.directory !== _ctx.directory) return Effect.void - return fn(event.data as EventV2.Data).pipe( - Effect.catchCause((cause) => - Effect.sync(() => log.error("share subscriber failed", { type: def.type, cause })), - ), - ) - }) - .pipe(Effect.tap((unsubscribe) => Effect.addFinalizer(() => unsubscribe))) + events.listen((event) => { + if (event.type !== def.type || event.location?.directory !== _ctx.directory) return Effect.void + return fn(event.data as EventV2.Data).pipe( + Effect.catchCause((cause) => + Effect.sync(() => log.error("share subscriber failed", { type: def.type, cause })), + ), + ) + }) yield* watch(Session.Event.Updated, (data) => Effect.gen(function* () { diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index bf31c588529..5069ed809e3 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -762,6 +762,17 @@ describe("session.compaction.prune", () => { expect(Object.getOwnPropertyDescriptor(small.state, "metadata")?.get).toBeUndefined() expect(small.state.metadata).toEqual({ description: "small" }) } + + part.state.metadata = { output: "x".repeat(200_000), description: "large again" } + yield* ssn.updatePart(part) + const largeAgain = (yield* MessageV2.filterCompactedEffect(info.id)) + .flatMap((msg) => msg.parts) + .find((item) => item.id === part.id) + expect(largeAgain?.type).toBe("tool") + if (largeAgain?.type === "tool" && largeAgain.state.status === "completed") { + expect(Object.getOwnPropertyDescriptor(largeAgain.state, "metadata")?.get).toBeFunction() + expect(largeAgain.state.metadata.output).toHaveLength(200_000) + } } } }), diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index 88b73da9b97..feb070a09cb 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -19,7 +19,6 @@ import { eq } from "drizzle-orm" import { provideTmpdirInstance } from "../fixture/fixture" import { resetDatabase } from "../fixture/db" import { testEffect } from "../lib/effect" -import { disposeInstance } from "@/effect/instance-registry" const env = Layer.mergeAll( Session.defaultLayer, @@ -41,10 +40,10 @@ const json = (req: Parameters[0], body: unkno const none = HttpClient.make(() => Effect.die("unexpected http call")) -function live(client: HttpClient.HttpClient, events = EventV2Bridge.defaultLayer) { +function live(client: HttpClient.HttpClient) { const http = Layer.succeed(HttpClient.HttpClient, client) return ShareNext.layer.pipe( - Layer.provide(events), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(Account.layer.pipe(Layer.provide(AccountRepo.defaultLayer), Layer.provide(http))), Layer.provide(Config.defaultLayer), Layer.provide(Database.defaultLayer), @@ -102,34 +101,6 @@ beforeEach(async () => { }) describe("ShareNext", () => { - it.live("unsubscribes event listeners when the instance is disposed", () => - provideTmpdirInstance((directory) => { - let active = 0 - const events = Layer.mock(EventV2Bridge.Service, { - listen: () => - Effect.sync(() => { - active++ - return Effect.sync(() => { - active-- - }) - }), - }) - - return Effect.gen(function* () { - const share = yield* ShareNext.Service - let peak = 0 - for (let index = 0; index < 20; index++) { - yield* share.init() - peak = Math.max(peak, active) - yield* Effect.promise(() => disposeInstance(directory)) - } - - expect(peak).toBe(5) - expect(active).toBe(0) - }).pipe(Effect.provide(live(none, events))) - }), - ) - it.live("request uses legacy share API without active org account", () => provideTmpdirInstance( () =>