From 93965df86028ca7b47491716cfff41e839bcbc78 Mon Sep 17 00:00:00 2001 From: Dax Date: Tue, 11 Aug 2026 19:03:57 -0700 Subject: [PATCH] feat(session): record location switches (#41899) --- .../client/src/promise/generated/types.ts | 54 +++++++++++++++++++ packages/core/src/session/compaction.ts | 2 + packages/core/src/session/message-updater.ts | 18 ++++++- packages/core/src/session/projector.ts | 29 ++++++++++ .../core/src/session/runner/to-llm-message.ts | 9 ++++ packages/core/test/session-move.test.ts | 17 ++++++ .../core/test/session-runner-message.test.ts | 26 +++++++-- packages/core/test/shared-schema.test.ts | 1 + packages/schema/src/session-message.ts | 29 +++++++++- packages/tui/src/component/prompt/move.tsx | 7 --- packages/tui/src/context/data.tsx | 22 +++++++- packages/tui/src/routes/session/index.tsx | 10 +++- packages/tui/test/cli/tui/data.test.tsx | 12 +++++ 13 files changed, 220 insertions(+), 16 deletions(-) diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index c5308381abd..a23aa7d7a44 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -408,6 +408,17 @@ export type ProviderRequest = { export type PermissionRule = { action: string; resource: string; effect: PermissionEffect } +export type SessionMessageLocationSwitched = { + id: string + metadata?: { [x: string]: JsonValue } + time: { created: number } + type: "location-switched" + location: LocationRef + projectID?: string + subpath?: string + previous?: { location: LocationRef; projectID?: string; subpath?: string } +} + export type SessionCreated = { id: string created: number @@ -1943,6 +1954,7 @@ export type SessionInputAdmitted = { export type SessionMessageInfo = | SessionMessageAgentSelected | SessionMessageModelSelected + | SessionMessageLocationSwitched | SessionMessageUser | SessionMessageSynthetic | SessionMessageSystem @@ -2546,6 +2558,20 @@ export type SessionImportInput = { readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } readonly previous?: { readonly id: string; readonly providerID: string; readonly variant?: string } } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly type: "location-switched" + readonly location: { readonly directory: string; readonly workspaceID?: string } + readonly projectID?: string + readonly subpath?: string + readonly previous?: { + readonly location: { readonly directory: string; readonly workspaceID?: string } + readonly projectID?: string + readonly subpath?: string + } + } | { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } @@ -2798,6 +2824,20 @@ export type SessionImportInput = { readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } readonly previous?: { readonly id: string; readonly providerID: string; readonly variant?: string } } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly type: "location-switched" + readonly location: { readonly directory: string; readonly workspaceID?: string } + readonly projectID?: string + readonly subpath?: string + readonly previous?: { + readonly location: { readonly directory: string; readonly workspaceID?: string } + readonly projectID?: string + readonly subpath?: string + } + } | { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } @@ -3050,6 +3090,20 @@ export type SessionImportInput = { readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } readonly previous?: { readonly id: string; readonly providerID: string; readonly variant?: string } } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly type: "location-switched" + readonly location: { readonly directory: string; readonly workspaceID?: string } + readonly projectID?: string + readonly subpath?: string + readonly previous?: { + readonly location: { readonly directory: string; readonly workspaceID?: string } + readonly projectID?: string + readonly subpath?: string + } + } | { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 26cbf325229..21761a1a1f3 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -136,6 +136,8 @@ const serialize = (message: SessionMessage.Info) => { const skills = message.skills?.map((skill) => `[Attached skill: ${skill.name}]\n${skill.text}`) ?? [] return [`[User]: ${message.text}`, ...skills, ...files].join("\n") } + if (message.type === "location-switched") + return `[User]: The working directory has been changed to ${message.location.directory}.` if (message.type === "assistant") { return message.content .flatMap((part) => { diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index bbcf6c575a9..a892e1c8c42 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -6,6 +6,7 @@ import { SessionMessage } from "./message.js" export interface Adapter { readonly getAgent: () => Effect.Effect readonly getModel: () => Effect.Effect + readonly getLocation: () => Effect.Effect readonly getCurrentAssistant: () => Effect.Effect readonly getAssistant: ( messageID: SessionMessage.ID, @@ -89,7 +90,22 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) { ) }) }, - "session.moved": () => Effect.void, + "session.moved": (event) => { + return Effect.gen(function* () { + yield* adapter.appendMessage( + SessionMessage.LocationSwitched.make({ + id: SessionMessage.ID.fromEvent(event.id), + type: "location-switched", + metadata: event.metadata, + location: event.data.location, + projectID: event.data.projectID, + subpath: event.data.subpath, + previous: yield* adapter.getLocation(), + time: { created: event.created }, + }), + ) + }) + }, "session.renamed": () => Effect.void, "session.deleted": () => Effect.void, "session.forked": () => Effect.void, diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 21c18441a21..dc9b25ad8bf 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -16,6 +16,7 @@ import { InstructionState } from "./instruction-state.js" import { SessionPendingTable, SessionMessageTable, SessionTable } from "./sql.js" import { Slug } from "../util/slug.js" import { Money } from "@opencode-ai/schema/money" +import { AbsolutePath, RelativePath } from "../schema.js" import type { SessionSchema } from "./schema.js" type DatabaseService = Database.Interface["db"] @@ -253,6 +254,33 @@ function run(db: DatabaseService, event: MessageEvent) { Effect.map((row) => (row?.model ? Schema.decodeUnknownSync(Model.Ref)(row.model) : undefined)), ) }, + getLocation() { + return db + .select({ + directory: SessionTable.directory, + workspaceID: SessionTable.workspace_id, + projectID: SessionTable.project_id, + subpath: SessionTable.path, + }) + .from(SessionTable) + .where(eq(SessionTable.id, event.data.sessionID)) + .get() + .pipe( + Effect.orDie, + Effect.map((row) => + row + ? { + location: { + directory: AbsolutePath.make(row.directory), + workspaceID: row.workspaceID ? Workspace.ID.make(row.workspaceID) : undefined, + }, + projectID: row.projectID, + subpath: row.subpath === null ? undefined : RelativePath.make(row.subpath), + } + : undefined, + ), + ) + }, getCurrentAssistant() { return Effect.gen(function* () { // A newer step supersedes stale incomplete rows; never resume an older assistant projection. @@ -391,6 +419,7 @@ const layer = Layer.effectDiscard( ) yield* bus.project(SessionEvent.Moved, (event) => Effect.gen(function* () { + yield* run(db, event) yield* db .update(SessionTable) .set({ diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index 4d748f7b01e..ed9f558aeaf 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -201,6 +201,15 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe case "agent-switched": case "model-switched": return [] + case "location-switched": + return [ + Message.make({ + id: message.id, + role: "user", + content: `The working directory has been changed to ${message.location.directory}.`, + metadata: message.metadata, + }), + ] case "user": const content = [ ...(message.skills ?? []).map((skill) => Message.text(skill.text)), diff --git a/packages/core/test/session-move.test.ts b/packages/core/test/session-move.test.ts index 4fa80656318..9b2798697ca 100644 --- a/packages/core/test/session-move.test.ts +++ b/packages/core/test/session-move.test.ts @@ -50,6 +50,23 @@ describe("Session.move", () => { yield* session.move({ sessionID: created.id, directory: destination }) expect((yield* session.get(created.id)).location.directory).toBe(destination) + const messages = yield* session.messages({ sessionID: created.id, order: "asc" }) + expect(messages).toEqual([ + expect.objectContaining({ + type: "location-switched", + location: { directory: destination }, + projectID: Project.ID.global, + previous: { + location: { directory: path.join(tmp.path, "deleted") }, + projectID: Project.ID.global, + subpath: "", + }, + subpath: "", + }), + ]) + + yield* session.move({ sessionID: created.id, directory: destination }) + expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toEqual(messages) }), ), ), diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 93c93c8114b..ce49a72714a 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -8,6 +8,8 @@ import { Skill } from "@opencode-ai/schema/skill" import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message" import { Agent } from "@opencode-ai/core/agent" import { Shell } from "@opencode-ai/schema/shell" +import { Location } from "@opencode-ai/schema/location" +import { AbsolutePath } from "@opencode-ai/schema/schema" import { DateTime } from "effect" const created = DateTime.makeUnsafe(0) @@ -67,6 +69,15 @@ describe("toLLMMessages", () => { model: { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") }, time: { created }, }), + SessionMessage.LocationSwitched.make({ + id: id("location"), + type: "location-switched", + location: Location.Ref.make({ directory: AbsolutePath.make("/destination") }), + previous: { + location: Location.Ref.make({ directory: AbsolutePath.make("/project") }), + }, + time: { created }, + }), SessionMessage.System.make({ id: id("system"), type: "system", @@ -110,9 +121,16 @@ describe("toLLMMessages", () => { model, ) - expect(messages.map((message) => message.role)).toEqual(["system", "user", "user", "user", "user"]) - expect(messages[0]).toEqual(Message.system("Updated context\n\nOther context")) - expect(messages[1]).toEqual( + expect(messages.map((message) => message.role)).toEqual(["user", "system", "user", "user", "user", "user"]) + expect(messages[0]).toEqual( + Message.make({ + id: id("location"), + role: "user", + content: "The working directory has been changed to /destination.", + }), + ) + expect(messages[1]).toEqual(Message.system("Updated context\n\nOther context")) + expect(messages[2]).toEqual( Message.make({ id: id("user"), role: "user", @@ -123,7 +141,7 @@ describe("toLLMMessages", () => { metadata: { agents: [{ name: "build" }] }, }), ) - expect(messages.slice(2).map((message) => message.content)).toEqual([ + expect(messages.slice(3).map((message) => message.content)).toEqual([ [{ type: "text", text: "Synthetic context" }], [ { diff --git a/packages/core/test/shared-schema.test.ts b/packages/core/test/shared-schema.test.ts index 508fba49a2b..db01613be70 100644 --- a/packages/core/test/shared-schema.test.ts +++ b/packages/core/test/shared-schema.test.ts @@ -134,6 +134,7 @@ test("Core reuses the canonical shared schemas", async () => { [coreSessionMessage.AssistantRetry, SessionMessage.AssistantRetry], [coreSessionMessage.AgentSelected, SessionMessage.AgentSelected], [coreSessionMessage.ModelSelected, SessionMessage.ModelSelected], + [coreSessionMessage.LocationSwitched, SessionMessage.LocationSwitched], [coreSessionMessage.User, SessionMessage.User], [coreSessionMessage.Synthetic, SessionMessage.Synthetic], [coreSessionMessage.System, SessionMessage.System], diff --git a/packages/schema/src/session-message.ts b/packages/schema/src/session-message.ts index bafb95e8dc7..1a42e8422c0 100644 --- a/packages/schema/src/session-message.ts +++ b/packages/schema/src/session-message.ts @@ -3,7 +3,9 @@ export * as SessionMessage from "./session-message.js" import { Schema } from "effect" import { optional } from "./schema.js" import { Content } from "./tool.js" +import { Location } from "./location.js" import { Model } from "./model.js" +import { Project } from "./project.js" import { Prompt } from "./prompt.js" import { DateTimeUtcFromMillis, PositiveInt, RelativePath, statics } from "./schema.js" import { ascending } from "./identifier.js" @@ -53,6 +55,20 @@ export const ModelSelected = Schema.Struct({ previous: Model.Ref.pipe(optional), }).annotate({ identifier: "Session.Message.ModelSelected" }) +export interface LocationSwitched extends Schema.Schema.Type {} +export const LocationSwitched = Schema.Struct({ + ...Base, + type: Schema.tag("location-switched"), + location: Location.Ref, + projectID: Project.ID.pipe(optional), + subpath: RelativePath.pipe(optional), + previous: Schema.Struct({ + location: Location.Ref, + projectID: Project.ID.pipe(optional), + subpath: RelativePath.pipe(optional), + }).pipe(optional), +}).annotate({ identifier: "Session.Message.LocationSwitched" }) + export interface User extends Schema.Schema.Type {} export const User = Schema.Struct({ ...Base, @@ -243,6 +259,7 @@ export type Compaction = CompactionRunning | CompactionCompleted | CompactionFai export const Info = Schema.Union([ AgentSelected, ModelSelected, + LocationSwitched, User, Synthetic, System, @@ -251,5 +268,15 @@ export const Info = Schema.Union([ Assistant, Compaction, ]).annotate({ identifier: "Session.Message.Info" }) -export type Info = AgentSelected | ModelSelected | User | Synthetic | System | Skill | Shell | Assistant | Compaction +export type Info = + | AgentSelected + | ModelSelected + | LocationSwitched + | User + | Synthetic + | System + | Skill + | Shell + | Assistant + | Compaction export type Type = Info["type"] diff --git a/packages/tui/src/component/prompt/move.tsx b/packages/tui/src/component/prompt/move.tsx index e7d420cd1b2..20570a83153 100644 --- a/packages/tui/src/component/prompt/move.tsx +++ b/packages/tui/src/component/prompt/move.tsx @@ -8,10 +8,6 @@ import { useToast } from "../../ui/toast" import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session" import { useData } from "../../context/data" -function moveReminderText(directory: string) { - return `The user has changed the current working directory to "${directory}". This is still the same project but at a possibly new location; take this into account when working with any files from now on.` -} - export function usePromptMove(input: { projectID: () => string | undefined; sessionID: () => string | undefined }) { const dialog = useDialog() const client = useClient() @@ -103,9 +99,6 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess setProgress("Moving session") try { await client.api.session.move({ sessionID, directory }) - await client.api.session - .synthetic({ sessionID, text: moveReminderText(directory), resume: false }) - .catch(() => undefined) dialog.clear() } catch (error) { toast.error(error) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index b352872ed3e..7b078ea3a94 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -431,14 +431,32 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ setStore("session", "info", event.data.sessionID, "title", event.data.title) }) break - case "session.moved": - if (store.session.info[event.data.sessionID]) { + case "session.moved": { + const current = store.session.info[event.data.sessionID] + if (current) { + const previous = { + location: { ...current.location }, + projectID: current.projectID, + subpath: current.subpath, + } setStore("session", "info", event.data.sessionID, "location", event.data.location) if (event.data.projectID) setStore("session", "info", event.data.sessionID, "projectID", event.data.projectID) setStore("session", "info", event.data.sessionID, "subpath", event.data.subpath) + message.update(event.data.sessionID, (draft, index) => { + message.append(draft, index, { + id: messageIDFromEvent(event.id), + type: "location-switched", + location: event.data.location, + projectID: event.data.projectID, + subpath: event.data.subpath, + previous, + time: { created: event.created }, + }) + }) } break + } case "session.input.promoted": { const admitted = store.session.input[event.data.sessionID]?.includes(event.data.inputID) ?? false removePending(event.data.sessionID, event.data.inputID) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index c9eb7ed5fa1..1f3caa04acc 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1379,7 +1379,13 @@ function SessionMessageView(props: { message: SessionMessageInfo }) { } /> - + { await wait(() => data.session.get("ses_test")?.location.directory === destination) expect(data.session.get("ses_test")?.projectID).toBe("project-moved") expect(data.session.get("ses_test")?.subpath).toBe("packages/cli") + expect(data.session.message.list("ses_test")).toContainEqual({ + id: "msg_moved_1", + type: "location-switched", + location: { directory: destination }, + projectID: "project-moved", + subpath: "packages/cli", + previous: { + location: { directory }, + projectID: "proj_test", + }, + time: { created: 1 }, + }) } finally { app.renderer.destroy() }