298 lines
8.2 KiB
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"
|
|
}
|