Two connector improvements for the linked-bot chat experience, modelled on
codex / OpenClaw connect:
1. Built-in slash commands. A user can type /new (aliases /start, /reset) to
open a fresh session or /clear to wipe the current one, instead of the
message being forwarded to the agent. The command set is fixed (never
dynamically extended) so behaviour stays predictable. Matching requires the
whole message to be the bare command, so a normal question that merely starts
with a slash is forwarded untouched. Costs no agent turn / tokens.
- connect_command.go: channel-agnostic parser + action table (+ tests).
- sessionResetter optional interface; execForwarder (Claude family) and the
codex app-server forwarder both implement it. The main loop intercepts a
command right after the owner-decision interceptor and acks via webhook.
2. Persist codex per-conversation context. codexThreadSessions previously kept
the convID->threadID map in memory only, so a codex connector restart lost
every conversation's context — unlike the Claude-family convSessions store
which already persisted. The thread identity is now the authoritative,
mutex-guarded map persisted to <config>/connect/<clientId>/codex-threads.json
(best-effort, atomic write), restored on startup. An empty clientId keeps it
in memory only, preserving the original behaviour. This also removes a data
race on threadID by moving it off the per-conversation state into the
sessions store.
69 lines
2.5 KiB
Go
69 lines
2.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 (
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
// TestCodexThreadSessionsPersist verifies that a codex conversation's thread
|
|
// survives a connector restart: a second store opened on the same path restores
|
|
// the convID→threadID mapping, and a reset is persisted too.
|
|
func TestCodexThreadSessionsPersist(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "codex-threads.json")
|
|
|
|
s := newCodexThreadSessions(path)
|
|
if got := s.threadID("conv-1"); got != "" {
|
|
t.Fatalf("fresh store threadID = %q, want empty", got)
|
|
}
|
|
s.setThreadID("conv-1", "thr_a")
|
|
s.setThreadID("conv-2", "thr_b")
|
|
|
|
// Simulate a restart: a new store on the same path must restore both.
|
|
restarted := newCodexThreadSessions(path)
|
|
if got := restarted.threadID("conv-1"); got != "thr_a" {
|
|
t.Errorf("after restart conv-1 = %q, want thr_a", got)
|
|
}
|
|
if got := restarted.threadID("conv-2"); got != "thr_b" {
|
|
t.Errorf("after restart conv-2 = %q, want thr_b", got)
|
|
}
|
|
|
|
// Reset is persisted: the dropped thread must not resurrect on restart.
|
|
restarted.reset("conv-1")
|
|
again := newCodexThreadSessions(path)
|
|
if got := again.threadID("conv-1"); got != "" {
|
|
t.Errorf("after reset+restart conv-1 = %q, want empty", got)
|
|
}
|
|
if got := again.threadID("conv-2"); got != "thr_b" {
|
|
t.Errorf("after reset+restart conv-2 = %q, want thr_b (untouched)", got)
|
|
}
|
|
}
|
|
|
|
// TestCodexThreadSessionsInMemory verifies that an empty path keeps the store
|
|
// purely in memory: it still works within the process but writes nothing to
|
|
// disk, preserving the pre-persistence behaviour.
|
|
func TestCodexThreadSessionsInMemory(t *testing.T) {
|
|
s := newCodexThreadSessions("")
|
|
s.setThreadID("conv-1", "thr_a")
|
|
if got := s.threadID("conv-1"); got != "thr_a" {
|
|
t.Errorf("in-memory conv-1 = %q, want thr_a", got)
|
|
}
|
|
// A separate in-memory store shares no state (nothing was persisted).
|
|
other := newCodexThreadSessions("")
|
|
if got := other.threadID("conv-1"); got != "" {
|
|
t.Errorf("separate in-memory store conv-1 = %q, want empty", got)
|
|
}
|
|
}
|