Module: Opencode::ToolPart

Defined in:
lib/opencode/tool_part.rb

Overview

Canonical shape of a tool part in an assistant reply.

A tool part starts ‘pending` and transitions through `running` to a terminal `completed` or `error`. The complete representation carries seven fields, all string-keyed so views read consistent keys whether the part came from a live streaming event or a post-stream message poll:

"type"     => "tool"
"tool"     => "edit"
"status"   => "completed"
"title"    => "Edited /INDEX.md"
"input"    => { ... }   # full args the agent passed, deep-stringified
"metadata" => { ... }   # tool-specific output: diff, preview, stdout, etc.
"output"   => "Edited successfully."
"error"    => "..."     # only when status == "error", truncated to 200 chars

The shape is produced two ways:

1. Opencode::Reply#apply_tool_state — live, mid-stream, merging
   incoming event state into an in-memory record (previous values
   survive when the new event omits a field).

2. Opencode::ResponseParser.build_tool_summary — post-stream, built
   fresh from a complete OpenCode message returned by
   /session/:id/message during recovery / final-exchange polling.

Existence reason: the two paths used to drift. ResponseParser stripped ‘metadata` and whitelisted `input` to a fixed key list, so `parts_json` saved on finalize had strictly less data than the streaming DOM had shown. The visible symptom was “I saw the diff while streaming and it disappeared when the turn finished”. This class is the single source of truth that prevents that drift.

Constant Summary collapse

MAX_ERROR_LEN =
200
INVALID_TOOL =
"invalid"

Class Method Summary collapse

Class Method Details

.from_message_part(part) ⇒ Object

Build a fresh canonical tool-part hash from one OpenCode message part (the shape that arrives through /session/:id/message). Used by ResponseParser for recovery and final-exchange polling.



46
47
48
49
50
51
52
53
54
55
56
57
# File 'lib/opencode/tool_part.rb', line 46

def from_message_part(part)
  state = state_of(part)
  build_canonical(
    tool: part[:tool] || part["tool"],
    status: state_value(state, :status),
    title: state_value(state, :title),
    input: state_value(state, :input),
    metadata: state_value(state, :metadata),
    output: state_value(state, :output),
    error: state_value(state, :error)
  )
end

.merge_streaming_state(record, part) ⇒ Object

Merge an incoming ‘message.part.updated` event state into an existing record. Used by Reply#apply_tool_state during streaming.

Fields the event omits (or that arrive empty) leave the record’s previous value intact. Mid-tool events are partial by design.

In addition to the canonical render fields (status, title, input, metadata, output, error), this also persists ‘callID` and `messageID` from the incoming state. Those identifiers are needed by downstream lookups (e.g. matching an ask-user reply event back to the originating tool part by callID) and would otherwise be silently dropped on the way into Reply.parts JSON.

Returns the (mutated) record for chaining.



73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
# File 'lib/opencode/tool_part.rb', line 73

def merge_streaming_state(record, part)
  state = state_of(part)

  tool = part[:tool] || part["tool"]
  # Preserve original tool name if OpenCode later renames to "invalid"
  # mid-session — we want to keep rendering the original name.
  record["tool"] = tool if tool.present? && tool != INVALID_TOOL

  status = state_value(state, :status)
  record["status"] = status if status

  title = state_value(state, :title)
  record["title"] = title if title.present?

  input = state_value(state, :input)
  record["input"] = stringify_deep(input) if input.present?

   = state_value(state, :metadata)
  record["metadata"] = stringify_deep() if .present?

  output = state_value(state, :output)
  record["output"] = output if output.present?

  error = state_value(state, :error)
  record["error"] = error.to_s.truncate(MAX_ERROR_LEN) if error.present?

  # callID and messageID moved from state.* to the part's top level
  # somewhere in opencode v1.15.x. Read top-level first, fall back
  # to state.* for any older versions that may still be in flight.
  # Without this, merge_pending_question_into_existing_tool_part
  # (which searches @parts by callID) silently no-ops, and the
  # question form renders with no questions or routing IDs.
  call_id = part[:callID] || part["callID"] || state_value(state, :callID)
  record["callID"] = call_id if call_id.present?

  message_id = part[:messageID] || part["messageID"] || state_value(state, :messageID)
  record["messageID"] = message_id if message_id.present?

  record
end