From 2937f0e635861a3e443c9c19b400fa26b13ecb1b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 21 Aug 2026 19:27:13 -0400 Subject: [PATCH] fix(core): omit running shells from forks (#44001) --- packages/core/src/session/projector.ts | 1 + packages/core/test/session-create.test.ts | 44 +++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index eff81500bb..05dc1cf30d 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -183,6 +183,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( lt(SessionMessageTable.seq, copiedSeq + 1), // Terminal events for active projections stay on the parent, so forks copy only settled history. sql`${SessionMessageTable.type} != 'assistant' or json_extract(${SessionMessageTable.data}, '$.time.completed') is not null`, + sql`${SessionMessageTable.type} != 'shell' or json_extract(${SessionMessageTable.data}, '$.status') != 'running'`, sql`${SessionMessageTable.type} != 'compaction' or json_extract(${SessionMessageTable.data}, '$.status') != 'running'`, ), ) diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 69de3a547e..a9d6065380 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -4,6 +4,7 @@ import fs from "fs/promises" import path from "path" import { DateTime, Effect, Layer, Stream } from "effect" import { Money } from "@opencode-ai/schema/money" +import { Shell } from "@opencode-ai/schema/shell" import { Agent } from "@opencode-ai/core/agent" import { asc, eq } from "drizzle-orm" import { Database } from "@opencode-ai/core/database/database" @@ -451,6 +452,49 @@ describe("Session.create", () => { }), ) + it.effect("copies only settled shell messages into forks", () => + Effect.gen(function* () { + const session = yield* Session.Service + const bus = yield* Bus.Service + const { db } = yield* Database.Service + const parent = yield* session.create({ location }) + yield* session.prompt({ sessionID: parent.id, text: "Run a shell", resume: false }) + yield* SessionInbox.promote(db, bus, parent.id, "steer") + const shell = Shell.Info.make({ + id: Shell.ID.make("sh_fork_running"), + status: "running", + command: "sleep 10", + cwd: location.directory, + shell: "/bin/sh", + file: "/tmp/sh_fork_running.out", + metadata: {}, + time: { started: 0 }, + }) + yield* bus.publish(SessionEvent.Shell.Started, { sessionID: parent.id, shell }) + + const running = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } }) + + expect(yield* session.context(parent.id)).toMatchObject([ + { type: "user", text: "Run a shell" }, + { type: "shell", command: "sleep 10", status: "running" }, + ]) + expect(yield* session.context(running.id)).toMatchObject([{ type: "user", text: "Run a shell" }]) + + yield* bus.publish(SessionEvent.Shell.Ended, { + sessionID: parent.id, + shell: { ...shell, status: "exited", exit: 0, time: { started: 0, completed: 1 } }, + output: { output: "complete", cursor: 8, size: 8, truncated: false }, + }) + const completed = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } }) + + expect(yield* session.context(running.id)).toMatchObject([{ type: "user", text: "Run a shell" }]) + expect(yield* session.context(completed.id)).toMatchObject([ + { type: "user", text: "Run a shell" }, + { type: "shell", command: "sleep 10", status: "exited", output: { output: "complete" } }, + ]) + }), + ) + it.effect("rejects forking an empty session", () => Effect.gen(function* () { const session = yield* Session.Service