Skip to content

tui: /copy misses final reply text when the last assistant message has no text part (+ args proposal) #56281

Open

Description

@fantisst

Summary

The built-in /copy (messages.copy, <leader>y) copies the text parts of the last assistant message only. A turn is usually spread across several assistant messages (tool calls, continued turns, reasoning splits). When that last assistant message has no text parts, /copy fails with "No text content found in last assistant message", even though the turn's actual final prose sits in an earlier assistant message of the same answer block.

It would also be very useful to copy older replies or only code blocks, which the current command can't do at all.

Environment

  • opencode version: 2.0.32 (latest)
  • OS: macOS 27.0.1, arm64 (Darwin 27.0.0)
  • Terminal: iTerm2 (TERM_PROGRAM=iTerm.app), COLORTERM=truecolor
  • Shell: zsh
  • Install/channel: official binary (~/.opencode/bin/opencode), latest channel
  • Active plugins: @tarquinen/opencode-dcp@latest, opencode-notificator, opencode-queue, local plugin ./plugins/copy1 (see below)

Reproduction

  1. Run a turn where the model calls tools and writes the final answer afterwards (easy with reasoning-heavy / interleaved models: the turn gets split into multiple assistant messages).
  2. Make sure the very last assistant message in that turn has no text parts (a tool-only part).
  3. Press <leader>y (or type /copy).

Expected Behavior

/copy should take the current answer block — the assistant messages between two consecutive user messages — and copy the text of its last text-bearing assistant message. Reasoning and tool parts stay excluded. That is what a "copy last reply" command means in practice with thinking models.

Actual Behavior

Toast "No text content found in last assistant message"; nothing is copied, even though the block contains the final prose in an earlier assistant part.

Additional Context

I maintain a local CLI plugin that implements exactly this block-based extraction plus argument support; happy to upstream the logic:

  • /copy (no args): final prose of the most recent answer block (last text-bearing assistant message within the block)
  • /copy N | A-B | A,B: pick older block(s); 1 = most recent
  • /copy code: copy only the fenced code blocks of the chosen reply
  • user turns that are themselves copy-command invocations are filtered out
  • warning when the extracted body is very short (< 50 chars, likely not the intended reply)

Reference implementation (pure client-side, no model calls):

/**
 * copy1 纯逻辑:不依赖任何运行时,可被 tui.ts 和单测直接引用。
 * 口径与 v1 一致:回答块 = 两条 user 消息之间的 assistant 消息集合;
 * 每块只取最后一条含文本的 assistant 消息 = 该轮最终正文;
 * 只取 type=text,reasoning/tool 等一律丢弃(思考开 max 也不怕)。
 */

export const MIN_WARN = 50

export const COMMAND_MARKERS = [
  "任务:把「本命令消息之前",
  "运行 `python3 ~/.config/opencode/scripts/copy-last-reply.py",
]

export type Role = "user" | "assistant" | string
export type Msg = { id: string; sessionID?: string; role: Role; time: number; texts: string[] }
export type Block = { userTime: number | null; userMid: string | null; msgs: Msg[] }
export type ExtractOk = {
  body: string
  total: number
  picks: number[]
  spec: string
  wantCode: boolean
  when: string
  session: string
}
export type ExtractErr = { error: string }
export type ExtractResult = ExtractOk | ExtractErr

function dieResult(msg: string): ExtractErr {
  return { error: msg }
}

function formatWhen(ms: number): string {
  const d = new Date(ms)
  const p = (n: number) => String(n).padStart(2, "0")
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
}

export function parseSpec(spec: string, total: number): number[] | string {
  const s = spec.trim()
  let picks: number[]
  if (/^\d+$/.test(s)) picks = [Number(s)]
  else if (/^(\d+)\s*[-]\s*(\d+)$/.test(s)) {
    const [, a, b] = s.match(/^(\d+)\s*[-]\s*(\d+)$/)!
    picks = []
    for (let n = Number(a); n <= Number(b); n++) picks.push(n)
  } else if (/^\d+([,/]\d+)+$/.test(s)) picks = s.split(/[,/]/).map(Number)
  else return `无法识别的参数: ${JSON.stringify(spec)}(支持 N、A-B、A,B/A/B、code)`
  const bad = picks.filter((p) => !(p >= 1 && p <= total))
  if (bad.length) return `只有 ${total} 条最终正文,序号越界: ${bad}`
  return picks.slice().sort((a, b) => a - b)
}

export function extractCode(md: string): string {
  const blocks: string[] = []
  const re = /^```[^\n]*\n([\s\S]*?)^```/gm
  let m: RegExpExecArray | null
  while ((m = re.exec(md))) blocks.push(m[1].replace(/\n$/, ""))
  return blocks.join("\n\n")
}

