feat(core): make path-local instruction discovery durable
Replace synthetic-message instruction injection with a durable discovery projection so discovered AGENTS.md files survive compaction, forks, reverts, and restarts. - Add the instruction_file table and the durable session.instructions.discovered event; projection stores each discovered path and content with its owning assistant-message boundary and durable discovery order. - Fold discovered files into the core/instructions source through InstructionDiscovery, absorbing SessionInstructions. Completed compaction rebaselines restate discovered instructions instead of summarizing them away. - Re-read discovered files live at each observation so mid-session edits reach the model; the frozen discovery content stands in only when a file becomes unreadable. - Narrate instruction file changes as per-file deltas via diffByKey, restating the full set only for pure reorderings. - Fork copies discoveries within the inherited transcript, committed revert removes discoveries past the revert boundary, and Session movement clears them so the destination initializes a complete baseline.
This commit is contained in:
parent
754dd57177
commit
0726f09b05
28 changed files with 1166 additions and 598 deletions
|
|
@ -10,6 +10,7 @@ import { ModelV2 } from "@opencode-ai/core/model"
|
|||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
|
|
@ -22,6 +23,7 @@ import { SessionInput } from "@opencode-ai/core/session/input"
|
|||
import { Shell } from "@opencode-ai/schema/shell"
|
||||
import {
|
||||
InstructionCheckpointTable,
|
||||
InstructionFileTable,
|
||||
SessionInputTable,
|
||||
SessionMessageTable,
|
||||
SessionTable,
|
||||
|
|
@ -50,6 +52,15 @@ const assistantRow = (
|
|||
return { id, session_id: sessionID, type, seq, time_created: DateTime.toEpochMillis(time.created), data }
|
||||
}
|
||||
|
||||
const systemRow = (id: SessionMessage.ID, seq: number) => {
|
||||
const {
|
||||
id: _,
|
||||
type,
|
||||
...data
|
||||
} = encodeMessage(SessionMessage.System.make({ id, type: "system", text: "historical update", time: { created } }))
|
||||
return { id, session_id: sessionID, type, seq, time_created: DateTime.toEpochMillis(created), data }
|
||||
}
|
||||
|
||||
describe("SessionProjector", () => {
|
||||
it.effect("projects staged, cleared, and committed reverts", () =>
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -111,6 +122,218 @@ describe("SessionProjector", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("copies only instruction files admitted within a fork boundary", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
const boundary = SessionMessage.ID.make("msg_fork_boundary")
|
||||
yield* db
|
||||
.insert(SessionMessageTable)
|
||||
.values([
|
||||
assistantRow(SessionMessage.ID.make("msg_copied"), 2),
|
||||
systemRow(SessionMessage.ID.make("msg_instruction_update"), 3),
|
||||
assistantRow(boundary, 4),
|
||||
])
|
||||
.run()
|
||||
yield* db
|
||||
.insert(InstructionCheckpointTable)
|
||||
.values({ session_id: sessionID, baseline: "future", snapshot: {}, baseline_seq: 4 })
|
||||
.run()
|
||||
yield* db
|
||||
.insert(InstructionFileTable)
|
||||
.values([
|
||||
{
|
||||
session_id: sessionID,
|
||||
path: AbsolutePath.make("/project/early.md"),
|
||||
content: "early",
|
||||
message_seq: 1,
|
||||
discovered_seq: 1,
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
session_id: sessionID,
|
||||
path: AbsolutePath.make("/project/edge.md"),
|
||||
content: "edge",
|
||||
message_seq: 2,
|
||||
discovered_seq: 2,
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
session_id: sessionID,
|
||||
path: AbsolutePath.make("/project/late.md"),
|
||||
content: "late",
|
||||
message_seq: 4,
|
||||
discovered_seq: 3,
|
||||
position: 2,
|
||||
},
|
||||
])
|
||||
.run()
|
||||
const forkedID = SessionV2.ID.make("ses_projector_fork")
|
||||
|
||||
const events = yield* EventV2.Service
|
||||
yield* events.publish(SessionEvent.Forked, {
|
||||
sessionID: forkedID,
|
||||
parentID: sessionID,
|
||||
from: boundary,
|
||||
})
|
||||
|
||||
expect(
|
||||
yield* db
|
||||
.select({ path: InstructionFileTable.path, message_seq: InstructionFileTable.message_seq })
|
||||
.from(InstructionFileTable)
|
||||
.where(eq(InstructionFileTable.session_id, forkedID))
|
||||
.orderBy(asc(InstructionFileTable.message_seq))
|
||||
.all(),
|
||||
).toEqual([
|
||||
{ path: AbsolutePath.make("/project/early.md"), message_seq: 1 },
|
||||
{ path: AbsolutePath.make("/project/edge.md"), message_seq: 2 },
|
||||
])
|
||||
expect(
|
||||
yield* db
|
||||
.select()
|
||||
.from(InstructionCheckpointTable)
|
||||
.where(eq(InstructionCheckpointTable.session_id, forkedID))
|
||||
.get(),
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
yield* db
|
||||
.select({ type: SessionMessageTable.type })
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.session_id, forkedID))
|
||||
.all(),
|
||||
).toEqual([{ type: "assistant" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("clears admitted instruction files when a session moves", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
yield* db
|
||||
.insert(InstructionFileTable)
|
||||
.values({
|
||||
session_id: sessionID,
|
||||
path: AbsolutePath.make("/project/AGENTS.md"),
|
||||
content: "instructions",
|
||||
message_seq: 1,
|
||||
discovered_seq: 1,
|
||||
position: 0,
|
||||
})
|
||||
.run()
|
||||
|
||||
const events = yield* EventV2.Service
|
||||
yield* events.publish(SessionEvent.Moved, {
|
||||
sessionID,
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }),
|
||||
})
|
||||
|
||||
expect(
|
||||
yield* db.select().from(InstructionFileTable).where(eq(InstructionFileTable.session_id, sessionID)).all(),
|
||||
).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes only instruction files admitted after a committed revert boundary", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
const boundary = SessionMessage.ID.make("msg_instruction_boundary")
|
||||
yield* db
|
||||
.insert(SessionMessageTable)
|
||||
.values([assistantRow(boundary, 2), assistantRow(SessionMessage.ID.make("msg_instruction_later"), 4)])
|
||||
.run()
|
||||
yield* db
|
||||
.insert(InstructionFileTable)
|
||||
.values([
|
||||
{
|
||||
session_id: sessionID,
|
||||
path: AbsolutePath.make("/project/early.md"),
|
||||
content: "early",
|
||||
message_seq: 1,
|
||||
discovered_seq: 1,
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
session_id: sessionID,
|
||||
path: AbsolutePath.make("/project/edge.md"),
|
||||
content: "edge",
|
||||
message_seq: 2,
|
||||
discovered_seq: 2,
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
session_id: sessionID,
|
||||
path: AbsolutePath.make("/project/late.md"),
|
||||
content: "late",
|
||||
message_seq: 3,
|
||||
discovered_seq: 3,
|
||||
position: 2,
|
||||
},
|
||||
])
|
||||
.run()
|
||||
|
||||
const events = yield* EventV2.Service
|
||||
yield* events.publish(SessionEvent.RevertEvent.Committed, {
|
||||
sessionID,
|
||||
messageID: boundary,
|
||||
})
|
||||
|
||||
expect(
|
||||
yield* db
|
||||
.select({ path: InstructionFileTable.path, message_seq: InstructionFileTable.message_seq })
|
||||
.from(InstructionFileTable)
|
||||
.where(eq(InstructionFileTable.session_id, sessionID))
|
||||
.orderBy(asc(InstructionFileTable.message_seq))
|
||||
.all(),
|
||||
).toEqual([
|
||||
{ path: AbsolutePath.make("/project/early.md"), message_seq: 1 },
|
||||
{ path: AbsolutePath.make("/project/edge.md"), message_seq: 2 },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("orders projected messages and context by durable aggregate sequence", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue