Преглед изворни кода

feat(tui): wire up undo/redo and revert for V2 sessions (#34263)

Dax пре 1 месец
родитељ
комит
d1d7ebc2c6

+ 59 - 48
packages/client/src/generated-effect/client.ts

@@ -89,15 +89,25 @@ const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Inp
     Effect.mapError(mapClientError),
   )
 
-type Endpoint0_6Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
+type Endpoint0_6Request = Parameters<RawClient["server.session"]["session.rename"]>[0]
 type Endpoint0_6Input = {
   readonly sessionID: Endpoint0_6Request["params"]["sessionID"]
-  readonly id?: Endpoint0_6Request["payload"]["id"]
-  readonly prompt: Endpoint0_6Request["payload"]["prompt"]
-  readonly delivery?: Endpoint0_6Request["payload"]["delivery"]
-  readonly resume?: Endpoint0_6Request["payload"]["resume"]
+  readonly title: Endpoint0_6Request["payload"]["title"]
 }
 const Endpoint0_6 = (raw: RawClient["server.session"]) => (input: Endpoint0_6Input) =>
+  raw["session.rename"]({ params: { sessionID: input.sessionID }, payload: { title: input.title } }).pipe(
+    Effect.mapError(mapClientError),
+  )
+
+type Endpoint0_7Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
+type Endpoint0_7Input = {
+  readonly sessionID: Endpoint0_7Request["params"]["sessionID"]
+  readonly id?: Endpoint0_7Request["payload"]["id"]
+  readonly prompt: Endpoint0_7Request["payload"]["prompt"]
+  readonly delivery?: Endpoint0_7Request["payload"]["delivery"]
+  readonly resume?: Endpoint0_7Request["payload"]["resume"]
+}
+const Endpoint0_7 = (raw: RawClient["server.session"]) => (input: Endpoint0_7Input) =>
   raw["session.prompt"]({
     params: { sessionID: input.sessionID },
     payload: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume },
@@ -106,23 +116,23 @@ const Endpoint0_6 = (raw: RawClient["server.session"]) => (input: Endpoint0_6Inp
     Effect.map((value) => value.data),
   )
 
-type Endpoint0_7Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
-type Endpoint0_7Input = { readonly sessionID: Endpoint0_7Request["params"]["sessionID"] }
-const Endpoint0_7 = (raw: RawClient["server.session"]) => (input: Endpoint0_7Input) =>
-  raw["session.compact"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
-
-type Endpoint0_8Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
+type Endpoint0_8Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
 type Endpoint0_8Input = { readonly sessionID: Endpoint0_8Request["params"]["sessionID"] }
 const Endpoint0_8 = (raw: RawClient["server.session"]) => (input: Endpoint0_8Input) =>
+  raw["session.compact"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
+
+type Endpoint0_9Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
+type Endpoint0_9Input = { readonly sessionID: Endpoint0_9Request["params"]["sessionID"] }
+const Endpoint0_9 = (raw: RawClient["server.session"]) => (input: Endpoint0_9Input) =>
   raw["session.wait"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
 
-type Endpoint0_9Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
-type Endpoint0_9Input = {
-  readonly sessionID: Endpoint0_9Request["params"]["sessionID"]
-  readonly messageID: Endpoint0_9Request["payload"]["messageID"]
-  readonly files?: Endpoint0_9Request["payload"]["files"]
+type Endpoint0_10Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
+type Endpoint0_10Input = {
+  readonly sessionID: Endpoint0_10Request["params"]["sessionID"]
+  readonly messageID: Endpoint0_10Request["payload"]["messageID"]
+  readonly files?: Endpoint0_10Request["payload"]["files"]
 }
-const Endpoint0_9 = (raw: RawClient["server.session"]) => (input: Endpoint0_9Input) =>
+const Endpoint0_10 = (raw: RawClient["server.session"]) => (input: Endpoint0_10Input) =>
   raw["session.revert.stage"]({
     params: { sessionID: input.sessionID },
     payload: { messageID: input.messageID, files: input.files },
@@ -131,30 +141,30 @@ const Endpoint0_9 = (raw: RawClient["server.session"]) => (input: Endpoint0_9Inp
     Effect.map((value) => value.data),
   )
 
-type Endpoint0_10Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
-type Endpoint0_10Input = { readonly sessionID: Endpoint0_10Request["params"]["sessionID"] }
-const Endpoint0_10 = (raw: RawClient["server.session"]) => (input: Endpoint0_10Input) =>
-  raw["session.revert.clear"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
-
-type Endpoint0_11Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
+type Endpoint0_11Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
 type Endpoint0_11Input = { readonly sessionID: Endpoint0_11Request["params"]["sessionID"] }
 const Endpoint0_11 = (raw: RawClient["server.session"]) => (input: Endpoint0_11Input) =>
-  raw["session.revert.commit"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
+  raw["session.revert.clear"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
 
-type Endpoint0_12Request = Parameters<RawClient["server.session"]["session.context"]>[0]
+type Endpoint0_12Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
 type Endpoint0_12Input = { readonly sessionID: Endpoint0_12Request["params"]["sessionID"] }
 const Endpoint0_12 = (raw: RawClient["server.session"]) => (input: Endpoint0_12Input) =>
+  raw["session.revert.commit"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
+
+type Endpoint0_13Request = Parameters<RawClient["server.session"]["session.context"]>[0]
+type Endpoint0_13Input = { readonly sessionID: Endpoint0_13Request["params"]["sessionID"] }
+const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13Input) =>
   raw["session.context"]({ params: { sessionID: input.sessionID } }).pipe(
     Effect.mapError(mapClientError),
     Effect.map((value) => value.data),
   )
 
-type Endpoint0_13Request = Parameters<RawClient["server.session"]["session.events"]>[0]
-type Endpoint0_13Input = {
-  readonly sessionID: Endpoint0_13Request["params"]["sessionID"]
-  readonly after?: Endpoint0_13Request["query"]["after"]
+type Endpoint0_14Request = Parameters<RawClient["server.session"]["session.events"]>[0]
+type Endpoint0_14Input = {
+  readonly sessionID: Endpoint0_14Request["params"]["sessionID"]
+  readonly after?: Endpoint0_14Request["query"]["after"]
 }
-const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13Input) =>
+const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) =>
   Stream.unwrap(
     raw["session.events"]({ params: { sessionID: input.sessionID }, query: { after: input.after } }).pipe(
       Effect.mapError(mapClientError),
@@ -162,17 +172,17 @@ const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13I
     ),
   )
 
-type Endpoint0_14Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
-type Endpoint0_14Input = { readonly sessionID: Endpoint0_14Request["params"]["sessionID"] }
-const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) =>
+type Endpoint0_15Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
+type Endpoint0_15Input = { readonly sessionID: Endpoint0_15Request["params"]["sessionID"] }
+const Endpoint0_15 = (raw: RawClient["server.session"]) => (input: Endpoint0_15Input) =>
   raw["session.interrupt"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
 
-type Endpoint0_15Request = Parameters<RawClient["server.session"]["session.message"]>[0]
-type Endpoint0_15Input = {
-  readonly sessionID: Endpoint0_15Request["params"]["sessionID"]
-  readonly messageID: Endpoint0_15Request["params"]["messageID"]
+type Endpoint0_16Request = Parameters<RawClient["server.session"]["session.message"]>[0]
+type Endpoint0_16Input = {
+  readonly sessionID: Endpoint0_16Request["params"]["sessionID"]
+  readonly messageID: Endpoint0_16Request["params"]["messageID"]
 }
-const Endpoint0_15 = (raw: RawClient["server.session"]) => (input: Endpoint0_15Input) =>
+const Endpoint0_16 = (raw: RawClient["server.session"]) => (input: Endpoint0_16Input) =>
   raw["session.message"]({ params: { sessionID: input.sessionID, messageID: input.messageID } }).pipe(
     Effect.mapError(mapClientError),
     Effect.map((value) => value.data),
@@ -185,16 +195,17 @@ const adaptGroup0 = (raw: RawClient["server.session"]) => ({
   get: Endpoint0_3(raw),
   switchAgent: Endpoint0_4(raw),
   switchModel: Endpoint0_5(raw),
-  prompt: Endpoint0_6(raw),
-  compact: Endpoint0_7(raw),
-  wait: Endpoint0_8(raw),
-  stage: Endpoint0_9(raw),
-  clear: Endpoint0_10(raw),
-  commit: Endpoint0_11(raw),
-  context: Endpoint0_12(raw),
-  events: Endpoint0_13(raw),
-  interrupt: Endpoint0_14(raw),
-  message: Endpoint0_15(raw),
+  rename: Endpoint0_6(raw),
+  prompt: Endpoint0_7(raw),
+  compact: Endpoint0_8(raw),
+  wait: Endpoint0_9(raw),
+  stage: Endpoint0_10(raw),
+  clear: Endpoint0_11(raw),
+  commit: Endpoint0_12(raw),
+  context: Endpoint0_13(raw),
+  events: Endpoint0_14(raw),
+  interrupt: Endpoint0_15(raw),
+  message: Endpoint0_16(raw),
 })
 
 const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) })

+ 17 - 3
packages/client/src/generated/client.ts

@@ -10,6 +10,8 @@ import type {
   SessionsSwitchAgentOutput,
   SessionsSwitchModelInput,
   SessionsSwitchModelOutput,
+  SessionsRenameInput,
+  SessionsRenameOutput,
   SessionsPromptInput,
   SessionsPromptOutput,
   SessionsCompactInput,
@@ -245,6 +247,18 @@ export function make(options: ClientOptions) {
           },
           requestOptions,
         ),
+      rename: (input: SessionsRenameInput, requestOptions?: RequestOptions) =>
+        request<SessionsRenameOutput>(
+          {
+            method: "POST",
+            path: `/api/session/${encodeURIComponent(input.sessionID)}/rename`,
+            body: { title: input.title },
+            successStatus: 204,
+            declaredStatuses: [404, 400, 401],
+            empty: true,
+          },
+          requestOptions,
+        ),
       prompt: (input: SessionsPromptInput, requestOptions?: RequestOptions) =>
         request<{ readonly data: SessionsPromptOutput }>(
           {
@@ -286,7 +300,7 @@ export function make(options: ClientOptions) {
             path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`,
             body: { messageID: input.messageID, files: input.files },
             successStatus: 200,
-            declaredStatuses: [404, 500, 400, 401],
+            declaredStatuses: [404, 409, 500, 400, 401],
             empty: false,
           },
           requestOptions,
@@ -297,7 +311,7 @@ export function make(options: ClientOptions) {
             method: "POST",
             path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`,
             successStatus: 204,
-            declaredStatuses: [404, 500, 400, 401],
+            declaredStatuses: [404, 409, 500, 400, 401],
             empty: true,
           },
           requestOptions,
@@ -308,7 +322,7 @@ export function make(options: ClientOptions) {
             method: "POST",
             path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`,
             successStatus: 204,
-            declaredStatuses: [404, 400, 401],
+            declaredStatuses: [404, 409, 400, 401],
             empty: true,
           },
           requestOptions,

+ 23 - 0
packages/client/src/generated/types.ts

@@ -56,6 +56,14 @@ export type MessageNotFoundError = {
 export const isMessageNotFoundError = (value: unknown): value is MessageNotFoundError =>
   typeof value === "object" && value !== null && "_tag" in value && value._tag === "MessageNotFoundError"
 
+export type SessionBusyError = {
+  readonly _tag: "SessionBusyError"
+  readonly sessionID: string
+  readonly message: string
+}
+export const isSessionBusyError = (value: unknown): value is SessionBusyError =>
+  typeof value === "object" && value !== null && "_tag" in value && value._tag === "SessionBusyError"
+
 export type UnknownError = {
   readonly _tag: "UnknownError"
   readonly message: string
@@ -297,6 +305,13 @@ export type SessionsSwitchModelInput = {
 
 export type SessionsSwitchModelOutput = void
 
+export type SessionsRenameInput = {
+  readonly sessionID: { readonly sessionID: string }["sessionID"]
+  readonly title: { readonly title: string }["title"]
+}
+
+export type SessionsRenameOutput = void
+
 export type SessionsPromptInput = {
   readonly sessionID: { readonly sessionID: string }["sessionID"]
   readonly id?: {
@@ -637,6 +652,14 @@ export type SessionsEventsOutput =
         readonly subdirectory?: string
       }
     }
+  | {
+      readonly id: string
+      readonly metadata?: { readonly [x: string]: unknown }
+      readonly type: "session.next.renamed"
+      readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
+      readonly location?: { readonly directory: string; readonly workspaceID?: string }
+      readonly data: { readonly timestamp: number; readonly sessionID: string; readonly title: string }
+    }
   | {
       readonly id: string
       readonly metadata?: { readonly [x: string]: unknown }

+ 24 - 3
packages/core/src/session.ts

@@ -102,6 +102,9 @@ export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictE
   sessionID: SessionSchema.ID,
   messageID: SessionMessage.ID,
 }) {}
+export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", {
+  sessionID: SessionSchema.ID,
+}) {}
 export const MessageNotFoundError = SessionRevert.MessageNotFoundError
 export type MessageNotFoundError = SessionRevert.MessageNotFoundError
 
@@ -136,6 +139,10 @@ export interface Interface {
     sessionID: SessionSchema.ID
     model: ModelV2.Ref
   }) => Effect.Effect<void, NotFoundError>
+  readonly rename: (input: {
+    sessionID: SessionSchema.ID
+    title: string
+  }) => Effect.Effect<void, NotFoundError>
   readonly prompt: (input: {
     id?: SessionMessage.ID
     sessionID: SessionSchema.ID
@@ -165,9 +172,9 @@ export interface Interface {
       sessionID: SessionSchema.ID
       messageID: SessionMessage.ID
       files?: boolean
-    }) => Effect.Effect<Revert.State, NotFoundError | MessageNotFoundError | Snapshot.Error>
-    readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | Snapshot.Error>
-    readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
+    }) => Effect.Effect<Revert.State, NotFoundError | MessageNotFoundError | BusyError | Snapshot.Error>
+    readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError | Snapshot.Error>
+    readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError>
   }
 }
 
@@ -404,6 +411,14 @@ export const layer = Layer.unwrap(
                 model: input.model,
               })
             }),
+            rename: Effect.fn("V2Session.rename")(function* (input) {
+              yield* result.get(input.sessionID)
+              yield* events.publish(SessionEvent.Renamed, {
+                sessionID: input.sessionID,
+                timestamp: yield* DateTime.now,
+                title: input.title,
+              })
+            }),
             compact: Effect.fn("V2Session.compact")(function* (input) {
               yield* result.get(input.sessionID)
               return yield* new OperationUnavailableError({ operation: "compact" })
@@ -423,6 +438,8 @@ export const layer = Layer.unwrap(
             revert: {
               stage: Effect.fn("V2Session.revert.stage")(function* (input) {
                 const session = yield* result.get(input.sessionID)
+                if ((yield* execution.active).has(input.sessionID))
+                  return yield* new BusyError({ sessionID: input.sessionID })
                 return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
                   Effect.provideService(Database.Service, database),
                   Effect.provideService(EventV2.Service, events),
@@ -431,6 +448,8 @@ export const layer = Layer.unwrap(
               }),
               clear: Effect.fn("V2Session.revert.clear")(function* (sessionID) {
                 const session = yield* result.get(sessionID)
+                if ((yield* execution.active).has(sessionID))
+                  return yield* new BusyError({ sessionID })
                 yield* SessionRevert.clear(session).pipe(
                   Effect.provideService(EventV2.Service, events),
                   Effect.provide(locations.get(session.location)),
@@ -438,6 +457,8 @@ export const layer = Layer.unwrap(
               }),
               commit: Effect.fn("V2Session.revert.commit")(function* (sessionID) {
                 const session = yield* result.get(sessionID)
+                if ((yield* execution.active).has(sessionID))
+                  return yield* new BusyError({ sessionID })
                 yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
               }),
             },

+ 1 - 0
packages/core/src/session/message-updater.ts

@@ -123,6 +123,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
         )
       },
       "session.next.moved": () => Effect.void,
+      "session.next.renamed": () => Effect.void,
       "session.next.prompted": (event) => {
         return adapter.appendMessage(
           SessionMessage.User.make({

+ 8 - 0
packages/core/src/session/projector.ts

@@ -347,6 +347,14 @@ export const layer = Layer.effectDiscard(
         yield* run(db, event)
       }),
     )
+    yield* events.project(SessionEvent.Renamed, (event) =>
+      db
+        .update(SessionTable)
+        .set({ title: event.data.title, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
+        .where(eq(SessionTable.id, event.data.sessionID))
+        .run()
+        .pipe(Effect.orDie),
+    )
     yield* events.project(SessionEvent.Prompted, (event) =>
       Effect.gen(function* () {
         if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")

+ 3 - 0
packages/opencode/test/server/httpapi-public-openapi.test.ts

@@ -251,6 +251,9 @@ describe("PublicApi OpenAPI v2 errors", () => {
       ["post", "/session/{sessionID}/revert"],
       ["post", "/session/{sessionID}/unrevert"],
       ["delete", "/session/{sessionID}/message/{messageID}"],
+      ["post", "/api/session/{sessionID}/revert/stage"],
+      ["post", "/api/session/{sessionID}/revert/clear"],
+      ["post", "/api/session/{sessionID}/revert/commit"],
     ] as const) {
       expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["409"]) ?? "")).toBe(
         "SessionBusyError",

+ 9 - 0
packages/protocol/src/errors.ts

@@ -25,6 +25,15 @@ export class ConflictError extends Schema.TaggedErrorClass<ConflictError>()(
   { httpApiStatus: 409 },
 ) {}
 
+export class SessionBusyError extends Schema.TaggedErrorClass<SessionBusyError>()(
+  "SessionBusyError",
+  {
+    sessionID: Schema.String,
+    message: Schema.String,
+  },
+  { httpApiStatus: 409 },
+) {}
+
 export class ServiceUnavailableError extends Schema.TaggedErrorClass<ServiceUnavailableError>()(
   "ServiceUnavailableError",
   {

+ 20 - 3
packages/protocol/src/groups/session.ts

@@ -13,6 +13,7 @@ import {
   InvalidRequestError,
   MessageNotFoundError,
   ServiceUnavailableError,
+  SessionBusyError,
   SessionNotFoundError,
   UnknownError,
 } from "../errors"
@@ -187,6 +188,22 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
           }),
         ),
     )
+    .add(
+      HttpApiEndpoint.post("session.rename", "/api/session/:sessionID/rename", {
+        params: { sessionID: Session.ID },
+        payload: Schema.Struct({ title: Schema.String }),
+        success: HttpApiSchema.NoContent,
+        error: SessionNotFoundError,
+      })
+        .middleware(sessionLocationMiddleware)
+        .annotateMerge(
+          OpenApi.annotations({
+            identifier: "v2.session.rename",
+            summary: "Rename session",
+            description: "Update the session title.",
+          }),
+        ),
+    )
     .add(
       HttpApiEndpoint.post("session.prompt", "/api/session/:sessionID/prompt", {
         params: { sessionID: Session.ID },
@@ -243,7 +260,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
         params: { sessionID: Session.ID },
         payload: Schema.Struct({ messageID: SessionMessage.ID, files: Schema.Boolean.pipe(Schema.optional) }),
         success: Schema.Struct({ data: Revert.State }),
-        error: [MessageNotFoundError, SessionNotFoundError, UnknownError],
+        error: [MessageNotFoundError, SessionNotFoundError, SessionBusyError, UnknownError],
       })
         .middleware(sessionLocationMiddleware)
         .annotateMerge(
@@ -258,7 +275,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
       HttpApiEndpoint.post("session.revert.clear", "/api/session/:sessionID/revert/clear", {
         params: { sessionID: Session.ID },
         success: HttpApiSchema.NoContent,
-        error: [SessionNotFoundError, UnknownError],
+        error: [SessionNotFoundError, SessionBusyError, UnknownError],
       })
         .middleware(sessionLocationMiddleware)
         .annotateMerge(OpenApi.annotations({ identifier: "v2.session.revert.clear", summary: "Clear staged revert" })),
@@ -267,7 +284,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
       HttpApiEndpoint.post("session.revert.commit", "/api/session/:sessionID/revert/commit", {
         params: { sessionID: Session.ID },
         success: HttpApiSchema.NoContent,
-        error: SessionNotFoundError,
+        error: [SessionNotFoundError, SessionBusyError],
       })
         .middleware(sessionLocationMiddleware)
         .annotateMerge(

+ 12 - 0
packages/schema/src/session-event.ts

@@ -84,6 +84,16 @@ export const Moved = Event.define({
 })
 export type Moved = typeof Moved.Type
 
+export const Renamed = Event.define({
+  type: "session.next.renamed",
+  ...options,
+  schema: {
+    ...Base,
+    title: Schema.String,
+  },
+})
+export type Renamed = typeof Renamed.Type
+
 export const Prompted = Event.define({
   type: "session.next.prompted",
   ...options,
@@ -449,6 +459,7 @@ export const DurableDefinitions = Event.inventory(
   AgentSwitched,
   ModelSwitched,
   Moved,
+  Renamed,
   Prompted,
   PromptAdmitted,
   ContextUpdated,
@@ -480,6 +491,7 @@ export const Definitions = Event.inventory(
   AgentSwitched,
   ModelSwitched,
   Moved,
+  Renamed,
   Prompted,
   PromptAdmitted,
   ContextUpdated,

+ 37 - 0
packages/sdk/js/src/v2/gen/sdk.gen.ts

@@ -369,6 +369,8 @@ import type {
   V2SessionQuestionRejectResponses,
   V2SessionQuestionReplyErrors,
   V2SessionQuestionReplyResponses,
+  V2SessionRenameErrors,
+  V2SessionRenameResponses,
   V2SessionRevertClearErrors,
   V2SessionRevertClearResponses,
   V2SessionRevertCommitErrors,
@@ -5612,6 +5614,41 @@ export class Session3 extends HeyApiClient {
     })
   }
 
+  /**
+   * Rename session
+   *
+   * Update the session title.
+   */
+  public rename<ThrowOnError extends boolean = false>(
+    parameters: {
+      sessionID: string
+      title?: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "path", key: "sessionID" },
+            { in: "body", key: "title" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).post<V2SessionRenameResponses, V2SessionRenameErrors, ThrowOnError>({
+      url: "/api/session/{sessionID}/rename",
+      ...options,
+      ...params,
+      headers: {
+        "Content-Type": "application/json",
+        ...options?.headers,
+        ...params.headers,
+      },
+    })
+  }
+
   /**
    * Send message
    *

+ 113 - 0
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -19,6 +19,7 @@ export type Event =
   | EventSessionNextAgentSwitched
   | EventSessionNextModelSwitched
   | EventSessionNextMoved
+  | EventSessionNextRenamed
   | EventSessionNextPrompted
   | EventSessionNextPromptAdmitted
   | EventSessionNextContextUpdated
@@ -848,6 +849,15 @@ export type GlobalEvent = {
           subdirectory?: string
         }
       }
+    | {
+        id: string
+        type: "session.next.renamed"
+        properties: {
+          timestamp: number
+          sessionID: string
+          title: string
+        }
+      }
     | {
         id: string
         type: "session.next.prompted"
@@ -1611,6 +1621,7 @@ export type GlobalEvent = {
     | SyncEventSessionNextAgentSwitched
     | SyncEventSessionNextModelSwitched
     | SyncEventSessionNextMoved
+    | SyncEventSessionNextRenamed
     | SyncEventSessionNextPrompted
     | SyncEventSessionNextPromptAdmitted
     | SyncEventSessionNextContextUpdated
@@ -2760,6 +2771,7 @@ export type V2Event =
   | V2EventSessionNextAgentSwitched
   | V2EventSessionNextModelSwitched
   | V2EventSessionNextMoved
+  | V2EventSessionNextRenamed
   | V2EventSessionNextPrompted
   | V2EventSessionNextPromptAdmitted
   | V2EventSessionNextContextUpdated
@@ -3240,6 +3252,22 @@ export type SyncEventSessionNextMoved = {
   }
 }
 
+export type SyncEventSessionNextRenamed = {
+  type: "sync"
+  id: string
+  syncEvent: {
+    type: "session.next.renamed.1"
+    id: string
+    seq: number
+    aggregateID: string
+    data: {
+      timestamp: number
+      sessionID: string
+      title: string
+    }
+  }
+}
+
 export type SyncEventSessionNextPrompted = {
   type: "sync"
   id: string
@@ -4113,6 +4141,25 @@ export type SessionNextMoved = {
   }
 }
 
+export type SessionNextRenamed = {
+  id: string
+  metadata?: {
+    [key: string]: unknown
+  }
+  type: "session.next.renamed"
+  durable?: {
+    aggregateID: string
+    seq: number | "NaN" | "Infinity" | "-Infinity"
+    version: number | "NaN" | "Infinity" | "-Infinity"
+  }
+  location?: LocationRef
+  data: {
+    timestamp: number
+    sessionID: string
+    title: string
+  }
+}
+
 export type SessionNextPrompted = {
   id: string
   metadata?: {
@@ -5168,6 +5215,25 @@ export type V2EventSessionNextMoved = {
   }
 }
 
+export type V2EventSessionNextRenamed = {
+  id: string
+  metadata?: {
+    [key: string]: unknown
+  }
+  durable?: {
+    aggregateID: string
+    seq: number
+    version: number
+  }
+  location?: LocationRef
+  type: "session.next.renamed"
+  data: {
+    timestamp: number
+    sessionID: string
+    title: string
+  }
+}
+
 export type V2EventSessionNextPrompted = {
   id: string
   metadata?: {
@@ -6836,6 +6902,16 @@ export type EventSessionNextMoved = {
   }
 }
 
+export type EventSessionNextRenamed = {
+  id: string
+  type: "session.next.renamed"
+  properties: {
+    timestamp: number
+    sessionID: string
+    title: string
+  }
+}
+
 export type EventSessionNextPrompted = {
   id: string
   type: "session.next.prompted"
@@ -12105,6 +12181,43 @@ export type V2SessionSwitchModelResponses = {
 
 export type V2SessionSwitchModelResponse = V2SessionSwitchModelResponses[keyof V2SessionSwitchModelResponses]
 
+export type V2SessionRenameData = {
+  body: {
+    title: string
+  }
+  path: {
+    sessionID: string
+  }
+  query?: never
+  url: "/api/session/{sessionID}/rename"
+}
+
+export type V2SessionRenameErrors = {
+  /**
+   * InvalidRequestError
+   */
+  400: InvalidRequestError
+  /**
+   * UnauthorizedError
+   */
+  401: UnauthorizedError
+  /**
+   * SessionNotFoundError
+   */
+  404: SessionNotFoundError
+}
+
+export type V2SessionRenameError = V2SessionRenameErrors[keyof V2SessionRenameErrors]
+
+export type V2SessionRenameResponses = {
+  /**
+   * <No Content>
+   */
+  204: void
+}
+
+export type V2SessionRenameResponse = V2SessionRenameResponses[keyof V2SessionRenameResponses]
+
 export type V2SessionPromptData = {
   body: {
     id?: string

+ 41 - 0
packages/server/src/handlers/session.ts

@@ -8,6 +8,7 @@ import {
   InvalidCursorError,
   MessageNotFoundError,
   ServiceUnavailableError,
+  SessionBusyError,
   SessionNotFoundError,
   UnknownError,
 } from "@opencode-ai/protocol/errors"
@@ -135,6 +136,22 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
           return HttpApiSchema.NoContent.make()
         }),
       )
+      .handle(
+        "session.rename",
+        Effect.fn(function* (ctx) {
+          yield* session.rename({ sessionID: ctx.params.sessionID, title: ctx.payload.title }).pipe(
+            Effect.catchTag("Session.NotFoundError", (error) =>
+              Effect.fail(
+                new SessionNotFoundError({
+                  sessionID: error.sessionID,
+                  message: `Session not found: ${error.sessionID}`,
+                }),
+              ),
+            ),
+          )
+          return HttpApiSchema.NoContent.make()
+        }),
+      )
       .handle(
         "session.prompt",
         Effect.fn(function* (ctx) {
@@ -238,6 +255,14 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
                     message: `Message not found: ${error.messageID}`,
                   }),
               ),
+              Effect.catchTag(
+                "Session.BusyError",
+                (error) =>
+                  new SessionBusyError({
+                    sessionID: error.sessionID,
+                    message: `Session is busy: ${error.sessionID}`,
+                  }),
+              ),
               Effect.catchTag("Snapshot.Error", (error) => {
                 const ref = `err_${crypto.randomUUID().slice(0, 8)}`
                 return Effect.logError("failed to stage session revert", { cause: error }).pipe(
@@ -267,6 +292,14 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
                   message: `Session not found: ${error.sessionID}`,
                 }),
             ),
+            Effect.catchTag(
+              "Session.BusyError",
+              (error) =>
+                new SessionBusyError({
+                  sessionID: error.sessionID,
+                  message: `Session is busy: ${error.sessionID}`,
+                }),
+            ),
             Effect.catchTag("Snapshot.Error", (error) => {
               const ref = `err_${crypto.randomUUID().slice(0, 8)}`
               return Effect.logError("failed to clear session revert", { cause: error }).pipe(
@@ -296,6 +329,14 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
                   message: `Session not found: ${error.sessionID}`,
                 }),
             ),
+            Effect.catchTag(
+              "Session.BusyError",
+              (error) =>
+                new SessionBusyError({
+                  sessionID: error.sessionID,
+                  message: `Session is busy: ${error.sessionID}`,
+                }),
+            ),
           )
           return HttpApiSchema.NoContent.make()
         }),

+ 3 - 1
packages/tui/src/component/dialog-session-list.tsx

@@ -13,6 +13,7 @@ import { useLocal } from "../context/local"
 import { createDebouncedSignal } from "../util/signal"
 import { useToast } from "../ui/toast"
 import { useCommandShortcut } from "../keymap"
+import { DialogSessionRename } from "./dialog-session-rename"
 import { Spinner } from "./spinner"
 
 export function DialogSessionList() {
@@ -121,7 +122,8 @@ export function DialogSessionList() {
         {
           command: "session.rename",
           title: "rename",
-          onTrigger: () => unavailable("Renaming"),
+          onTrigger: (option: { value: string }) =>
+            DialogSessionRename.show(dialog, option.value, data.session.get(option.value)?.title),
         },
       ]}
       footerHints={quickSwitchFooterHints()}

+ 19 - 17
packages/tui/src/component/dialog-session-rename.tsx

@@ -1,31 +1,33 @@
 import { DialogPrompt } from "../ui/dialog-prompt"
-import { useDialog } from "../ui/dialog"
-import { useSync } from "../context/sync"
-import { createMemo } from "solid-js"
+import { type DialogContext, useDialog } from "../ui/dialog"
 import { useSDK } from "../context/sdk"
+import { useToast } from "../ui/toast"
+import { errorMessage } from "../util/error"
 
-interface DialogSessionRenameProps {
-  session: string
-}
-
-export function DialogSessionRename(props: DialogSessionRenameProps) {
+export function DialogSessionRename(props: { sessionID: string; currentTitle?: string }) {
   const dialog = useDialog()
-  const sync = useSync()
   const sdk = useSDK()
-  const session = createMemo(() => sync.session.get(props.session))
+  const toast = useToast()
 
   return (
     <DialogPrompt
-      title="Rename Session"
-      value={session()?.title}
+      title="Rename session"
+      placeholder="Session title"
+      value={props.currentTitle}
       onConfirm={(value) => {
-        void sdk.client.session.update({
-          sessionID: props.session,
-          title: value,
-        })
-        dialog.clear()
+        const title = value.trim()
+        if (!title) return
+        void sdk.client.v2.session
+          .rename({ sessionID: props.sessionID, title }, { throwOnError: true })
+          .then(() => dialog.clear())
+          .catch((error) =>
+            toast.show({ message: `Failed to rename session: ${errorMessage(error)}`, variant: "error", duration: 5000 }),
+          )
       }}
       onCancel={() => dialog.clear()}
     />
   )
 }
+
+DialogSessionRename.show = (dialog: DialogContext, sessionID: string, currentTitle?: string) =>
+  dialog.replace(() => <DialogSessionRename sessionID={sessionID} currentTitle={currentTitle} />)

+ 7 - 0
packages/tui/src/component/prompt/index.tsx

@@ -1104,6 +1104,13 @@ export function Prompt(props: PromptProps) {
           { throwOnError: true },
         )
       }
+      if (session?.revert) {
+        const revertResult = await sdk.client.v2.session.revert.commit({ sessionID })
+        if (revertResult.error) {
+          toast.show({ title: "Failed to commit revert", message: errorMessage(revertResult.error), variant: "error" })
+          return false
+        }
+      }
       const result = await sdk.client.v2.session.prompt({
         sessionID,
         prompt: {

+ 13 - 0
packages/tui/src/context/data.tsx

@@ -169,6 +169,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
             })
           })
           break
+        case "session.next.renamed":
+          if (store.session.info[event.data.sessionID])
+            setStore("session", "info", event.data.sessionID, "title", event.data.title)
+          break
         case "session.next.prompted": {
           setStore("session", "status", event.data.sessionID, "running")
           message.update(event.data.sessionID, (draft, index) => {
@@ -405,6 +409,15 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
         case "session.next.compaction.started":
           setStore("session", "status", event.data.sessionID, "running")
           break
+        case "session.next.revert.staged":
+          if (store.session.info[event.data.sessionID])
+            setStore("session", "info", event.data.sessionID, "revert", event.data.revert)
+          break
+        case "session.next.revert.cleared":
+        case "session.next.revert.committed":
+          if (store.session.info[event.data.sessionID])
+            setStore("session", "info", event.data.sessionID, "revert", undefined)
+          break
         case "session.next.compaction.delta":
           break
         case "session.next.compaction.ended":

+ 9 - 2
packages/tui/src/routes/session/dialog-message.tsx

@@ -3,11 +3,14 @@ import { useData } from "../../context/data"
 import { DialogSelect } from "../../ui/dialog-select"
 import { useClipboard } from "../../context/clipboard"
 import { useToast } from "../../ui/toast"
+import { useSDK } from "../../context/sdk"
+import { errorMessage } from "../../util/error"
 
 export function DialogMessage(props: { messageID: string; sessionID: string; setPrompt?: unknown }) {
   const data = useData()
   const clipboard = useClipboard()
   const toast = useToast()
+  const sdk = useSDK()
   const message = createMemo(() =>
     data.session.message.get(props.sessionID, props.messageID),
   )
@@ -20,8 +23,12 @@ export function DialogMessage(props: { messageID: string; sessionID: string; set
           title: "Revert",
           value: "session.revert",
           description: "undo messages and file changes",
-          onSelect: (dialog) => {
-            toast.show({ message: "Reverting is not implemented for V2 sessions yet", variant: "error", duration: 5000 })
+          onSelect: async (dialog) => {
+            const result = await sdk.client.v2.session.revert.stage({
+              sessionID: props.sessionID,
+              messageID: props.messageID,
+            })
+            if (result.error) toast.show({ message: errorMessage(result.error), variant: "error", duration: 5000 })
             dialog.clear()
           },
         },

+ 44 - 8
packages/tui/src/routes/session/index.tsx

@@ -42,6 +42,7 @@ import { useSDK } from "../../context/sdk"
 import { useEditorContext } from "../../context/editor"
 import { openEditor } from "../../editor"
 import { useDialog } from "../../ui/dialog"
+import { DialogSessionRename } from "../../component/dialog-session-rename"
 import { TodoItem } from "../../component/todo-item"
 import { DialogMessage } from "./dialog-message"
 import { Sidebar } from "./sidebar"
@@ -335,7 +336,7 @@ export function Session() {
       value: "session.rename",
       category: "Session",
       slash: { name: "rename" },
-      run: () => unavailable("Renaming"),
+      run: () => DialogSessionRename.show(dialog, route.sessionID, session()?.title),
     },
     {
       title: "Jump to message",
@@ -377,7 +378,28 @@ export function Session() {
       value: "session.undo",
       category: "Session",
       slash: { name: "undo" },
-      run: () => unavailable("Undo"),
+      run: () => {
+        void (async () => {
+          const boundary = session()?.revert?.messageID
+          const list = messages()
+          let target: string | undefined
+          for (let i = list.length - 1; i >= 0; i--) {
+            const message = list[i]
+            if (message.type !== "user" || !message.text.trim()) continue
+            if (boundary && message.id >= boundary) continue
+            target = message.id
+            break
+          }
+          if (!target) {
+            toast.show({ message: "Nothing to undo", variant: "error", duration: 3000 })
+            dialog.clear()
+            return
+          }
+          const result = await sdk.client.v2.session.revert.stage({ sessionID: route.sessionID, messageID: target })
+          if (result.error) toast.show({ message: errorMessage(result.error), variant: "error", duration: 5000 })
+          dialog.clear()
+        })()
+      },
     },
     {
       title: "Redo",
@@ -385,7 +407,13 @@ export function Session() {
       category: "Session",
       enabled: !!session()?.revert?.messageID,
       slash: { name: "redo" },
-      run: () => unavailable("Redo"),
+      run: () => {
+        void (async () => {
+          const result = await sdk.client.v2.session.revert.clear({ sessionID: route.sessionID })
+          if (result.error) toast.show({ message: errorMessage(result.error), variant: "error", duration: 5000 })
+          dialog.clear()
+        })()
+      },
     },
     {
       title: sidebarVisible() ? "Hide sidebar" : "Show sidebar",
@@ -848,6 +876,11 @@ export function Session() {
                     />
                   )}
                 </For>
+                <Show when={session()?.revert?.messageID}>
+                  <RevertMessage
+                    count={messages().filter((message) => message.id > session()!.revert!.messageID).length}
+                  />
+                </Show>
               </scrollbox>
               <box flexShrink={0}>
                 <Show when={permissions().length > 0}>
@@ -1136,9 +1169,10 @@ function CompactionMessage() {
 
 function RevertMessage(props: { count: number }) {
   const { theme } = useTheme()
-  const dialog = useDialog()
-  const renderer = useRenderer()
+  const route = useRouteData("session")
+  const sdk = useSDK()
   const toast = useToast()
+  const renderer = useRenderer()
   const [hover, setHover] = createSignal(false)
   return (
     <box
@@ -1146,8 +1180,10 @@ function RevertMessage(props: { count: number }) {
       onMouseOut={() => setHover(false)}
       onMouseUp={() => {
         if (renderer.getSelection()?.getSelectedText()) return
-        toast.show({ message: "Redo is not implemented for V2 sessions yet", variant: "error", duration: 5000 })
-        dialog.clear()
+        void (async () => {
+          const result = await sdk.client.v2.session.revert.clear({ sessionID: route.sessionID })
+          if (result.error) toast.show({ message: errorMessage(result.error), variant: "error", duration: 5000 })
+        })()
       }}
       flexShrink={0}
       border={["left"]}
@@ -1156,7 +1192,7 @@ function RevertMessage(props: { count: number }) {
     >
       <box paddingTop={1} paddingBottom={1} paddingLeft={2} backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}>
         <text fg={theme.textMuted}>{props.count} message{props.count === 1 ? "" : "s"} reverted</text>
-        <text fg={theme.textMuted}>Redo is not implemented for V2 sessions yet</text>
+        <text fg={theme.textMuted}>Click to redo</text>
       </box>
     </box>
   )

+ 16 - 2
packages/tui/src/routes/session/rows.ts

@@ -23,6 +23,13 @@ export type SessionRow =
 export function createSessionRows(sessionID: Accessor<string>) {
   const data = useData()
   const [rows, setRows] = createStore<SessionRow[]>([])
+  const revertBoundary = () => data.session.get(sessionID())?.revert?.messageID
+
+  function reduce() {
+    const messages = data.session.message.list(sessionID())
+    const boundary = revertBoundary()
+    return reduceSessionRows(boundary ? messages.filter((message) => message.id <= boundary) : messages)
+  }
 
   createEffect(() => {
     const pending = new Set(
@@ -44,17 +51,24 @@ export function createSessionRows(sessionID: Accessor<string>) {
 
   createEffect(
     on(sessionID, (id) => {
-      setRows(reconcile(reduceSessionRows(data.session.message.list(id))))
+      setRows(reconcile(reduce()))
       void data.session.message.refresh(id).then(
         () => {
           if (sessionID() !== id) return
-          setRows(reconcile(reduceSessionRows(data.session.message.list(id))))
+          setRows(reconcile(reduce()))
         },
         () => undefined,
       )
     }),
   )
 
+  // Re-reduce when the revert boundary changes (stage/clear/commit).
+  createEffect(
+    on(revertBoundary, () => {
+      setRows(reconcile(reduce()))
+    }),
+  )
+
   const appendMessage = (messageID: string) =>
     setRows(
       produce((draft) => {