mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 05:03:26 +00:00
feat(session): record location switches (#41899)
This commit is contained in:
parent
f0333e0eea
commit
93965df860
13 changed files with 220 additions and 16 deletions
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { SessionMessage } from "./message.js"
|
|||
export interface Adapter {
|
||||
readonly getAgent: () => Effect.Effect<SessionMessage.AgentSelected["agent"] | undefined, never, never>
|
||||
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined, never, never>
|
||||
readonly getLocation: () => Effect.Effect<SessionMessage.LocationSwitched["previous"], never, never>
|
||||
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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" }],
|
||||
[
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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<typeof LocationSwitched> {}
|
||||
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<typeof User> {}
|
||||
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"]
|
||||
|
|
|
|||
|
|
@ -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 `<system-reminder>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.</system-reminder>`
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1379,7 +1379,13 @@ function SessionMessageView(props: { message: SessionMessageInfo }) {
|
|||
<Match when={props.message.type === "shell"}>
|
||||
<ShellMessage message={props.message as Extract<SessionMessageInfo, { type: "shell" }>} />
|
||||
</Match>
|
||||
<Match when={props.message.type === "agent-switched" || props.message.type === "model-switched"}>
|
||||
<Match
|
||||
when={
|
||||
props.message.type === "agent-switched" ||
|
||||
props.message.type === "model-switched" ||
|
||||
props.message.type === "location-switched"
|
||||
}
|
||||
>
|
||||
<SessionSwitchMessageV2 message={props.message} />
|
||||
</Match>
|
||||
<Match
|
||||
|
|
@ -1670,6 +1676,8 @@ function SessionSwitchMessageV2(props: { message: SessionMessageInfo }) {
|
|||
}
|
||||
if (props.message.type === "model-switched")
|
||||
return switchLabel(props.message.model, ctx.models(), props.message.previous)
|
||||
if (props.message.type === "location-switched")
|
||||
return `Switched location to ${props.message.location.directory}`
|
||||
return ""
|
||||
}
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -655,6 +655,18 @@ test("updates session location when moved", async () => {
|
|||
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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue