652 lines
18 KiB
Go
652 lines
18 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"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
|
)
|
|
|
|
const codexRobotDeveloperInstructions = "你是钉钉群聊里的智能助手,请用简洁、自然的中文直接回答用户问题;不要提及系统提示、内部协议或运行时细节;不要主动读写文件或执行命令。仅当用户消息明确附带了本地附件路径时,可以只读该附件或运行分析该附件所必需的只读命令,不得访问其它文件。"
|
|
|
|
var (
|
|
codexAbsPath = filepath.Abs
|
|
codexExecCommandContext = exec.CommandContext
|
|
codexNewAppServerClient = newCodexAppServerClient
|
|
)
|
|
|
|
// codexAppServerForwarder uses Codex's official app-server JSON-RPC protocol to
|
|
// keep one Codex thread per DingTalk conversation.
|
|
type codexAppServerForwarder struct {
|
|
bin string
|
|
env []string
|
|
timeout time.Duration
|
|
workDir string
|
|
model string
|
|
yolo bool
|
|
sessions *codexThreadSessions
|
|
}
|
|
|
|
func newCodexAppServerForwarder(bin string, env []string, timeout time.Duration, opts connectAgentOptions, clientID string) forwarder {
|
|
var sessions *codexThreadSessions
|
|
if opts.Memory {
|
|
// Scope the on-disk thread store by clientId so multiple bots on one
|
|
// machine stay isolated; an empty clientId disables persistence and the
|
|
// map stays in memory (original behaviour).
|
|
sessions = newCodexThreadSessions(codexThreadStorePath(clientID))
|
|
}
|
|
return &codexAppServerForwarder{
|
|
bin: bin,
|
|
env: env,
|
|
timeout: timeout,
|
|
workDir: opts.WorkDir,
|
|
model: opts.Model,
|
|
yolo: opts.Yolo,
|
|
sessions: sessions,
|
|
}
|
|
}
|
|
|
|
// codexThreadStorePath returns the on-disk location for a robot's codex
|
|
// conversation→thread map, scoped by clientId so multiple bots on one machine
|
|
// stay isolated: <config dir>/connect/<clientId>/codex-threads.json. It mirrors
|
|
// the Claude-family connectSessionStorePath layout but uses a distinct filename
|
|
// so the two stores never collide. An empty clientId means "do not persist"
|
|
// (in-memory only). The clientId is sanitized with the same rule as the connect
|
|
// lock file so it is always filesystem-safe.
|
|
func codexThreadStorePath(clientID string) string {
|
|
if clientID == "" {
|
|
return ""
|
|
}
|
|
return filepath.Join(config.DefaultConfigDir(), "connect", sanitizeLockID(clientID), "codex-threads.json")
|
|
}
|
|
|
|
// resetSession drops the conversation's Codex thread so the next message starts
|
|
// a fresh one. Implements sessionResetter for the built-in /new and /clear
|
|
// commands. A no-op when per-conversation memory is disabled.
|
|
func (f *codexAppServerForwarder) resetSession(convID string) {
|
|
if f.sessions != nil {
|
|
f.sessions.reset(convID)
|
|
}
|
|
}
|
|
|
|
func (f *codexAppServerForwarder) canStream() bool { return true }
|
|
|
|
func (f *codexAppServerForwarder) label() string {
|
|
memo := "stateless"
|
|
if f.sessions != nil {
|
|
memo = "thread-memory"
|
|
}
|
|
return fmt.Sprintf("codex-app-server:%s (%s)", f.bin, memo)
|
|
}
|
|
|
|
func (f *codexAppServerForwarder) forward(ctx context.Context, convID, text string) (string, error) {
|
|
return f.forwardStream(ctx, convID, text, nil)
|
|
}
|
|
|
|
func (f *codexAppServerForwarder) forwardWithAttachments(ctx context.Context, convID, text string, attachments []connectMediaAttachment) (string, error) {
|
|
return f.forwardStreamWithAttachments(ctx, convID, text, attachments, nil)
|
|
}
|
|
|
|
func (f *codexAppServerForwarder) forwardStream(ctx context.Context, convID, text string, onDelta func(string)) (string, error) {
|
|
return f.forwardStreamWithAttachments(ctx, convID, text, nil, onDelta)
|
|
}
|
|
|
|
func (f *codexAppServerForwarder) forwardStreamWithAttachments(ctx context.Context, convID, text string, attachments []connectMediaAttachment, onDelta func(string)) (string, error) {
|
|
return f.forwardAppServer(ctx, convID, text, attachments, onDelta)
|
|
}
|
|
|
|
func (f *codexAppServerForwarder) forwardAppServer(ctx context.Context, convID, text string, attachments []connectMediaAttachment, onDelta func(string)) (string, error) {
|
|
ctx, cancel := applyTimeout(ctx, f.timeout)
|
|
defer cancel()
|
|
|
|
var state *codexThreadState
|
|
if f.sessions != nil {
|
|
state = f.sessions.state(convID)
|
|
state.mu.Lock()
|
|
defer state.mu.Unlock()
|
|
}
|
|
|
|
cli, err := codexNewAppServerClient(ctx, f.bin, f.env, f.cwd())
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer cli.close()
|
|
|
|
if err := cli.initialize(ctx); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
_ = state // held only for its per-conversation turn lock (see above)
|
|
threadID := ""
|
|
if f.sessions != nil {
|
|
threadID = f.sessions.threadID(convID)
|
|
}
|
|
if threadID != "" {
|
|
resumed, err := cli.resumeThread(ctx, f.threadParams(threadID))
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "[connect][codex] resume thread %s 失败,重建会话: %v\n", threadID, err)
|
|
threadID = ""
|
|
f.sessions.setThreadID(convID, "")
|
|
} else {
|
|
threadID = resumed
|
|
}
|
|
}
|
|
if threadID == "" {
|
|
started, err := cli.startThread(ctx, f.threadParams(""))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
threadID = started
|
|
if f.sessions != nil {
|
|
f.sessions.setThreadID(convID, threadID)
|
|
}
|
|
}
|
|
|
|
reply, err := cli.runTurn(ctx, threadID, text, attachments, onDelta)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return reply, nil
|
|
}
|
|
|
|
func (f *codexAppServerForwarder) cwd() string {
|
|
if f.workDir == "" {
|
|
return connectWorkDir()
|
|
}
|
|
if abs, err := codexAbsPath(f.workDir); err == nil {
|
|
return abs
|
|
}
|
|
return f.workDir
|
|
}
|
|
|
|
func (f *codexAppServerForwarder) threadParams(threadID string) map[string]any {
|
|
sandbox := "read-only"
|
|
if f.yolo {
|
|
sandbox = "workspace-write"
|
|
}
|
|
params := map[string]any{
|
|
"approvalPolicy": "never",
|
|
"cwd": f.cwd(),
|
|
"developerInstructions": codexRobotDeveloperInstructions,
|
|
"sandbox": sandbox,
|
|
}
|
|
if f.model != "" {
|
|
params["model"] = f.model
|
|
}
|
|
if threadID != "" {
|
|
params["threadId"] = threadID
|
|
}
|
|
return params
|
|
}
|
|
|
|
// codexThreadSessions maps a DingTalk conversation to its Codex thread so
|
|
// multi-turn context survives within a conversation. The convID→threadID map
|
|
// (threads, guarded by mu) is the authoritative store and is persisted to disk
|
|
// so the context also survives a connector restart — the codex equivalent of
|
|
// the Claude-family convSessions store. An empty path keeps it in memory only
|
|
// (persistence disabled), preserving the original behaviour exactly.
|
|
//
|
|
// states holds a per-conversation lock that serializes turns within one
|
|
// conversation; it carries no thread identity of its own.
|
|
type codexThreadSessions struct {
|
|
mu sync.Mutex
|
|
states map[string]*codexThreadState // per-conversation turn lock
|
|
threads map[string]string // convID→threadID, persisted
|
|
path string // on-disk store; empty disables persistence
|
|
}
|
|
|
|
// codexThreadState is a per-conversation turn lock. Holding it for the duration
|
|
// of a turn keeps two messages in the same conversation from interleaving their
|
|
// app-server calls; thread identity lives in codexThreadSessions.threads.
|
|
type codexThreadState struct {
|
|
mu sync.Mutex
|
|
}
|
|
|
|
// newCodexThreadSessions builds the session map, restoring any persisted
|
|
// convID→threadID entries from path. A missing or corrupt file degrades to an
|
|
// empty map (see loadConvSessionMap) — it never panics or blocks startup. An
|
|
// empty path means in-memory only.
|
|
func newCodexThreadSessions(path string) *codexThreadSessions {
|
|
return &codexThreadSessions{
|
|
states: make(map[string]*codexThreadState),
|
|
threads: loadConvSessionMap(path),
|
|
path: path,
|
|
}
|
|
}
|
|
|
|
// codexConvKey normalizes a conversation ID into a stable, non-empty map key.
|
|
func codexConvKey(convID string) string {
|
|
key := strings.TrimSpace(convID)
|
|
if key == "" {
|
|
key = "_default"
|
|
}
|
|
return key
|
|
}
|
|
|
|
// state returns the per-conversation turn lock, minting one on first sight.
|
|
func (s *codexThreadSessions) state(convID string) *codexThreadState {
|
|
key := codexConvKey(convID)
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if st, ok := s.states[key]; ok {
|
|
return st
|
|
}
|
|
st := &codexThreadState{}
|
|
s.states[key] = st
|
|
return st
|
|
}
|
|
|
|
// threadID returns the Codex thread bound to a conversation, or "" if none.
|
|
func (s *codexThreadSessions) threadID(convID string) string {
|
|
key := codexConvKey(convID)
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.threads[key]
|
|
}
|
|
|
|
// setThreadID binds a conversation to a Codex thread and persists the snapshot.
|
|
// A "" threadID forgets the binding (same as reset). Persistence is best-effort:
|
|
// a failed save only logs a warning and never blocks message handling.
|
|
func (s *codexThreadSessions) setThreadID(convID, threadID string) {
|
|
key := codexConvKey(convID)
|
|
s.mu.Lock()
|
|
if threadID == "" {
|
|
delete(s.threads, key)
|
|
} else {
|
|
s.threads[key] = threadID
|
|
}
|
|
snapshot := make(map[string]string, len(s.threads))
|
|
for k, v := range s.threads {
|
|
snapshot[k] = v
|
|
}
|
|
path := s.path
|
|
s.mu.Unlock()
|
|
saveConvSessionMap(path, snapshot)
|
|
}
|
|
|
|
// reset forgets a conversation's thread so the next message starts a fresh one.
|
|
// The removal is persisted so a restart does not resurrect the dropped thread.
|
|
func (s *codexThreadSessions) reset(convID string) {
|
|
s.setThreadID(convID, "")
|
|
}
|
|
|
|
type codexAppServerClient struct {
|
|
cmd *exec.Cmd
|
|
stdin io.WriteCloser
|
|
msgs chan codexRPCMessage
|
|
readErr chan error
|
|
done chan struct{}
|
|
closeOnce sync.Once
|
|
stderr *lockedBuffer
|
|
nextID int
|
|
}
|
|
|
|
type lockedBuffer struct {
|
|
mu sync.Mutex
|
|
buf bytes.Buffer
|
|
}
|
|
|
|
func (b *lockedBuffer) Write(p []byte) (int, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.buf.Write(p)
|
|
}
|
|
|
|
func (b *lockedBuffer) String() string {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.buf.String()
|
|
}
|
|
|
|
type codexRPCMessage struct {
|
|
ID *int `json:"id,omitempty"`
|
|
Method string `json:"method,omitempty"`
|
|
Params json.RawMessage `json:"params,omitempty"`
|
|
Result json.RawMessage `json:"result,omitempty"`
|
|
Error *codexRPCError `json:"error,omitempty"`
|
|
}
|
|
|
|
type codexRPCError struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
func newCodexAppServerClient(ctx context.Context, bin string, env []string, cwd string) (*codexAppServerClient, error) {
|
|
cmd := codexExecCommandContext(ctx, bin, "app-server", "--stdio")
|
|
cmd.Dir = cwd
|
|
if len(env) > 0 {
|
|
cmd.Env = append(os.Environ(), env...)
|
|
}
|
|
stdin, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stderr := &lockedBuffer{}
|
|
cmd.Stderr = stderr
|
|
if err := cmd.Start(); err != nil {
|
|
return nil, err
|
|
}
|
|
cli := &codexAppServerClient{
|
|
cmd: cmd,
|
|
stdin: stdin,
|
|
msgs: make(chan codexRPCMessage, 64),
|
|
readErr: make(chan error, 1),
|
|
done: make(chan struct{}),
|
|
stderr: stderr,
|
|
nextID: 1,
|
|
}
|
|
go cli.readLoop(stdout)
|
|
return cli, nil
|
|
}
|
|
|
|
func (c *codexAppServerClient) readLoop(stdout io.Reader) {
|
|
scanner := bufio.NewScanner(stdout)
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
var msg codexRPCMessage
|
|
if err := json.Unmarshal([]byte(line), &msg); err != nil {
|
|
c.reportReadErr(fmt.Errorf("parse app-server JSONL: %w", err))
|
|
close(c.msgs)
|
|
return
|
|
}
|
|
select {
|
|
case c.msgs <- msg:
|
|
case <-c.done:
|
|
return
|
|
}
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
c.reportReadErr(err)
|
|
} else {
|
|
c.reportReadErr(io.EOF)
|
|
}
|
|
close(c.msgs)
|
|
}
|
|
|
|
func (c *codexAppServerClient) close() {
|
|
c.closeOnce.Do(func() {
|
|
close(c.done)
|
|
_ = c.stdin.Close()
|
|
if c.cmd.Process != nil {
|
|
_ = c.cmd.Process.Kill()
|
|
}
|
|
_ = c.cmd.Wait()
|
|
})
|
|
}
|
|
|
|
func (c *codexAppServerClient) reportReadErr(err error) {
|
|
select {
|
|
case c.readErr <- err:
|
|
case <-c.done:
|
|
}
|
|
}
|
|
|
|
func (c *codexAppServerClient) initialize(ctx context.Context) error {
|
|
id := c.requestID()
|
|
if err := c.send(map[string]any{
|
|
"id": id,
|
|
"method": "initialize",
|
|
"params": map[string]any{
|
|
"capabilities": map[string]any{"experimentalApi": true},
|
|
"clientInfo": map[string]any{
|
|
"name": "dws-devapp-robot-connect",
|
|
"title": "DWS DevApp Robot Connect",
|
|
"version": "0.1.0",
|
|
},
|
|
},
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if _, err := c.waitResponse(ctx, id); err != nil {
|
|
return err
|
|
}
|
|
return c.send(map[string]any{"method": "initialized", "params": map[string]any{}})
|
|
}
|
|
|
|
func (c *codexAppServerClient) startThread(ctx context.Context, params map[string]any) (string, error) {
|
|
id := c.requestID()
|
|
if err := c.send(map[string]any{"id": id, "method": "thread/start", "params": params}); err != nil {
|
|
return "", err
|
|
}
|
|
return codexThreadIDFromResult(c.waitResponse(ctx, id))
|
|
}
|
|
|
|
func (c *codexAppServerClient) resumeThread(ctx context.Context, params map[string]any) (string, error) {
|
|
id := c.requestID()
|
|
if err := c.send(map[string]any{"id": id, "method": "thread/resume", "params": params}); err != nil {
|
|
return "", err
|
|
}
|
|
return codexThreadIDFromResult(c.waitResponse(ctx, id))
|
|
}
|
|
|
|
func (c *codexAppServerClient) runTurn(ctx context.Context, threadID, text string, attachments []connectMediaAttachment, onDelta func(string)) (string, error) {
|
|
id := c.requestID()
|
|
input := []map[string]string{{"type": "text", "text": text}}
|
|
for _, attachment := range attachments {
|
|
if attachment.MediaType == "image" && strings.TrimSpace(attachment.LocalPath) != "" {
|
|
input = append(input, map[string]string{"type": "localImage", "path": attachment.LocalPath})
|
|
}
|
|
}
|
|
if err := c.send(map[string]any{
|
|
"id": id,
|
|
"method": "turn/start",
|
|
"params": map[string]any{
|
|
"input": input,
|
|
"threadId": threadID,
|
|
},
|
|
}); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var acc strings.Builder
|
|
for {
|
|
msg, err := c.next(ctx)
|
|
if err != nil {
|
|
return "", c.withStderr("app-server stream ended before turn/completed", err)
|
|
}
|
|
if msg.ID != nil && msg.Method != "" {
|
|
c.rejectServerRequest(*msg.ID, msg.Method)
|
|
continue
|
|
}
|
|
if msg.ID != nil && *msg.ID == id && msg.Error != nil {
|
|
return "", fmt.Errorf("turn/start: %s", msg.Error.Message)
|
|
}
|
|
switch msg.Method {
|
|
case "item/agentMessage/delta":
|
|
var p struct {
|
|
Delta string `json:"delta"`
|
|
ThreadID string `json:"threadId"`
|
|
}
|
|
if json.Unmarshal(msg.Params, &p) == nil && p.ThreadID == threadID && p.Delta != "" {
|
|
acc.WriteString(p.Delta)
|
|
if onDelta != nil {
|
|
onDelta(acc.String())
|
|
}
|
|
}
|
|
case "turn/completed":
|
|
final, status, errMsg, ok := codexTurnCompletedText(msg.Params, threadID)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if status == "failed" {
|
|
if errMsg == "" {
|
|
errMsg = "turn failed"
|
|
}
|
|
return "", fmt.Errorf("%s", errMsg)
|
|
}
|
|
if final == "" {
|
|
final = strings.TrimSpace(acc.String())
|
|
}
|
|
if final == "" {
|
|
return "", fmt.Errorf("turn completed without agent message")
|
|
}
|
|
return final, nil
|
|
case "error":
|
|
return "", fmt.Errorf("app-server error notification: %s", truncateRunes(string(msg.Params), 300))
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *codexAppServerClient) requestID() int {
|
|
id := c.nextID
|
|
c.nextID++
|
|
return id
|
|
}
|
|
|
|
func (c *codexAppServerClient) send(v any) error {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = fmt.Fprintln(c.stdin, string(b))
|
|
return err
|
|
}
|
|
|
|
func (c *codexAppServerClient) waitResponse(ctx context.Context, id int) (json.RawMessage, error) {
|
|
for {
|
|
msg, err := c.next(ctx)
|
|
if err != nil {
|
|
return nil, c.withStderr("app-server exited before response", err)
|
|
}
|
|
if msg.ID != nil && msg.Method != "" {
|
|
c.rejectServerRequest(*msg.ID, msg.Method)
|
|
continue
|
|
}
|
|
if msg.ID == nil || *msg.ID != id {
|
|
continue
|
|
}
|
|
if msg.Error != nil {
|
|
return nil, fmt.Errorf("%s", msg.Error.Message)
|
|
}
|
|
return msg.Result, nil
|
|
}
|
|
}
|
|
|
|
func (c *codexAppServerClient) next(ctx context.Context) (codexRPCMessage, error) {
|
|
// The read loop reports EOF before closing msgs, so when the process
|
|
// exits right after its last frame both channels are ready and select
|
|
// would pick one at random — drain buffered frames (e.g. the final
|
|
// turn/completed) before honoring a read error.
|
|
select {
|
|
case msg, ok := <-c.msgs:
|
|
if !ok {
|
|
return codexRPCMessage{}, io.EOF
|
|
}
|
|
return msg, nil
|
|
default:
|
|
}
|
|
select {
|
|
case msg, ok := <-c.msgs:
|
|
if !ok {
|
|
return codexRPCMessage{}, io.EOF
|
|
}
|
|
return msg, nil
|
|
case err := <-c.readErr:
|
|
return codexRPCMessage{}, err
|
|
case <-c.done:
|
|
return codexRPCMessage{}, io.EOF
|
|
case <-ctx.Done():
|
|
return codexRPCMessage{}, ctx.Err()
|
|
}
|
|
}
|
|
|
|
func (c *codexAppServerClient) rejectServerRequest(id int, method string) {
|
|
_ = c.send(map[string]any{
|
|
"id": id,
|
|
"error": map[string]any{
|
|
"code": -32000,
|
|
"message": "DWS robot connect does not support interactive Codex app-server request: " + method,
|
|
},
|
|
})
|
|
}
|
|
|
|
func (c *codexAppServerClient) withStderr(prefix string, err error) error {
|
|
stderr := strings.TrimSpace(c.stderr.String())
|
|
if stderr == "" {
|
|
return fmt.Errorf("%s: %w", prefix, err)
|
|
}
|
|
return fmt.Errorf("%s: %w: %s", prefix, err, truncateRunes(stderr, 300))
|
|
}
|
|
|
|
func codexThreadIDFromResult(result json.RawMessage, err error) (string, error) {
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var r struct {
|
|
Thread struct {
|
|
ID string `json:"id"`
|
|
} `json:"thread"`
|
|
}
|
|
if err := json.Unmarshal(result, &r); err != nil {
|
|
return "", err
|
|
}
|
|
if r.Thread.ID == "" {
|
|
return "", fmt.Errorf("app-server response missing thread.id")
|
|
}
|
|
return r.Thread.ID, nil
|
|
}
|
|
|
|
func codexTurnCompletedText(params json.RawMessage, wantThreadID string) (final, status, errMsg string, ok bool) {
|
|
var p struct {
|
|
ThreadID string `json:"threadId"`
|
|
Turn struct {
|
|
Status string `json:"status"`
|
|
Error *struct {
|
|
Message string `json:"message"`
|
|
AdditionalDetails string `json:"additionalDetails"`
|
|
} `json:"error"`
|
|
Items []struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
} `json:"items"`
|
|
} `json:"turn"`
|
|
}
|
|
if err := json.Unmarshal(params, &p); err != nil || p.ThreadID != wantThreadID {
|
|
return "", "", "", false
|
|
}
|
|
for i := len(p.Turn.Items) - 1; i >= 0; i-- {
|
|
if p.Turn.Items[i].Type == "agentMessage" && strings.TrimSpace(p.Turn.Items[i].Text) != "" {
|
|
final = strings.TrimSpace(p.Turn.Items[i].Text)
|
|
break
|
|
}
|
|
}
|
|
if p.Turn.Error != nil {
|
|
errMsg = strings.TrimSpace(p.Turn.Error.Message)
|
|
if p.Turn.Error.AdditionalDetails != "" {
|
|
errMsg = strings.TrimSpace(errMsg + ": " + p.Turn.Error.AdditionalDetails)
|
|
}
|
|
}
|
|
return final, p.Turn.Status, errMsg, true
|
|
}
|