mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-18 21:53:12 -04:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0304825aed | |||
| edebe43a0d | |||
| f07dc20b7c | |||
| a8eb7717ec | |||
| d914b688ab | |||
| 6c9ba69106 | |||
| ef937431c4 | |||
| e11c56d518 | |||
| 0e7586a009 |
@@ -615,6 +615,7 @@
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -382,6 +382,15 @@ export type Endpoint5_31Output =
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly title: string }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.viewed"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
@@ -914,6 +923,10 @@ export type Endpoint5_34Input = { readonly sessionID: Session.ID; readonly messa
|
||||
export type Endpoint5_34Output = SessionMessage.Info
|
||||
export type SessionMessageOperation<E = never> = (input: Endpoint5_34Input) => Effect.Effect<Endpoint5_34Output, E>
|
||||
|
||||
export type Endpoint5_35Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_35Output = void
|
||||
export type SessionViewOperation<E = never> = (input: Endpoint5_35Input) => Effect.Effect<Endpoint5_35Output, E>
|
||||
|
||||
export interface SessionApi<E = never> {
|
||||
readonly list: SessionListOperation<E>
|
||||
readonly create: SessionCreateOperation<E>
|
||||
@@ -958,6 +971,7 @@ export interface SessionApi<E = never> {
|
||||
readonly interrupt: SessionInterruptOperation<E>
|
||||
readonly background: SessionBackgroundOperation<E>
|
||||
readonly message: SessionMessageOperation<E>
|
||||
readonly view: SessionViewOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint6_0Input = {
|
||||
|
||||
@@ -86,6 +86,8 @@ import type {
|
||||
Endpoint5_33Output,
|
||||
Endpoint5_34Input,
|
||||
Endpoint5_34Output,
|
||||
Endpoint5_35Input,
|
||||
Endpoint5_35Output,
|
||||
Endpoint6_0Input,
|
||||
Endpoint6_0Output,
|
||||
Endpoint7_0Input,
|
||||
@@ -610,6 +612,11 @@ const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_35 = (raw: RawClient["server.session"]) => (input: Endpoint5_35Input) =>
|
||||
preserveEffect<Endpoint5_35Output>()(
|
||||
raw["session.view"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup5 = (raw: RawClient["server.session"]) => ({
|
||||
list: Endpoint5_0(raw),
|
||||
create: Endpoint5_1(raw),
|
||||
@@ -639,6 +646,7 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
|
||||
interrupt: Endpoint5_32(raw),
|
||||
background: Endpoint5_33(raw),
|
||||
message: Endpoint5_34(raw),
|
||||
view: Endpoint5_35(raw),
|
||||
})
|
||||
|
||||
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
|
||||
|
||||
@@ -80,6 +80,8 @@ import type {
|
||||
SessionBackgroundOutput,
|
||||
SessionMessageInput,
|
||||
SessionMessageOutput,
|
||||
SessionViewInput,
|
||||
SessionViewOutput,
|
||||
MessageListInput,
|
||||
MessageListOutput,
|
||||
ModelListInput,
|
||||
@@ -896,6 +898,17 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
view: (input: SessionViewInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionViewOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/view`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 401, 400],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
message: {
|
||||
list: (input: MessageListInput, requestOptions?: RequestOptions) =>
|
||||
|
||||
@@ -479,6 +479,16 @@ export type SessionRenamed = {
|
||||
data: { sessionID: string; title: string }
|
||||
}
|
||||
|
||||
export type SessionViewed = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.viewed"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string }
|
||||
}
|
||||
|
||||
export type SessionDeleted = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1510,7 +1520,7 @@ export type SessionInfo = {
|
||||
model?: ModelRef
|
||||
cost: MoneyUSD
|
||||
tokens: TokenUsageInfo
|
||||
time: { created: number; updated: number; archived?: number }
|
||||
time: { created: number; updated: number; idle?: number; viewed?: number; archived?: number }
|
||||
title?: string
|
||||
location: LocationRef
|
||||
subpath?: string
|
||||
@@ -1923,6 +1933,7 @@ export type SessionEventDurable =
|
||||
| SessionModelSelected
|
||||
| SessionMoved
|
||||
| SessionRenamed
|
||||
| SessionViewed
|
||||
| SessionDeleted
|
||||
| SessionForked
|
||||
| SessionInboxDelivered
|
||||
@@ -2013,6 +2024,7 @@ export type V2Event =
|
||||
| SessionModelSelected
|
||||
| SessionMoved
|
||||
| SessionRenamed
|
||||
| SessionViewed
|
||||
| SessionUsageUpdated
|
||||
| SessionDeleted
|
||||
| SessionForked
|
||||
@@ -2476,7 +2488,13 @@ export type SessionImportInput = {
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number }
|
||||
readonly time: {
|
||||
readonly created: number
|
||||
readonly updated: number
|
||||
readonly idle?: number
|
||||
readonly viewed?: number
|
||||
readonly archived?: number
|
||||
}
|
||||
readonly title?: string
|
||||
readonly location: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly subpath?: string
|
||||
@@ -2743,7 +2761,13 @@ export type SessionImportInput = {
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number }
|
||||
readonly time: {
|
||||
readonly created: number
|
||||
readonly updated: number
|
||||
readonly idle?: number
|
||||
readonly viewed?: number
|
||||
readonly archived?: number
|
||||
}
|
||||
readonly title?: string
|
||||
readonly location: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly subpath?: string
|
||||
@@ -3010,7 +3034,13 @@ export type SessionImportInput = {
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number }
|
||||
readonly time: {
|
||||
readonly created: number
|
||||
readonly updated: number
|
||||
readonly idle?: number
|
||||
readonly viewed?: number
|
||||
readonly archived?: number
|
||||
}
|
||||
readonly title?: string
|
||||
readonly location: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly subpath?: string
|
||||
@@ -3936,6 +3966,10 @@ export type SessionMessageInput = {
|
||||
|
||||
export type SessionMessageOutput = { data: SessionMessageInfo }["data"]
|
||||
|
||||
export type SessionViewInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type SessionViewOutput = void
|
||||
|
||||
export type MessageListInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly limit?: {
|
||||
|
||||
@@ -814,6 +814,13 @@ export function createData(config: CreateDataInput) {
|
||||
const currentAssistant = message.activeAssistant(draft)
|
||||
if (currentAssistant) currentAssistant.retry = undefined
|
||||
})
|
||||
if (event.type === "session.execution.interrupted" && event.data.reason === "shutdown") return
|
||||
result.session.invalidate(event.data.sessionID)
|
||||
void result.session.sync(event.data.sessionID)
|
||||
return
|
||||
case "session.viewed":
|
||||
result.session.invalidate(event.data.sessionID)
|
||||
void result.session.sync(event.data.sessionID)
|
||||
return
|
||||
case "session.revert.staged":
|
||||
if (store.session.info[event.data.sessionID])
|
||||
|
||||
@@ -136,8 +136,10 @@ test("event.subscribe terminates on Effect protocol decode failures", async () =
|
||||
|
||||
test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
const logQueries: Array<Record<string, string>> = []
|
||||
const requests: Array<{ method: string; url: string }> = []
|
||||
const httpClient = HttpClient.make((request) => {
|
||||
const url = request.url
|
||||
requests.push({ method: request.method, url })
|
||||
if (url.includes("/log")) {
|
||||
logQueries.push(Object.fromEntries(request.urlParams.params))
|
||||
return Effect.succeed(
|
||||
@@ -183,6 +185,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
const created = yield* client.session.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }),
|
||||
})
|
||||
yield* client.session.view({ sessionID: Session.ID.make("ses_test") })
|
||||
yield* client.session.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") })
|
||||
yield* client.session.switchModel({
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
@@ -207,7 +210,11 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
return { page, active, created, admitted, context, log, message }
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
|
||||
const listed = result.page.data[0]
|
||||
if (!listed?.time.idle || !listed.time.viewed) throw new Error("Expected attention times")
|
||||
expect(DateTime.toEpochMillis(listed.time.created)).toBe(1_717_171_717_000)
|
||||
expect(DateTime.toEpochMillis(listed.time.idle)).toBe(1_717_171_717_002)
|
||||
expect(DateTime.toEpochMillis(listed.time.viewed)).toBe(1_717_171_717_001)
|
||||
expect(result.active).toEqual({ ses_test: { type: "running" } })
|
||||
expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype)
|
||||
expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
|
||||
@@ -217,11 +224,10 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
|
||||
expect(result.context).toEqual([])
|
||||
expect(logQueries[0]).toEqual({ after: "0" })
|
||||
expect(requests).toContainEqual({ method: "POST", url: "http://localhost:3000/api/session/ses_test/view" })
|
||||
const logged = Array.from(result.log)
|
||||
expect(logged.map((item) => item.type)).toEqual(["session.model.selected", "log.synced"])
|
||||
expect(logged[0]?.type === "session.model.selected" && DateTime.toEpochMillis(logged[0].created)).toBe(
|
||||
1_717_171_717_000,
|
||||
)
|
||||
expect(logged[0]?.type === "session.model.selected" && logged[0].created).toBe(1_717_171_717_000)
|
||||
expect(logged.at(-1)).toEqual(synced)
|
||||
expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" }))
|
||||
})
|
||||
@@ -260,6 +266,8 @@ const session = {
|
||||
time: {
|
||||
created: 1_717_171_717_000,
|
||||
updated: 1_717_171_717_000,
|
||||
idle: 1_717_171_717_002,
|
||||
viewed: 1_717_171_717_001,
|
||||
},
|
||||
title: "Test",
|
||||
location: { directory: "/tmp/project" },
|
||||
|
||||
@@ -539,6 +539,7 @@ test("session methods use the public HTTP contract", async () => {
|
||||
const page = await client.session.list({ limit: 10, order: "desc", parentID: null })
|
||||
const active = await client.session.active()
|
||||
const created = await client.session.create({ location: { directory: "/tmp/project" } })
|
||||
await client.session.view({ sessionID: "ses_test" })
|
||||
await client.session.switchAgent({ sessionID: "ses_test", agent: "build" })
|
||||
await client.session.switchModel({
|
||||
sessionID: "ses_test",
|
||||
@@ -565,6 +566,7 @@ test("session methods use the public HTTP contract", async () => {
|
||||
const message = await client.session.message({ sessionID: "ses_test", messageID: "msg_model" })
|
||||
|
||||
expect(page.cursor.next).toBe("next")
|
||||
expect(page.data[0].time).toMatchObject({ idle: 1_717_171_717_002, viewed: 1_717_171_717_001 })
|
||||
expect(active).toEqual({ ses_test: { type: "running" } })
|
||||
expect(created.id).toBe("ses_test")
|
||||
expect(admitted.id).toBe("msg_test")
|
||||
@@ -577,6 +579,7 @@ test("session methods use the public HTTP contract", async () => {
|
||||
["GET", "http://localhost:3000/api/session?limit=10&order=desc&parentID=null"],
|
||||
["GET", "http://localhost:3000/api/session/active"],
|
||||
["POST", "http://localhost:3000/api/session"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/view"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/agent"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/model"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/prompt"],
|
||||
@@ -651,6 +654,8 @@ const session = {
|
||||
time: {
|
||||
created: 1_717_171_717_000,
|
||||
updated: 1_717_171_717_000,
|
||||
idle: 1_717_171_717_002,
|
||||
viewed: 1_717_171_717_001,
|
||||
},
|
||||
title: "Test",
|
||||
location: { directory: "/tmp/project" },
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "dcde8e6b-4bf4-4f6b-b2be-4030c2c3e936",
|
||||
"prevIds": ["5c1aa56b-c3ee-4283-9a84-c0bf626dc604"],
|
||||
"id": "94b6c496-ad84-426f-9d5d-3e1ac3ebfb56",
|
||||
"prevIds": ["dcde8e6b-4bf4-4f6b-b2be-4030c2c3e936"],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "account_state",
|
||||
@@ -1350,6 +1350,26 @@
|
||||
"entityType": "columns",
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "time_idle",
|
||||
"entityType": "columns",
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "time_viewed",
|
||||
"entityType": "columns",
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
|
||||
+2
@@ -43,6 +43,7 @@ import m40 from "./migration/20260808023530_workspace_domain.js"
|
||||
import m41 from "./migration/20260811161259_execution_claim_attempts.js"
|
||||
import m42 from "./migration/20260812181746_session_inbox.js"
|
||||
import m43 from "./migration/20260812213948_worktree.js"
|
||||
import m44 from "./migration/20260815182818_session_viewed_state.js"
|
||||
|
||||
export const migrations = [
|
||||
m00,
|
||||
@@ -89,4 +90,5 @@ export const migrations = [
|
||||
m41,
|
||||
m42,
|
||||
m43,
|
||||
m44,
|
||||
] satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -1,43 +1,10 @@
|
||||
import { Effect } from "effect"
|
||||
import { sql } from "drizzle-orm"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
|
||||
const previousV2Marker = "20260730195856_optional_session_title"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260804233008_loose_psylocke",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
// This marker identifies the completed pre-split V2 lineage. Its V2 tables
|
||||
// are canonical, so rename them in place instead of replaying the V1 squash.
|
||||
if (yield* tx.get(sql`SELECT id FROM migration WHERE id = ${previousV2Marker}`)) {
|
||||
const v1Only = yield* tx.get(sql`
|
||||
SELECT 1
|
||||
FROM message
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM session_message WHERE session_message.session_id = message.session_id
|
||||
)
|
||||
LIMIT 1
|
||||
`)
|
||||
if (v1Only) return yield* Effect.die(new Error("Previous V2 database contains V1-only session history"))
|
||||
|
||||
yield* tx.run(`DROP INDEX IF EXISTS \`session_project_idx\`;`)
|
||||
yield* tx.run(`DROP INDEX IF EXISTS \`session_workspace_idx\`;`)
|
||||
yield* tx.run(`DROP INDEX IF EXISTS \`session_parent_idx\`;`)
|
||||
yield* tx.run(`DROP INDEX IF EXISTS \`session_time_suspended_idx\`;`)
|
||||
yield* tx.run(`ALTER TABLE \`session\` RENAME TO \`session_v2\`;`)
|
||||
yield* tx.run(`CREATE INDEX \`session_v2_project_idx\` ON \`session_v2\` (\`project_id\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`session_v2_workspace_idx\` ON \`session_v2\` (\`workspace_id\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`session_v2_parent_idx\` ON \`session_v2\` (\`parent_id\`);`)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_v2_time_suspended_idx\` ON \`session_v2\` (\`time_suspended\`) WHERE "session_v2"."time_suspended" is not null;`,
|
||||
)
|
||||
yield* tx.run(`DROP TABLE IF EXISTS \`data_migration\`;`)
|
||||
yield* tx.run(`DROP TABLE IF EXISTS \`session_context_epoch\`;`)
|
||||
yield* tx.run(`DROP TABLE IF EXISTS \`session_input\`;`)
|
||||
return
|
||||
}
|
||||
|
||||
yield* tx.run(`
|
||||
CREATE TABLE IF NOT EXISTS \`kv\` (
|
||||
\`key\` text PRIMARY KEY,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260815182818_session_viewed_state",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`session_v2\` ADD \`time_idle\` integer;`)
|
||||
yield* tx.run(`ALTER TABLE \`session_v2\` ADD \`time_viewed\` integer;`)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default migration
|
||||
@@ -209,6 +209,8 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
|
||||
\`model\` text,
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL,
|
||||
\`time_idle\` integer,
|
||||
\`time_viewed\` integer,
|
||||
\`time_compacting\` integer,
|
||||
\`time_archived\` integer,
|
||||
\`time_suspended\` integer,
|
||||
|
||||
@@ -33,7 +33,7 @@ const layer = Layer.effect(
|
||||
` Workspace root folder: ${location.project.directory}`,
|
||||
` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`,
|
||||
` Platform: ${process.platform}`,
|
||||
` Prefer ${global.tmp} over generic system temporary directories such as /tmp; it is pre-created and approved for external access.`,
|
||||
` Use ${global.tmp} for temporary work outside the workspace; it already exists and is pre-approved for external directory access.`,
|
||||
"</env>",
|
||||
].join("\n"),
|
||||
),
|
||||
|
||||
@@ -165,6 +165,7 @@ export interface Interface {
|
||||
input: ForkInput,
|
||||
) => Effect.Effect<SessionSchema.Info, NotFoundError | MessageNotFoundError | ForkEmptyError>
|
||||
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info, NotFoundError>
|
||||
readonly view: (input: { sessionID: SessionSchema.ID }) => Effect.Effect<void, NotFoundError>
|
||||
readonly remove: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
|
||||
readonly messages: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
@@ -447,6 +448,17 @@ const layer = Layer.effect(
|
||||
if (!session) return yield* new NotFoundError({ sessionID })
|
||||
return session
|
||||
}),
|
||||
view: Effect.fn("Session.view")(function* (input) {
|
||||
const row = yield* db
|
||||
.select({ idle: SessionTable.time_idle, viewed: SessionTable.time_viewed })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, input.sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row) return yield* new NotFoundError({ sessionID: input.sessionID })
|
||||
if (row.idle === null || (row.viewed !== null && row.viewed >= row.idle)) return
|
||||
yield* bus.publish(SessionEvent.Viewed, { sessionID: input.sessionID })
|
||||
}),
|
||||
remove: Effect.fn("Session.remove")(function* (sessionID) {
|
||||
const session = yield* result.get(sessionID)
|
||||
yield* execution.interrupt(sessionID)
|
||||
|
||||
@@ -53,6 +53,8 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
|
||||
time: {
|
||||
created: DateTime.makeUnsafe(row.time_created),
|
||||
updated: DateTime.makeUnsafe(row.time_updated),
|
||||
idle: row.time_idle === null ? undefined : DateTime.makeUnsafe(row.time_idle),
|
||||
viewed: row.time_viewed === null ? undefined : DateTime.makeUnsafe(row.time_viewed),
|
||||
archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -60,6 +60,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
Match.type<SessionEvent.DurableEvent>(),
|
||||
Match.discriminatorsExhaustive("type")({
|
||||
"session.created": () => Effect.void,
|
||||
"session.viewed": () => Effect.void,
|
||||
"session.usage.recorded": () => Effect.void,
|
||||
"session.agent.selected": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
|
||||
@@ -50,7 +50,6 @@ interface Channel {
|
||||
interface State {
|
||||
readonly lock: Semaphore.Semaphore
|
||||
closed: boolean
|
||||
httpFallback: boolean
|
||||
channel?: Channel
|
||||
}
|
||||
|
||||
@@ -120,7 +119,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
const state = (sessionID: SessionSchema.ID) => {
|
||||
const current = states.get(sessionID)
|
||||
if (current) return current
|
||||
const created = { lock: Semaphore.makeUnsafe(1), closed: false, httpFallback: false }
|
||||
const created = { lock: Semaphore.makeUnsafe(1), closed: false }
|
||||
states.set(sessionID, created)
|
||||
return created
|
||||
}
|
||||
@@ -242,9 +241,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
channel.active?.lifecycle.delivery === "terminal" ||
|
||||
(error.reason._tag === "Transport" && error.reason.code === "queue-overflow")
|
||||
? "accepted"
|
||||
: error.reason._tag === "Transport" && error.reason.code === "1009"
|
||||
? "rejected"
|
||||
: "ambiguous",
|
||||
: "ambiguous",
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -277,7 +274,6 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
phase: "queue",
|
||||
delivery: "not-sent",
|
||||
})
|
||||
if (owner.httpFallback) return fallback(exchange)
|
||||
const key = affinity(exchange)
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const current = owner.channel
|
||||
@@ -424,26 +420,6 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
yield* poison(owner, channel, error)
|
||||
}),
|
||||
),
|
||||
Stream.catch((error) => {
|
||||
if (
|
||||
error.reason._tag !== "Transport" ||
|
||||
error.reason.code !== "1009" ||
|
||||
error.reason.delivery !== "rejected"
|
||||
)
|
||||
return Stream.fail(error)
|
||||
owner.httpFallback = true
|
||||
return Stream.unwrap(
|
||||
Effect.logWarning("session websocket request too large; using http", {
|
||||
sessionTransport: "websocket",
|
||||
phase: "close",
|
||||
delivery: "rejected",
|
||||
code: error.reason.code,
|
||||
}).pipe(
|
||||
Effect.andThen(metric("fallback", { reason: "message_too_large" })),
|
||||
Effect.as(exchange.fallback()),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
const complete = Effect.sync(() => {
|
||||
if (owner.channel !== channel || channel.pending?.token !== token) return
|
||||
|
||||
@@ -391,6 +391,30 @@ function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, me
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
function projectIdle(
|
||||
db: DatabaseService,
|
||||
event:
|
||||
| typeof SessionEvent.Execution.Succeeded.Type
|
||||
| typeof SessionEvent.Execution.Failed.Type
|
||||
| typeof SessionEvent.Execution.Interrupted.Type,
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
yield* run(db, event)
|
||||
if (event.type === SessionEvent.Execution.Interrupted.type && event.data.reason === "shutdown") return
|
||||
const time = event.created
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
// Unread uses a strict timestamp comparison, so every terminal must advance even within one millisecond.
|
||||
time_idle: sql`max(${time}, coalesce(${SessionTable.time_idle} + 1, ${time}))`,
|
||||
time_updated: sql`${SessionTable.time_updated}`,
|
||||
})
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
}
|
||||
|
||||
const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
@@ -512,6 +536,17 @@ const layer = Layer.effectDiscard(
|
||||
.run()
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
yield* bus.project(SessionEvent.Viewed, (event) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
time_viewed: sql`${SessionTable.time_idle}`,
|
||||
time_updated: sql`${SessionTable.time_updated}`,
|
||||
})
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
yield* bus.project(SessionEvent.UsageRecorded, (event) => applyUsage(db, event.data.sessionID, event.data))
|
||||
yield* bus.project(SessionEvent.Forked, (event) => projectFork(db, event))
|
||||
yield* bus.project(SessionEvent.InboxDelivered, (event) =>
|
||||
@@ -580,9 +615,9 @@ const layer = Layer.effectDiscard(
|
||||
delivery: event.data.delivery,
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.Execution.Succeeded, (event) => run(db, event))
|
||||
yield* bus.project(SessionEvent.Execution.Failed, (event) => run(db, event))
|
||||
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => run(db, event))
|
||||
yield* bus.project(SessionEvent.Execution.Succeeded, (event) => projectIdle(db, event))
|
||||
yield* bus.project(SessionEvent.Execution.Failed, (event) => projectIdle(db, event))
|
||||
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => projectIdle(db, event))
|
||||
yield* bus.project(SessionEvent.InstructionsUpdated, (event) =>
|
||||
Effect.gen(function* () {
|
||||
yield* run(db, event)
|
||||
|
||||
@@ -56,6 +56,8 @@ export const SessionTable = sqliteTable(
|
||||
variant?: string
|
||||
}>(),
|
||||
...Timestamps,
|
||||
time_idle: integer(),
|
||||
time_viewed: integer(),
|
||||
time_compacting: integer(),
|
||||
time_archived: integer(),
|
||||
/** The execution claim timestamp (historical column name; see SessionStore.claim). */
|
||||
|
||||
@@ -117,6 +117,10 @@ const layer = Layer.effect(
|
||||
tokens_cache_write: input.data.info.tokens.cache.write,
|
||||
time_created: DateTime.toEpochMillis(input.data.info.time.created),
|
||||
time_updated: DateTime.toEpochMillis(input.data.info.time.updated),
|
||||
time_idle: input.data.info.time.idle ? DateTime.toEpochMillis(input.data.info.time.idle) : null,
|
||||
time_viewed: input.data.info.time.viewed
|
||||
? DateTime.toEpochMillis(input.data.info.time.viewed)
|
||||
: null,
|
||||
time_archived: input.data.info.time.archived
|
||||
? DateTime.toEpochMillis(input.data.info.time.archived)
|
||||
: null,
|
||||
|
||||
@@ -324,7 +324,7 @@ export const layer = (options?: ShellSelect.Options) =>
|
||||
runFork(
|
||||
handle.exitCode.pipe(
|
||||
Effect.flatMap((code) => finish("exited", code)),
|
||||
Effect.catch(() => finish("exited")),
|
||||
Effect.catch(() => Effect.void),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -13,10 +13,7 @@ import { tmpdir } from "./fixture/tmpdir"
|
||||
import type { SqlClient } from "effect/unstable/sql/SqlClient"
|
||||
import legacyCredentialsMigration from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
|
||||
import worktreeMigration from "@opencode-ai/core/database/migration/20260812213948_worktree"
|
||||
import previousV2Migration from "@opencode-ai/core/database/migration/20260804233008_loose_psylocke"
|
||||
import workspaceMigration from "@opencode-ai/core/database/migration/20260808023530_workspace_domain"
|
||||
import executionClaimsMigration from "@opencode-ai/core/database/migration/20260811161259_execution_claim_attempts"
|
||||
import sessionInboxMigration from "@opencode-ai/core/database/migration/20260812181746_session_inbox"
|
||||
import sessionViewedStateMigration from "@opencode-ai/core/database/migration/20260815182818_session_viewed_state"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
const run = <A, E>(
|
||||
@@ -77,6 +74,27 @@ describe("DatabaseMigration", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("adds nullable attention state to existing sessions", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`CREATE TABLE session_v2 (id text PRIMARY KEY, title text)`)
|
||||
yield* db.run(sql`INSERT INTO session_v2 (id, title) VALUES ('ses_existing', 'Existing')`)
|
||||
|
||||
yield* DatabaseMigration.applyOnly(db, [sessionViewedStateMigration])
|
||||
yield* DatabaseMigration.applyOnly(db, [sessionViewedStateMigration])
|
||||
|
||||
expect(yield* db.get(sql`SELECT id, title, time_idle, time_viewed FROM session_v2`)).toEqual({
|
||||
id: "ses_existing",
|
||||
title: "Existing",
|
||||
time_idle: null,
|
||||
time_viewed: null,
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT count(*) AS count FROM migration`)).toEqual({ count: 1 })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("rejects a non-empty database without a session table", async () => {
|
||||
await expect(
|
||||
run(
|
||||
@@ -132,142 +150,6 @@ describe("DatabaseMigration", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("preserves previous V2 state through the current migration lineage", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`PRAGMA foreign_keys = ON`)
|
||||
yield* db.run(sql`CREATE TABLE migration (id text PRIMARY KEY, time_completed integer NOT NULL)`)
|
||||
yield* db.run(sql`
|
||||
INSERT INTO migration (id, time_completed)
|
||||
VALUES ('20260730195856_optional_session_title', 1)
|
||||
`)
|
||||
yield* db.run(sql`CREATE TABLE project (id text PRIMARY KEY)`)
|
||||
yield* db.run(sql`
|
||||
CREATE TABLE project_directory (
|
||||
project_id text NOT NULL,
|
||||
directory text NOT NULL,
|
||||
type text,
|
||||
strategy text,
|
||||
time_created integer NOT NULL,
|
||||
PRIMARY KEY (project_id, directory)
|
||||
)
|
||||
`)
|
||||
yield* db.run(sql`
|
||||
CREATE TABLE workspace (
|
||||
id text PRIMARY KEY,
|
||||
type text NOT NULL,
|
||||
name text NOT NULL,
|
||||
project_id text NOT NULL,
|
||||
time_used integer NOT NULL
|
||||
)
|
||||
`)
|
||||
yield* db.run(sql`
|
||||
CREATE TABLE session (
|
||||
id text PRIMARY KEY,
|
||||
project_id text NOT NULL REFERENCES project(id) ON DELETE CASCADE,
|
||||
workspace_id text,
|
||||
parent_id text,
|
||||
time_suspended integer
|
||||
)
|
||||
`)
|
||||
yield* db.run(sql`CREATE INDEX session_project_idx ON session (project_id)`)
|
||||
yield* db.run(sql`CREATE INDEX session_workspace_idx ON session (workspace_id)`)
|
||||
yield* db.run(sql`CREATE INDEX session_parent_idx ON session (parent_id)`)
|
||||
yield* db.run(
|
||||
sql`CREATE INDEX session_time_suspended_idx ON session (time_suspended) WHERE "session"."time_suspended" IS NOT NULL`,
|
||||
)
|
||||
yield* db.run(sql`
|
||||
CREATE TABLE session_message (
|
||||
id text PRIMARY KEY,
|
||||
session_id text NOT NULL REFERENCES session(id) ON DELETE CASCADE,
|
||||
data text NOT NULL
|
||||
)
|
||||
`)
|
||||
yield* db.run(sql`CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL)`)
|
||||
yield* db.run(sql`
|
||||
CREATE TABLE session_pending (
|
||||
id text PRIMARY KEY,
|
||||
session_id text NOT NULL REFERENCES session(id) ON DELETE CASCADE
|
||||
)
|
||||
`)
|
||||
yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`)
|
||||
yield* db.run(sql`
|
||||
CREATE TABLE event (
|
||||
id text PRIMARY KEY,
|
||||
aggregate_id text NOT NULL REFERENCES event_sequence(aggregate_id) ON DELETE CASCADE,
|
||||
seq integer NOT NULL,
|
||||
created integer NOT NULL,
|
||||
type text NOT NULL,
|
||||
data text NOT NULL
|
||||
)
|
||||
`)
|
||||
yield* db.run(sql`CREATE TABLE data_migration (name text PRIMARY KEY)`)
|
||||
yield* db.run(sql`INSERT INTO project VALUES ('project')`)
|
||||
yield* db.run(sql`INSERT INTO project_directory VALUES ('project', '/repo', 'main', NULL, 1)`)
|
||||
yield* db.run(sql`INSERT INTO session VALUES ('session', 'project', NULL, NULL, NULL)`)
|
||||
yield* db.run(sql`INSERT INTO session_message VALUES ('message', 'session', '{"text":"preserved"}')`)
|
||||
yield* db.run(sql`INSERT INTO session_pending VALUES ('pending', 'session')`)
|
||||
yield* db.run(sql`INSERT INTO event_sequence VALUES ('session', 41)`)
|
||||
yield* db.run(sql`INSERT INTO event VALUES ('event', 'session', 41, 1, 'session.text.ended.1', '{}')`)
|
||||
|
||||
yield* DatabaseMigration.applyOnly(db, [
|
||||
previousV2Migration,
|
||||
workspaceMigration,
|
||||
executionClaimsMigration,
|
||||
sessionInboxMigration,
|
||||
worktreeMigration,
|
||||
])
|
||||
|
||||
expect(yield* db.get(sql`SELECT id, resume_attempts FROM session_v2`)).toEqual({
|
||||
id: "session",
|
||||
resume_attempts: 0,
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT id, data FROM session_message`)).toEqual({
|
||||
id: "message",
|
||||
data: '{"text":"preserved"}',
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT id FROM session_pending`)).toEqual({ id: "pending" })
|
||||
expect(yield* db.get(sql`SELECT seq FROM event_sequence`)).toEqual({ seq: 41 })
|
||||
expect(yield* db.get(sql`SELECT id, seq FROM event`)).toEqual({ id: "event", seq: 41 })
|
||||
expect(
|
||||
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`),
|
||||
).toBeUndefined()
|
||||
expect(yield* db.get(sql`SELECT directory FROM worktree`)).toEqual({ directory: "/repo" })
|
||||
expect(yield* db.all<{ table: string }>(sql`PRAGMA foreign_key_list(session_message)`)).toContainEqual(
|
||||
expect.objectContaining({ table: "session_v2" }),
|
||||
)
|
||||
expect(yield* db.all<{ table: string }>(sql`PRAGMA foreign_key_list(session_pending)`)).toContainEqual(
|
||||
expect.objectContaining({ table: "session_v2" }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("rejects previous V2 databases with V1-only session history", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`CREATE TABLE migration (id text PRIMARY KEY, time_completed integer NOT NULL)`)
|
||||
yield* db.run(sql`
|
||||
INSERT INTO migration (id, time_completed)
|
||||
VALUES ('20260730195856_optional_session_title', 1)
|
||||
`)
|
||||
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`)
|
||||
yield* db.run(sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL)`)
|
||||
yield* db.run(sql`CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL)`)
|
||||
yield* db.run(sql`INSERT INTO session VALUES ('session')`)
|
||||
yield* db.run(sql`INSERT INTO message VALUES ('message', 'session')`)
|
||||
|
||||
expect((yield* Effect.exit(DatabaseMigration.applyOnly(db, [previousV2Migration])))._tag).toBe("Failure")
|
||||
expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({
|
||||
name: "session",
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT id FROM migration WHERE id = ${previousV2Migration.id}`)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("copies project directories into worktrees without removing the old table", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -51,7 +51,7 @@ describe("InstructionBuiltIns", () => {
|
||||
` Workspace root folder: ${projectDirectory}`,
|
||||
" Is directory a git repo: yes",
|
||||
` Platform: ${process.platform}`,
|
||||
` Prefer ${temporary} over generic system temporary directories such as /tmp; it is pre-created and approved for external access.`,
|
||||
` Use ${temporary} for temporary work outside the workspace; it already exists and is pre-approved for external directory access.`,
|
||||
"</env>",
|
||||
"",
|
||||
`Today's date: ${localDate(timestamp)}`,
|
||||
|
||||
@@ -840,7 +840,15 @@ describe("SessionTransfer", () => {
|
||||
|
||||
const imported = yield* transfer.import({
|
||||
data: {
|
||||
info: { ...template, id: sessionID },
|
||||
info: {
|
||||
...template,
|
||||
id: sessionID,
|
||||
time: {
|
||||
...template.time,
|
||||
idle: DateTime.makeUnsafe(200),
|
||||
viewed: DateTime.makeUnsafe(150),
|
||||
},
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
id: sourceMessageID,
|
||||
@@ -863,13 +871,18 @@ describe("SessionTransfer", () => {
|
||||
const messages = yield* session.messages({ sessionID, order: "asc" })
|
||||
|
||||
expect(imported).toMatchObject({ id: sessionID, title: "Exported", location })
|
||||
expect(imported.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150) })
|
||||
expect(messages).toMatchObject([
|
||||
{ id: sourceMessageID, type: "user", text: "Imported message" },
|
||||
{ id: errorMessageID, type: "compaction", error: { type: "test_error", message: "Original error" } },
|
||||
])
|
||||
expect(yield* Bus.latestSequence(db, sessionID)).toBe(2)
|
||||
expect((yield* transfer.export({ sessionID })).messages).toEqual(messages)
|
||||
expect((yield* transfer.export({ sessionID, sanitize: true })).messages).toMatchObject([
|
||||
const exported = yield* transfer.export({ sessionID })
|
||||
expect(exported.info.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150) })
|
||||
expect(exported.messages).toEqual(messages)
|
||||
const sanitized = yield* transfer.export({ sessionID, sanitize: true })
|
||||
expect(sanitized.info.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150) })
|
||||
expect(sanitized.messages).toMatchObject([
|
||||
{ id: sourceMessageID, text: `[redacted:text:${sourceMessageID}]` },
|
||||
{ id: errorMessageID, error: { type: "test_error", message: "Original error" } },
|
||||
])
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
} from "@opencode-ai/ai/route"
|
||||
import { SessionModelTransport } from "@opencode-ai/core/session/model-transport"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Cause, Deferred, Effect, Fiber, Metric, Queue, Stream } from "effect"
|
||||
import { Deferred, Effect, Fiber, Metric, Queue, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
|
||||
@@ -545,63 +545,6 @@ describe("SessionModelTransport", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("falls back to HTTP after close code 1009 and keeps the Session on HTTP", async () => {
|
||||
const messages = queue<string | Uint8Array, AIError>()
|
||||
let opened = 0
|
||||
let fallbacks = 0
|
||||
let closed = 0
|
||||
const connector: WebSocketConnector = {
|
||||
open: () =>
|
||||
Effect.sync(() => {
|
||||
opened++
|
||||
return {
|
||||
sendText: () =>
|
||||
Effect.sync(() => {
|
||||
Queue.failCauseUnsafe(
|
||||
messages,
|
||||
Cause.fail(
|
||||
new AIError({
|
||||
module: "test",
|
||||
method: "websocket",
|
||||
reason: new TransportReason({
|
||||
message: "message too big",
|
||||
transport: "websocket",
|
||||
operation: "read",
|
||||
code: "1009",
|
||||
phase: "close",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
messages: Stream.fromQueue(messages),
|
||||
close: Effect.sync(() => closed++).pipe(Effect.andThen(Queue.shutdown(messages)), Effect.asVoid),
|
||||
}
|
||||
}),
|
||||
}
|
||||
const item = (id: string) =>
|
||||
exchange(id, {
|
||||
fallback: () => {
|
||||
fallbacks++
|
||||
return Stream.make(`http:${id}`)
|
||||
},
|
||||
})
|
||||
|
||||
await run(
|
||||
connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const executor = transport.bind(session)
|
||||
|
||||
expect(yield* collect(executor, item("first"))).toEqual(["http:first"])
|
||||
expect(yield* collect(executor, item("second"))).toEqual(["http:second"])
|
||||
expect(opened).toBe(1)
|
||||
expect(fallbacks).toBe(2)
|
||||
expect(closed).toBe(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("does not fall back after an ambiguous send failure", async () => {
|
||||
const messages = queue<string | Uint8Array, AIError>()
|
||||
let fallbacks = 0
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { DateTime, Effect, Layer } from "effect"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { globalProjectLayer } from "./lib/project"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[Project.node, globalProjectLayer],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
],
|
||||
),
|
||||
)
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
|
||||
describe("Session.view", () => {
|
||||
it.effect("copies the latest idle time without changing session recency", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const { db } = yield* Database.Service
|
||||
const created = yield* session.create({ location })
|
||||
|
||||
expect(created.time.idle).toBeUndefined()
|
||||
expect(created.time.viewed).toBeUndefined()
|
||||
|
||||
yield* session.view({ sessionID: created.id })
|
||||
expect((yield* session.get(created.id)).time.viewed).toBeUndefined()
|
||||
|
||||
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: created.id })
|
||||
const idle = yield* session.get(created.id)
|
||||
expect(idle.time.idle).toBeDefined()
|
||||
expect(idle.time.viewed).toBeUndefined()
|
||||
expect(idle.time.updated).toEqual(created.time.updated)
|
||||
|
||||
yield* session.view({ sessionID: created.id })
|
||||
const viewed = yield* session.get(created.id)
|
||||
if (!viewed.time.idle || !viewed.time.viewed) return yield* Effect.die(new Error("Expected attention times"))
|
||||
expect(viewed.time.viewed).toEqual(viewed.time.idle)
|
||||
expect(viewed.time.updated).toEqual(created.time.updated)
|
||||
expect(
|
||||
yield* db
|
||||
.select({ idle: SessionTable.time_idle, viewed: SessionTable.time_viewed })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, created.id))
|
||||
.get(),
|
||||
).toEqual({
|
||||
idle: DateTime.toEpochMillis(viewed.time.idle),
|
||||
viewed: DateTime.toEpochMillis(viewed.time.viewed),
|
||||
})
|
||||
expect((yield* session.list()).data.find((item) => item.id === created.id)?.time).toEqual(viewed.time)
|
||||
|
||||
yield* session.view({ sessionID: created.id })
|
||||
expect((yield* session.get(created.id)).time).toEqual(viewed.time)
|
||||
|
||||
yield* bus.publish(SessionEvent.Execution.Failed, {
|
||||
sessionID: created.id,
|
||||
error: { type: "unknown", message: "failed" },
|
||||
})
|
||||
const unread = yield* session.get(created.id)
|
||||
if (!unread.time.idle || !unread.time.viewed) return yield* Effect.die(new Error("Expected attention times"))
|
||||
expect(DateTime.toEpochMillis(unread.time.idle)).toBeGreaterThan(DateTime.toEpochMillis(unread.time.viewed))
|
||||
|
||||
yield* session.view({ sessionID: created.id })
|
||||
expect((yield* session.get(created.id)).time.viewed).toEqual(unread.time.idle)
|
||||
|
||||
yield* bus.publish(SessionEvent.Execution.Interrupted, { sessionID: created.id, reason: "shutdown" })
|
||||
expect((yield* session.get(created.id)).time.idle).toEqual(unread.time.idle)
|
||||
|
||||
yield* bus.publish(SessionEvent.Execution.Interrupted, { sessionID: created.id, reason: "user" })
|
||||
const interrupted = yield* session.get(created.id)
|
||||
if (!interrupted.time.idle || !interrupted.time.viewed)
|
||||
return yield* Effect.die(new Error("Expected attention times"))
|
||||
expect(DateTime.toEpochMillis(interrupted.time.idle)).toBeGreaterThan(
|
||||
DateTime.toEpochMillis(interrupted.time.viewed),
|
||||
)
|
||||
expect(
|
||||
(yield* db
|
||||
.select({ type: EventTable.type })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, created.id))
|
||||
.all()).filter((event) => event.type === Bus.versionedType(SessionEvent.Viewed.type, 1)),
|
||||
).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects an unknown session", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const sessionID = Session.ID.make("ses_missing_view")
|
||||
expect(yield* Effect.flip(session.view({ sessionID }))).toEqual(new Session.NotFoundError({ sessionID }))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays viewed state into a fresh database", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const sourceDb = (yield* Database.Service).db
|
||||
const created = yield* session.create({ id: Session.ID.make("ses_view_replay"), location })
|
||||
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: created.id })
|
||||
yield* session.view({ sessionID: created.id })
|
||||
yield* bus.publish(SessionEvent.Execution.Failed, {
|
||||
sessionID: created.id,
|
||||
error: { type: "unknown", message: "failed" },
|
||||
})
|
||||
const expected = yield* session.get(created.id)
|
||||
if (!expected.time.idle || !expected.time.viewed) return yield* Effect.die(new Error("Expected attention times"))
|
||||
const expectedIdle = DateTime.toEpochMillis(expected.time.idle)
|
||||
const expectedViewed = DateTime.toEpochMillis(expected.time.viewed)
|
||||
const serialized = (yield* sourceDb
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, created.id))
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)).map((event) => ({
|
||||
id: event.id,
|
||||
created: event.created,
|
||||
aggregateID: event.aggregate_id,
|
||||
seq: event.seq,
|
||||
type: event.type,
|
||||
data: event.data,
|
||||
}))
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const targetLayer = AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]),
|
||||
[
|
||||
[Database.node, Database.configured({ path: path.join(tmp.path, "target.sqlite") })],
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
],
|
||||
)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
const targetBus = yield* Bus.Service
|
||||
const store = yield* SessionStore.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: location.directory, sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* Effect.forEach(serialized, (event) => targetBus.replay(event), { discard: true })
|
||||
|
||||
expect((yield* store.get(created.id))?.time).toEqual(expected.time)
|
||||
expect(expected.time.updated).toEqual(created.time.updated)
|
||||
expect(expectedIdle).toBeGreaterThan(expectedViewed)
|
||||
}).pipe(Effect.provide(Layer.fresh(targetLayer)))
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -782,40 +782,6 @@ describe("ShellTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
if (!isWindows) {
|
||||
it.live("settles a shell terminated by an external signal", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const shell = yield* Shell.Service
|
||||
const settled = yield* executeTool(
|
||||
registry,
|
||||
call({ command: idleCommand, background: true }, "call-external-signal"),
|
||||
)
|
||||
const shellID = settled.metadata?.shellID
|
||||
expect(typeof shellID).toBe("string")
|
||||
if (typeof shellID !== "string") return
|
||||
const id = ShellSchema.ID.make(shellID)
|
||||
const info = yield* shell.get(id)
|
||||
expect(typeof info.pid).toBe("number")
|
||||
if (info.pid === undefined) return
|
||||
|
||||
process.kill(-info.pid, "SIGTERM")
|
||||
const result = yield* shell.wait(id).pipe(Effect.timeoutOption(Duration.seconds(1)))
|
||||
expect(result._tag).toBe("Some")
|
||||
if (result._tag === "Some") expect(result.value.status).toBe("exited")
|
||||
expect((yield* shell.list()).map((item) => item.id)).not.toContain(id)
|
||||
}),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
it.live("backgrounds a foreground command when the session is signaled", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -60,6 +60,8 @@ const session = (
|
||||
model: null,
|
||||
time_created: 1,
|
||||
time_updated: 2,
|
||||
time_idle: null,
|
||||
time_viewed: null,
|
||||
time_compacting: 3,
|
||||
time_archived: null,
|
||||
time_suspended: null,
|
||||
|
||||
@@ -4127,6 +4127,65 @@
|
||||
"summary": "Get session message"
|
||||
}
|
||||
},
|
||||
"/api/session/{sessionID}/view": {
|
||||
"post": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.view",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
{
|
||||
"pattern": "^ses"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "<No Content>"
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "SessionNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SessionNotFoundError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Mark the latest recorded idle transition as viewed.",
|
||||
"summary": "View session"
|
||||
}
|
||||
},
|
||||
"/api/session/{sessionID}/message": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
@@ -12135,6 +12194,12 @@
|
||||
"updated": {
|
||||
"type": "number"
|
||||
},
|
||||
"idle": {
|
||||
"type": "number"
|
||||
},
|
||||
"viewed": {
|
||||
"type": "number"
|
||||
},
|
||||
"archived": {
|
||||
"type": "number"
|
||||
}
|
||||
@@ -14276,6 +14341,71 @@
|
||||
"required": ["id", "created", "type", "durable", "data"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"session.viewed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
{
|
||||
"pattern": "^evt_"
|
||||
}
|
||||
]
|
||||
},
|
||||
"created": {
|
||||
"type": "number"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["session.viewed"]
|
||||
},
|
||||
"durable": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"aggregateID": {
|
||||
"type": "string"
|
||||
},
|
||||
"seq": {
|
||||
"type": "integer",
|
||||
"allOf": [
|
||||
{
|
||||
"minimum": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"version": {
|
||||
"type": "number",
|
||||
"enum": [1]
|
||||
}
|
||||
},
|
||||
"required": ["aggregateID", "seq", "version"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"location": {
|
||||
"$ref": "#/components/schemas/Location.Ref"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sessionID": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
{
|
||||
"pattern": "^ses"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["sessionID"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["id", "created", "type", "durable", "data"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"session.deleted": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -17296,6 +17426,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/session.renamed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/session.viewed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/session.deleted"
|
||||
},
|
||||
@@ -22664,6 +22797,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/session.renamed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/session.viewed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/session.usage.updated"
|
||||
},
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { OpenApi } from "effect/unstable/httpapi"
|
||||
import { format } from "prettier"
|
||||
import { fileURLToPath } from "url"
|
||||
import { ClientApi } from "../src/client.js"
|
||||
|
||||
const document = JSON.stringify(OpenApi.fromApi(ClientApi), null, 2) + "\n"
|
||||
const document = await format(JSON.stringify(OpenApi.fromApi(ClientApi), null, 2), { parser: "json", printWidth: 120 })
|
||||
const target = fileURLToPath(new URL("../openapi.json", import.meta.url))
|
||||
|
||||
if (process.argv.includes("--check")) {
|
||||
|
||||
@@ -693,6 +693,19 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("session.view", "/api/session/:sessionID/view", {
|
||||
params: { sessionID: Session.ID },
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: SessionNotFoundError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.view",
|
||||
summary: "View session",
|
||||
description: "Mark the latest recorded idle transition as viewed.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "session",
|
||||
|
||||
@@ -105,6 +105,13 @@ export const Renamed = Event.durable({
|
||||
})
|
||||
export type Renamed = typeof Renamed.Type
|
||||
|
||||
export const Viewed = Event.durable({
|
||||
type: "session.viewed",
|
||||
...options,
|
||||
schema: Base,
|
||||
})
|
||||
export type Viewed = typeof Viewed.Type
|
||||
|
||||
export const UsageRecorded = Event.durable({
|
||||
type: "session.usage.recorded",
|
||||
...options,
|
||||
@@ -580,6 +587,7 @@ export const Definitions = Event.inventory(
|
||||
ModelSelected,
|
||||
Moved,
|
||||
Renamed,
|
||||
Viewed,
|
||||
UsageUpdated,
|
||||
Deleted,
|
||||
Forked,
|
||||
|
||||
@@ -40,6 +40,8 @@ export const Info = Schema.Struct({
|
||||
time: Schema.Struct({
|
||||
created: DateTimeUtcFromMillis,
|
||||
updated: DateTimeUtcFromMillis,
|
||||
idle: DateTimeUtcFromMillis.pipe(optional),
|
||||
viewed: DateTimeUtcFromMillis.pipe(optional),
|
||||
archived: DateTimeUtcFromMillis.pipe(optional),
|
||||
}),
|
||||
title: Schema.String.pipe(optional),
|
||||
|
||||
@@ -54,17 +54,29 @@ describe("contract hygiene", () => {
|
||||
}),
|
||||
).toEqual({ text: "completed" })
|
||||
|
||||
const info = Session.Info.make({
|
||||
id: Session.ID.make("ses_untitled"),
|
||||
projectID: Project.ID.make("global"),
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: {
|
||||
created: DateTime.makeUnsafe(0),
|
||||
updated: DateTime.makeUnsafe(0),
|
||||
idle: undefined,
|
||||
viewed: undefined,
|
||||
},
|
||||
title: undefined,
|
||||
location: { directory: AbsolutePath.make("/project") },
|
||||
})
|
||||
const encoded = Schema.encodeSync(Session.Info)(info)
|
||||
expect(encoded).not.toHaveProperty("title")
|
||||
expect(encoded.time).toEqual({ created: 0, updated: 0 })
|
||||
expect(
|
||||
Schema.encodeSync(Session.Info)({
|
||||
id: Session.ID.make("ses_untitled"),
|
||||
projectID: Project.ID.make("global"),
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||
title: undefined,
|
||||
location: { directory: AbsolutePath.make("/project") },
|
||||
}),
|
||||
).not.toHaveProperty("title")
|
||||
...info,
|
||||
time: { ...info.time, idle: DateTime.makeUnsafe(2), viewed: DateTime.makeUnsafe(1) },
|
||||
}).time,
|
||||
).toEqual({ created: 0, updated: 0, idle: 2, viewed: 1 })
|
||||
})
|
||||
|
||||
test("session inbox items omit the internal enqueue sequence", () => {
|
||||
|
||||
@@ -83,6 +83,7 @@ describe("public event manifest", () => {
|
||||
"session.model.selected.1",
|
||||
"session.moved.1",
|
||||
"session.renamed.1",
|
||||
"session.viewed.1",
|
||||
"session.usage.recorded.1",
|
||||
"session.forked.2",
|
||||
"session.inbox.delivered.1",
|
||||
|
||||
@@ -180,6 +180,22 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
}
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.view",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* session.view({ sessionID: ctx.params.sessionID }).pipe(
|
||||
Effect.catchTag(
|
||||
"Session.NotFoundError",
|
||||
(error) =>
|
||||
new SessionNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
message: `Session not found: ${error.sessionID}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.remove",
|
||||
Effect.fn(function* (ctx) {
|
||||
|
||||
@@ -52,6 +52,36 @@ it.live("serves unauthenticated and answers CORS preflight when no password is c
|
||||
}).pipe(Effect.scoped),
|
||||
)
|
||||
|
||||
it.live("serves the session view operation and missing-session error", () =>
|
||||
Effect.gen(function* () {
|
||||
const handler = yield* ServerFetch.make(options)
|
||||
const created = yield* Effect.promise(() =>
|
||||
handler(
|
||||
new Request("http://opencode.local/api/session", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: "{}",
|
||||
}),
|
||||
).then((response) => response.json()),
|
||||
)
|
||||
if (typeof created !== "object" || created === null || !("data" in created))
|
||||
return yield* Effect.die(new Error("Expected a session response"))
|
||||
const data = created.data
|
||||
if (typeof data !== "object" || data === null || !("id" in data) || typeof data.id !== "string")
|
||||
return yield* Effect.die(new Error("Expected a session ID"))
|
||||
|
||||
const viewed = yield* Effect.promise(() =>
|
||||
handler(new Request(`http://opencode.local/api/session/${data.id}/view`, { method: "POST" })),
|
||||
)
|
||||
expect(viewed.status).toBe(204)
|
||||
|
||||
const missing = yield* Effect.promise(() =>
|
||||
handler(new Request("http://opencode.local/api/session/ses_missing_view/view", { method: "POST" })),
|
||||
)
|
||||
expect(missing.status).toBe(404)
|
||||
}).pipe(Effect.scoped),
|
||||
)
|
||||
|
||||
// Pins the eager-boot guarantee: the application layer is built before the handler returns, so
|
||||
// an aborted first request cannot interrupt layer construction and wedge every later request
|
||||
// (the Effect-TS/effect#6319 failure class that lazy first-request builds are prone to).
|
||||
|
||||
@@ -452,6 +452,7 @@ export function Prompt(props: PromptProps) {
|
||||
title: "Queue prompt",
|
||||
name: "prompt.queue",
|
||||
category: "Prompt",
|
||||
palette: undefined,
|
||||
run: async (_input: string | undefined, event?: KeyEvent) => {
|
||||
event?.preventDefault()
|
||||
event?.stopPropagation()
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
NEW_SESSION_TAB_TITLE,
|
||||
sessionTabComplete,
|
||||
sessionTabDetail,
|
||||
sessionTabNumberLabel,
|
||||
sessionTabShortcutLabel,
|
||||
seedSessionTabMotion,
|
||||
sessionTabOverflowWidth,
|
||||
type SessionTab,
|
||||
@@ -426,7 +426,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
const value = session()
|
||||
return value ? data.project.get(value.projectID) : undefined
|
||||
})
|
||||
const numberWidth = () => Math.max(2, String(items().length).length)
|
||||
const numberWidth = () => 2
|
||||
const restingTitleWidth = () => Math.max(1, width() - numberWidth() - 2)
|
||||
const hoveredTitleWidth = () => Math.max(1, restingTitleWidth() - 1)
|
||||
const titleWidth = () => (hovered() === tab.sessionID ? hoveredTitleWidth() : restingTitleWidth())
|
||||
@@ -657,14 +657,14 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
backgroundColor={pulseBackground()}
|
||||
onLevel={setSweepLevel}
|
||||
/>
|
||||
<box zIndex={1} width="100%" flexDirection="row" paddingRight={1}>
|
||||
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={1} paddingRight={1}>
|
||||
<text
|
||||
width={numberWidth() + 1}
|
||||
width={numberWidth()}
|
||||
fg={numberColor()}
|
||||
selectable={false}
|
||||
attributes={selected() ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
{sessionTabNumberLabel(index()).padStart(numberWidth())}
|
||||
{sessionTabShortcutLabel(index())}
|
||||
</text>
|
||||
<text
|
||||
width={titleWidth()}
|
||||
@@ -1040,7 +1040,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
const glows = () => !selected() && (status().attention || (!status().busy && status().unread !== undefined))
|
||||
const title = () => tab.title ?? "Untitled session"
|
||||
const tabNumber = createMemo(() => items().findIndex((item) => item.sessionID === tab.sessionID) + 1)
|
||||
const numberWidth = () => Math.max(2, String(items().length).length)
|
||||
// Shortcut labels stay one cell wide: 1-9, 0 for ten, then a neutral dot.
|
||||
const numberWidth = () => 2
|
||||
// Hovering reveals the close mark, so the title's right bound shifts left of it.
|
||||
const restingTitleWidth = () => Math.max(1, width() - 1 - numberWidth())
|
||||
const hoveredTitleWidth = () => Math.max(1, restingTitleWidth() - 2)
|
||||
@@ -1140,8 +1141,11 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
onLevel={setSweepLevel}
|
||||
/>
|
||||
<box zIndex={1} width="100%" flexDirection="row">
|
||||
<text width={numberWidth() + 1} fg={numberColor()} selectable={false} attributes={bold()}>
|
||||
{(tab === NEW_SESSION_TAB ? "+" : sessionTabNumberLabel(tabNumber() - 1)).padStart(numberWidth())}
|
||||
<text width={1} selectable={false}>
|
||||
{" "}
|
||||
</text>
|
||||
<text width={numberWidth()} fg={numberColor()} selectable={false} attributes={bold()}>
|
||||
{tab === NEW_SESSION_TAB ? "+" : sessionTabShortcutLabel(tabNumber() - 1)}
|
||||
</text>
|
||||
<text
|
||||
width={availableTitleWidth()}
|
||||
|
||||
@@ -178,7 +178,7 @@ export const Definitions = {
|
||||
"session.toggle.thinking": keybind("none", "Toggle thinking blocks visibility"),
|
||||
|
||||
"prompt.submit": keybind("none", "Submit prompt"),
|
||||
"prompt.queue": keybind("<leader>return", "Queue prompt"),
|
||||
"prompt.queue": keybind("alt+return", "Queue prompt"),
|
||||
"prompt.editor_context.clear": keybind("none", "Clear editor context"),
|
||||
"prompt.images.view": keybind("<leader>i", "View image attachments"),
|
||||
"prompt.skills": keybind("none", "Open skill selector"),
|
||||
|
||||
@@ -163,7 +163,7 @@ export const Definitions = {
|
||||
display_thinking: keybind("none", "Toggle thinking blocks visibility"),
|
||||
|
||||
prompt_submit: keybind("none", "Submit prompt"),
|
||||
prompt_queue: keybind("<leader>return", "Queue prompt"),
|
||||
prompt_queue: keybind("alt+return", "Queue prompt"),
|
||||
prompt_editor_context_clear: keybind("none", "Clear editor context"),
|
||||
prompt_images_view: keybind("<leader>i", "View image attachments"),
|
||||
prompt_skills: keybind("none", "Open skill selector"),
|
||||
|
||||
@@ -7,8 +7,10 @@ export type SessionTabUnread = "activity" | "error"
|
||||
|
||||
export const NEW_SESSION_TAB_TITLE = "New session"
|
||||
|
||||
export function sessionTabNumberLabel(index: number) {
|
||||
return String(index + 1)
|
||||
export function sessionTabShortcutLabel(index: number) {
|
||||
if (index >= 0 && index < 9) return String(index + 1)
|
||||
if (index === 9) return "0"
|
||||
return "·"
|
||||
}
|
||||
|
||||
export function sessionTabDetail(
|
||||
|
||||
@@ -25,12 +25,12 @@ import {
|
||||
type ClosedSessionTab,
|
||||
type SessionTab,
|
||||
type SessionTabHistory,
|
||||
type SessionTabUnread,
|
||||
} from "./session-tabs-model"
|
||||
|
||||
type TabsState = {
|
||||
tabs: SessionTab[]
|
||||
unread: Record<string, SessionTabUnread>
|
||||
// Read only long enough to remove the former client-owned state from persisted tab files.
|
||||
unread?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type PersistedState = {
|
||||
@@ -43,7 +43,7 @@ type ScrollAnchor = {
|
||||
screenY: number
|
||||
}
|
||||
|
||||
const empty = (): TabsState => ({ tabs: [], unread: {} })
|
||||
const empty = (): TabsState => ({ tabs: [] })
|
||||
|
||||
// Deliberately after connect settles: the visible session's mount syncs win the first slots.
|
||||
const TAB_PREFETCH_DELAY = 300
|
||||
@@ -60,7 +60,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
const paths = useTuiPaths()
|
||||
const renderer = useRenderer()
|
||||
const enabled = () => config.tabs.enabled
|
||||
// Focus reporting emits transitions, so an interactive launch owns unread state until its first blur.
|
||||
// Focus reporting emits transitions, so an interactive launch may acknowledge viewed sessions until its first blur.
|
||||
const [focused, setFocused] = createSignal(true)
|
||||
// Keyed reconcile keeps tab object identity across reorders, so strip rows move instead of
|
||||
// mutating in place, which per-row animations and drag state depend on.
|
||||
@@ -105,16 +105,20 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
const session = data.session.get(sessionID)
|
||||
return session?.title ?? persisted ?? fallback ?? (session ? withTimestampedFallback(session) : undefined)
|
||||
}
|
||||
const isUnread = (sessionID: string) => {
|
||||
const info = data.session.get(sessionID)
|
||||
return info?.time.idle !== undefined && (info.time.viewed === undefined || info.time.idle > info.time.viewed)
|
||||
}
|
||||
const family = (sessionID: string) => {
|
||||
const session = root(sessionID)
|
||||
const members = data.session.family(session)
|
||||
return members.length > 0 ? members : [session]
|
||||
}
|
||||
const normalize = (value: TabsState) => ({
|
||||
tabs: value.tabs.reduce<SessionTab[]>((tabs, tab) => {
|
||||
const sessionID = root(tab.sessionID)
|
||||
return openSessionTab(tabs, { sessionID, title: title(sessionID, tab.title) })
|
||||
}, []),
|
||||
unread: Object.entries(value.unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
|
||||
const sessionID = root(entry[0])
|
||||
result[sessionID] = result[sessionID] === "error" ? "error" : entry[1]
|
||||
return result
|
||||
}, {}),
|
||||
})
|
||||
const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined)
|
||||
const newTab = createMemo((open = false) => {
|
||||
@@ -125,29 +129,17 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
}, false)
|
||||
const status = (sessionID: string) => {
|
||||
const session = root(sessionID)
|
||||
const members = data.session.family(session)
|
||||
const family = members.length > 0 ? members : [session]
|
||||
const members = family(session)
|
||||
return {
|
||||
unread: state().unread[session],
|
||||
unread: members.some(isUnread) ? ("activity" as const) : undefined,
|
||||
promptPulse: promptPulses()[session] ?? 0,
|
||||
attention: family.some(
|
||||
attention: members.some(
|
||||
(id) => (data.session.permission.list(id)?.length ?? 0) > 0 || (data.session.form.list(id)?.length ?? 0) > 0,
|
||||
),
|
||||
busy: family.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0),
|
||||
busy: members.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0),
|
||||
}
|
||||
}
|
||||
|
||||
function markUnread(sessionID: string, unread: SessionTabUnread) {
|
||||
if (!enabled() || !focused()) return
|
||||
const session = root(sessionID)
|
||||
if (current() === session || !state().tabs.some((tab) => tab.sessionID === session)) return
|
||||
if (state().unread[session] === unread) return
|
||||
update((draft) => {
|
||||
if (!draft.tabs.some((tab) => tab.sessionID === session)) return
|
||||
draft.unread[session] = unread
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!enabled()) return
|
||||
if (route.data.type !== "session" || route.data.sessionID === "dummy") return
|
||||
@@ -170,11 +162,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
createEffect(() => {
|
||||
if (!enabled() || !focused()) return
|
||||
if (route.data.type !== "session" || route.data.sessionID === "dummy") return
|
||||
const sessionID = root(route.data.sessionID)
|
||||
if (!state().unread[sessionID]) return
|
||||
update((draft) => {
|
||||
delete draft.unread[sessionID]
|
||||
})
|
||||
const unread = family(route.data.sessionID).filter(isUnread)
|
||||
if (unread.length === 0) return
|
||||
void Promise.allSettled(unread.map((id) => client.api.session.view({ sessionID: id })))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -184,7 +174,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
update((draft) => {
|
||||
const next = normalize(draft)
|
||||
draft.tabs = next.tabs
|
||||
draft.unread = next.unread
|
||||
delete draft.unread
|
||||
})
|
||||
})
|
||||
|
||||
@@ -205,7 +195,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
const sessionIDs = signature.split("\n")
|
||||
let stale = false
|
||||
void (async () => {
|
||||
await Promise.allSettled(sessionIDs.map((sessionID) => data.session.sync(sessionID)))
|
||||
await Promise.allSettled(sessionIDs.map((sessionID) => data.session.sync(sessionID, { children: true })))
|
||||
if (stale) return
|
||||
const locations = new Map(
|
||||
sessionIDs
|
||||
@@ -239,9 +229,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(event.on("session.execution.succeeded", (evt) => markUnread(evt.data.sessionID, "activity")))
|
||||
onCleanup(event.on("session.execution.interrupted", (evt) => markUnread(evt.data.sessionID, "activity")))
|
||||
onCleanup(event.on("session.execution.failed", (evt) => markUnread(evt.data.sessionID, "error")))
|
||||
onCleanup(
|
||||
event.on("session.moved", (evt) => {
|
||||
if (!enabled() || !state().tabs.some((tab) => tab.sessionID === root(evt.data.sessionID))) return
|
||||
@@ -277,7 +264,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
history = previous.history
|
||||
update((draft) => {
|
||||
draft.tabs = closeSessionTab(draft.tabs, target).tabs
|
||||
delete draft.unread[target]
|
||||
})
|
||||
setPromptPulses((pulses) => {
|
||||
if (pulses[target] === undefined) return pulses
|
||||
@@ -373,7 +359,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
cycleUnread(direction: 1 | -1) {
|
||||
if (!enabled()) return
|
||||
const tab = cycleSessionTab(state().tabs, current(), direction, (tab) =>
|
||||
Boolean(state().unread[tab.sessionID] || status(tab.sessionID).attention),
|
||||
Boolean(status(tab.sessionID).unread || status(tab.sessionID).attention),
|
||||
)
|
||||
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
|
||||
},
|
||||
|
||||
@@ -1050,7 +1050,6 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
id: "prompt.queue",
|
||||
title: "Queue prompt",
|
||||
group: "Prompt",
|
||||
palette: true,
|
||||
run() {
|
||||
syncDraft()
|
||||
submitPrompt(promptCopy(draft), "queue")
|
||||
|
||||
@@ -595,7 +595,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
{
|
||||
id: "session.queued_prompts",
|
||||
title: "View queued prompts",
|
||||
group: "Prompt",
|
||||
group: "Session",
|
||||
run: openQueuedMenu,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1062,7 +1062,7 @@ export function Session(props: { verticalTabsWidth: number }) {
|
||||
{
|
||||
title: "View queued prompts",
|
||||
id: "session.queued_prompts",
|
||||
group: "Prompt",
|
||||
group: "Session",
|
||||
enabled: queuedPrompts().length > 0,
|
||||
run: openQueuedPrompts,
|
||||
},
|
||||
|
||||
@@ -107,7 +107,6 @@ test("preserves migrated v1 keybind defaults", () => {
|
||||
const pairs = [
|
||||
["app.exit", "app_exit"],
|
||||
["prompt.paste", "input_paste"],
|
||||
["prompt.queue", "prompt_queue"],
|
||||
["session.delete", "session_delete"],
|
||||
["session.list", "session_list"],
|
||||
["agent.list", "agent_list"],
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
sessionTabComplete,
|
||||
sessionTabDetail,
|
||||
sessionTabOverflowWidth,
|
||||
sessionTabNumberLabel,
|
||||
sessionTabShortcutLabel,
|
||||
} from "../../src/context/session-tabs-model"
|
||||
|
||||
describe("session tabs", () => {
|
||||
@@ -25,8 +25,8 @@ describe("session tabs", () => {
|
||||
expect(sessionTabDetail("opencode", undefined, "main", true)).toBe("opencode")
|
||||
})
|
||||
|
||||
test("labels tabs by ordinal", () => {
|
||||
expect(Array.from({ length: 12 }, (_, index) => sessionTabNumberLabel(index))).toEqual([
|
||||
test("labels direct shortcut tabs and marks unbound tabs with a dot", () => {
|
||||
expect(Array.from({ length: 12 }, (_, index) => sessionTabShortcutLabel(index))).toEqual([
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
@@ -36,9 +36,9 @@ describe("session tabs", () => {
|
||||
"7",
|
||||
"8",
|
||||
"9",
|
||||
"10",
|
||||
"11",
|
||||
"12",
|
||||
"0",
|
||||
"·",
|
||||
"·",
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ async function renderSessionTabs(
|
||||
persisted?: string[]
|
||||
sessionGate?: Promise<void>
|
||||
sessionDirectories?: Record<string, string>
|
||||
sessionParents?: Record<string, string>
|
||||
sessionTimes?: Record<string, { idle?: number; viewed?: number }>
|
||||
newLocation?: "launch" | "inherit"
|
||||
},
|
||||
) {
|
||||
@@ -53,9 +55,13 @@ async function renderSessionTabs(
|
||||
}
|
||||
const events = createEventStream()
|
||||
const sessions: string[] = []
|
||||
const views: string[] = []
|
||||
const locations: string[] = []
|
||||
const vcsLocations: string[] = []
|
||||
const calls = createFetch(async (url) => {
|
||||
const sessionTimes = Object.fromEntries(
|
||||
Object.entries(options?.sessionTimes ?? {}).map(([sessionID, time]) => [sessionID, { ...time }]),
|
||||
)
|
||||
const calls = createFetch(async (url, request) => {
|
||||
if (url.pathname === "/api/location") {
|
||||
const requested = url.searchParams.get("location[directory]") ?? directory
|
||||
locations.push(requested)
|
||||
@@ -72,6 +78,13 @@ async function renderSessionTabs(
|
||||
data: { branch: { current: "main", default: "main" } },
|
||||
})
|
||||
}
|
||||
const viewed = url.pathname.match(/^\/api\/session\/([^/]+)\/view$/)?.[1]
|
||||
if (viewed && request.method === "POST") {
|
||||
views.push(viewed)
|
||||
const time = (sessionTimes[viewed] ??= {})
|
||||
time.viewed = time.idle
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1]
|
||||
if (!sessionID) return undefined
|
||||
sessions.push(sessionID)
|
||||
@@ -79,12 +92,13 @@ async function renderSessionTabs(
|
||||
return json({
|
||||
data: {
|
||||
id: sessionID,
|
||||
parentID: options?.sessionParents?.[sessionID],
|
||||
title: sessionID === initialSessionID ? options?.title : undefined,
|
||||
projectID: "project",
|
||||
location: { directory: options?.sessionDirectories?.[sessionID] ?? directory },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
time: { created: 0, updated: 0, ...sessionTimes[sessionID] },
|
||||
},
|
||||
})
|
||||
}, events)
|
||||
@@ -138,9 +152,13 @@ async function renderSessionTabs(
|
||||
route,
|
||||
data,
|
||||
sessions,
|
||||
views,
|
||||
locations,
|
||||
vcsLocations,
|
||||
state,
|
||||
setSessionTime(sessionID: string, time: { idle?: number; viewed?: number }) {
|
||||
sessionTimes[sessionID] = time
|
||||
},
|
||||
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
|
||||
focus: () => app.renderer.emit("focus"),
|
||||
blur: () => app.renderer.emit("blur"),
|
||||
@@ -153,14 +171,6 @@ async function renderSessionTabs(
|
||||
}
|
||||
}
|
||||
|
||||
const executionSucceeded = (sessionID: string): OpenCodeEvent => ({
|
||||
id: `evt_done_${sessionID}`,
|
||||
created: Date.now(),
|
||||
type: "session.execution.succeeded",
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID },
|
||||
})
|
||||
|
||||
test("loads persisted tab metadata concurrently on connect", async () => {
|
||||
let release!: () => void
|
||||
const sessionGate = new Promise<void>((resolve) => (release = resolve))
|
||||
@@ -230,10 +240,10 @@ test("stores session tabs for the current working directory by default", async (
|
||||
const file = path.join(setup.state, "test", "tui", "tabs.json")
|
||||
await wait(() => Bun.file(file).size > 0)
|
||||
const stored = await Bun.file(file).json()
|
||||
expect(stored.global).toEqual({ tabs: [], unread: {} })
|
||||
expect(stored.global).toEqual({ tabs: [] })
|
||||
expect(Object.keys(stored.cwd)).toEqual([directory])
|
||||
expect(stored.cwd[directory].tabs.map((tab: { sessionID: string }) => tab.sessionID)).toEqual(["first"])
|
||||
expect(stored.cwd[directory].unread).toEqual({})
|
||||
expect(stored.cwd[directory]).not.toHaveProperty("unread")
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
}
|
||||
@@ -256,47 +266,85 @@ test("keeps scroll anchors for open session tabs", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("only the foreground TUI mutates unread state", async () => {
|
||||
await using temporary = await tmpdir()
|
||||
let foreground: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
|
||||
let background: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
|
||||
|
||||
test("derives unread state from server session times", async () => {
|
||||
const setup = await renderSessionTabs("first", {
|
||||
home: true,
|
||||
persisted: ["first", "second"],
|
||||
sessionTimes: { second: { idle: 2 } },
|
||||
})
|
||||
try {
|
||||
foreground = await renderSessionTabs("first", { state: temporary.path, persisted: ["first", "second"] })
|
||||
background = await renderSessionTabs("second", { state: temporary.path })
|
||||
foreground.focus()
|
||||
background.blur()
|
||||
await wait(() => foreground?.tabs.tabs().length === 2 && background?.tabs.tabs().length === 2, 2_000, "shared tabs")
|
||||
|
||||
const firstDone = executionSucceeded("first")
|
||||
foreground.emit(firstDone)
|
||||
background.emit(firstDone)
|
||||
await Promise.all([foreground.flush(), background.flush()])
|
||||
expect(foreground.tabs.status("first").unread).toBeUndefined()
|
||||
expect(background.tabs.status("first").unread).toBeUndefined()
|
||||
|
||||
const secondDone = executionSucceeded("second")
|
||||
foreground.emit(secondDone)
|
||||
background.emit(secondDone)
|
||||
await wait(
|
||||
() =>
|
||||
foreground?.tabs.status("second").unread === "activity" &&
|
||||
background?.tabs.status("second").unread === "activity",
|
||||
10_000,
|
||||
"shared unread activity",
|
||||
)
|
||||
|
||||
foreground.tabs.select("second")
|
||||
await wait(
|
||||
() =>
|
||||
foreground?.tabs.status("second").unread === undefined &&
|
||||
background?.tabs.status("second").unread === undefined,
|
||||
10_000,
|
||||
"shared unread clearing",
|
||||
)
|
||||
await wait(() => setup.tabs.status("second").unread === "activity")
|
||||
expect(setup.tabs.status("first").unread).toBeUndefined()
|
||||
} finally {
|
||||
if (foreground) await foreground.destroy()
|
||||
if (background) await background.destroy()
|
||||
await setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("refreshes server session times after terminal events", async () => {
|
||||
const setup = await renderSessionTabs("first", { home: true, persisted: ["first"] })
|
||||
try {
|
||||
setup.setSessionTime("first", { idle: 2 })
|
||||
setup.emit({
|
||||
id: "evt_done_first",
|
||||
created: 2,
|
||||
type: "session.execution.succeeded",
|
||||
durable: { aggregateID: "first", seq: 1, version: 1 },
|
||||
data: { sessionID: "first" },
|
||||
})
|
||||
await wait(() => setup.tabs.status("first").unread === "activity")
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("views a selected unread session only while focused", async () => {
|
||||
const setup = await renderSessionTabs("first", {
|
||||
home: true,
|
||||
persisted: ["first"],
|
||||
sessionTimes: { first: { idle: 2 } },
|
||||
})
|
||||
try {
|
||||
setup.blur()
|
||||
setup.route.navigate({ type: "session", sessionID: "first" })
|
||||
await wait(() => setup.tabs.current() === "first" && setup.tabs.status("first").unread === "activity")
|
||||
await Bun.sleep(20)
|
||||
expect(setup.views).toEqual([])
|
||||
|
||||
setup.focus()
|
||||
await wait(() => setup.views.includes("first"))
|
||||
setup.emit({
|
||||
id: "evt_viewed_first",
|
||||
created: 3,
|
||||
type: "session.viewed",
|
||||
durable: { aggregateID: "first", seq: 2, version: 1 },
|
||||
data: { sessionID: "first" },
|
||||
})
|
||||
await wait(() => setup.tabs.status("first").unread === undefined)
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("views unread child sessions through their root tab", async () => {
|
||||
const setup = await renderSessionTabs("root", {
|
||||
home: true,
|
||||
persisted: ["root"],
|
||||
sessionParents: { child: "root" },
|
||||
sessionTimes: { child: { idle: 2 } },
|
||||
})
|
||||
try {
|
||||
setup.blur()
|
||||
await setup.data.session.sync("child")
|
||||
await wait(() => setup.tabs.status("root").unread === "activity")
|
||||
|
||||
setup.route.navigate({ type: "session", sessionID: "root" })
|
||||
await Bun.sleep(20)
|
||||
expect(setup.views).toEqual([])
|
||||
setup.focus()
|
||||
await wait(() => setup.views.includes("child"))
|
||||
expect(setup.views).not.toContain("root")
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -981,8 +981,7 @@ test("direct footer steers the oldest queued prompt from an empty composer", asy
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
app.mockInput.pressKey("x", { ctrl: true })
|
||||
app.mockInput.pressEnter()
|
||||
app.mockInput.pressEnter({ meta: true })
|
||||
await Bun.sleep(0)
|
||||
expect(steered).toEqual([])
|
||||
app.mockInput.pressEnter()
|
||||
@@ -1035,8 +1034,7 @@ test("direct footer rejects local commands submitted with the queue shortcut", a
|
||||
try {
|
||||
await app.renderOnce()
|
||||
await app.mockInput.typeText("/settings ")
|
||||
app.mockInput.pressKey("x", { ctrl: true })
|
||||
app.mockInput.pressEnter()
|
||||
app.mockInput.pressEnter({ meta: true })
|
||||
await Bun.sleep(0)
|
||||
expect(submitted).toEqual([])
|
||||
expect(statuses).toContain("this prompt cannot be queued")
|
||||
|
||||
@@ -22,7 +22,7 @@ describe("run runtime boot", () => {
|
||||
expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+c")
|
||||
expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("return")
|
||||
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,ctrl+j")
|
||||
expect(result.keybinds.get("prompt.queue")?.[0]?.key).toBe("<leader>return")
|
||||
expect(result.keybinds.get("prompt.queue")?.[0]?.key).toBe("alt+return")
|
||||
})
|
||||
|
||||
test("preserves shared config while resolving independent Mini defaults", async () => {
|
||||
|
||||
@@ -4127,6 +4127,65 @@
|
||||
"summary": "Get session message"
|
||||
}
|
||||
},
|
||||
"/api/session/{sessionID}/view": {
|
||||
"post": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.view",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
{
|
||||
"pattern": "^ses"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "<No Content>"
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "SessionNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SessionNotFoundError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Mark the latest recorded idle transition as viewed.",
|
||||
"summary": "View session"
|
||||
}
|
||||
},
|
||||
"/api/session/{sessionID}/message": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
@@ -12135,6 +12194,12 @@
|
||||
"updated": {
|
||||
"type": "number"
|
||||
},
|
||||
"idle": {
|
||||
"type": "number"
|
||||
},
|
||||
"viewed": {
|
||||
"type": "number"
|
||||
},
|
||||
"archived": {
|
||||
"type": "number"
|
||||
}
|
||||
@@ -14276,6 +14341,71 @@
|
||||
"required": ["id", "created", "type", "durable", "data"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"session.viewed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
{
|
||||
"pattern": "^evt_"
|
||||
}
|
||||
]
|
||||
},
|
||||
"created": {
|
||||
"type": "number"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["session.viewed"]
|
||||
},
|
||||
"durable": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"aggregateID": {
|
||||
"type": "string"
|
||||
},
|
||||
"seq": {
|
||||
"type": "integer",
|
||||
"allOf": [
|
||||
{
|
||||
"minimum": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"version": {
|
||||
"type": "number",
|
||||
"enum": [1]
|
||||
}
|
||||
},
|
||||
"required": ["aggregateID", "seq", "version"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"location": {
|
||||
"$ref": "#/components/schemas/Location.Ref"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sessionID": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
{
|
||||
"pattern": "^ses"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["sessionID"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["id", "created", "type", "durable", "data"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"session.deleted": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -17296,6 +17426,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/session.renamed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/session.viewed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/session.deleted"
|
||||
},
|
||||
@@ -22664,6 +22797,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/session.renamed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/session.viewed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/session.usage.updated"
|
||||
},
|
||||
|
||||
@@ -4127,6 +4127,65 @@
|
||||
"summary": "Get session message"
|
||||
}
|
||||
},
|
||||
"/api/session/{sessionID}/view": {
|
||||
"post": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.view",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
{
|
||||
"pattern": "^ses"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "<No Content>"
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "SessionNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SessionNotFoundError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Mark the latest recorded idle transition as viewed.",
|
||||
"summary": "View session"
|
||||
}
|
||||
},
|
||||
"/api/session/{sessionID}/message": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
@@ -12135,6 +12194,12 @@
|
||||
"updated": {
|
||||
"type": "number"
|
||||
},
|
||||
"idle": {
|
||||
"type": "number"
|
||||
},
|
||||
"viewed": {
|
||||
"type": "number"
|
||||
},
|
||||
"archived": {
|
||||
"type": "number"
|
||||
}
|
||||
@@ -14276,6 +14341,71 @@
|
||||
"required": ["id", "created", "type", "durable", "data"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"session.viewed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
{
|
||||
"pattern": "^evt_"
|
||||
}
|
||||
]
|
||||
},
|
||||
"created": {
|
||||
"type": "number"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["session.viewed"]
|
||||
},
|
||||
"durable": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"aggregateID": {
|
||||
"type": "string"
|
||||
},
|
||||
"seq": {
|
||||
"type": "integer",
|
||||
"allOf": [
|
||||
{
|
||||
"minimum": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"version": {
|
||||
"type": "number",
|
||||
"enum": [1]
|
||||
}
|
||||
},
|
||||
"required": ["aggregateID", "seq", "version"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"location": {
|
||||
"$ref": "#/components/schemas/Location.Ref"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sessionID": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
{
|
||||
"pattern": "^ses"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["sessionID"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["id", "created", "type", "durable", "data"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"session.deleted": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -17296,6 +17426,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/session.renamed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/session.viewed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/session.deleted"
|
||||
},
|
||||
@@ -22664,6 +22797,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/session.renamed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/session.viewed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/session.usage.updated"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user