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