function commandUserText(block: Block, byId: Map<string, Msg>): boolean {
  if (!block.userMid) return false
  const msg = byId.get(block.userMid)
  const head = ((msg?.texts[0] ?? "") as string).replace(/^\s+/, "")
  return COMMAND_MARKERS.some((m) => head.startsWith(m))
}

export function extractFromMessages(messages: Msg[], rawSpec: string): ExtractResult {
  let spec = rawSpec.trim()
  const wantCode = spec === "code"
  if (wantCode) spec = ""

  if (!messages.length) return dieResult("会话里没有消息")

  const byId = new Map(messages.map((m) => [m.id, m]))
  const blocks: Block[] = []
  let cur: Block = { userTime: null, userMid: null, msgs: [] }
  for (const m of messages) {
    if (m.role === "user") {
      if (cur.msgs.length) blocks.push(cur)
      cur = { userTime: m.time, userMid: m.id, msgs: [] }
    } else if (m.role === "assistant") {
      cur.msgs.push(m)
    }
  }
  if (cur.msgs.length) blocks.push(cur)

  const usable = blocks.filter((b) => !commandUserText(b, byId))
  if (!usable.length) return dieResult("过滤命令轮后没有可复制的回答块")

  const picks = spec ? parseSpec(spec, usable.length) : [1]
  if (typeof picks === "string") return dieResult(picks)

  const chosen = picks.map((p) => usable[usable.length - p])
  const bodies: string[] = []
  let last: Msg | undefined
  for (let i = 0; i < chosen.length; i++) {
    const block = chosen[i]
    let body = ""
    for (let j = block.msgs.length - 1; j >= 0; j--) {
      const msg = block.msgs[j]
      if (msg.texts.length) {
        body = msg.texts.join("\n\n").trim()
        last = msg
        break
      }
    }
    if (!body) return dieResult(`第 ${picks[i]} 块没有任何文本内容`)
    bodies.push(wantCode ? extractCode(body) : body)
  }

  let body = bodies.join("\n\n").trim()
  if (wantCode && !body) return dieResult("所选最终正文里没有围栏代码块")
  if (!body) return dieResult("该回答块没有任何文本内容")
  if (!last) return dieResult("该回答块没有任何文本内容")

  const when = formatWhen(last.time)
  return {
    body,
    total: usable.length,
    picks,
    spec,
    wantCode,
    when,
    session: last.sessionID ?? "",
  }
}

Activity

  1. opencode-agent commented on Oct 17, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report! I reproduced this on v2.0.32 with a simulated model:

    1. In one turn, the model writes a reply and calls a tool (ls).
    2. Its next step has only reasoning and no text, so the turn's last assistant message has no text part.
    3. Running /copy then shows "No text content found in last assistant message". The reply is still on screen in that same turn.

    It still happens on the latest dev (65932e8). The recording and screenshots below show it, and the script below reproduces it.

    The cause is that /copy only looks at the last assistant message, even when that message has no text. Your proposed arguments (/copy N, A-B, code) are a separate feature request; this confirmation covers only the bug.

    Related: #56053 made /copy copy the last assistant message, and #56055 added compaction summaries to /copy.

    recording.mp4

    Screenshot

    Reproduction script (drive.ts)
    // Repro for anomalyco/opencode#56281: /copy fails when the turn's last assistant
    // message has no text part, even though an earlier assistant message of the
    // same turn contains the reply text.
    import { Effect } from "effect"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        tui: { recording: true },
        tools: ["shell"],
      },
      ({ ui, llm, tools }) =>
        Effect.gen(function* () {
          const shells = yield* tools.control("shell")
          // Step 1: final prose + a tool call.
          yield* llm.queue(
            Llm.text("FINAL ANSWER: the build is green and all tests pass."),
            Llm.toolCall({ index: 0, id: "call_ls", name: "shell", input: { command: "ls", description: "List files" } }),
            Llm.finish("tool-calls"),
          )
          // Step 2: only reasoning, no text part.
          yield* llm.queue(Llm.reasoning("Nothing more to add."), Llm.finish("stop"))
          yield* ui.submit("Check the build")
          const call = yield* shells.take("call_ls")
          yield* call.succeed({ output: "README.md\n", exit: 0 })
          yield* ui.waitFor("FINAL ANSWER")
          yield* Effect.sleep(2000)
          yield* ui.screenshot("before-copy")
          yield* ui.type("/copy")
          yield* Effect.sleep(500)
          yield* ui.enter()
          yield* ui.waitFor("No text content found")
          yield* ui.screenshot("copy-error")
          yield* Effect.sleep(1500)
        }),
    )

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

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