Compare commits

...

4 Commits

Author SHA1 Message Date
Aiden Cline c53f4cfb09 fix(core): clarify temporary directory guidance (#43095) 2026-08-17 11:50:51 -05:00
Kit Langton 45a49ae32a fix(core): preserve previous V2 database lineage (#43092) 2026-08-17 12:39:02 -04:00
Kit Langton 238ce304a9 fix(tui): expose queued prompt shortcut (#43083) 2026-08-17 12:33:18 -04:00
opencode-agent[bot] cf606660fb fix(core): settle externally signaled shells (#43086)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-08-17 21:57:35 +05:30
15 changed files with 221 additions and 11 deletions
@@ -1,10 +1,43 @@
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,
+1 -1
View File
@@ -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}`,
` Use ${global.tmp} for temporary work outside the workspace; it already exists and is pre-approved for external directory access.`,
` Prefer ${global.tmp} over generic system temporary directories such as /tmp; it is pre-created and approved for external access.`,
"</env>",
].join("\n"),
),
+1 -1
View File
@@ -324,7 +324,7 @@ export const layer = (options?: ShellSelect.Options) =>
runFork(
handle.exitCode.pipe(
Effect.flatMap((code) => finish("exited", code)),
Effect.catch(() => Effect.void),
Effect.catch(() => finish("exited")),
),
)
@@ -13,6 +13,10 @@ 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 { Global } from "@opencode-ai/util/global"
const run = <A, E>(
@@ -128,6 +132,142 @@ 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}`,
` Use ${temporary} for temporary work outside the workspace; it already exists and is pre-approved for external directory access.`,
` Prefer ${temporary} over generic system temporary directories such as /tmp; it is pre-created and approved for external access.`,
"</env>",
"",
`Today's date: ${localDate(timestamp)}`,
+34
View File
@@ -782,6 +782,40 @@ 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()),
@@ -452,7 +452,6 @@ 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()
+1 -1
View File
@@ -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("alt+return", "Queue prompt"),
"prompt.queue": keybind("<leader>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"),
+1 -1
View File
@@ -163,7 +163,7 @@ export const Definitions = {
display_thinking: keybind("none", "Toggle thinking blocks visibility"),
prompt_submit: keybind("none", "Submit prompt"),
prompt_queue: keybind("alt+return", "Queue prompt"),
prompt_queue: keybind("<leader>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"),
+1
View File
@@ -1050,6 +1050,7 @@ export function createPromptState(input: PromptInput): PromptState {
id: "prompt.queue",
title: "Queue prompt",
group: "Prompt",
palette: true,
run() {
syncDraft()
submitPrompt(promptCopy(draft), "queue")
+1 -1
View File
@@ -595,7 +595,7 @@ export function RunFooterView(props: RunFooterViewProps) {
{
id: "session.queued_prompts",
title: "View queued prompts",
group: "Session",
group: "Prompt",
run: openQueuedMenu,
},
],
+1 -1
View File
@@ -1062,7 +1062,7 @@ export function Session(props: { verticalTabsWidth: number }) {
{
title: "View queued prompts",
id: "session.queued_prompts",
group: "Session",
group: "Prompt",
enabled: queuedPrompts().length > 0,
run: openQueuedPrompts,
},
+1
View File
@@ -107,6 +107,7 @@ 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"],
+4 -2
View File
@@ -981,7 +981,8 @@ test("direct footer steers the oldest queued prompt from an empty composer", asy
try {
await app.renderOnce()
app.mockInput.pressEnter({ meta: true })
app.mockInput.pressKey("x", { ctrl: true })
app.mockInput.pressEnter()
await Bun.sleep(0)
expect(steered).toEqual([])
app.mockInput.pressEnter()
@@ -1034,7 +1035,8 @@ test("direct footer rejects local commands submitted with the queue shortcut", a
try {
await app.renderOnce()
await app.mockInput.typeText("/settings ")
app.mockInput.pressEnter({ meta: true })
app.mockInput.pressKey("x", { ctrl: true })
app.mockInput.pressEnter()
await Bun.sleep(0)
expect(submitted).toEqual([])
expect(statuses).toContain("this prompt cannot be queued")
+1 -1
View File
@@ -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("alt+return")
expect(result.keybinds.get("prompt.queue")?.[0]?.key).toBe("<leader>return")
})
test("preserves shared config while resolving independent Mini defaults", async () => {