iop/apps/node/internal/adapters/cli/opencode_sse_events.go

298 lines
8.2 KiB
Go

package cli
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"iop/apps/node/internal/runtime"
)
type opencodeEnvelope struct {
Type string `json:"type"`
Properties map[string]any `json:"properties"`
}
type opencodeGlobalEnvelope struct {
Payload *opencodeEnvelope `json:"payload"`
}
func opencodeEventEnvelope(data []byte) (opencodeEnvelope, bool) {
var env opencodeEnvelope
if err := json.Unmarshal(data, &env); err == nil && env.Type != "" && env.Type != "sync" {
return env, true
}
var global opencodeGlobalEnvelope
if err := json.Unmarshal(data, &global); err != nil || global.Payload == nil || global.Payload.Type == "" || global.Payload.Type == "sync" {
return opencodeEnvelope{}, false
}
return *global.Payload, true
}
type opencodeEventHandler func(r *opencodeSSERun, ev opencodeEnvelope, sidStatus string) (bool, error)
var opencodeEventHandlers = map[string]opencodeEventHandler{
"session.next.text.delta": func(r *opencodeSSERun, ev opencodeEnvelope, sidStatus string) (bool, error) {
delta := opencodeDeltaText(ev.Properties)
if delta != "" {
r.outputTokens += len(strings.Fields(delta))
_ = r.sink.Emit(r.ctx, runtime.RuntimeEvent{
RunID: r.spec.RunID,
Type: runtime.EventTypeDelta,
Delta: delta,
Timestamp: time.Now(),
})
r.addTrace(sseEvtTrace{Type: ev.Type, SID: sidStatus, Outcome: "delta"})
} else {
r.addTrace(sseEvtTrace{Type: ev.Type, SID: sidStatus, Outcome: "skip"})
}
return false, nil
},
"message.part.updated": func(r *opencodeSSERun, ev opencodeEnvelope, sidStatus string) (bool, error) {
if partID, partType, ok := opencodePartInfo(ev.Properties); ok {
r.partTypes[partID] = partType
}
if in, out, ok := opencodeStepTokens(ev.Properties); ok {
if in > 0 {
r.inputTokens = in
}
if out > 0 {
r.outputTokens = out
}
}
r.addTrace(sseEvtTrace{Type: ev.Type, SID: sidStatus, Outcome: "ok"})
return false, nil
},
"message.part.delta": func(r *opencodeSSERun, ev opencodeEnvelope, sidStatus string) (bool, error) {
delta := opencodeMessagePartDeltaText(ev.Properties, r.partTypes)
if delta != "" {
r.outputTokens += len(strings.Fields(delta))
_ = r.sink.Emit(r.ctx, runtime.RuntimeEvent{
RunID: r.spec.RunID,
Type: runtime.EventTypeDelta,
Delta: delta,
Timestamp: time.Now(),
})
r.addTrace(sseEvtTrace{Type: ev.Type, SID: sidStatus, Outcome: "delta"})
} else {
r.addTrace(sseEvtTrace{Type: ev.Type, SID: sidStatus, Outcome: "skip"})
}
return false, nil
},
"session.next.step.ended": func(r *opencodeSSERun, ev opencodeEnvelope, sidStatus string) (bool, error) {
if in, out, ok := opencodeStepTokens(ev.Properties); ok {
if in > 0 {
r.inputTokens = in
}
if out > 0 {
r.outputTokens = out
}
}
r.addTrace(sseEvtTrace{Type: ev.Type, SID: sidStatus, Outcome: "ok"})
return false, nil
},
"session.idle": func(r *opencodeSSERun, ev opencodeEnvelope, sidStatus string) (bool, error) {
r.addTrace(sseEvtTrace{Type: ev.Type, SID: sidStatus, Outcome: "idle"})
return true, emitOpencodeComplete(r.ctx, r.sink, r.spec.RunID, r.inputTokens, r.outputTokens)
},
"session.status": func(r *opencodeSSERun, ev opencodeEnvelope, sidStatus string) (bool, error) {
if opencodeStatusIdle(ev.Properties) {
r.addTrace(sseEvtTrace{Type: ev.Type, SID: sidStatus, Outcome: "idle"})
return true, emitOpencodeComplete(r.ctx, r.sink, r.spec.RunID, r.inputTokens, r.outputTokens)
}
r.addTrace(sseEvtTrace{Type: ev.Type, SID: sidStatus, Outcome: "skip"})
return false, nil
},
"session.error": func(r *opencodeSSERun, ev opencodeEnvelope, sidStatus string) (bool, error) {
msg := opencodeErrorMessage(ev.Properties)
r.addTrace(sseEvtTrace{Type: ev.Type, SID: sidStatus, Outcome: "error"})
_ = r.sink.Emit(r.ctx, runtime.RuntimeEvent{
RunID: r.spec.RunID,
Type: runtime.EventTypeError,
Error: msg,
Timestamp: time.Now(),
})
return true, fmt.Errorf("cli adapter: opencode session error: %s", msg)
},
"permission.asked": func(r *opencodeSSERun, ev opencodeEnvelope, sidStatus string) (bool, error) {
permID, _ := stringFromProp(ev.Properties, "id")
if permID == "" {
permID, _ = stringFromProp(ev.Properties, "permissionID")
}
if permID != "" {
reply := "reject"
if r.opts.DangerouslySkipPermissions {
reply = "once"
}
bg, cancel := context.WithTimeout(context.Background(), 5*time.Second)
_ = opencodePermissionReply(bg, r.sess.serverURL, permID, reply)
cancel()
}
r.addTrace(sseEvtTrace{Type: ev.Type, SID: sidStatus, Outcome: "ok"})
return false, nil
},
}
func (r *opencodeSSERun) handleEvent(ev opencodeEnvelope, sidStatus string) (bool, error) {
handler := opencodeEventHandlers[ev.Type]
if handler == nil {
r.addTrace(sseEvtTrace{Type: ev.Type, SID: sidStatus, Outcome: "skip"})
return false, nil
}
return handler(r, ev, sidStatus)
}
func stringFromProp(props map[string]any, key string) (string, bool) {
if props == nil {
return "", false
}
v, ok := props[key]
if !ok {
return "", false
}
s, ok := v.(string)
return s, ok
}
func opencodeDeltaText(props map[string]any) string {
if props == nil {
return ""
}
if s, ok := props["delta"].(string); ok && s != "" {
return s
}
if part, ok := props["part"].(map[string]any); ok {
if s, ok := part["text"].(string); ok {
return s
}
}
return ""
}
func opencodePartInfo(props map[string]any) (string, string, bool) {
if props == nil {
return "", "", false
}
part, ok := props["part"].(map[string]any)
if !ok {
return "", "", false
}
id, _ := part["id"].(string)
partType, _ := part["type"].(string)
if id == "" || partType == "" {
return "", "", false
}
return id, partType, true
}
func opencodeMessagePartDeltaText(props map[string]any, partTypes map[string]string) string {
if props == nil {
return ""
}
field, _ := props["field"].(string)
if field != "text" {
return ""
}
partID, _ := props["partID"].(string)
if partID != "" {
if pt, known := partTypes[partID]; known && pt != "text" {
return ""
}
}
delta, _ := props["delta"].(string)
return delta
}
func opencodeStepTokens(props map[string]any) (int, int, bool) {
if props == nil {
return 0, 0, false
}
tokens, ok := props["tokens"].(map[string]any)
if !ok {
if part, partOK := props["part"].(map[string]any); partOK {
tokens, ok = part["tokens"].(map[string]any)
}
}
if !ok {
return 0, 0, false
}
in := intFromNumber(tokens["input"])
out := intFromNumber(tokens["output"])
return in, out, true
}
func intFromNumber(v any) int {
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
case json.Number:
if i, err := n.Int64(); err == nil {
return int(i)
}
}
return 0
}
func emitOpencodeComplete(ctx context.Context, sink runtime.EventSink, runID string, inputTokens, outputTokens int) error {
return sink.Emit(ctx, runtime.RuntimeEvent{
RunID: runID,
Type: runtime.EventTypeComplete,
Message: "opencode sse execution complete",
Usage: &runtime.UsageStats{
InputTokens: inputTokens,
OutputTokens: outputTokens,
},
Timestamp: time.Now(),
})
}
func opencodeModelPayload(s string) (map[string]any, error) {
idx := strings.Index(s, "/")
if idx < 0 {
return nil, fmt.Errorf("cli adapter: opencode model %q missing provider/model separator", s)
}
provider := s[:idx]
model := s[idx+1:]
if provider == "" || model == "" {
return nil, fmt.Errorf("cli adapter: opencode model %q has empty provider or model", s)
}
return map[string]any{"providerID": provider, "modelID": model}, nil
}
func opencodeStatusIdle(props map[string]any) bool {
if props == nil {
return false
}
switch v := props["status"].(type) {
case string:
return v == "idle"
case map[string]any:
t, _ := v["type"].(string)
return t == "idle"
}
return false
}
func opencodeErrorMessage(props map[string]any) string {
if props == nil {
return "opencode error"
}
if errObj, ok := props["error"].(map[string]any); ok {
if data, ok := errObj["data"].(map[string]any); ok {
if m, ok := data["message"].(string); ok && m != "" {
return m
}
}
if name, ok := errObj["name"].(string); ok && name != "" {
return name
}
}
if m, ok := props["message"].(string); ok && m != "" {
return m
}
return "opencode error"
}