mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 19:09:49 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 54a2dd9235 | |||
| 0104216d17 | |||
| fe28f3b1dd |
@@ -0,0 +1,9 @@
|
|||||||
|
ALTER TABLE `part` ADD `data_model` text;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE part
|
||||||
|
SET data_model = json_remove(data, '$.state.metadata')
|
||||||
|
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;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -8,12 +8,15 @@ import { Flag } from "../flag/flag"
|
|||||||
import { isAbsolute, join } from "path"
|
import { isAbsolute, join } from "path"
|
||||||
import { DatabaseMigration } from "./migration"
|
import { DatabaseMigration } from "./migration"
|
||||||
import { InstallationChannel } from "../installation/version"
|
import { InstallationChannel } from "../installation/version"
|
||||||
|
import { Sqlite } from "./sqlite"
|
||||||
|
|
||||||
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
|
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
|
||||||
type DatabaseShape = Effect.Success<typeof makeDatabase>
|
type DatabaseShape = Effect.Success<typeof makeDatabase>
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
db: DatabaseShape
|
db: DatabaseShape
|
||||||
|
// Lazy property getters cannot yield an Effect.
|
||||||
|
sync: Sqlite.DrizzleClient
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/storage/Database") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/storage/Database") {}
|
||||||
@@ -22,6 +25,7 @@ export const layer = Layer.effect(
|
|||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const db = yield* makeDatabase
|
const db = yield* makeDatabase
|
||||||
|
const sync = yield* Sqlite.Drizzle
|
||||||
|
|
||||||
yield* db.run("PRAGMA journal_mode = WAL")
|
yield* db.run("PRAGMA journal_mode = WAL")
|
||||||
yield* db.run("PRAGMA synchronous = NORMAL")
|
yield* db.run("PRAGMA synchronous = NORMAL")
|
||||||
@@ -31,7 +35,7 @@ export const layer = Layer.effect(
|
|||||||
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
|
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
|
||||||
yield* DatabaseMigration.apply(db)
|
yield* DatabaseMigration.apply(db)
|
||||||
|
|
||||||
return { db }
|
return { db, sync }
|
||||||
}).pipe(Effect.orDie),
|
}).pipe(Effect.orDie),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+1
@@ -27,5 +27,6 @@ export const migrations = (
|
|||||||
import("./migration/20260601202201_amazing_prowler"),
|
import("./migration/20260601202201_amazing_prowler"),
|
||||||
import("./migration/20260602002951_lowly_union_jack"),
|
import("./migration/20260602002951_lowly_union_jack"),
|
||||||
import("./migration/20260602182828_add_project_directories"),
|
import("./migration/20260602182828_add_project_directories"),
|
||||||
|
import("./migration/20260603120017_warm_guardsmen"),
|
||||||
])
|
])
|
||||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Effect } from "effect"
|
||||||
|
import type { DatabaseMigration } from "../migration"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: "20260603120017_warm_guardsmen",
|
||||||
|
up(tx) {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
yield* tx.run(`ALTER TABLE \`part\` ADD \`data_model\` text;`)
|
||||||
|
// Keep canonical history intact while avoiding prompt-time decoding. This
|
||||||
|
// one-time transactional backfill may briefly grow the WAL on large stores.
|
||||||
|
yield* tx.run(`
|
||||||
|
UPDATE part
|
||||||
|
SET data_model = json_remove(data, '$.state.metadata')
|
||||||
|
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
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
} satisfies DatabaseMigration.Migration
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export * as SessionPartModelData from "./model-data"
|
||||||
|
|
||||||
|
import type { SessionV1 } from "../v1/session"
|
||||||
|
|
||||||
|
type V1PartData<Data extends SessionV1.Part = SessionV1.Part> = Data extends SessionV1.Part
|
||||||
|
? Omit<Data, "id" | "sessionID" | "messageID">
|
||||||
|
: never
|
||||||
|
|
||||||
|
export type ModelData = Omit<V1PartData<SessionV1.ToolPart>, "state"> & {
|
||||||
|
state: Omit<SessionV1.ToolStateCompleted, "metadata">
|
||||||
|
}
|
||||||
|
|
||||||
|
export const THRESHOLD = 64 * 1024
|
||||||
|
|
||||||
|
// Strip UI-only metadata only when the stored prompt projection benefits.
|
||||||
|
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
|
||||||
|
if (!("status" in data.state) || data.state.status !== "completed") return null
|
||||||
|
if (!("metadata" in data.state)) return 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 ModelData
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { SessionMessage } from "./message"
|
|||||||
import { SessionMessageUpdater } from "./message-updater"
|
import { SessionMessageUpdater } from "./message-updater"
|
||||||
import { MessageTable, PartTable, SessionMessageTable, SessionTable } from "./sql"
|
import { MessageTable, PartTable, SessionMessageTable, SessionTable } from "./sql"
|
||||||
import type { DeepMutable } from "../schema"
|
import type { DeepMutable } from "../schema"
|
||||||
|
import { SessionPartModelData } from "./model-data"
|
||||||
|
|
||||||
type DatabaseService = Database.Interface["db"]
|
type DatabaseService = Database.Interface["db"]
|
||||||
|
|
||||||
@@ -309,7 +310,7 @@ export const layer = Layer.effectDiscard(
|
|||||||
yield* events.project(SessionV1.Event.MessageRemoved, (event) =>
|
yield* events.project(SessionV1.Event.MessageRemoved, (event) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const rows = yield* db
|
const rows = yield* db
|
||||||
.select()
|
.select({ session_id: PartTable.session_id, data: PartTable.data })
|
||||||
.from(PartTable)
|
.from(PartTable)
|
||||||
.where(and(eq(PartTable.message_id, event.data.messageID), eq(PartTable.session_id, event.data.sessionID)))
|
.where(and(eq(PartTable.message_id, event.data.messageID), eq(PartTable.session_id, event.data.sessionID)))
|
||||||
.all()
|
.all()
|
||||||
@@ -328,7 +329,7 @@ export const layer = Layer.effectDiscard(
|
|||||||
yield* events.project(SessionV1.Event.PartRemoved, (event) =>
|
yield* events.project(SessionV1.Event.PartRemoved, (event) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const row = yield* db
|
const row = yield* db
|
||||||
.select()
|
.select({ session_id: PartTable.session_id, data: PartTable.data })
|
||||||
.from(PartTable)
|
.from(PartTable)
|
||||||
.where(and(eq(PartTable.id, event.data.partID), eq(PartTable.session_id, event.data.sessionID)))
|
.where(and(eq(PartTable.id, event.data.partID), eq(PartTable.session_id, event.data.sessionID)))
|
||||||
.get()
|
.get()
|
||||||
@@ -348,11 +349,17 @@ export const layer = Layer.effectDiscard(
|
|||||||
const messageID = event.data.part.messageID
|
const messageID = event.data.part.messageID
|
||||||
const sessionID = event.data.part.sessionID
|
const sessionID = event.data.part.sessionID
|
||||||
const data = partData(event.data.part)
|
const data = partData(event.data.part)
|
||||||
const row = yield* db.select().from(PartTable).where(eq(PartTable.id, id)).get().pipe(Effect.orDie)
|
const data_model = SessionPartModelData.create(data)
|
||||||
|
const row = yield* db
|
||||||
|
.select({ session_id: PartTable.session_id, data: PartTable.data })
|
||||||
|
.from(PartTable)
|
||||||
|
.where(eq(PartTable.id, id))
|
||||||
|
.get()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
yield* db
|
yield* db
|
||||||
.insert(PartTable)
|
.insert(PartTable)
|
||||||
.values({ id, message_id: messageID, session_id: sessionID, time_created: event.data.time, data })
|
.values({ id, message_id: messageID, session_id: sessionID, time_created: event.data.time, data, data_model })
|
||||||
.onConflictDoUpdate({ target: PartTable.id, set: { data } })
|
.onConflictDoUpdate({ target: PartTable.id, set: { data, data_model } })
|
||||||
.run()
|
.run()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
const previous = row && usage(row.data)
|
const previous = row && usage(row.data)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { SessionSchema } from "./schema"
|
|||||||
import type { MessageID, PartID, SessionV1 } from "../v1/session"
|
import type { MessageID, PartID, SessionV1 } from "../v1/session"
|
||||||
import { WorkspaceV2 } from "../workspace"
|
import { WorkspaceV2 } from "../workspace"
|
||||||
import { Timestamps } from "../database/schema.sql"
|
import { Timestamps } from "../database/schema.sql"
|
||||||
|
import type { ModelData } from "./model-data"
|
||||||
|
|
||||||
type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id">
|
type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id">
|
||||||
type V1MessageData = Omit<SessionV1.Info, "id" | "sessionID">
|
type V1MessageData = Omit<SessionV1.Info, "id" | "sessionID">
|
||||||
@@ -85,6 +86,8 @@ export const PartTable = sqliteTable(
|
|||||||
session_id: text().$type<SessionSchema.ID>().notNull(),
|
session_id: text().$type<SessionSchema.ID>().notNull(),
|
||||||
...Timestamps,
|
...Timestamps,
|
||||||
data: text({ mode: "json" }).notNull().$type<V1PartData>(),
|
data: text({ mode: "json" }).notNull().$type<V1PartData>(),
|
||||||
|
// Derived prompt projection; data remains canonical.
|
||||||
|
data_model: text({ mode: "json" }).$type<ModelData>(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index("part_message_id_id_idx").on(table.message_id, table.id),
|
index("part_message_id_id_idx").on(table.message_id, table.id),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
|||||||
import { SessionSchema } from "@opencode-ai/core/session/schema"
|
import { SessionSchema } from "@opencode-ai/core/session/schema"
|
||||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||||
import sessionMetadataMigration from "@opencode-ai/core/database/migration/20260511173437_session-metadata"
|
import sessionMetadataMigration from "@opencode-ai/core/database/migration/20260511173437_session-metadata"
|
||||||
|
import partModelDataMigration from "@opencode-ai/core/database/migration/20260603120017_warm_guardsmen"
|
||||||
import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient"
|
import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient"
|
||||||
|
|
||||||
const run = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
|
const run = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
|
||||||
@@ -43,7 +44,7 @@ describe("DatabaseMigration", () => {
|
|||||||
expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({
|
expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({
|
||||||
name: "session",
|
name: "session",
|
||||||
})
|
})
|
||||||
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 25 })
|
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 26 })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -77,6 +78,32 @@ describe("DatabaseMigration", () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("backfills lightweight model data for oversized completed tool metadata", async () => {
|
||||||
|
await run(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const db = yield* makeDb
|
||||||
|
yield* db.run(sql`CREATE TABLE part (id text PRIMARY KEY, data text NOT NULL)`)
|
||||||
|
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" } } })
|
||||||
|
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])
|
||||||
|
|
||||||
|
expect(yield* db.get(sql`SELECT data, data_model FROM part WHERE id = ${"large"}`)).toEqual({
|
||||||
|
data: large,
|
||||||
|
data_model: JSON.stringify({ type: "tool", state: { status: "completed" } }),
|
||||||
|
})
|
||||||
|
expect(yield* db.get(sql`SELECT data_model FROM part WHERE id = ${"small"}`)).toEqual({ data_model: null })
|
||||||
|
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 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
test("normalizes Windows storage paths and leaves POSIX paths untouched", async () => {
|
test("normalizes Windows storage paths and leaves POSIX paths untouched", async () => {
|
||||||
await run(
|
await run(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import path from "path"
|
|||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import type { InstanceContext } from "@/project/instance-context"
|
import type { InstanceContext } from "@/project/instance-context"
|
||||||
|
import { SessionPartModelData } from "@opencode-ai/core/session/model-data"
|
||||||
|
|
||||||
const decodeMessageInfo = Schema.decodeUnknownSync(SessionV1.Info)
|
const decodeMessageInfo = Schema.decodeUnknownSync(SessionV1.Info)
|
||||||
const decodePart = Schema.decodeUnknownSync(SessionV1.Part)
|
const decodePart = Schema.decodeUnknownSync(SessionV1.Part)
|
||||||
@@ -212,6 +213,8 @@ const runImport = Effect.fn("Cli.import.body")(function* (file: string, ctx: Ins
|
|||||||
message_id: messageID,
|
message_id: messageID,
|
||||||
session_id: row.id,
|
session_id: row.id,
|
||||||
data: partData,
|
data: partData,
|
||||||
|
// Keep imported history on the same prompt path as live updates.
|
||||||
|
data_model: SessionPartModelData.create(partData),
|
||||||
})
|
})
|
||||||
.onConflictDoNothing()
|
.onConflictDoNothing()
|
||||||
.run()
|
.run()
|
||||||
|
|||||||
@@ -402,7 +402,7 @@ export const layer = Layer.effect(
|
|||||||
{ context: [], prompt: undefined },
|
{ context: [], prompt: undefined },
|
||||||
)
|
)
|
||||||
const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context })
|
const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context })
|
||||||
const msgs = structuredClone(selected.head)
|
const msgs = MessageV2.cloneForTransform(selected.head)
|
||||||
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
||||||
const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, {
|
const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, {
|
||||||
stripMedia: true,
|
stripMedia: true,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { eq } from "drizzle-orm"
|
|||||||
import { inArray } from "drizzle-orm"
|
import { inArray } from "drizzle-orm"
|
||||||
import { lt } from "drizzle-orm"
|
import { lt } from "drizzle-orm"
|
||||||
import { or } from "drizzle-orm"
|
import { or } from "drizzle-orm"
|
||||||
|
import { sql } from "drizzle-orm"
|
||||||
import { MessageTable, PartTable, SessionTable } from "@opencode-ai/core/session/sql"
|
import { MessageTable, PartTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||||
import { ProviderError } from "@/provider/error"
|
import { ProviderError } from "@/provider/error"
|
||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
@@ -47,6 +48,7 @@ interface FetchDecompressionError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached media from tool result:"
|
export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached media from tool result:"
|
||||||
|
const COMPACTED_TOOL_OUTPUT = "[Old tool result content cleared]"
|
||||||
export { isMedia }
|
export { isMedia }
|
||||||
|
|
||||||
function truncateToolOutput(text: string, maxChars?: number) {
|
function truncateToolOutput(text: string, maxChars?: number) {
|
||||||
@@ -96,7 +98,7 @@ const info = (row: typeof MessageTable.$inferSelect) =>
|
|||||||
sessionID: row.session_id,
|
sessionID: row.session_id,
|
||||||
}) as Info
|
}) as Info
|
||||||
|
|
||||||
const part = (row: typeof PartTable.$inferSelect) =>
|
const part = (row: Pick<typeof PartTable.$inferSelect, "id" | "session_id" | "message_id" | "data">) =>
|
||||||
({
|
({
|
||||||
...row.data,
|
...row.data,
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -107,20 +109,41 @@ const part = (row: typeof PartTable.$inferSelect) =>
|
|||||||
const older = (row: Cursor) =>
|
const older = (row: Cursor) =>
|
||||||
or(lt(MessageTable.time_created, row.time), and(eq(MessageTable.time_created, row.time), lt(MessageTable.id, row.id)))
|
or(lt(MessageTable.time_created, row.time), and(eq(MessageTable.time_created, row.time), lt(MessageTable.id, row.id)))
|
||||||
|
|
||||||
function hydrate(db: Database.Interface["db"], rows: (typeof MessageTable.$inferSelect)[]) {
|
function hydrate(
|
||||||
|
db: Database.Interface["db"],
|
||||||
|
rows: (typeof MessageTable.$inferSelect)[],
|
||||||
|
sync?: Database.Interface["sync"],
|
||||||
|
) {
|
||||||
const ids = rows.map((row) => row.id)
|
const ids = rows.map((row) => row.id)
|
||||||
const partByMessage = new Map<string, Part[]>()
|
const partByMessage = new Map<string, Part[]>()
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
if (ids.length > 0) {
|
if (ids.length > 0) {
|
||||||
const partRows = yield* db
|
const partRows = sync
|
||||||
.select()
|
? yield* db
|
||||||
.from(PartTable)
|
.select({
|
||||||
.where(inArray(PartTable.message_id, ids))
|
id: PartTable.id,
|
||||||
.orderBy(PartTable.message_id, PartTable.id)
|
message_id: PartTable.message_id,
|
||||||
.all()
|
session_id: PartTable.session_id,
|
||||||
.pipe(Effect.orDie)
|
// Keep oversized UI metadata available to extensions without decoding it for every prompt.
|
||||||
|
data: sql`coalesce(${PartTable.data_model}, ${PartTable.data})`
|
||||||
|
.mapWith(PartTable.data)
|
||||||
|
.as("data"),
|
||||||
|
lazy_metadata: sql<number>`${PartTable.data_model} IS NOT NULL`.as("lazy_metadata"),
|
||||||
|
})
|
||||||
|
.from(PartTable)
|
||||||
|
.where(inArray(PartTable.message_id, ids))
|
||||||
|
.orderBy(PartTable.message_id, PartTable.id)
|
||||||
|
.all()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
: yield* db
|
||||||
|
.select({ id: PartTable.id, message_id: PartTable.message_id, session_id: PartTable.session_id, data: PartTable.data })
|
||||||
|
.from(PartTable)
|
||||||
|
.where(inArray(PartTable.message_id, ids))
|
||||||
|
.orderBy(PartTable.message_id, PartTable.id)
|
||||||
|
.all()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
for (const row of partRows) {
|
for (const row of partRows) {
|
||||||
const next = part(row)
|
const next = "lazy_metadata" in row && row.lazy_metadata && sync ? lazyMetadata(sync, part(row)) : part(row)
|
||||||
const list = partByMessage.get(row.message_id)
|
const list = partByMessage.get(row.message_id)
|
||||||
if (list) list.push(next)
|
if (list) list.push(next)
|
||||||
else partByMessage.set(row.message_id, [next])
|
else partByMessage.set(row.message_id, [next])
|
||||||
@@ -134,6 +157,67 @@ function hydrate(db: Database.Interface["db"], rows: (typeof MessageTable.$infer
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function lazyMetadata(db: Database.Interface["sync"], part: Part) {
|
||||||
|
if (part.type !== "tool" || part.state.status !== "completed") return part
|
||||||
|
defineLazyMetadata(part.state, () => {
|
||||||
|
// Prompt history is short-lived. Resolve against canonical storage only when
|
||||||
|
// an extension explicitly reads metadata instead of decoding it every turn.
|
||||||
|
const row = db
|
||||||
|
.select({ metadata: sql<string>`json_extract(${PartTable.data}, '$.state.metadata')` })
|
||||||
|
.from(PartTable)
|
||||||
|
.where(eq(PartTable.id, part.id))
|
||||||
|
.get()
|
||||||
|
return row?.metadata ? JSON.parse(row.metadata) : {}
|
||||||
|
})
|
||||||
|
return part
|
||||||
|
}
|
||||||
|
|
||||||
|
function defineLazyMetadata(state: SessionV1.ToolStateCompleted, load: () => Record<string, any>) {
|
||||||
|
let metadata: Record<string, any> | undefined
|
||||||
|
Object.defineProperty(state, "metadata", {
|
||||||
|
configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
get() {
|
||||||
|
metadata ??= load()
|
||||||
|
return metadata
|
||||||
|
},
|
||||||
|
set(value: Record<string, any>) {
|
||||||
|
metadata = value
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneForTransform(input: WithParts[]) {
|
||||||
|
// structuredClone resolves getters, so mask and restore lazy metadata.
|
||||||
|
const lazy = new Map<string, () => Record<string, any>>()
|
||||||
|
const masked = input.map((msg) => ({
|
||||||
|
...msg,
|
||||||
|
parts: msg.parts.map((part) => {
|
||||||
|
if (part.type !== "tool" || part.state.status !== "completed") return part
|
||||||
|
const load = Object.getOwnPropertyDescriptor(part.state, "metadata")?.get
|
||||||
|
if (!load) return part
|
||||||
|
lazy.set(part.id, () => structuredClone(load.call(part.state)))
|
||||||
|
return {
|
||||||
|
...part,
|
||||||
|
state: Object.fromEntries(
|
||||||
|
Object.keys(part.state)
|
||||||
|
.filter((key) => key !== "metadata")
|
||||||
|
.map((key) => [key, Reflect.get(part.state, key)]),
|
||||||
|
),
|
||||||
|
} as Part
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
const result = structuredClone(masked) as WithParts[]
|
||||||
|
for (const msg of result) {
|
||||||
|
for (const part of msg.parts) {
|
||||||
|
if (part.type !== "tool" || part.state.status !== "completed") continue
|
||||||
|
const load = lazy.get(part.id)
|
||||||
|
if (load) defineLazyMetadata(part.state, load)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
function providerMeta(metadata: Record<string, any> | undefined) {
|
function providerMeta(metadata: Record<string, any> | undefined) {
|
||||||
if (!metadata) return undefined
|
if (!metadata) return undefined
|
||||||
const { providerExecuted: _, ...rest } = metadata
|
const { providerExecuted: _, ...rest } = metadata
|
||||||
@@ -302,7 +386,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
|
|||||||
toolNames.add(part.tool)
|
toolNames.add(part.tool)
|
||||||
if (part.state.status === "completed") {
|
if (part.state.status === "completed") {
|
||||||
const outputText = part.state.time.compacted
|
const outputText = part.state.time.compacted
|
||||||
? "[Old tool result content cleared]"
|
? COMPACTED_TOOL_OUTPUT
|
||||||
: truncateToolOutput(part.state.output, options?.toolOutputMaxChars)
|
: truncateToolOutput(part.state.output, options?.toolOutputMaxChars)
|
||||||
const attachments = part.state.time.compacted || options?.stripMedia ? [] : (part.state.attachments ?? [])
|
const attachments = part.state.time.compacted || options?.stripMedia ? [] : (part.state.attachments ?? [])
|
||||||
|
|
||||||
@@ -433,12 +517,17 @@ export function toModelMessages(
|
|||||||
return Effect.runPromise(toModelMessagesEffect(input, model, options).pipe(Effect.provide(EffectLogger.layer)))
|
return Effect.runPromise(toModelMessagesEffect(input, model, options).pipe(Effect.provide(EffectLogger.layer)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const page = Effect.fn("MessageV2.page")(function* (input: {
|
type PageInput = {
|
||||||
sessionID: SessionID
|
sessionID: SessionID
|
||||||
limit: number
|
limit: number
|
||||||
before?: string
|
before?: string
|
||||||
}) {
|
}
|
||||||
const { db } = yield* Database.Service
|
|
||||||
|
const pageWithOptions = Effect.fnUntraced(function* (
|
||||||
|
input: PageInput & { lazyCompletedToolMetadata?: boolean },
|
||||||
|
) {
|
||||||
|
const database = yield* Database.Service
|
||||||
|
const db = database.db
|
||||||
const before = input.before ? cursor.decode(input.before) : undefined
|
const before = input.before ? cursor.decode(input.before) : undefined
|
||||||
const where = before
|
const where = before
|
||||||
? and(eq(MessageTable.session_id, input.sessionID), older(before))
|
? and(eq(MessageTable.session_id, input.sessionID), older(before))
|
||||||
@@ -467,7 +556,7 @@ export const page = Effect.fn("MessageV2.page")(function* (input: {
|
|||||||
|
|
||||||
const more = rows.length > input.limit
|
const more = rows.length > input.limit
|
||||||
const slice = more ? rows.slice(0, input.limit) : rows
|
const slice = more ? rows.slice(0, input.limit) : rows
|
||||||
const items = yield* hydrate(db, slice)
|
const items = yield* hydrate(db, slice, input.lazyCompletedToolMetadata ? database.sync : undefined)
|
||||||
items.reverse()
|
items.reverse()
|
||||||
const tail = slice.at(-1)
|
const tail = slice.at(-1)
|
||||||
return {
|
return {
|
||||||
@@ -477,6 +566,10 @@ export const page = Effect.fn("MessageV2.page")(function* (input: {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const page = Effect.fn("MessageV2.page")(function* (input: PageInput) {
|
||||||
|
return yield* pageWithOptions(input)
|
||||||
|
})
|
||||||
|
|
||||||
export function stream(sessionID: SessionID) {
|
export function stream(sessionID: SessionID) {
|
||||||
const size = 50
|
const size = 50
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
@@ -504,7 +597,7 @@ export function parts(messageID: MessageID) {
|
|||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
const rows = yield* db
|
const rows = yield* db
|
||||||
.select()
|
.select({ id: PartTable.id, message_id: PartTable.message_id, session_id: PartTable.session_id, data: PartTable.data })
|
||||||
.from(PartTable)
|
.from(PartTable)
|
||||||
.where(eq(PartTable.message_id, messageID))
|
.where(eq(PartTable.message_id, messageID))
|
||||||
.orderBy(PartTable.id)
|
.orderBy(PartTable.id)
|
||||||
@@ -529,29 +622,57 @@ export const get = Effect.fn("MessageV2.get")(function* (input: { sessionID: Ses
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const related = Effect.fn("MessageV2.related")(function* (input: { sessionID: SessionID; messageID: MessageID }) {
|
||||||
|
const { db } = yield* Database.Service
|
||||||
|
return yield* db
|
||||||
|
.select()
|
||||||
|
.from(MessageTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(MessageTable.session_id, input.sessionID),
|
||||||
|
or(
|
||||||
|
eq(MessageTable.id, input.messageID),
|
||||||
|
and(
|
||||||
|
sql`json_extract(${MessageTable.data}, '$.role') = 'assistant'`,
|
||||||
|
sql`json_extract(${MessageTable.data}, '$.parentID') = ${input.messageID}`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(MessageTable.time_created, MessageTable.id)
|
||||||
|
.all()
|
||||||
|
.pipe(Effect.flatMap((rows) => hydrate(db, rows)), Effect.orDie)
|
||||||
|
})
|
||||||
|
|
||||||
export function filterCompacted(msgs: Iterable<WithParts>) {
|
export function filterCompacted(msgs: Iterable<WithParts>) {
|
||||||
const result = [] as WithParts[]
|
const result = [] as WithParts[]
|
||||||
const completed = new Set<string>()
|
const state = compactedState()
|
||||||
let retain: MessageID | undefined
|
|
||||||
for (const msg of msgs) {
|
for (const msg of msgs) {
|
||||||
result.push(msg)
|
result.push(msg)
|
||||||
if (retain) {
|
if (reachedCompactedBoundary(state, msg)) break
|
||||||
if (msg.info.id === retain) break
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (msg.info.role === "user" && completed.has(msg.info.id)) {
|
|
||||||
const part = msg.parts.find((item): item is CompactionPart => item.type === "compaction")
|
|
||||||
if (!part) continue
|
|
||||||
if (!part.tail_start_id) break
|
|
||||||
retain = part.tail_start_id
|
|
||||||
if (msg.info.id === retain) break
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (msg.info.role === "user" && completed.has(msg.info.id) && msg.parts.some((part) => part.type === "compaction"))
|
|
||||||
break
|
|
||||||
if (msg.info.role === "assistant" && msg.info.summary && msg.info.finish && !msg.info.error)
|
|
||||||
completed.add(msg.info.parentID)
|
|
||||||
}
|
}
|
||||||
|
return reorderCompacted(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactedState() {
|
||||||
|
return { completed: new Set<string>(), retain: undefined as MessageID | undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
function reachedCompactedBoundary(state: ReturnType<typeof compactedState>, msg: WithParts) {
|
||||||
|
if (state.retain) return msg.info.id === state.retain
|
||||||
|
if (msg.info.role === "user" && state.completed.has(msg.info.id)) {
|
||||||
|
const part = msg.parts.find((item): item is CompactionPart => item.type === "compaction")
|
||||||
|
if (!part) return false
|
||||||
|
if (!part.tail_start_id) return true
|
||||||
|
state.retain = part.tail_start_id
|
||||||
|
return msg.info.id === state.retain
|
||||||
|
}
|
||||||
|
if (msg.info.role === "assistant" && msg.info.summary && msg.info.finish && !msg.info.error)
|
||||||
|
state.completed.add(msg.info.parentID)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function reorderCompacted(result: WithParts[]) {
|
||||||
result.reverse()
|
result.reverse()
|
||||||
const compactionIndex = result.findLastIndex(
|
const compactionIndex = result.findLastIndex(
|
||||||
(msg) =>
|
(msg) =>
|
||||||
@@ -583,7 +704,28 @@ export function filterCompacted(msgs: Iterable<WithParts>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const filterCompactedEffect = Effect.fnUntraced(function* (sessionID: SessionID) {
|
export const filterCompactedEffect = Effect.fnUntraced(function* (sessionID: SessionID) {
|
||||||
return filterCompacted(yield* stream(sessionID))
|
// Stop paging once older compacted history would be discarded anyway.
|
||||||
|
const size = 50
|
||||||
|
const result = [] as WithParts[]
|
||||||
|
const state = compactedState()
|
||||||
|
let before: string | undefined
|
||||||
|
while (true) {
|
||||||
|
const next = yield* pageWithOptions({ sessionID, limit: size, before, lazyCompletedToolMetadata: true }).pipe(
|
||||||
|
Effect.catchIf(NotFoundError.isInstance, () =>
|
||||||
|
Effect.succeed({ items: [] as WithParts[], more: false, cursor: undefined }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (next.items.length === 0) break
|
||||||
|
for (let i = next.items.length - 1; i >= 0; i--) {
|
||||||
|
const item = next.items[i]
|
||||||
|
if (!item) continue
|
||||||
|
result.push(item)
|
||||||
|
if (reachedCompactedBoundary(state, item)) return reorderCompacted(result)
|
||||||
|
}
|
||||||
|
if (!next.more || !next.cursor) break
|
||||||
|
before = next.cursor
|
||||||
|
}
|
||||||
|
return reorderCompacted(result)
|
||||||
})
|
})
|
||||||
|
|
||||||
// filterCompacted reorders messages for model consumption
|
// filterCompacted reorders messages for model consumption
|
||||||
|
|||||||
@@ -720,7 +720,7 @@ export const layer: Layer.Layer<
|
|||||||
|
|
||||||
const getPart: Interface["getPart"] = Effect.fn("Session.getPart")(function* (input) {
|
const getPart: Interface["getPart"] = Effect.fn("Session.getPart")(function* (input) {
|
||||||
const row = yield* db
|
const row = yield* db
|
||||||
.select()
|
.select({ id: PartTable.id, session_id: PartTable.session_id, message_id: PartTable.message_id, data: PartTable.data })
|
||||||
.from(PartTable)
|
.from(PartTable)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import { Snapshot } from "@/snapshot"
|
|||||||
import { Session } from "./session"
|
import { Session } from "./session"
|
||||||
import { SessionID, MessageID } from "./schema"
|
import { SessionID, MessageID } from "./schema"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
|
import { MessageV2 } from "./message-v2"
|
||||||
|
import { Database } from "@opencode-ai/core/database/database"
|
||||||
|
import { NotFoundError } from "@/storage/storage"
|
||||||
|
|
||||||
function unquoteGitPath(input: string) {
|
function unquoteGitPath(input: string) {
|
||||||
if (!input.startsWith('"')) return input
|
if (!input.startsWith('"')) return input
|
||||||
@@ -77,6 +80,7 @@ export const layer = Layer.effect(
|
|||||||
const snapshot = yield* Snapshot.Service
|
const snapshot = yield* Snapshot.Service
|
||||||
const events = yield* EventV2Bridge.Service
|
const events = yield* EventV2Bridge.Service
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
|
const database = yield* Database.Service
|
||||||
|
|
||||||
const computeDiff = Effect.fn("SessionSummary.computeDiff")(function* (input: { messages: SessionV1.WithParts[] }) {
|
const computeDiff = Effect.fn("SessionSummary.computeDiff")(function* (input: { messages: SessionV1.WithParts[] }) {
|
||||||
let from: string | undefined
|
let from: string | undefined
|
||||||
@@ -112,12 +116,7 @@ export const layer = Layer.effect(
|
|||||||
})
|
})
|
||||||
yield* events.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: [] })
|
yield* events.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: [] })
|
||||||
if ((yield* config.get()).snapshot === false) return
|
if ((yield* config.get()).snapshot === false) return
|
||||||
const all = yield* sessions.messages({ sessionID: input.sessionID }).pipe(Effect.orDie)
|
const messages = yield* MessageV2.related(input).pipe(Effect.provideService(Database.Service, database))
|
||||||
if (!all.length) return
|
|
||||||
|
|
||||||
const messages = all.filter(
|
|
||||||
(m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID),
|
|
||||||
)
|
|
||||||
const target = messages.find((m) => m.info.id === input.messageID)
|
const target = messages.find((m) => m.info.id === input.messageID)
|
||||||
if (!target || target.info.role !== "user") return
|
if (!target || target.info.role !== "user") return
|
||||||
const msgDiffs = yield* computeDiff({ messages })
|
const msgDiffs = yield* computeDiff({ messages })
|
||||||
@@ -127,8 +126,11 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
const diff = Effect.fn("SessionSummary.diff")(function* (input: { sessionID: SessionID; messageID?: MessageID }) {
|
const diff = Effect.fn("SessionSummary.diff")(function* (input: { sessionID: SessionID; messageID?: MessageID }) {
|
||||||
if (!input.messageID) return []
|
if (!input.messageID) return []
|
||||||
const message = (yield* sessions.messages({ sessionID: input.sessionID }).pipe(Effect.orDie)).find(
|
const message = yield* MessageV2.get({ sessionID: input.sessionID, messageID: input.messageID }).pipe(
|
||||||
(item) => item.info.id === input.messageID,
|
Effect.provideService(Database.Service, database),
|
||||||
|
Effect.catchIf(NotFoundError.isInstance, () =>
|
||||||
|
sessions.get(input.sessionID).pipe(Effect.orDie, Effect.as(undefined)),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if (!message || message.info.role !== "user") return []
|
if (!message || message.info.role !== "user") return []
|
||||||
const diffs = message.info.summary?.diffs ?? []
|
const diffs = message.info.summary?.diffs ?? []
|
||||||
@@ -150,6 +152,7 @@ export const defaultLayer = Layer.suspend(() =>
|
|||||||
Layer.provide(Snapshot.defaultLayer),
|
Layer.provide(Snapshot.defaultLayer),
|
||||||
Layer.provide(EventV2Bridge.defaultLayer),
|
Layer.provide(EventV2Bridge.defaultLayer),
|
||||||
Layer.provide(Config.defaultLayer),
|
Layer.provide(Config.defaultLayer),
|
||||||
|
Layer.provide(Database.defaultLayer),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -382,6 +382,20 @@ function autocontinue(enabled: boolean) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function messagesTransform(inspect: (messages: SessionV1.WithParts[]) => void) {
|
||||||
|
return Layer.mock(Plugin.Service)({
|
||||||
|
trigger: <Name extends string, Input, Output>(name: Name, _input: Input, output: Output) => {
|
||||||
|
if (name !== "experimental.chat.messages.transform") return Effect.succeed(output)
|
||||||
|
return Effect.sync(() => {
|
||||||
|
inspect((output as { messages: SessionV1.WithParts[] }).messages)
|
||||||
|
return output
|
||||||
|
})
|
||||||
|
},
|
||||||
|
list: () => Effect.succeed([]),
|
||||||
|
init: () => Effect.void,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
describe("session.compaction.isOverflow", () => {
|
describe("session.compaction.isOverflow", () => {
|
||||||
it.live(
|
it.live(
|
||||||
"returns true when token count exceeds usable context",
|
"returns true when token count exceeds usable context",
|
||||||
@@ -682,8 +696,18 @@ describe("session.compaction.prune", () => {
|
|||||||
status: "completed",
|
status: "completed",
|
||||||
input: {},
|
input: {},
|
||||||
output: "x".repeat(200_000),
|
output: "x".repeat(200_000),
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
id: PartID.ascending(),
|
||||||
|
messageID: b.id,
|
||||||
|
sessionID: info.id,
|
||||||
|
type: "file",
|
||||||
|
mime: "text/plain",
|
||||||
|
url: "data:text/plain;base64,eA==",
|
||||||
|
},
|
||||||
|
],
|
||||||
title: "done",
|
title: "done",
|
||||||
metadata: {},
|
metadata: { output: "x".repeat(200_000), description: "done" },
|
||||||
time: { start: Date.now(), end: Date.now() },
|
time: { start: Date.now(), end: Date.now() },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -713,9 +737,45 @@ describe("session.compaction.prune", () => {
|
|||||||
expect(part?.state.status).toBe("completed")
|
expect(part?.state.status).toBe("completed")
|
||||||
if (part?.type === "tool" && part.state.status === "completed") {
|
if (part?.type === "tool" && part.state.status === "completed") {
|
||||||
expect(part.state.time.compacted).toBeNumber()
|
expect(part.state.time.compacted).toBeNumber()
|
||||||
|
expect(part.state.output).toHaveLength(200_000)
|
||||||
|
expect(part.state.metadata.output).toHaveLength(200_000)
|
||||||
|
expect(part.state.attachments).toHaveLength(1)
|
||||||
|
|
||||||
|
const compacted = (yield* MessageV2.filterCompactedEffect(info.id))
|
||||||
|
.flatMap((msg) => msg.parts)
|
||||||
|
.find((part) => part.type === "tool")
|
||||||
|
expect(compacted?.type).toBe("tool")
|
||||||
|
if (compacted?.type === "tool" && compacted.state.status === "completed") {
|
||||||
|
expect(Object.getOwnPropertyDescriptor(compacted.state, "metadata")?.get).toBeFunction()
|
||||||
|
expect(compacted.state.output).toHaveLength(200_000)
|
||||||
|
expect(compacted.state.metadata.output).toHaveLength(200_000)
|
||||||
|
expect(compacted.state.attachments).toHaveLength(1)
|
||||||
|
expect(JSON.parse(JSON.stringify(compacted.state)).metadata.output).toHaveLength(200_000)
|
||||||
|
|
||||||
|
part.state.metadata = { description: "small" }
|
||||||
|
yield* ssn.updatePart(part)
|
||||||
|
const small = (yield* MessageV2.filterCompactedEffect(info.id))
|
||||||
|
.flatMap((msg) => msg.parts)
|
||||||
|
.find((item) => item.id === part.id)
|
||||||
|
expect(small?.type).toBe("tool")
|
||||||
|
if (small?.type === "tool" && small.state.status === "completed") {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
{
|
{
|
||||||
config: {
|
config: {
|
||||||
compaction: { prune: true },
|
compaction: { prune: true },
|
||||||
@@ -815,6 +875,52 @@ describe("session.compaction.prune", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("session.compaction.process", () => {
|
describe("session.compaction.process", () => {
|
||||||
|
itCompaction.instance(
|
||||||
|
"keeps oversized tool metadata lazy through the plugin transform clone",
|
||||||
|
() => {
|
||||||
|
let lazy = false
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
const test = yield* TestInstance
|
||||||
|
const ssn = yield* SessionNs.Service
|
||||||
|
const session = yield* ssn.create({})
|
||||||
|
const first = yield* createUserMessage(session.id, "first")
|
||||||
|
const assistant = yield* createAssistantMessage(session.id, first.id, test.directory)
|
||||||
|
yield* ssn.updatePart({
|
||||||
|
id: PartID.ascending(),
|
||||||
|
messageID: assistant.id,
|
||||||
|
sessionID: session.id,
|
||||||
|
type: "tool",
|
||||||
|
tool: "apply_patch",
|
||||||
|
callID: "call_test",
|
||||||
|
state: {
|
||||||
|
status: "completed",
|
||||||
|
input: {},
|
||||||
|
output: "done",
|
||||||
|
title: "done",
|
||||||
|
metadata: { diff: "x".repeat(70_000) },
|
||||||
|
time: { start: Date.now(), end: Date.now() },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const parent = yield* createUserMessage(session.id, "compact")
|
||||||
|
const msgs = yield* MessageV2.filterCompactedEffect(session.id)
|
||||||
|
|
||||||
|
yield* SessionCompaction.use.process({ parentID: parent.id, messages: msgs, sessionID: session.id, auto: false })
|
||||||
|
|
||||||
|
expect(lazy).toBe(true)
|
||||||
|
}).pipe(
|
||||||
|
withCompaction({
|
||||||
|
config: cfg({ tail_turns: 0 }),
|
||||||
|
plugin: messagesTransform((messages) => {
|
||||||
|
const part = messages.flatMap((msg) => msg.parts).find((part) => part.type === "tool")
|
||||||
|
if (part?.type === "tool" && part.state.status === "completed") {
|
||||||
|
lazy = Object.getOwnPropertyDescriptor(part.state, "metadata")?.get !== undefined
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
it.instance(
|
it.instance(
|
||||||
"throws when parent is not a user message",
|
"throws when parent is not a user message",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|||||||
@@ -553,6 +553,32 @@ describe("MessageV2.get", () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("MessageV2.related", () => {
|
||||||
|
it.instance("returns one user turn and its assistant replies", () =>
|
||||||
|
withSession(({ session, sessionID }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* addUser(sessionID, "older")
|
||||||
|
const user = yield* addUser(sessionID, "target")
|
||||||
|
const first = yield* addAssistant(sessionID, user)
|
||||||
|
const second = yield* addAssistant(sessionID, user)
|
||||||
|
yield* session.updatePart({
|
||||||
|
id: PartID.ascending(),
|
||||||
|
sessionID,
|
||||||
|
messageID: second,
|
||||||
|
type: "text",
|
||||||
|
text: "response",
|
||||||
|
})
|
||||||
|
yield* addUser(sessionID, "newer")
|
||||||
|
|
||||||
|
const result = yield* MessageV2.related({ sessionID, messageID: user })
|
||||||
|
|
||||||
|
expect(result.map((item) => item.info.id)).toEqual([user, first, second])
|
||||||
|
expect(result[2].parts).toHaveLength(1)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
describe("Session.messages", () => {
|
describe("Session.messages", () => {
|
||||||
it.instance("returns all messages in chronological order across pages", () =>
|
it.instance("returns all messages in chronological order across pages", () =>
|
||||||
withSession(({ session, sessionID }) =>
|
withSession(({ session, sessionID }) =>
|
||||||
@@ -645,6 +671,31 @@ describe("MessageV2.filterCompacted", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.instance("effect stops at compaction boundary beyond the first page", () =>
|
||||||
|
withSession(({ session, sessionID }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* fill(sessionID, 55, (index: number) => index)
|
||||||
|
const compact = yield* addUser(sessionID)
|
||||||
|
yield* addCompactionPart(sessionID, compact)
|
||||||
|
const summary = yield* addAssistant(sessionID, compact, { summary: true, finish: "end_turn" })
|
||||||
|
yield* session.updatePart({
|
||||||
|
id: PartID.ascending(),
|
||||||
|
sessionID,
|
||||||
|
messageID: summary,
|
||||||
|
type: "text",
|
||||||
|
text: "summary",
|
||||||
|
})
|
||||||
|
yield* fill(sessionID, 55)
|
||||||
|
|
||||||
|
const expected = MessageV2.filterCompacted(yield* MessageV2.stream(sessionID))
|
||||||
|
const result = yield* MessageV2.filterCompactedEffect(sessionID)
|
||||||
|
|
||||||
|
expect(result).toEqual(expected)
|
||||||
|
expect(result).toHaveLength(57)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("handles empty iterable", () =>
|
it.live("handles empty iterable", () =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
const result = MessageV2.filterCompacted([])
|
const result = MessageV2.filterCompacted([])
|
||||||
@@ -665,6 +716,22 @@ describe("MessageV2.filterCompacted", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.instance("does not break on summary without matching compaction part", () =>
|
||||||
|
withSession(({ sessionID }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const user = yield* addUser(sessionID, "hello")
|
||||||
|
yield* addAssistant(sessionID, user, { summary: true, finish: "end_turn" })
|
||||||
|
yield* addUser(sessionID, "world")
|
||||||
|
|
||||||
|
const expected = MessageV2.filterCompacted(yield* MessageV2.stream(sessionID))
|
||||||
|
const result = yield* MessageV2.filterCompactedEffect(sessionID)
|
||||||
|
|
||||||
|
expect(result).toEqual(expected)
|
||||||
|
expect(result).toHaveLength(3)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.instance("skips assistant with error even if marked as summary", () =>
|
it.instance("skips assistant with error even if marked as summary", () =>
|
||||||
withSession(({ sessionID }) =>
|
withSession(({ sessionID }) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|||||||
Reference in New Issue
Block a user