opencode persists its own sessions but, unlike the Claude family, will not let the caller mint the session id up front: `opencode run` creates a session on the first turn and only reports its id in the `--format json` event stream, then continues it with `--session <id>`. So the connector now CAPTURES the id from the output (like codex's thread id) instead of minting a UUID: - connect_opencode.go: a dedicated opencodeForwarder that runs `opencode run --pure --format json`, accumulates reply text from `text` events, captures the top-level sessionID, and on the first turn persists the convID->sessionID map to <config>/connect/<clientId>/opencode-sessions.json (best-effort, restored on startup). The next message — and a connector restart — replays it with `--session <id>`. Implements streamingForwarder and sessionResetter, so /new and /clear work and streaming cards stream. - forwarderForChannel routes the opencode channel to it. - connectAgentOptionsPayload reports opencode memory as "per-conversation-captured"; --agent-memory help now lists codex/opencode too. Verified end to end against the real opencode CLI: turn 1 captures the session id, turn 2 with --session recalls a fact from turn 1. Unit tests cover capture + resume (stub bin), store persistence across restart, and reset. gemini also exposes --session-id/--resume but could not be verified end to end here (no GEMINI_API_KEY) and its --resume id semantics are unconfirmed, so it is left for a follow-up rather than wired blind. qoder's qodercli still has only --resume (no addressable id) and cannot do per-conversation memory.
273 lines
8.5 KiB
Go
273 lines
8.5 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package helpers
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
|
)
|
|
|
|
// opencode keeps per-conversation context, but unlike the Claude family it does
|
|
// not let the caller mint the session id up front: `opencode run` creates a
|
|
// session on the first turn and reports its id in the `--format json` event
|
|
// stream, and a later turn continues it with `--session <id>`. So the connector
|
|
// CAPTURES the id from the output (like codex's thread id) instead of minting a
|
|
// UUID, persists the convID→sessionID map, and replays it on the next message.
|
|
// This is what lets opencode behave as an always-on digital employee whose
|
|
// context survives both follow-up messages and a connector restart.
|
|
|
|
// opencodeForwarder forwards one DingTalk message to a local `opencode run`,
|
|
// parsing the JSON event stream for the reply text and the assigned session id.
|
|
type opencodeForwarder struct {
|
|
bin string
|
|
env []string
|
|
timeout time.Duration
|
|
workDir string
|
|
model string
|
|
sessions *opencodeSessions // convID→sessionID, persisted; nil = stateless
|
|
}
|
|
|
|
func newOpencodeForwarder(bin string, env []string, timeout time.Duration, opts connectAgentOptions, clientID string) forwarder {
|
|
var sessions *opencodeSessions
|
|
if opts.Memory {
|
|
sessions = newOpencodeSessions(opencodeSessionStorePath(clientID))
|
|
}
|
|
return &opencodeForwarder{
|
|
bin: bin,
|
|
env: env,
|
|
timeout: timeout,
|
|
workDir: opts.WorkDir,
|
|
model: opts.Model,
|
|
sessions: sessions,
|
|
}
|
|
}
|
|
|
|
func (f *opencodeForwarder) canStream() bool { return true }
|
|
|
|
func (f *opencodeForwarder) label() string {
|
|
memo := "stateless"
|
|
if f.sessions != nil {
|
|
memo = "session-memory"
|
|
}
|
|
return fmt.Sprintf("opencode:%s (%s)", f.bin, memo)
|
|
}
|
|
|
|
func (f *opencodeForwarder) forward(ctx context.Context, convID, text string) (string, error) {
|
|
return f.forwardStream(ctx, convID, text, nil)
|
|
}
|
|
|
|
// forwardStream runs `opencode run --format json`, accumulating the visible text
|
|
// from `text` events (calling onDelta with the text-so-far when non-nil) and
|
|
// capturing the session id the stream reports. On the first turn of a
|
|
// conversation no `--session` is passed; the captured id is then persisted so
|
|
// the next message — and a restart — continues the same session.
|
|
func (f *opencodeForwarder) forwardStream(ctx context.Context, convID, text string, onDelta func(string)) (string, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, f.timeout)
|
|
defer cancel()
|
|
|
|
priorSession := ""
|
|
if f.sessions != nil {
|
|
priorSession = f.sessions.id(convID)
|
|
}
|
|
|
|
args := []string{"run", "--pure", "--format", "json"}
|
|
if f.model != "" {
|
|
args = append(args, "--model", f.model)
|
|
}
|
|
if priorSession != "" {
|
|
args = append(args, "--session", priorSession)
|
|
}
|
|
args = append(args, text)
|
|
|
|
cmd := exec.CommandContext(ctx, f.bin, args...)
|
|
if f.workDir != "" {
|
|
cmd.Dir = f.workDir
|
|
} else {
|
|
cmd.Dir = connectWorkDir()
|
|
}
|
|
if len(f.env) > 0 {
|
|
cmd.Env = append(os.Environ(), f.env...)
|
|
}
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var stderrBuf strings.Builder
|
|
cmd.Stderr = &stderrBuf
|
|
if err := cmd.Start(); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var acc strings.Builder
|
|
capturedSession := ""
|
|
scanner := bufio.NewScanner(stdout)
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" || !strings.HasPrefix(line, "{") {
|
|
continue
|
|
}
|
|
sid, delta := parseOpencodeLine(line)
|
|
if sid != "" {
|
|
capturedSession = sid
|
|
}
|
|
if delta != "" {
|
|
acc.WriteString(delta)
|
|
if onDelta != nil {
|
|
onDelta(brandReply(f.name(), acc.String()))
|
|
}
|
|
}
|
|
}
|
|
waitErr := cmd.Wait()
|
|
|
|
// Persist the captured session as soon as we have one for a conversation
|
|
// that did not have it yet (first turn), so the next message continues it.
|
|
if f.sessions != nil && capturedSession != "" && capturedSession != priorSession {
|
|
f.sessions.set(convID, capturedSession)
|
|
}
|
|
|
|
finalText := strings.TrimSpace(acc.String())
|
|
// A bare backend error must not be forwarded as the answer; drop the session
|
|
// so the next message starts clean, and return an actionable hint.
|
|
if agentReplyIsError(finalText) {
|
|
if f.sessions != nil {
|
|
f.sessions.reset(convID)
|
|
}
|
|
return agentBackendErrorReply(finalText), nil
|
|
}
|
|
if finalText != "" {
|
|
return brandReply(f.name(), finalText), nil
|
|
}
|
|
if waitErr != nil {
|
|
msg := waitErr.Error()
|
|
if s := strings.TrimSpace(stderrBuf.String()); s != "" {
|
|
msg = s
|
|
}
|
|
return "", fmt.Errorf("本地 opencode agent 调用失败:%s", truncateRunes(msg, 300))
|
|
}
|
|
return "(本地 agent 无文本输出)", nil
|
|
}
|
|
|
|
func (f *opencodeForwarder) name() string { return "opencode" }
|
|
|
|
// resetSession drops the conversation's opencode session so the next message
|
|
// starts a fresh one. Implements sessionResetter for the /new and /clear
|
|
// commands. A no-op when per-conversation memory is disabled.
|
|
func (f *opencodeForwarder) resetSession(convID string) {
|
|
if f.sessions != nil {
|
|
f.sessions.reset(convID)
|
|
}
|
|
}
|
|
|
|
// parseOpencodeLine extracts (sessionID, visible-text delta) from one
|
|
// `opencode run --format json` event line. Every event carries the top-level
|
|
// sessionID; only `text` events carry reply text in part.text. Either return
|
|
// value may be empty.
|
|
func parseOpencodeLine(line string) (sessionID, delta string) {
|
|
var ev struct {
|
|
Type string `json:"type"`
|
|
SessionID string `json:"sessionID"`
|
|
Part struct {
|
|
Text string `json:"text"`
|
|
} `json:"part"`
|
|
}
|
|
if json.Unmarshal([]byte(line), &ev) != nil {
|
|
return "", ""
|
|
}
|
|
if ev.Type == "text" {
|
|
return ev.SessionID, ev.Part.Text
|
|
}
|
|
return ev.SessionID, ""
|
|
}
|
|
|
|
// opencodeSessionStorePath returns the on-disk location for a robot's opencode
|
|
// conversation→session map, scoped by clientId so multiple bots on one machine
|
|
// stay isolated: <config dir>/connect/<clientId>/opencode-sessions.json. It
|
|
// mirrors the Claude-family connectSessionStorePath layout with a distinct
|
|
// filename so the stores never collide. An empty clientId means "do not persist"
|
|
// (in-memory only). The clientId is sanitized like the connect lock file so it
|
|
// is always filesystem-safe.
|
|
func opencodeSessionStorePath(clientID string) string {
|
|
if clientID == "" {
|
|
return ""
|
|
}
|
|
return filepath.Join(config.DefaultConfigDir(), "connect", sanitizeLockID(clientID), "opencode-sessions.json")
|
|
}
|
|
|
|
// opencodeSessions maps a DingTalk conversation to the opencode session id
|
|
// captured from the agent's output. The map is the authoritative store (guarded
|
|
// by mu) and is persisted to disk so context survives a connector restart. An
|
|
// empty path keeps it in memory only (persistence disabled).
|
|
type opencodeSessions struct {
|
|
mu sync.Mutex
|
|
m map[string]string
|
|
path string
|
|
}
|
|
|
|
func newOpencodeSessions(path string) *opencodeSessions {
|
|
return &opencodeSessions{m: loadConvSessionMap(path), path: path}
|
|
}
|
|
|
|
func opencodeConvKey(convID string) string {
|
|
key := strings.TrimSpace(convID)
|
|
if key == "" {
|
|
key = "_default"
|
|
}
|
|
return key
|
|
}
|
|
|
|
// id returns the opencode session bound to a conversation, or "" if none.
|
|
func (s *opencodeSessions) id(convID string) string {
|
|
key := opencodeConvKey(convID)
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.m[key]
|
|
}
|
|
|
|
// set binds a conversation to an opencode session and persists the snapshot.
|
|
// Best-effort: a failed save only logs a warning and never blocks message
|
|
// handling.
|
|
func (s *opencodeSessions) set(convID, sessionID string) {
|
|
key := opencodeConvKey(convID)
|
|
s.mu.Lock()
|
|
if sessionID == "" {
|
|
delete(s.m, key)
|
|
} else {
|
|
s.m[key] = sessionID
|
|
}
|
|
snapshot := make(map[string]string, len(s.m))
|
|
for k, v := range s.m {
|
|
snapshot[k] = v
|
|
}
|
|
path := s.path
|
|
s.mu.Unlock()
|
|
saveConvSessionMap(path, snapshot)
|
|
}
|
|
|
|
// reset forgets a conversation's session so the next message starts a fresh one.
|
|
// The removal is persisted so a restart does not resurrect the dropped session.
|
|
func (s *opencodeSessions) reset(convID string) {
|
|
s.set(convID, "")
|
|
}
|