- 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.
540 lines
13 KiB
Go
540 lines
13 KiB
Go
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
|
||
}
|