mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-07-03 12:28:10 +08:00
feat(api): support combining --as-prompt with --new-thread in send command (#590)
Adds three new engine methods: - InjectPrompt: inject a message into the agent's stdin as a prompt - PostToNewThread: post a message to the platform as a new top-level message - InjectPromptToNewThread: combine both for completion-watcher flows Adds CLI flags --as-prompt and --new-thread to the send subcommand, both usable alone or together. The previous handleSend dispatched on AsPrompt first and never reached the NewThread branch, so the combined mode was silently broken. Fresh implementation supersedes the stale #593 branch. Fixes #590. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -142,6 +142,10 @@ func parseSendArgs(args []string) (core.SendRequest, string, error) {
|
||||
}
|
||||
case "--at-all":
|
||||
req.AtAll = true
|
||||
case "--as-prompt":
|
||||
req.AsPrompt = true
|
||||
case "--new-thread":
|
||||
req.NewThread = true
|
||||
case "--data-dir":
|
||||
if i+1 >= len(args) {
|
||||
return req, "", fmt.Errorf("--data-dir requires a value")
|
||||
@@ -356,6 +360,8 @@ Options:
|
||||
--stdin Read message from stdin (best for long/special-char messages)
|
||||
--at-users <ids> @ user IDs, comma-separated (DingTalk)
|
||||
--at-all @ everyone (DingTalk)
|
||||
--as-prompt Inject the message into the agent's session as a prompt (skip platform post)
|
||||
--new-thread Post to the platform as a new top-level message (not as a thread reply)
|
||||
-p, --project <name> Target project (optional if only one project)
|
||||
-s, --session <key> Target session key (optional, picks first active)
|
||||
--data-dir <path> Data directory (default: ~/.cc-connect)
|
||||
@@ -369,6 +375,9 @@ Examples:
|
||||
cc-connect send --video /tmp/demo.mp4
|
||||
cc-connect send --audio /tmp/voice.opus
|
||||
cc-connect send --tts "Hello from cc-connect"
|
||||
cc-connect send --as-prompt "Check the latest commits"
|
||||
cc-connect send --new-thread "Build completed"
|
||||
cc-connect send --as-prompt --new-thread "Process this in a fresh thread"
|
||||
cc-connect send --stdin <<'EOF'
|
||||
Long message with "special" chars, $variables, and newlines
|
||||
EOF`)
|
||||
|
||||
@@ -293,3 +293,48 @@ func TestBuildSendPayload_JSONRoundTrip(t *testing.T) {
|
||||
t.Fatalf("decoded files = %#v", decoded.Files)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseSendArgs_AsPromptAndNewThread exercises the --as-prompt and
|
||||
// --new-thread flags introduced for issue #590. The two flags can be
|
||||
// combined for completion-watcher flows that need both a visible
|
||||
// notification and an auto-processed task.
|
||||
func TestParseSendArgs_AsPromptAndNewThread(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want core.SendRequest
|
||||
}{
|
||||
{
|
||||
name: "as_prompt_only",
|
||||
args: []string{"-m", "check commits", "--as-prompt"},
|
||||
want: core.SendRequest{Message: "check commits", AsPrompt: true},
|
||||
},
|
||||
{
|
||||
name: "new_thread_only",
|
||||
args: []string{"-m", "build done", "--new-thread"},
|
||||
want: core.SendRequest{Message: "build done", NewThread: true},
|
||||
},
|
||||
{
|
||||
name: "as_prompt_and_new_thread_combined",
|
||||
args: []string{"-m", "process in fresh thread", "--as-prompt", "--new-thread"},
|
||||
want: core.SendRequest{Message: "process in fresh thread", AsPrompt: true, NewThread: true},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req, _, err := parseSendArgs(tc.args)
|
||||
if err != nil {
|
||||
t.Fatalf("parseSendArgs: %v", err)
|
||||
}
|
||||
if req.AsPrompt != tc.want.AsPrompt {
|
||||
t.Errorf("AsPrompt = %v, want %v", req.AsPrompt, tc.want.AsPrompt)
|
||||
}
|
||||
if req.NewThread != tc.want.NewThread {
|
||||
t.Errorf("NewThread = %v, want %v", req.NewThread, tc.want.NewThread)
|
||||
}
|
||||
if req.Message != tc.want.Message {
|
||||
t.Errorf("Message = %q, want %q", req.Message, tc.want.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
34
core/api.go
34
core/api.go
@@ -49,6 +49,15 @@ type SendRequest struct {
|
||||
Videos []FileAttachment `json:"videos,omitempty"`
|
||||
AtUsers []string `json:"at_users,omitempty"`
|
||||
AtAll bool `json:"at_all,omitempty"`
|
||||
// AsPrompt, when true, injects the message into the running agent session
|
||||
// as a user prompt instead of posting to the platform. Combined with
|
||||
// NewThread, it posts to a new top-level thread AND injects as a prompt
|
||||
// (see issue #590).
|
||||
AsPrompt bool `json:"as_prompt,omitempty"`
|
||||
// NewThread, when true, posts the message as a new top-level message
|
||||
// rather than replying in the existing thread. Only effective when the
|
||||
// underlying platform supports top-level posting (Send).
|
||||
NewThread bool `json:"new_thread,omitempty"`
|
||||
}
|
||||
|
||||
// NewAPIServer creates an API server on a Unix socket.
|
||||
@@ -198,12 +207,29 @@ func (s *APIServer) handleSend(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Message != "" || len(req.Images) > 0 || len(req.Files) > 0 {
|
||||
if err := engine.SendToSessionWithAttachments(req.SessionKey, req.Message, req.Images, req.Files, req.AtUsers, req.AtAll); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
// Dispatch based on AsPrompt / NewThread combination. When both are
|
||||
// false, fall through to the legacy SendToSessionWithAttachments path
|
||||
// so the AtUsers / AtAll behavior is preserved exactly.
|
||||
var sendErr error
|
||||
switch {
|
||||
case req.AsPrompt && req.NewThread:
|
||||
// Post to platform as a new thread AND inject as a prompt. AtUsers
|
||||
// and attachments are ignored here — they are not meaningful in
|
||||
// the "programmatic prompt" path.
|
||||
sendErr = engine.InjectPromptToNewThread(req.SessionKey, req.Message)
|
||||
case req.AsPrompt:
|
||||
sendErr = engine.InjectPrompt(req.SessionKey, req.Message, req.Images, req.Files)
|
||||
case req.NewThread:
|
||||
sendErr = engine.PostToNewThread(req.SessionKey, req.Message)
|
||||
default:
|
||||
if req.Message != "" || len(req.Images) > 0 || len(req.Files) > 0 {
|
||||
sendErr = engine.SendToSessionWithAttachments(req.SessionKey, req.Message, req.Images, req.Files, req.AtUsers, req.AtAll)
|
||||
}
|
||||
}
|
||||
if sendErr != nil {
|
||||
http.Error(w, sendErr.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Audios) > 0 {
|
||||
if err := engine.SendAudiosToSession(req.SessionKey, req.Audios); err != nil {
|
||||
|
||||
177
core/engine.go
177
core/engine.go
@@ -10604,6 +10604,183 @@ func (e *Engine) resolveOutboundSessionTarget(sessionKey string, hasAttachments
|
||||
return state, p, replyCtx, nil
|
||||
}
|
||||
|
||||
// InjectPrompt injects a message directly into the agent's stdin as if the user
|
||||
// had sent it. The message is not posted to the platform; only the running
|
||||
// agent session sees it. This is useful for programmatic triggering of agent
|
||||
// actions (e.g. cron jobs, completion watchers) where the user-facing channel
|
||||
// already received the message via a different path.
|
||||
func (e *Engine) InjectPrompt(sessionKey, message string, images []ImageAttachment, files []FileAttachment) error {
|
||||
if message == "" && len(images) == 0 && len(files) == 0 {
|
||||
return fmt.Errorf("message or attachment is required")
|
||||
}
|
||||
state := e.lookupInteractiveState(sessionKey)
|
||||
if state == nil {
|
||||
return fmt.Errorf("no active session found (key=%q)", sessionKey)
|
||||
}
|
||||
state.mu.Lock()
|
||||
as := state.agentSession
|
||||
state.mu.Unlock()
|
||||
if as == nil || !as.Alive() {
|
||||
return fmt.Errorf("agent session not alive for InjectPrompt (key=%q)", sessionKey)
|
||||
}
|
||||
|
||||
prompt := message
|
||||
if e.injectSender {
|
||||
state.mu.Lock()
|
||||
p := state.platform
|
||||
sk := e.lookupSessionKeyForStateLocked(state)
|
||||
state.mu.Unlock()
|
||||
var platformName string
|
||||
if p != nil {
|
||||
platformName = p.Name()
|
||||
}
|
||||
prompt = e.buildSenderPrompt(message, "", "", platformName, sk, "")
|
||||
}
|
||||
|
||||
if err := as.Send(prompt, images, files); err != nil {
|
||||
return fmt.Errorf("inject prompt: %w", err)
|
||||
}
|
||||
slog.Info("inject prompt sent to agent", "session_key", sessionKey, "message_len", len(message))
|
||||
return nil
|
||||
}
|
||||
|
||||
// PostToNewThread posts a message to the platform as a new top-level message
|
||||
// (not as a reply in the existing thread). The agent is not invoked. This is
|
||||
// the half of completion-watcher flows that announces a new event without
|
||||
// spawning an inline conversation.
|
||||
func (e *Engine) PostToNewThread(sessionKey, message string) error {
|
||||
if message == "" {
|
||||
return fmt.Errorf("message is required")
|
||||
}
|
||||
p, replyCtx, err := e.resolvePlatformForSend(sessionKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := e.waitOutgoing(p); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.Send(e.ctx, replyCtx, message); err != nil {
|
||||
return fmt.Errorf("post to new thread: %w", err)
|
||||
}
|
||||
slog.Info("posted message to new thread", "session_key", sessionKey, "platform", p.Name())
|
||||
return nil
|
||||
}
|
||||
|
||||
// InjectPromptToNewThread combines InjectPrompt and PostToNewThread: the
|
||||
// message is posted to the platform as a new top-level message AND injected
|
||||
// into the agent session as a prompt. The intended use case is a
|
||||
// completion-watcher that needs both a visible notification and an
|
||||
// auto-processed task.
|
||||
func (e *Engine) InjectPromptToNewThread(sessionKey, message string) error {
|
||||
if err := e.PostToNewThread(sessionKey, message); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := e.InjectPrompt(sessionKey, message, nil, nil); err != nil {
|
||||
return fmt.Errorf("inject prompt to new thread: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// lookupInteractiveState returns the active state for a given session key,
|
||||
// applying the same multi-workspace and "single session" fallbacks as
|
||||
// SendToSessionWithAttachments so all three *Send helpers behave consistently.
|
||||
func (e *Engine) lookupInteractiveState(sessionKey string) *interactiveState {
|
||||
e.interactiveMu.Lock()
|
||||
defer e.interactiveMu.Unlock()
|
||||
|
||||
if sessionKey != "" {
|
||||
if s := e.interactiveStates[sessionKey]; s != nil {
|
||||
return s
|
||||
}
|
||||
if e.multiWorkspace {
|
||||
if iKey := e.interactiveKeyForSessionKeyLocked(sessionKey); iKey != sessionKey {
|
||||
if s := e.interactiveStates[iKey]; s != nil {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(e.interactiveStates) == 1 {
|
||||
for _, s := range e.interactiveStates {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// lookupSessionKeyForStateLocked returns the session key for the given state
|
||||
// by scanning interactiveStates. Caller must hold e.interactiveMu OR the
|
||||
// state.mu for the duration; here we rely on the caller holding state.mu and
|
||||
// the interactive map being effectively immutable for the duration of a turn.
|
||||
func (e *Engine) lookupSessionKeyForStateLocked(state *interactiveState) string {
|
||||
if state == nil {
|
||||
return ""
|
||||
}
|
||||
e.interactiveMu.Lock()
|
||||
defer e.interactiveMu.Unlock()
|
||||
for k, s := range e.interactiveStates {
|
||||
if s == state {
|
||||
return k
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolvePlatformForSend resolves a Platform and replyCtx for a given session
|
||||
// key, applying the same fallbacks (single-session, multi-workspace prefix,
|
||||
// ReplyContextReconstructor) as SendToSessionWithAttachments. Returns
|
||||
// ErrNoActiveSession-style errors when no platform can be reached.
|
||||
func (e *Engine) resolvePlatformForSend(sessionKey string) (Platform, any, error) {
|
||||
state := e.lookupInteractiveState(sessionKey)
|
||||
if state != nil {
|
||||
state.mu.Lock()
|
||||
p := state.platform
|
||||
replyCtx := state.replyCtx
|
||||
state.mu.Unlock()
|
||||
if p != nil {
|
||||
return p, replyCtx, nil
|
||||
}
|
||||
}
|
||||
if sessionKey == "" {
|
||||
return nil, nil, fmt.Errorf("no active session found (key=%q)", sessionKey)
|
||||
}
|
||||
strippedKey := sessionKey
|
||||
platformName := ""
|
||||
if idx := strings.Index(strippedKey, ":"); idx > 0 {
|
||||
platformName = strippedKey[:idx]
|
||||
}
|
||||
var targetPlatform Platform
|
||||
for _, candidate := range e.platforms {
|
||||
if candidate.Name() == platformName {
|
||||
targetPlatform = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if targetPlatform == nil {
|
||||
for _, candidate := range e.platforms {
|
||||
needle := ":" + candidate.Name() + ":"
|
||||
if idx := strings.Index(strippedKey, needle); idx >= 0 {
|
||||
targetPlatform = candidate
|
||||
strippedKey = strippedKey[idx+1:]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if targetPlatform == nil {
|
||||
return nil, nil, fmt.Errorf("no active session found (key=%q)", sessionKey)
|
||||
}
|
||||
rc, ok := targetPlatform.(ReplyContextReconstructor)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("platform %q does not support proactive messaging", targetPlatform.Name())
|
||||
}
|
||||
reconstructed, err := rc.ReconstructReplyCtx(strippedKey)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("reconstruct reply context: %w", err)
|
||||
}
|
||||
return targetPlatform, reconstructed, nil
|
||||
}
|
||||
|
||||
// sendPermissionPrompt sends a permission prompt with interactive buttons when
|
||||
// the platform supports them. Fallback chain: InlineButtonSender → CardSender → plain text.
|
||||
func (e *Engine) sendPermissionPrompt(p Platform, replyCtx any, prompt, toolName, toolInput string) {
|
||||
|
||||
313
core/send_modes_test.go
Normal file
313
core/send_modes_test.go
Normal file
@@ -0,0 +1,313 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── Recording helpers ──────────────────────────────────────────
|
||||
|
||||
type recordingAgentSessionSend struct {
|
||||
stubAgentSession
|
||||
mu sync.Mutex
|
||||
prompts []string
|
||||
images [][]ImageAttachment
|
||||
files [][]FileAttachment
|
||||
aliveVal bool
|
||||
}
|
||||
|
||||
func (s *recordingAgentSessionSend) Send(prompt string, images []ImageAttachment, files []FileAttachment) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.prompts = append(s.prompts, prompt)
|
||||
imgs := append([]ImageAttachment(nil), images...)
|
||||
fls := append([]FileAttachment(nil), files...)
|
||||
s.images = append(s.images, imgs)
|
||||
s.files = append(s.files, fls)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *recordingAgentSessionSend) Alive() bool { return s.aliveVal }
|
||||
|
||||
func (s *recordingAgentSessionSend) promptCount() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return len(s.prompts)
|
||||
}
|
||||
|
||||
func (s *recordingAgentSessionSend) lastPrompt() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.prompts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return s.prompts[len(s.prompts)-1]
|
||||
}
|
||||
|
||||
type sendTrackingPlatform struct {
|
||||
stubPlatformEngine
|
||||
mu sync.Mutex
|
||||
sendCalls []string
|
||||
replyCalls []string
|
||||
}
|
||||
|
||||
func (p *sendTrackingPlatform) Send(ctx context.Context, replyCtx any, content string) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.sendCalls = append(p.sendCalls, content)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *sendTrackingPlatform) Reply(ctx context.Context, replyCtx any, content string) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.replyCalls = append(p.replyCalls, content)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *sendTrackingPlatform) sendCount() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return len(p.sendCalls)
|
||||
}
|
||||
|
||||
func (p *sendTrackingPlatform) replyCount() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return len(p.replyCalls)
|
||||
}
|
||||
|
||||
// sendAgent wraps a single AgentSession returned by StartSession.
|
||||
type sendAgent struct {
|
||||
session AgentSession
|
||||
}
|
||||
|
||||
func (a *sendAgent) Name() string { return "send-agent" }
|
||||
func (a *sendAgent) StartSession(_ context.Context, _ string) (AgentSession, error) {
|
||||
return a.session, nil
|
||||
}
|
||||
func (a *sendAgent) ListSessions(_ context.Context) ([]AgentSessionInfo, error) { return nil, nil }
|
||||
func (a *sendAgent) Stop() error { return nil }
|
||||
|
||||
func newSendEngine(t *testing.T, sess AgentSession, p Platform) *Engine {
|
||||
t.Helper()
|
||||
e := NewEngine("test", &sendAgent{session: sess}, []Platform{p}, "", LangEnglish)
|
||||
e.interactiveStates["test:session-1"] = &interactiveState{
|
||||
agentSession: sess,
|
||||
platform: p,
|
||||
replyCtx: "rctx-1",
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func postSend(t *testing.T, api *APIServer, body SendRequest) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/send", bytes.NewReader(raw))
|
||||
rec := httptest.NewRecorder()
|
||||
api.handleSend(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// ── Engine-level tests ─────────────────────────────────────────
|
||||
|
||||
func TestEngineInjectPrompt_DeliversToAgentSession(t *testing.T) {
|
||||
sess := &recordingAgentSessionSend{aliveVal: true}
|
||||
plat := &sendTrackingPlatform{stubPlatformEngine: stubPlatformEngine{n: "test"}}
|
||||
e := newSendEngine(t, sess, plat)
|
||||
|
||||
if err := e.InjectPrompt("test:session-1", "hello agent", nil, nil); err != nil {
|
||||
t.Fatalf("InjectPrompt: %v", err)
|
||||
}
|
||||
if sess.promptCount() != 1 {
|
||||
t.Fatalf("agent session Send call count = %d, want 1", sess.promptCount())
|
||||
}
|
||||
if got := sess.lastPrompt(); got != "hello agent" {
|
||||
t.Errorf("prompt = %q, want %q", got, "hello agent")
|
||||
}
|
||||
// Platform should NOT receive the message — that's the whole point of
|
||||
// the as-prompt path.
|
||||
if plat.sendCount() != 0 {
|
||||
t.Errorf("platform Send called %d times, want 0", plat.sendCount())
|
||||
}
|
||||
if plat.replyCount() != 0 {
|
||||
t.Errorf("platform Reply called %d times, want 0", plat.replyCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineInjectPrompt_NoSession(t *testing.T) {
|
||||
sess := &recordingAgentSessionSend{aliveVal: true}
|
||||
plat := &sendTrackingPlatform{stubPlatformEngine: stubPlatformEngine{n: "test"}}
|
||||
e := newSendEngine(t, sess, plat)
|
||||
|
||||
if err := e.InjectPrompt("test:nonexistent", "hello", nil, nil); err == nil {
|
||||
t.Fatal("expected error for missing session, got nil")
|
||||
}
|
||||
if sess.promptCount() != 0 {
|
||||
t.Errorf("agent session should not have been called, got %d", sess.promptCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineInjectPrompt_DeadSession(t *testing.T) {
|
||||
sess := &recordingAgentSessionSend{aliveVal: false}
|
||||
plat := &sendTrackingPlatform{stubPlatformEngine: stubPlatformEngine{n: "test"}}
|
||||
e := newSendEngine(t, sess, plat)
|
||||
|
||||
if err := e.InjectPrompt("test:session-1", "hello", nil, nil); err == nil {
|
||||
t.Fatal("expected error for dead session, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnginePostToNewThread_CallsPlatformSend(t *testing.T) {
|
||||
sess := &recordingAgentSessionSend{aliveVal: true}
|
||||
plat := &sendTrackingPlatform{stubPlatformEngine: stubPlatformEngine{n: "test"}}
|
||||
e := newSendEngine(t, sess, plat)
|
||||
|
||||
if err := e.PostToNewThread("test:session-1", "build completed"); err != nil {
|
||||
t.Fatalf("PostToNewThread: %v", err)
|
||||
}
|
||||
if plat.sendCount() != 1 {
|
||||
t.Errorf("platform Send call count = %d, want 1", plat.sendCount())
|
||||
}
|
||||
if plat.replyCount() != 0 {
|
||||
t.Errorf("platform Reply should NOT be called (would go in thread), got %d", plat.replyCount())
|
||||
}
|
||||
// Agent session should not receive the message — only the platform sees it.
|
||||
if sess.promptCount() != 0 {
|
||||
t.Errorf("agent session should not have been called, got %d", sess.promptCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineInjectPromptToNewThread_BothPathsFire(t *testing.T) {
|
||||
sess := &recordingAgentSessionSend{aliveVal: true}
|
||||
plat := &sendTrackingPlatform{stubPlatformEngine: stubPlatformEngine{n: "test"}}
|
||||
e := newSendEngine(t, sess, plat)
|
||||
|
||||
if err := e.InjectPromptToNewThread("test:session-1", "watcher tick"); err != nil {
|
||||
t.Fatalf("InjectPromptToNewThread: %v", err)
|
||||
}
|
||||
if plat.sendCount() != 1 {
|
||||
t.Errorf("platform Send call count = %d, want 1", plat.sendCount())
|
||||
}
|
||||
if sess.promptCount() != 1 {
|
||||
t.Errorf("agent session Send call count = %d, want 1", sess.promptCount())
|
||||
}
|
||||
if got := sess.lastPrompt(); got != "watcher tick" {
|
||||
t.Errorf("agent prompt = %q, want %q", got, "watcher tick")
|
||||
}
|
||||
}
|
||||
|
||||
// ── API-level dispatch tests (issue #590 regression) ──────────
|
||||
|
||||
func TestHandleSend_DispatchesAsPrompt(t *testing.T) {
|
||||
sess := &recordingAgentSessionSend{aliveVal: true}
|
||||
plat := &sendTrackingPlatform{stubPlatformEngine: stubPlatformEngine{n: "test"}}
|
||||
e := newSendEngine(t, sess, plat)
|
||||
api := &APIServer{engines: map[string]*Engine{"test": e}}
|
||||
|
||||
rec := postSend(t, api, SendRequest{
|
||||
Project: "test",
|
||||
SessionKey: "test:session-1",
|
||||
Message: "investigate build",
|
||||
AsPrompt: true,
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if sess.promptCount() != 1 {
|
||||
t.Errorf("agent Send count = %d, want 1", sess.promptCount())
|
||||
}
|
||||
if plat.sendCount() != 0 {
|
||||
t.Errorf("platform Send count = %d, want 0 (as-prompt should NOT post to platform)", plat.sendCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSend_DispatchesNewThread(t *testing.T) {
|
||||
sess := &recordingAgentSessionSend{aliveVal: true}
|
||||
plat := &sendTrackingPlatform{stubPlatformEngine: stubPlatformEngine{n: "test"}}
|
||||
e := newSendEngine(t, sess, plat)
|
||||
api := &APIServer{engines: map[string]*Engine{"test": e}}
|
||||
|
||||
rec := postSend(t, api, SendRequest{
|
||||
Project: "test",
|
||||
SessionKey: "test:session-1",
|
||||
Message: "build completed",
|
||||
NewThread: true,
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if plat.sendCount() != 1 {
|
||||
t.Errorf("platform Send count = %d, want 1", plat.sendCount())
|
||||
}
|
||||
if plat.replyCount() != 0 {
|
||||
t.Errorf("platform Reply count = %d, want 0 (new-thread uses Send, not Reply)", plat.replyCount())
|
||||
}
|
||||
if sess.promptCount() != 0 {
|
||||
t.Errorf("agent Send count = %d, want 0 (new-thread alone does NOT inject)", sess.promptCount())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleSend_DispatchesAsPromptAndNewThread is the regression for
|
||||
// issue #590: previously the handleSend returned early after the AsPrompt
|
||||
// branch, so --as-prompt --new-thread silently dropped the new-thread
|
||||
// behavior. With the fix, BOTH branches must fire.
|
||||
func TestHandleSend_DispatchesAsPromptAndNewThread(t *testing.T) {
|
||||
sess := &recordingAgentSessionSend{aliveVal: true}
|
||||
plat := &sendTrackingPlatform{stubPlatformEngine: stubPlatformEngine{n: "test"}}
|
||||
e := newSendEngine(t, sess, plat)
|
||||
api := &APIServer{engines: map[string]*Engine{"test": e}}
|
||||
|
||||
rec := postSend(t, api, SendRequest{
|
||||
Project: "test",
|
||||
SessionKey: "test:session-1",
|
||||
Message: "completion event",
|
||||
AsPrompt: true,
|
||||
NewThread: true,
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// BOTH must happen — the whole point of the combined mode.
|
||||
if plat.sendCount() != 1 {
|
||||
t.Errorf("platform Send count = %d, want 1 (new-thread)", plat.sendCount())
|
||||
}
|
||||
if sess.promptCount() != 1 {
|
||||
t.Errorf("agent Send count = %d, want 1 (as-prompt)", sess.promptCount())
|
||||
}
|
||||
if got := sess.lastPrompt(); got != "completion event" {
|
||||
t.Errorf("agent prompt = %q, want %q", got, "completion event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSend_NoFlagsFallsThroughToLegacy(t *testing.T) {
|
||||
sess := &recordingAgentSessionSend{aliveVal: true}
|
||||
plat := &sendTrackingPlatform{stubPlatformEngine: stubPlatformEngine{n: "test"}}
|
||||
e := newSendEngine(t, sess, plat)
|
||||
api := &APIServer{engines: map[string]*Engine{"test": e}}
|
||||
|
||||
rec := postSend(t, api, SendRequest{
|
||||
Project: "test",
|
||||
SessionKey: "test:session-1",
|
||||
Message: "plain message",
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// Default path uses platform Send (legacy behavior).
|
||||
if plat.sendCount() != 1 {
|
||||
t.Errorf("platform Send count = %d, want 1 (legacy)", plat.sendCount())
|
||||
}
|
||||
// No prompt injection.
|
||||
if sess.promptCount() != 0 {
|
||||
t.Errorf("agent Send count = %d, want 0 (no flags means no injection)", sess.promptCount())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user