yao/agent/sandbox/v2/opencode/parse.go
Max f230f1e90c feat(workspace): enhance attachment handling and execution context
- Added support for reading files from workspace URIs in the delivery process, allowing for more flexible attachment management.
- Introduced a new `convertWorkspaceAttachment` function to handle workspace-based file retrieval and integration into messenger attachments.
- Updated the `AgentCaller` to include execution mode in the context, improving task execution tracking.
- Enhanced the `RunDelivery` method to utilize workspace manifests for delivery input, reducing token usage and improving efficiency.
- Implemented locale handling in various request structures to support multi-language capabilities in user interfaces.
2026-05-07 19:32:48 +08:00

540 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package opencode
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"strings"
"time"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/agent/sandbox/v2/shared"
)
// streamParser handles OpenCode's JSONL output (--format json).
//
// OpenCode emits one JSON object per line with a "type" field. Unlike Claude's
// streaming events which provide incremental deltas, OpenCode events arrive at
// completion boundaries:
//
// step_start agent step begins (contains sessionID, part metadata)
// text completed text block (full text, only emitted when part.time.end is set)
// tool_use completed tool call (status: completed|error, contains full input/output)
// step_finish step ended (reason: stop|tool-calls; includes token/cost info)
// reasoning thinking/reasoning output
// error error event
//
// Because tool_use events only arrive after the tool finishes, the parser
// emits a "running" execute chunk at step_start so the frontend has immediate
// feedback that work is in progress. It also tracks intermediate tool_use
// events (status=completed) so they are shown as soon as they arrive, even
// before step_finish.
type streamParser struct {
handler message.StreamFunc
completed bool
toolIndex int
// textActive tracks whether a text message group is currently open.
textActive bool
textMsgID string
// pendingExec tracks tools that were announced at step_start but haven't
// received their completed tool_use event yet. Key = step ID.
pendingExec map[string]string // stepID -> msgID
}
func newStreamParser(handler message.StreamFunc) *streamParser {
return &streamParser{
handler: handler,
pendingExec: make(map[string]string),
}
}
func (p *streamParser) parse(ctx context.Context, stdout io.ReadCloser) error {
doneParsing := make(chan struct{})
defer close(doneParsing)
go func() {
select {
case <-ctx.Done():
stdout.Close()
case <-doneParsing:
}
}()
reader := bufio.NewReaderSize(stdout, 64*1024)
startTime := time.Now()
lineCount := 0
lastHeartbeat := time.Now()
log.Trace("[opencode-parse] stream started")
for {
line, skipped, err := shared.ReadJSONLine(reader)
if err == io.EOF {
break
}
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return err
}
if skipped {
log.Warn("[opencode-parse] skipped oversized JSONL line (>%dMB)", shared.MaxLineSize/1024/1024)
continue
}
if len(line) == 0 {
continue
}
lineCount++
if time.Since(lastHeartbeat) > 30*time.Second {
log.Trace("[opencode-parse] heartbeat: lines=%d elapsed=%v",
lineCount, time.Since(startTime).Round(time.Second))
lastHeartbeat = time.Now()
}
var msg map[string]any
if err := json.Unmarshal(line, &msg); err != nil {
if len(line) > 200 {
log.Trace("[opencode-parse] JSON unmarshal error: %v (line len=%d)", err, len(line))
} else {
log.Trace("[opencode-parse] JSON unmarshal error: %v (line=%q)", err, string(line))
}
continue
}
msgType, _ := msg["type"].(string)
switch msgType {
case "step_start":
if p.handleStepStart(msg) {
return nil
}
case "text":
if p.handleText(msg) {
return nil
}
case "tool_use":
if p.handleToolUse(msg) {
return nil
}
case "step_finish":
if err := p.handleStepFinish(msg); err != nil {
return err
}
if p.completed {
log.Trace("[opencode-parse] stream completed: lines=%d elapsed=%v",
lineCount, time.Since(startTime).Round(time.Second))
return nil
}
log.Trace("[opencode-parse] step_finish (intermediate, reason!=stop): continuing parse loop")
case "reasoning":
p.handleReasoning(msg)
case "error":
log.Trace("[opencode-parse] error event: lines=%d elapsed=%v",
lineCount, time.Since(startTime).Round(time.Second))
return p.handleError(msg)
default:
log.Trace("[opencode-parse] unknown event type: %s", msgType)
}
}
log.Trace("[opencode-parse] stream ended: lines=%d elapsed=%v completed=%v",
lineCount, time.Since(startTime).Round(time.Second), p.completed)
return nil
}
// --- Message lifecycle helpers (mirroring Claude parser) ---
func (p *streamParser) beginMessageWithID(id, msgType string) (stopped bool) {
startData := message.EventMessageStartData{
MessageID: id,
Type: msgType,
Timestamp: time.Now().UnixMilli(),
}
sd, _ := json.Marshal(startData)
return p.handler != nil && p.handler(message.ChunkMessageStart, sd) != 0
}
func (p *streamParser) beginMessage(msgType string) (messageID string, stopped bool) {
id := fmt.Sprintf("sandbox-%s-%s", msgType, message.GenerateNanoID())
return id, p.beginMessageWithID(id, msgType)
}
func (p *streamParser) endMessage() {
if p.handler != nil {
p.handler(message.ChunkMessageEnd, nil)
}
}
func (p *streamParser) closeTextMessage() {
if p.textActive {
p.endMessage()
p.textActive = false
p.textMsgID = ""
}
}
func (p *streamParser) ensureTextMessage() (stopped bool) {
if !p.textActive {
id, stopped := p.beginMessage("text")
if stopped {
return true
}
p.textActive = true
p.textMsgID = id
}
return false
}
func (p *streamParser) emitText(text string) (stopped bool) {
text = strings.ReplaceAll(text, "\r\n", "\n")
text = strings.ReplaceAll(text, "\r", "\n")
return p.handler != nil && p.handler(message.ChunkText, []byte(text)) != 0
}
func (p *streamParser) emitExecute(props map[string]any) (stopped bool) {
data, _ := json.Marshal(props)
return p.handler != nil && p.handler(message.ChunkExecute, data) != 0
}
func (p *streamParser) emitMetadata(data map[string]any) {
if p.handler == nil {
return
}
encoded, _ := json.Marshal(data)
p.handler(message.ChunkMetadata, encoded)
}
// --- Event handlers ---
func (p *streamParser) handleStepStart(msg map[string]any) (stopped bool) {
if p.handler == nil {
return false
}
sessionID, _ := msg["sessionID"].(string)
meta := map[string]any{"opencode_session_id": sessionID}
part, _ := msg["part"].(map[string]any)
if part != nil {
if id, ok := part["id"].(string); ok {
meta["step_id"] = id
}
}
p.emitMetadata(meta)
// Record the step ID so that a subsequent tool_use can be correlated.
// We do NOT emit a "running" execute here because we can't tell yet
// whether the step will involve tool calls or just text output. For
// pure text responses an empty execute widget would look wrong. The
// "running" indicator is instead emitted lazily in handleToolUse when
// the first tool event actually arrives.
if part != nil {
if stepID, ok := part["id"].(string); ok && stepID != "" {
p.pendingExec[stepID] = "" // placeholder — msgID assigned later
}
}
return false
}
func (p *streamParser) handleText(msg map[string]any) (stopped bool) {
if p.handler == nil {
return false
}
part, _ := msg["part"].(map[string]any)
if part == nil {
return false
}
content, _ := part["text"].(string)
if content == "" {
content, _ = part["content"].(string)
}
if content == "" {
return false
}
// Close any pending execute message before text output.
p.closePendingExec(msg)
if p.ensureTextMessage() {
return true
}
if p.emitText(content) {
p.closeTextMessage()
return true
}
p.closeTextMessage()
return false
}
func (p *streamParser) handleToolUse(msg map[string]any) (stopped bool) {
if p.handler == nil {
return false
}
part, _ := msg["part"].(map[string]any)
if part == nil {
return false
}
state, _ := part["state"].(map[string]any)
if state == nil {
return false
}
toolName, _ := part["toolName"].(string)
if toolName == "" {
toolName, _ = part["tool"].(string)
}
toolID, _ := part["toolCallId"].(string)
if toolID == "" {
toolID = fmt.Sprintf("oc-tool_%d_%d", p.toolIndex, time.Now().UnixNano())
}
p.toolIndex++
status, _ := state["status"].(string)
isError := status == "error"
p.closeTextMessage()
// Clear any pending step placeholder (no msgID to reuse since we didn't
// emit anything at step_start).
p.closePendingExec(msg)
// Build execute properties.
execProps := map[string]any{
"tool": toolName,
"tool_id": toolID,
"status": status,
"runner": "opencode-cli",
}
if isError {
execProps["is_error"] = true
}
var inputStr string
if input, ok := state["input"].(string); ok && input != "" {
inputStr = input
} else if inputObj, ok := state["input"].(map[string]any); ok {
inputJSON, _ := json.Marshal(inputObj)
inputStr = string(inputJSON)
}
if inputStr != "" {
execProps["input"] = json.RawMessage(inputStr)
summary := extractSummary(toolName, inputStr)
if summary != "" {
execProps["summary"] = summary
}
}
if output, ok := state["output"].(string); ok && output != "" {
execProps["output"] = output
} else if outputObj := state["output"]; outputObj != nil {
execProps["output"] = outputObj
}
// Single message group with the complete tool result.
if _, stopped := p.beginMessage("execute"); stopped {
return true
}
if p.emitExecute(execProps) {
p.endMessage()
return true
}
p.endMessage()
return false
}
func (p *streamParser) handleStepFinish(msg map[string]any) error {
p.closeTextMessage()
p.closePendingExec(msg)
part, _ := msg["part"].(map[string]any)
reason := ""
if part != nil {
reason, _ = part["reason"].(string)
if reason == "" {
reason, _ = part["finishReason"].(string)
}
}
if reason == "stop" || reason == "end_turn" {
p.completed = true
if p.handler != nil {
finishMeta := map[string]any{
"result_summary": map[string]any{
"finish_reason": reason,
},
}
// Include token/cost info if available.
if part != nil {
if tokens, ok := part["tokens"].(map[string]any); ok {
finishMeta["result_summary"].(map[string]any)["tokens"] = tokens
}
if cost, ok := part["cost"]; ok {
finishMeta["result_summary"].(map[string]any)["cost"] = cost
}
}
p.emitMetadata(finishMeta)
}
return nil
}
// reason == "tool-calls" or other intermediate reasons: not final.
// Emit metadata so the frontend knows a new round is starting.
if p.handler != nil && reason != "" {
p.emitMetadata(map[string]any{
"step_transition": map[string]any{
"reason": reason,
},
})
}
return nil
}
func (p *streamParser) handleReasoning(msg map[string]any) {
if p.handler == nil {
return
}
part, _ := msg["part"].(map[string]any)
if part == nil {
return
}
content, _ := part["text"].(string)
if content == "" {
content, _ = part["content"].(string)
}
if content == "" {
return
}
p.emitMetadata(map[string]any{
"reasoning": content,
})
}
func (p *streamParser) handleError(msg map[string]any) error {
p.closePendingExec(msg)
var errMsg string
if part, ok := msg["part"].(map[string]any); ok {
errMsg, _ = part["error"].(string)
if errMsg == "" {
errMsg, _ = part["message"].(string)
}
}
if errMsg == "" {
switch e := msg["error"].(type) {
case string:
errMsg = e
case map[string]any:
errMsg, _ = e["message"].(string)
if errMsg == "" {
if data, ok := e["data"].(map[string]any); ok {
errMsg, _ = data["message"].(string)
}
}
if errMsg == "" {
name, _ := e["name"].(string)
if name != "" {
errMsg = name
}
}
}
}
if errMsg == "" {
errMsg = "unknown OpenCode error"
}
if p.handler != nil {
p.handler(message.ChunkError, []byte(errMsg))
}
return fmt.Errorf("OpenCode CLI error: %s", errMsg)
}
// --- Pending exec helpers ---
// closePendingExec clears step placeholders recorded at step_start.
func (p *streamParser) closePendingExec(msg map[string]any) {
if len(p.pendingExec) == 0 {
return
}
// Best-effort: clear matching step or all if we can't match.
part, _ := msg["part"].(map[string]any)
if part != nil {
for _, key := range []string{"id", "messageID"} {
if id, _ := part[key].(string); id != "" {
if _, ok := p.pendingExec[id]; ok {
delete(p.pendingExec, id)
return
}
}
}
}
// Fallback: clear the single pending entry (most common).
if len(p.pendingExec) == 1 {
for k := range p.pendingExec {
delete(p.pendingExec, k)
}
}
}
// --- Utility ---
// extractSummary builds a short human-readable summary from the tool input.
func extractSummary(toolName string, inputJSON string) string {
if inputJSON == "" {
return ""
}
var obj map[string]any
if err := json.Unmarshal([]byte(inputJSON), &obj); err != nil {
return ""
}
switch strings.ToLower(toolName) {
case "bash", "execute":
if cmd, ok := obj["command"].(string); ok {
return truncate(cmd, 80)
}
case "write", "create":
if fp, ok := obj["file_path"].(string); ok {
return fp
}
case "read":
if fp, ok := obj["file_path"].(string); ok {
return fp
}
case "edit":
if fp, ok := obj["file_path"].(string); ok {
return fp
}
}
for _, key := range []string{"path", "file_path", "command", "url", "query"} {
if v, ok := obj[key].(string); ok {
return truncate(v, 80)
}
}
return ""
}
func truncate(s string, max int) string {
s = strings.TrimSpace(s)
s = strings.ReplaceAll(s, "\n", " ")
if len(s) > max {
return s[:max] + "..."
}
return s
}