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.
111 lines
3.7 KiB
Go
111 lines
3.7 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 (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestOpencodeForwarderCapturesAndResumesSession verifies the capture-based
|
|
// session flow: the first turn passes no --session and captures the id from the
|
|
// JSON stream (persisting it), and the second turn replays it with --session.
|
|
func TestOpencodeForwarderCapturesAndResumesSession(t *testing.T) {
|
|
dir := t.TempDir()
|
|
logPath := filepath.Join(dir, "args.log")
|
|
storePath := filepath.Join(dir, "opencode-sessions.json")
|
|
opencode := writeShellExecutable(t, dir, "opencode", `
|
|
echo "$@" >> "$OPENCODE_STUB_LOG"
|
|
sid=ses_stub
|
|
printf '%s\n' '{"type":"step_start","sessionID":"'"$sid"'","part":{}}'
|
|
printf '%s\n' '{"type":"text","sessionID":"'"$sid"'","part":{"text":"hello"}}'
|
|
printf '%s\n' '{"type":"step_finish","sessionID":"'"$sid"'","part":{}}'
|
|
`)
|
|
f := &opencodeForwarder{
|
|
bin: opencode,
|
|
env: []string{"OPENCODE_STUB_LOG=" + logPath},
|
|
timeout: 5 * time.Second,
|
|
workDir: dir,
|
|
sessions: newOpencodeSessions(storePath),
|
|
}
|
|
|
|
// First turn: captures and persists ses_stub; reply is the accumulated text.
|
|
reply, err := f.forwardStream(context.Background(), "conv-1", "hi", nil)
|
|
if err != nil {
|
|
t.Fatalf("first forward: %v", err)
|
|
}
|
|
if reply != "hello" {
|
|
t.Fatalf("first reply = %q, want hello", reply)
|
|
}
|
|
if got := f.sessions.id("conv-1"); got != "ses_stub" {
|
|
t.Fatalf("captured session = %q, want ses_stub", got)
|
|
}
|
|
|
|
// Second turn: must replay the captured session via --session.
|
|
if _, err := f.forwardStream(context.Background(), "conv-1", "again", nil); err != nil {
|
|
t.Fatalf("second forward: %v", err)
|
|
}
|
|
logBytes, err := os.ReadFile(logPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
lines := strings.Split(strings.TrimSpace(string(logBytes)), "\n")
|
|
if len(lines) != 2 {
|
|
t.Fatalf("expected 2 invocations, got %d:\n%s", len(lines), string(logBytes))
|
|
}
|
|
if strings.Contains(lines[0], "--session") {
|
|
t.Errorf("first invocation should not pass --session, got: %s", lines[0])
|
|
}
|
|
if !strings.Contains(lines[1], "--session ses_stub") {
|
|
t.Errorf("second invocation should resume --session ses_stub, got: %s", lines[1])
|
|
}
|
|
|
|
// reset drops the mapping: the next turn starts fresh (no --session).
|
|
f.resetSession("conv-1")
|
|
if got := f.sessions.id("conv-1"); got != "" {
|
|
t.Errorf("after reset session = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
// TestOpencodeSessionsPersist verifies the convID→sessionID store survives a
|
|
// restart and that reset is persisted.
|
|
func TestOpencodeSessionsPersist(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "opencode-sessions.json")
|
|
|
|
s := newOpencodeSessions(path)
|
|
s.set("conv-1", "ses_a")
|
|
s.set("conv-2", "ses_b")
|
|
|
|
restarted := newOpencodeSessions(path)
|
|
if got := restarted.id("conv-1"); got != "ses_a" {
|
|
t.Errorf("after restart conv-1 = %q, want ses_a", got)
|
|
}
|
|
if got := restarted.id("conv-2"); got != "ses_b" {
|
|
t.Errorf("after restart conv-2 = %q, want ses_b", got)
|
|
}
|
|
|
|
restarted.reset("conv-1")
|
|
again := newOpencodeSessions(path)
|
|
if got := again.id("conv-1"); got != "" {
|
|
t.Errorf("after reset+restart conv-1 = %q, want empty", got)
|
|
}
|
|
if got := again.id("conv-2"); got != "ses_b" {
|
|
t.Errorf("after reset+restart conv-2 = %q, want ses_b (untouched)", got)
|
|
}
|
|
}
|