Skip to content

Plugin session.update with an empty title clears the title instead of generating a new one #58547

Open

Description

@jeremysamuel13

Description

Calling context.session.update({ sessionID, title: "" }) from a plugin clears the session's title. The same call over HTTP generates a new title instead.

The HTTP route treats an empty title as a request to generate one. The TUI relies on this to regenerate titles (data.tsx):

if (ctx.payload.title !== undefined) {
if (ctx.payload.title) {
yield* session
.rename({ sessionID: ctx.params.sessionID, title: ctx.payload.title })
.pipe(Effect.catchTag("Session.NotFoundError", missingSession))
} else {
const title = yield* SessionTitle.Service
yield* title.generate(ctx.params.sessionID)
}
}

The plugin host passes the title straight to rename, so an empty title is saved as is:

update: Effect.fn(function* (input) {
yield* sessions.get(input.sessionID)
if (input.title !== undefined) yield* sessions.rename({ sessionID: input.sessionID, title: input.title })
if (input.metadata !== undefined)
yield* sessions.setMetadata({ sessionID: input.sessionID, metadata: input.metadata })
if (input.permissions !== undefined)
yield* sessions.setPermissions({ sessionID: input.sessionID, permissions: input.permissions })
}),

The plugin session.update takes the same input as the HTTP route (adapter.ts), so I'd expect it to behave the same way. No other plugin call runs the title generator.

A possible fix is to move the empty-title check into Session.Service so the route and the plugin host share it. I have a branch with that change and can open a PR.

Plugins

a local plugin

OpenCode version

2.1.2

Steps to reproduce

  1. In a plugin, call await context.session.update({ sessionID, title: "" }) for a session that already has a title.
  2. Check the session's title.

The title is now "". Through the HTTP route (PATCH /api/session/:id with { "title": "" }), the same input generates a new title.

Screenshot

No response

Operating System

No response

Terminal

No response

Activity

  1. opencode-agent commented on Oct 23, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report, @jeremysamuel13. I reproduced this on 2.1.2, and it still happens on the latest dev (df6fb9a).

    What I did, with a local plugin in .opencode/plugins/ and opencode serve:

    1. Created two sessions with titles over the API.
    2. PATCH /api/session/<A> with {"title":""} returned 204 and did not clear the title. It took the title-generation path; without model credentials the old title stayed.
    3. In the plugin, await context.session.update({ sessionID: B, title: "" }) set the title to "", and GET /api/session/<B> returns "title":"" too.

    The cause is what you described. The HTTP handler in packages/server/src/handlers/session.ts sends an empty title to the title generator, but the plugin host's update in packages/core/src/plugin/host.ts passes it straight to rename. The script below reproduces it and creates the plugin itself.

    Related, but not the same problem: #51468 (plugin session.update dropping metadata) and #58176 / #58504 (automatic title generation).

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Reproduces #58547: a plugin's context.session.update({ sessionID, title: "" })
    # saves "" as the title, while PATCH /api/session/:id { "title": "" } treats it
    # as "generate a title" and never stores an empty title.
    # Usage: ./repro.sh                          (uses `opencode` on PATH)
    #        OC_DEV=/path/to/opencode/checkout ./repro.sh   (runs from source)
    set -u
    ROOT="$(cd "$(dirname "$0")" && pwd)"
    DIR="$ROOT/proj"; PORT=${PORT:-4599}; T=/tmp/opencode
    mkdir -p "$DIR/.opencode/plugins" "$T"; rm -f $T/trigger $T/result $T/loaded
    # Local plugin: when $T/trigger holds a session ID, call session.update({ title: "" }).
    cat > "$DIR/.opencode/plugins/clear-title.ts" <<'PLUGIN'
    import * as fs from "node:fs"
    export default {
      id: "clear-title-repro",
      setup: async (ctx: any) => {
        fs.writeFileSync("/tmp/opencode/loaded", "1")
        const timer = setInterval(async () => {
          if (!fs.existsSync("/tmp/opencode/trigger")) return
          const sessionID = fs.readFileSync("/tmp/opencode/trigger", "utf8").trim()
          fs.rmSync("/tmp/opencode/trigger")
          try {
            const before = await ctx.session.get({ sessionID })
            await ctx.session.update({ sessionID, title: "" })
            const after = await ctx.session.get({ sessionID })
            fs.writeFileSync("/tmp/opencode/result", JSON.stringify({ before: before.title, after: after.title }))
          } catch (e) { fs.writeFileSync("/tmp/opencode/result", "ERROR " + String(e)) }
        }, 300)
        return () => clearInterval(timer)
      },
    }
    PLUGIN
    cd "$DIR"
    if [ -n "${OC_DEV:-}" ]; then
      OC=(bun --conditions=browser --preload="$OC_DEV/packages/tui/node_modules/@opentui/solid/scripts/preload.js" "$OC_DEV/packages/cli/src/index.ts")
    else OC=(opencode); fi
    echo "version: $("${OC[@]}" --version)"
    export OPENCODE_PASSWORD=repro
    OPENCODE_PASSWORD=repro "${OC[@]}" serve --port $PORT > $T/serve.log 2>&1 &
    PID=$!; trap 'kill $PID 2>/dev/null' EXIT
    c() { curl -s -u opencode:repro -H 'content-type: application/json' "$@"; }
    API="http://127.0.0.1:$PORT/api/session"
    for i in $(seq 1 90); do c -f "$API" >/dev/null && break; sleep 1; done
    mk() { c -X POST "$API" -d "{\"title\":\"$1\",\"location\":{\"directory\":\"$DIR\"}}" | sed -E 's/.*"id":"([^"]+)".*/\1/'; }
    title() { c "$API/$1" | grep -o '"title":"[^"]*"'; }
    
    A=$(mk "Title A"); B=$(mk "Title B")
    echo "== HTTP: PATCH /api/session/$A {\"title\":\"\"}"
    echo "before: $(title $A)"
    c -o /dev/null -w "status %{http_code}\n" -X PATCH "$API/$A" -d '{"title":""}'
    sleep 2; echo "after:  $(title $A)"
    
    for i in $(seq 1 120); do [ -f $T/loaded ] && break; sleep 0.5; done
    echo "== plugin: context.session.update({ sessionID: \"$B\", title: \"\" })  (plugin loaded: $([ -f $T/loaded ] && echo yes || echo no))"
    echo "before: $(title $B)"
    echo "$B" > $T/trigger
    for i in $(seq 1 60); do [ -f $T/result ] && break; sleep 0.5; done
    echo "plugin saw: $(cat $T/result 2>/dev/null)"
    echo "after:  $(title $B)"

    Run it: bash repro.sh

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions