refactor(core): separate v2 message identity
This commit is contained in:
parent
789e4d57b9
commit
f2d133edf7
26 changed files with 772 additions and 289 deletions
23
packages/core/AGENTS.md
Normal file
23
packages/core/AGENTS.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Core database migrations
|
||||
|
||||
## V2 beta reset boundary
|
||||
|
||||
During the pre-launch V2 beta period, these unreleased tables may be truncated
|
||||
by an ordinary compatibility migration:
|
||||
|
||||
- `session_message`: disposable V2 timeline projection.
|
||||
- `session_input`: disposable V2 prompt-admission inbox. Truncating it may drop
|
||||
accepted but unpromoted beta prompts, so call that out explicitly.
|
||||
- `event`: unreleased workspace synchronization history.
|
||||
- `event_sequence`: unreleased workspace synchronization cursor and owner state.
|
||||
|
||||
Resetting `event` and `event_sequence` intentionally makes existing Sessions
|
||||
non-warpable until new replayable history is recorded. Call that out explicitly.
|
||||
|
||||
Do not truncate these tables as part of a V2 compatibility migration:
|
||||
|
||||
- `session`, `message`, `part`: canonical V1 Session history.
|
||||
|
||||
If a proposed V2 schema change appears to require resetting anything outside
|
||||
the wipeable beta tables, stop and design an explicit compatibility or
|
||||
fresh-database cutover plan instead.
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
DELETE FROM `session_input`;--> statement-breakpoint
|
||||
DELETE FROM `session_message`;--> statement-breakpoint
|
||||
DELETE FROM `event`;--> statement-breakpoint
|
||||
DELETE FROM `event_sequence`;
|
||||
1
packages/core/src/database/migration.gen.ts
generated
1
packages/core/src/database/migration.gen.ts
generated
|
|
@ -31,5 +31,6 @@ export const migrations = (
|
|||
import("./migration/20260603040000_session_message_projection_order"),
|
||||
import("./migration/20260603141458_session_input_inbox"),
|
||||
import("./migration/20260603160727_jittery_ezekiel_stane"),
|
||||
import("./migration/20260604153000_session_message_identity"),
|
||||
])
|
||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260604153000_session_message_identity",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
// These tables remain disposable until workspace sync and V2 Sessions launch.
|
||||
// Preserve canonical V1 session, message, and part rows.
|
||||
yield* tx.run(`DELETE FROM \`session_input\`;`)
|
||||
yield* tx.run(`DELETE FROM \`session_message\`;`)
|
||||
yield* tx.run(`DELETE FROM \`event\`;`)
|
||||
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
|
|
@ -8,7 +8,7 @@ import { Location } from "./location"
|
|||
import { externalID, type ExternalID, NonNegativeInt, withStatics } from "./schema"
|
||||
import { Identifier } from "./util/identifier"
|
||||
|
||||
export const ID = Schema.String.pipe(
|
||||
export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe(
|
||||
Schema.brand("Event.ID"),
|
||||
withStatics((schema) => ({
|
||||
create: () => schema.make("evt_" + Identifier.ascending()),
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ export * as SessionInput from "./input"
|
|||
import { and, asc, eq, inArray, isNull } from "drizzle-orm"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import type { Database } from "../database/database"
|
||||
import type { EventV2 } from "../event"
|
||||
import { EventV2 } from "../event"
|
||||
import { EventTable } from "../event/sql"
|
||||
import { NonNegativeInt, PositiveInt } from "../schema"
|
||||
import { V2Schema } from "../v2-schema"
|
||||
|
|
@ -66,7 +66,7 @@ export const admit = Effect.fn("SessionInput.admit")(function* (
|
|||
const event = yield* db
|
||||
.select({ id: EventTable.id })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.id, input.id))
|
||||
.where(eq(EventTable.id, SessionMessage.ID.toEvent(input.id)))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const message = yield* db
|
||||
|
|
@ -133,7 +133,7 @@ export const guardReservedID = Effect.fn("SessionInput.guardReservedID")(functio
|
|||
db: DatabaseService,
|
||||
event: EventV2.Payload,
|
||||
) {
|
||||
const admitted = yield* find(db, event.id)
|
||||
const admitted = yield* find(db, SessionMessage.ID.fromEvent(event.id))
|
||||
if (admitted === undefined) return
|
||||
if (!Schema.is(SessionEvent.Prompted)(event))
|
||||
return yield* Effect.die("Durable event conflicts with admitted prompt input")
|
||||
|
|
@ -225,7 +225,7 @@ const publish = Effect.fn("SessionInput.publish")(function* (
|
|||
prompt: decodePrompt(row.prompt),
|
||||
delivery: row.delivery,
|
||||
},
|
||||
{ id: SessionMessage.ID.make(row.id) },
|
||||
{ id: SessionMessage.ID.toEvent(SessionMessage.ID.make(row.id)) },
|
||||
)
|
||||
}
|
||||
return rows.length
|
||||
|
|
|
|||
16
packages/core/src/session/message-id.ts
Normal file
16
packages/core/src/session/message-id.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
export * as SessionMessageID from "./message-id"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { EventV2 } from "../event"
|
||||
import { withStatics } from "../schema"
|
||||
import { Identifier } from "../util/identifier"
|
||||
|
||||
export const ID = Schema.String.check(Schema.isStartsWith("msg_")).pipe(
|
||||
Schema.brand("Session.Message.ID"),
|
||||
withStatics((schema) => ({
|
||||
create: () => schema.make("msg_" + Identifier.ascending()),
|
||||
fromEvent: (id: EventV2.ID) => schema.make("msg" + id.slice(3)),
|
||||
toEvent: (id: ID) => EventV2.ID.make("evt" + id.slice(3)),
|
||||
})),
|
||||
)
|
||||
export type ID = typeof ID.Type
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { castDraft, produce, type WritableDraft } from "immer"
|
||||
import { Effect } from "effect"
|
||||
import type { EventV2 } from "../event"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
|
||||
|
|
@ -112,9 +113,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
const latestReasoning = (assistant: DraftAssistant | undefined, reasoningID: string) =>
|
||||
assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && item.id === reasoningID)
|
||||
|
||||
const updateOwnedAssistant = (messageID: SessionMessage.ID, recipe: (draft: DraftAssistant) => void) =>
|
||||
const updateOwnedAssistant = (messageID: EventV2.ID, recipe: (draft: DraftAssistant) => void) =>
|
||||
Effect.gen(function* () {
|
||||
const assistant = yield* adapter.getAssistant(messageID)
|
||||
const assistant = yield* adapter.getAssistant(SessionMessage.ID.fromEvent(messageID))
|
||||
if (assistant) yield* adapter.updateAssistant(produce(assistant, recipe))
|
||||
})
|
||||
|
||||
|
|
@ -123,7 +124,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
"session.next.agent.switched": (event) => {
|
||||
return adapter.appendMessage(
|
||||
new SessionMessage.AgentSwitched({
|
||||
id: event.id,
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "agent-switched",
|
||||
metadata: event.metadata,
|
||||
agent: event.data.agent,
|
||||
|
|
@ -134,7 +135,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
"session.next.model.switched": (event) => {
|
||||
return adapter.appendMessage(
|
||||
new SessionMessage.ModelSwitched({
|
||||
id: event.id,
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "model-switched",
|
||||
metadata: event.metadata,
|
||||
model: event.data.model,
|
||||
|
|
@ -145,7 +146,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
"session.next.prompted": (event) => {
|
||||
return adapter.appendMessage(
|
||||
new SessionMessage.User({
|
||||
id: event.id,
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "user",
|
||||
metadata: event.metadata,
|
||||
text: event.data.prompt.text,
|
||||
|
|
@ -161,7 +162,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
new SessionMessage.Synthetic({
|
||||
sessionID: event.data.sessionID,
|
||||
text: event.data.text,
|
||||
id: event.id,
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "synthetic",
|
||||
time: { created: event.data.timestamp },
|
||||
}),
|
||||
|
|
@ -170,7 +171,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
"session.next.shell.started": (event) => {
|
||||
return adapter.appendMessage(
|
||||
new SessionMessage.Shell({
|
||||
id: event.id,
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "shell",
|
||||
metadata: event.metadata,
|
||||
callID: event.data.callID,
|
||||
|
|
@ -205,7 +206,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
}
|
||||
yield* adapter.appendMessage(
|
||||
new SessionMessage.Assistant({
|
||||
id: event.id,
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "assistant",
|
||||
agent: event.data.agent,
|
||||
model: event.data.model,
|
||||
|
|
@ -419,7 +420,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
"session.next.compaction.started": (event) => {
|
||||
return adapter.appendMessage(
|
||||
new SessionMessage.Compaction({
|
||||
id: event.id,
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "compaction",
|
||||
metadata: event.metadata,
|
||||
reason: event.data.reason,
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@ export * as SessionMessage from "./message"
|
|||
|
||||
import { Schema } from "effect"
|
||||
import { ProviderMetadata } from "@opencode-ai/llm"
|
||||
import { EventV2 } from "../event"
|
||||
import { ModelV2 } from "../model"
|
||||
import { ToolOutput } from "../tool-output"
|
||||
import { V2Schema } from "../v2-schema"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionMessageID } from "./message-id"
|
||||
import { Prompt } from "./prompt"
|
||||
|
||||
export const ID = EventV2.ID
|
||||
export const ID = SessionMessageID.ID
|
||||
export type ID = Schema.Schema.Type<typeof ID>
|
||||
|
||||
const Base = {
|
||||
|
|
|
|||
|
|
@ -335,10 +335,11 @@ export const layer = Layer.effectDiscard(
|
|||
)
|
||||
yield* events.project(SessionEvent.Prompted, (event) =>
|
||||
Effect.gen(function* () {
|
||||
const messageID = SessionMessage.ID.fromEvent(event.id)
|
||||
const existing = yield* db
|
||||
.select({ id: SessionMessageTable.id })
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.id, event.id))
|
||||
.where(eq(SessionMessageTable.id, messageID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (existing) return yield* Effect.die(new PromptAlreadyProjected())
|
||||
|
|
@ -346,7 +347,7 @@ export const layer = Layer.effectDiscard(
|
|||
const row = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.id, event.id))
|
||||
.where(eq(SessionMessageTable.id, messageID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row) return yield* Effect.die("Prompt projection was not stored")
|
||||
|
|
@ -355,7 +356,7 @@ export const layer = Layer.effectDiscard(
|
|||
if (event.seq === undefined)
|
||||
return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
|
||||
yield* SessionInput.project(db, {
|
||||
id: SessionMessage.ID.make(event.id),
|
||||
id: messageID,
|
||||
sessionID: event.data.sessionID,
|
||||
prompt: event.data.prompt,
|
||||
delivery: event.data.delivery,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { ToolRegistry } from "../../tool-registry"
|
|||
import { SessionRunnerModel } from "./model"
|
||||
import { Database } from "../../database/database"
|
||||
import { SessionInput } from "../input"
|
||||
import { SessionMessage } from "../message"
|
||||
import { QuestionV2 } from "../../question"
|
||||
|
||||
/**
|
||||
|
|
@ -106,7 +107,7 @@ export const layer = Layer.effect(
|
|||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
assistantMessageID: message.id,
|
||||
assistantMessageID: SessionMessage.ID.toEvent(message.id),
|
||||
callID: tool.id,
|
||||
error: { type: "unknown", message: "Tool execution interrupted" },
|
||||
provider: {
|
||||
|
|
|
|||
|
|
@ -7,9 +7,11 @@ import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
|
|||
import { Effect, Layer } from "effect"
|
||||
import { eq, inArray, sql } from "drizzle-orm"
|
||||
import { DatabaseMigration } from "@opencode-ai/core/database/migration"
|
||||
import { migrations } from "@opencode-ai/core/database/migration.gen"
|
||||
import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510033149_session_usage"
|
||||
import normalizeStoragePathsMigration from "@opencode-ai/core/database/migration/20260601010001_normalize_storage_paths"
|
||||
import sessionMessageProjectionOrderMigration from "@opencode-ai/core/database/migration/20260603040000_session_message_projection_order"
|
||||
import sessionMessageIdentityMigration from "@opencode-ai/core/database/migration/20260604153000_session_message_identity"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
|
|
@ -62,7 +64,7 @@ describe("DatabaseMigration", () => {
|
|||
expect(
|
||||
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_input'`),
|
||||
).toEqual({ name: "session_input" })
|
||||
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 29 })
|
||||
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 30 })
|
||||
expect(
|
||||
yield* db.all(
|
||||
sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`,
|
||||
|
|
@ -134,6 +136,100 @@ describe("DatabaseMigration", () => {
|
|||
)
|
||||
})
|
||||
|
||||
test("resets unreleased EventV2 state without deleting canonical Session history", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`)
|
||||
yield* db.run(sql`CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL)`)
|
||||
yield* db.run(sql`CREATE TABLE part (id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL)`)
|
||||
yield* db.run(sql`CREATE TABLE session_message (id text PRIMARY KEY)`)
|
||||
yield* db.run(sql`CREATE TABLE session_input (id text PRIMARY KEY)`)
|
||||
yield* db.run(
|
||||
sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL, owner_id text)`,
|
||||
)
|
||||
yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY, aggregate_id text NOT NULL, seq integer NOT NULL)`)
|
||||
yield* db.run(sql`INSERT INTO session (id) VALUES ('session')`)
|
||||
yield* db.run(sql`INSERT INTO message (id, session_id) VALUES ('legacy_message', 'session')`)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO part (id, message_id, session_id) VALUES ('legacy_part', 'legacy_message', 'session')`,
|
||||
)
|
||||
yield* db.run(sql`INSERT INTO session_message (id) VALUES ('experimental_message')`)
|
||||
yield* db.run(sql`INSERT INTO session_input (id) VALUES ('experimental_input')`)
|
||||
yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq, owner_id) VALUES ('session', 1, 'workspace')`)
|
||||
yield* db.run(sql`INSERT INTO event (id, aggregate_id, seq) VALUES ('experimental_event', 'session', 1)`)
|
||||
|
||||
yield* DatabaseMigration.applyOnly(db, [sessionMessageIdentityMigration])
|
||||
|
||||
expect(yield* db.all(sql`SELECT id FROM session`)).toEqual([{ id: "session" }])
|
||||
expect(yield* db.all(sql`SELECT id FROM message`)).toEqual([{ id: "legacy_message" }])
|
||||
expect(yield* db.all(sql`SELECT id FROM part`)).toEqual([{ id: "legacy_part" }])
|
||||
expect(yield* db.all(sql`SELECT id FROM session_message`)).toEqual([])
|
||||
expect(yield* db.all(sql`SELECT id FROM session_input`)).toEqual([])
|
||||
expect(yield* db.all(sql`SELECT id FROM event`)).toEqual([])
|
||||
expect(yield* db.all(sql`SELECT aggregate_id FROM event_sequence`)).toEqual([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("applies the Session-message identity reset through normal database startup", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filename = path.join(tmp.path, "identity-reset.sqlite")
|
||||
const before = migrations.filter((migration) => migration.id !== sessionMessageIdentityMigration.id)
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`PRAGMA foreign_keys = ON`)
|
||||
yield* DatabaseMigration.applyOnly(db, before)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO project (id, worktree, sandboxes, time_created, time_updated) VALUES ('project', '/project', '[]', 1, 1)`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated) VALUES ('session', 'project', 'session', '/project', 'Session', 'test', 1, 1)`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES ('legacy_message', 'session', 1, 1, '{}')`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES ('legacy_part', 'legacy_message', 'session', 1, 1, '{}')`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO todo (session_id, content, status, priority, position, time_created, time_updated) VALUES ('session', 'keep', 'pending', 'low', 0, 1, 1)`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('evt_message', 'session', 'user', 1, 1, 1, '{}')`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO session_input (id, session_id, prompt, delivery, time_created) VALUES ('evt_input', 'session', '{}', 'queue', 1)`,
|
||||
)
|
||||
yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq, owner_id) VALUES ('session', 1, 'workspace')`)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO event (id, aggregate_id, seq, type, data) VALUES ('evt_event', 'session', 1, 'session.created.1', '{}')`,
|
||||
)
|
||||
}).pipe(Effect.provide(SqliteClient.layer({ filename, disableWAL: true })), Effect.scoped),
|
||||
)
|
||||
|
||||
await Effect.runPromise(Effect.scoped(Layer.build(Database.layerFromPath(filename))))
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
expect(yield* db.all(sql`SELECT id FROM session`)).toEqual([{ id: "session" }])
|
||||
expect(yield* db.all(sql`SELECT id FROM message`)).toEqual([{ id: "legacy_message" }])
|
||||
expect(yield* db.all(sql`SELECT id FROM part`)).toEqual([{ id: "legacy_part" }])
|
||||
expect(yield* db.all(sql`SELECT content FROM todo`)).toEqual([{ content: "keep" }])
|
||||
expect(yield* db.all(sql`SELECT id FROM session_message`)).toEqual([])
|
||||
expect(yield* db.all(sql`SELECT id FROM session_input`)).toEqual([])
|
||||
expect(yield* db.all(sql`SELECT id FROM event`)).toEqual([])
|
||||
expect(yield* db.all(sql`SELECT aggregate_id FROM event_sequence`)).toEqual([])
|
||||
expect(yield* db.get(sql`SELECT id FROM migration WHERE id = ${sessionMessageIdentityMigration.id}`)).toEqual({
|
||||
id: sessionMessageIdentityMigration.id,
|
||||
})
|
||||
}).pipe(Effect.provide(SqliteClient.layer({ filename, disableWAL: true })), Effect.scoped),
|
||||
)
|
||||
})
|
||||
|
||||
test("runs session usage backfill in order with schema changes", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { Location } from "@opencode-ai/core/location"
|
|||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
|
||||
import { V2Schema } from "@opencode-ai/core/v2-schema"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
|
@ -84,6 +85,21 @@ const SyncTimestamp = EventV2.define({
|
|||
})
|
||||
|
||||
describe("EventV2", () => {
|
||||
it.effect("keeps event IDs in the evt namespace", () =>
|
||||
Effect.sync(() => {
|
||||
expect(EventV2.ID.create()).toMatch(/^evt_/)
|
||||
expect(() => EventV2.ID.make("msg_wrong_namespace")).toThrow()
|
||||
expect(() => EventV2.ID.make("evtx")).toThrow()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("round-trips Session message IDs through creator event IDs", () =>
|
||||
Effect.sync(() => {
|
||||
expect(String(SessionMessage.ID.fromEvent(EventV2.ID.make("evt_custom")))).toBe("msg_custom")
|
||||
expect(String(SessionMessage.ID.toEvent(SessionMessage.ID.make("msg_custom")))).toBe("evt_custom")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("derives stable namespaced external IDs", () =>
|
||||
Effect.sync(() => {
|
||||
const input = { namespace: "opencord.agent-input", key: "input-1" }
|
||||
|
|
|
|||
|
|
@ -67,13 +67,23 @@ describe("SessionProjector", () => {
|
|||
|
||||
yield* events.publish(
|
||||
SessionEvent.Prompted,
|
||||
{ sessionID, timestamp: created, prompt: new Prompt({ text: "first" }), delivery: "steer" },
|
||||
{ id: SessionMessage.ID.make("evt_z") },
|
||||
{
|
||||
sessionID,
|
||||
timestamp: created,
|
||||
prompt: new Prompt({ text: "first" }),
|
||||
delivery: "steer",
|
||||
},
|
||||
{ id: EventV2.ID.make("evt_z") },
|
||||
)
|
||||
yield* events.publish(
|
||||
SessionEvent.Prompted,
|
||||
{ sessionID, timestamp: created, prompt: new Prompt({ text: "second" }), delivery: "steer" },
|
||||
{ id: SessionMessage.ID.make("evt_a") },
|
||||
{
|
||||
sessionID,
|
||||
timestamp: created,
|
||||
prompt: new Prompt({ text: "second" }),
|
||||
delivery: "steer",
|
||||
},
|
||||
{ id: EventV2.ID.make("evt_a") },
|
||||
)
|
||||
|
||||
const sessions = yield* SessionV2.Service
|
||||
|
|
@ -131,13 +141,13 @@ describe("SessionProjector", () => {
|
|||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const events = yield* EventV2.Service
|
||||
const id = SessionMessage.ID.make("evt_admitted")
|
||||
const id = SessionMessage.ID.make("msg_admitted")
|
||||
yield* SessionInput.admit(db, { id, sessionID, prompt: new Prompt({ text: "promote me" }), delivery: "steer" })
|
||||
|
||||
const event = yield* events.publish(
|
||||
SessionEvent.Prompted,
|
||||
{ sessionID, timestamp: created, prompt: new Prompt({ text: "promote me" }), delivery: "steer" },
|
||||
{ id },
|
||||
{ id: SessionMessage.ID.toEvent(id) },
|
||||
)
|
||||
|
||||
expect(
|
||||
|
|
@ -168,9 +178,21 @@ describe("SessionProjector", () => {
|
|||
.pipe(Effect.orDie)
|
||||
const events = yield* EventV2.Service
|
||||
|
||||
yield* events.publish(SessionEvent.AgentSwitched, { sessionID, timestamp: created, agent: "build" })
|
||||
yield* events.publish(SessionEvent.ModelSwitched, { sessionID, timestamp: created, model })
|
||||
yield* events.publish(SessionEvent.Synthetic, { sessionID, timestamp: created, text: "synthetic context" })
|
||||
yield* events.publish(SessionEvent.AgentSwitched, {
|
||||
sessionID,
|
||||
timestamp: created,
|
||||
agent: "build",
|
||||
})
|
||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||
sessionID,
|
||||
timestamp: created,
|
||||
model,
|
||||
})
|
||||
yield* events.publish(SessionEvent.Synthetic, {
|
||||
sessionID,
|
||||
timestamp: created,
|
||||
text: "synthetic context",
|
||||
})
|
||||
yield* events.publish(SessionEvent.Shell.Started, {
|
||||
sessionID,
|
||||
timestamp: created,
|
||||
|
|
@ -183,7 +205,11 @@ describe("SessionProjector", () => {
|
|||
callID: "shell-1",
|
||||
output: "/project",
|
||||
})
|
||||
yield* events.publish(SessionEvent.Compaction.Started, { sessionID, timestamp: created, reason: "manual" })
|
||||
yield* events.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID,
|
||||
timestamp: created,
|
||||
reason: "manual",
|
||||
})
|
||||
yield* events.publish(SessionEvent.Compaction.Delta, { sessionID, timestamp: created, text: "partial" })
|
||||
yield* events.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID,
|
||||
|
|
@ -228,6 +254,53 @@ describe("SessionProjector", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects a creator event that reuses an existing projected message ID", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const events = yield* EventV2.Service
|
||||
yield* events.publish(
|
||||
SessionEvent.Synthetic,
|
||||
{ sessionID, timestamp: created, text: "first" },
|
||||
{ id: EventV2.ID.make("evt_same") },
|
||||
)
|
||||
|
||||
const duplicate = yield* events
|
||||
.publish(
|
||||
SessionEvent.Synthetic,
|
||||
{ sessionID, timestamp: created, text: "second" },
|
||||
{ id: EventV2.ID.make("evt_same") },
|
||||
)
|
||||
.pipe(Effect.exit)
|
||||
|
||||
expect(duplicate._tag).toBe("Failure")
|
||||
expect(
|
||||
yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.id, SessionMessage.ID.make("msg_same")))
|
||||
.get()
|
||||
.pipe(Effect.orDie),
|
||||
).toMatchObject({ data: { text: "first" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects a Prompted event that conflicts with an admitted inbox row", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
|
|
@ -249,14 +322,14 @@ describe("SessionProjector", () => {
|
|||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const events = yield* EventV2.Service
|
||||
const id = SessionMessage.ID.make("evt_conflict")
|
||||
const id = SessionMessage.ID.make("msg_conflict")
|
||||
yield* SessionInput.admit(db, { id, sessionID, prompt: new Prompt({ text: "admitted" }), delivery: "steer" })
|
||||
|
||||
const exit = yield* events
|
||||
.publish(
|
||||
SessionEvent.Prompted,
|
||||
{ sessionID, timestamp: created, prompt: new Prompt({ text: "different" }), delivery: "steer" },
|
||||
{ id },
|
||||
{ id: SessionMessage.ID.toEvent(id) },
|
||||
)
|
||||
.pipe(Effect.exit)
|
||||
|
||||
|
|
@ -288,12 +361,16 @@ describe("SessionProjector", () => {
|
|||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const events = yield* EventV2.Service
|
||||
const id = SessionMessage.ID.make("evt_delivery_conflict")
|
||||
const id = SessionMessage.ID.make("msg_delivery_conflict")
|
||||
const prompt = new Prompt({ text: "admitted" })
|
||||
yield* SessionInput.admit(db, { id, sessionID, prompt, delivery: "queue" })
|
||||
|
||||
const exit = yield* events
|
||||
.publish(SessionEvent.Prompted, { sessionID, timestamp: created, prompt, delivery: "steer" }, { id })
|
||||
.publish(
|
||||
SessionEvent.Prompted,
|
||||
{ sessionID, timestamp: created, prompt, delivery: "steer" },
|
||||
{ id: SessionMessage.ID.toEvent(id) },
|
||||
)
|
||||
.pipe(Effect.exit)
|
||||
|
||||
expect(String(exit)).toContain("Prompt projection conflicts with admitted input")
|
||||
|
|
@ -306,7 +383,7 @@ describe("SessionProjector", () => {
|
|||
it.effect("does not revive a stale incomplete in-memory assistant projection", () =>
|
||||
Effect.gen(function* () {
|
||||
const stale = new SessionMessage.Assistant({
|
||||
id: SessionMessage.ID.make("evt_assistant_stale"),
|
||||
id: SessionMessage.ID.make("msg_assistant_stale"),
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model,
|
||||
|
|
@ -314,7 +391,7 @@ describe("SessionProjector", () => {
|
|||
time: { created },
|
||||
})
|
||||
const completed = new SessionMessage.Assistant({
|
||||
id: SessionMessage.ID.make("evt_assistant_completed"),
|
||||
id: SessionMessage.ID.make("msg_assistant_completed"),
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model,
|
||||
|
|
@ -351,8 +428,8 @@ describe("SessionProjector", () => {
|
|||
yield* db
|
||||
.insert(SessionMessageTable)
|
||||
.values([
|
||||
assistantRow(SessionMessage.ID.make("evt_assistant_1"), 0),
|
||||
assistantRow(SessionMessage.ID.make("evt_assistant_2"), 1),
|
||||
assistantRow(SessionMessage.ID.make("msg_assistant_1"), 0),
|
||||
assistantRow(SessionMessage.ID.make("msg_assistant_2"), 1),
|
||||
])
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
|
@ -361,7 +438,7 @@ describe("SessionProjector", () => {
|
|||
yield* service.publish(SessionEvent.Step.Ended, {
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
assistantMessageID: SessionMessage.ID.make("evt_assistant_2"),
|
||||
assistantMessageID: EventV2.ID.make("evt_assistant_2"),
|
||||
finish: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
|
|
@ -409,8 +486,8 @@ describe("SessionProjector", () => {
|
|||
yield* db
|
||||
.insert(SessionMessageTable)
|
||||
.values([
|
||||
assistantRow(SessionMessage.ID.make("evt_assistant_stale"), 0),
|
||||
assistantRow(SessionMessage.ID.make("evt_assistant_completed"), 1, {
|
||||
assistantRow(SessionMessage.ID.make("msg_assistant_stale"), 0),
|
||||
assistantRow(SessionMessage.ID.make("msg_assistant_completed"), 1, {
|
||||
created: DateTime.makeUnsafe(1),
|
||||
completed: DateTime.makeUnsafe(2),
|
||||
}),
|
||||
|
|
@ -437,7 +514,7 @@ describe("SessionProjector", () => {
|
|||
)
|
||||
expect(messages).toEqual([
|
||||
new SessionMessage.Assistant({
|
||||
id: SessionMessage.ID.make("evt_assistant_completed"),
|
||||
id: SessionMessage.ID.make("msg_assistant_completed"),
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model,
|
||||
|
|
@ -445,7 +522,7 @@ describe("SessionProjector", () => {
|
|||
time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
|
||||
}),
|
||||
new SessionMessage.Assistant({
|
||||
id: SessionMessage.ID.make("evt_assistant_stale"),
|
||||
id: SessionMessage.ID.make("msg_assistant_stale"),
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model,
|
||||
|
|
|
|||
|
|
@ -310,7 +310,7 @@ describe("SessionV2.prompt", () => {
|
|||
yield* events.publish(
|
||||
SessionEvent.Prompted,
|
||||
{ sessionID, timestamp: yield* DateTime.now, prompt, delivery: "steer" },
|
||||
{ id: messageID },
|
||||
{ id: SessionMessage.ID.toEvent(messageID) },
|
||||
)
|
||||
|
||||
const retried = yield* session.prompt({ id: messageID, sessionID, prompt, resume: false })
|
||||
|
|
@ -329,7 +329,7 @@ describe("SessionV2.prompt", () => {
|
|||
yield* events.publish(
|
||||
SessionEvent.Prompted,
|
||||
{ sessionID, timestamp: yield* DateTime.now, prompt, delivery: "queue" },
|
||||
{ id: messageID },
|
||||
{ id: SessionMessage.ID.toEvent(messageID) },
|
||||
)
|
||||
|
||||
const retried = yield* session.prompt({ id: messageID, sessionID, prompt, delivery: "queue", resume: false })
|
||||
|
|
@ -339,7 +339,7 @@ describe("SessionV2.prompt", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects an input ID already used by a durable non-prompt event", () =>
|
||||
it.effect("rejects an input ID already used by a projected non-prompt message", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
|
|
@ -347,7 +347,7 @@ describe("SessionV2.prompt", () => {
|
|||
yield* events.publish(
|
||||
SessionEvent.Synthetic,
|
||||
{ sessionID, timestamp: yield* DateTime.now, text: "Collision" },
|
||||
{ id: messageID },
|
||||
{ id: SessionMessage.ID.toEvent(messageID) },
|
||||
)
|
||||
|
||||
const failure = yield* session
|
||||
|
|
@ -359,7 +359,7 @@ describe("SessionV2.prompt", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects a durable event ID reserved by an admitted prompt without poisoning promotion", () =>
|
||||
it.effect("keeps event envelope IDs separate from admitted message IDs", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const { db } = yield* Database.Service
|
||||
|
|
@ -368,24 +368,51 @@ describe("SessionV2.prompt", () => {
|
|||
const prompt = new Prompt({ text: "Reserved prompt" })
|
||||
yield* session.prompt({ id: messageID, sessionID, prompt, resume: false })
|
||||
|
||||
yield* events.publish(
|
||||
SessionEvent.Synthetic,
|
||||
{ sessionID, timestamp: yield* DateTime.now, text: "Synthetic" },
|
||||
{ id: EventV2.ID.make("evt_reserved_prompt") },
|
||||
)
|
||||
|
||||
expect(yield* admitted(messageID)).not.toHaveProperty("promotedSeq")
|
||||
|
||||
yield* SessionInput.promoteSteers(db, events, sessionID)
|
||||
|
||||
expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 1 })
|
||||
expect(yield* session.messages({ sessionID })).toMatchObject([
|
||||
{ id: messageID, type: "user", text: "Reserved prompt" },
|
||||
{ type: "synthetic", text: "Synthetic" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps Session message IDs in the msg namespace", () =>
|
||||
Effect.sync(() => {
|
||||
expect(SessionMessage.ID.create()).toMatch(/^msg_/)
|
||||
expect(() => SessionMessage.ID.make("evt_wrong_namespace")).toThrow()
|
||||
expect(() => SessionMessage.ID.make("msgx")).toThrow()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects a non-prompt event that reuses an admitted message ID", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const prompt = new Prompt({ text: "Reserved prompt" })
|
||||
yield* session.prompt({ id: messageID, sessionID, prompt, resume: false })
|
||||
|
||||
const failure = yield* events
|
||||
.publish(
|
||||
SessionEvent.Synthetic,
|
||||
{ sessionID, timestamp: yield* DateTime.now, text: "Conflicting synthetic" },
|
||||
{ id: messageID },
|
||||
{ id: SessionMessage.ID.toEvent(messageID) },
|
||||
)
|
||||
.pipe(Effect.catchDefect(Effect.succeed))
|
||||
|
||||
expect(failure).toBe("Durable event conflicts with admitted prompt input")
|
||||
expect(yield* admitted(messageID)).not.toHaveProperty("promotedSeq")
|
||||
expect(yield* session.messages({ sessionID })).toEqual([])
|
||||
|
||||
yield* SessionInput.promoteSteers(db, events, sessionID)
|
||||
|
||||
expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 0 })
|
||||
expect(yield* session.messages({ sessionID })).toMatchObject([
|
||||
{ id: messageID, type: "user", text: "Reserved prompt" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { Message, Model } from "@opencode-ai/llm"
|
||||
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
|
|
@ -12,7 +11,7 @@ import { ToolOutput } from "@opencode-ai/core/tool-output"
|
|||
import { DateTime } from "effect"
|
||||
|
||||
const created = DateTime.makeUnsafe(0)
|
||||
const id = (value: string) => EventV2.ID.make(`evt_${value}`)
|
||||
const id = (value: string) => SessionMessage.ID.make(`msg_${value}`)
|
||||
const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route })
|
||||
|
||||
describe("toLLMMessages", () => {
|
||||
|
|
|
|||
|
|
@ -1218,30 +1218,30 @@ describe("SessionRunnerLLM", () => {
|
|||
const events = yield* EventV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Recover interrupted tool" }), resume: false })
|
||||
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID)
|
||||
const assistant = yield* events.publish(SessionEvent.Step.Started, {
|
||||
const assistantMessageID = (yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
agent: "build",
|
||||
model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
|
||||
})
|
||||
})).id
|
||||
yield* events.publish(SessionEvent.Tool.Input.Started, {
|
||||
sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
assistantMessageID: assistant.id,
|
||||
assistantMessageID,
|
||||
callID: "call-interrupted",
|
||||
name: "echo",
|
||||
})
|
||||
yield* events.publish(SessionEvent.Tool.Input.Ended, {
|
||||
sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
assistantMessageID: assistant.id,
|
||||
assistantMessageID,
|
||||
callID: "call-interrupted",
|
||||
text: '{"text":"stale"}',
|
||||
})
|
||||
yield* events.publish(SessionEvent.Tool.Called, {
|
||||
sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
assistantMessageID: assistant.id,
|
||||
assistantMessageID,
|
||||
callID: "call-interrupted",
|
||||
tool: "echo",
|
||||
input: { text: "stale" },
|
||||
|
|
@ -1280,30 +1280,30 @@ describe("SessionRunnerLLM", () => {
|
|||
resume: false,
|
||||
})
|
||||
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID)
|
||||
const assistant = yield* events.publish(SessionEvent.Step.Started, {
|
||||
const assistantMessageID = (yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
agent: "build",
|
||||
model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
|
||||
})
|
||||
})).id
|
||||
yield* events.publish(SessionEvent.Tool.Input.Started, {
|
||||
sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
assistantMessageID: assistant.id,
|
||||
assistantMessageID,
|
||||
callID: "call-hosted-interrupted",
|
||||
name: "web_search",
|
||||
})
|
||||
yield* events.publish(SessionEvent.Tool.Input.Ended, {
|
||||
sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
assistantMessageID: assistant.id,
|
||||
assistantMessageID,
|
||||
callID: "call-hosted-interrupted",
|
||||
text: '{"query":"stale"}',
|
||||
})
|
||||
yield* events.publish(SessionEvent.Tool.Called, {
|
||||
sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
assistantMessageID: assistant.id,
|
||||
assistantMessageID,
|
||||
callID: "call-hosted-interrupted",
|
||||
tool: "web_search",
|
||||
input: { query: "stale" },
|
||||
|
|
@ -1338,16 +1338,16 @@ describe("SessionRunnerLLM", () => {
|
|||
resume: false,
|
||||
})
|
||||
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID)
|
||||
const assistant = yield* events.publish(SessionEvent.Step.Started, {
|
||||
const assistantMessageID = (yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
agent: "build",
|
||||
model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
|
||||
})
|
||||
})).id
|
||||
yield* events.publish(SessionEvent.Tool.Input.Started, {
|
||||
sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
assistantMessageID: assistant.id,
|
||||
assistantMessageID,
|
||||
callID: "call-pending-interrupted",
|
||||
name: "echo",
|
||||
})
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ describe("Tool.Progress", () => {
|
|||
const row = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.id, assistantMessageID))
|
||||
.where(eq(SessionMessageTable.id, SessionMessage.ID.fromEvent(assistantMessageID)))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row) return yield* Effect.die("Missing projected assistant")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue