Serve print, JSON, and RPC modes on AgentSession
Why: The library had no process entry, and the product backend still called the loop directly. Evidence: go test -count=1 ./... Impact: exec.Backend keeps the same request and result. Execute now goes through AgentSession. Extensions and packages are not a sandbox. Refs: feature/pi-headless N6 N7 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -319,7 +319,7 @@ func (b *Backend) run(ctx context.Context, model string, budget *runtime.Budget,
|
|||||||
if strings.TrimSpace(execID) == "" {
|
if strings.TrimSpace(execID) == "" {
|
||||||
execID = b.opts.ExecutionID
|
execID = b.opts.ExecutionID
|
||||||
}
|
}
|
||||||
runRes, err := runtime.Run(ctx, runtime.Config{
|
agent := runtime.NewAgentSession(runtime.Config{
|
||||||
Model: model,
|
Model: model,
|
||||||
SystemPrompt: b.systemPrompt(req, model, profile, workDir, skillIndex),
|
SystemPrompt: b.systemPrompt(req, model, profile, workDir, skillIndex),
|
||||||
Tools: reg,
|
Tools: reg,
|
||||||
@@ -351,8 +351,8 @@ func (b *Backend) run(ctx context.Context, model string, budget *runtime.Budget,
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}, llm.Message{Role: llm.RoleUser, Content: req.Prompt})
|
})
|
||||||
return runRes, err
|
return agent.Prompt(ctx, llm.Message{Role: llm.RoleUser, Content: req.Prompt})
|
||||||
}
|
}
|
||||||
|
|
||||||
// maybeCompact 在转录超过阈值时压缩头部历史。压缩是优化性质:
|
// maybeCompact 在转录超过阈值时压缩头部历史。压缩是优化性质:
|
||||||
|
|||||||
@@ -47,8 +47,11 @@ func TestBackendCompactBeforeRun(t *testing.T) {
|
|||||||
t.Fatal("compaction should have run before the loop")
|
t.Fatal("compaction should have run before the loop")
|
||||||
}
|
}
|
||||||
data, _ := os.ReadFile(transcript)
|
data, _ := os.ReadFile(transcript)
|
||||||
if !strings.Contains(string(data), "[conversation summary]") {
|
if !strings.Contains(string(data), "预置历史摘要") || !strings.Contains(string(data), `"type":"compaction"`) {
|
||||||
t.Fatalf("transcript should contain the summary marker, got:\n%.200s", data)
|
t.Fatalf("transcript should keep history and append a compaction entry, got:\n%.400s", data)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), `"role":"system"`) {
|
||||||
|
t.Fatal("compaction must not rewrite the original transcript")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package native
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"agent-native/rpc"
|
||||||
|
"agent-native/runtime"
|
||||||
|
"agent-native/runtime/llm"
|
||||||
|
"agent-native/runtime/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEventAndRPCFixtures(t *testing.T) {
|
||||||
|
var types []string
|
||||||
|
agent := runtime.NewAgentSession(runtime.Config{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: func(ctx context.Context, model string, messages []llm.Message, td []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||||
|
ch := make(chan llm.Event, 1)
|
||||||
|
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}}
|
||||||
|
close(ch)
|
||||||
|
return ch
|
||||||
|
},
|
||||||
|
OnEvent: func(ev runtime.Event) { types = append(types, ev.Type) },
|
||||||
|
})
|
||||||
|
if _, err := agent.Prompt(context.Background(), llm.Message{Role: llm.RoleUser, Content: "hi"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(types)
|
||||||
|
for _, want := range []string{"message_start", "message_end", "agent_settled"} {
|
||||||
|
if !contains(types, want) {
|
||||||
|
t.Fatalf("fixture missing %s in %s", want, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
store, err := session.Open(dir + "/s.jsonl")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
srv := &rpc.Server{Agent: agent, Store: store}
|
||||||
|
resp, err := srv.Handle(context.Background(), []byte(`{"type":"get_session_stats"}`))
|
||||||
|
if err != nil || !json.Valid(resp) {
|
||||||
|
t.Fatalf("rpc fixture: %s %v", resp, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPiCrossCompareOptional(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("pi"); err != nil {
|
||||||
|
t.Skip("pi is not installed")
|
||||||
|
}
|
||||||
|
if os.Getenv("PI_CROSS") != "1" {
|
||||||
|
t.Skip("set PI_CROSS=1 to compare a live pi process")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(list []string, want string) bool {
|
||||||
|
for _, item := range list {
|
||||||
|
if item == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"agent-native/rpc"
|
||||||
|
"agent-native/runtime"
|
||||||
|
"agent-native/runtime/llm"
|
||||||
|
"agent-native/runtime/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
mode := flag.String("mode", "print", "print, json, or rpc")
|
||||||
|
model := flag.String("model", "", "provider/model[:thinking]")
|
||||||
|
prompt := flag.String("prompt", "", "user message for print and json modes")
|
||||||
|
sessionPath := flag.String("session", "", "transcript path")
|
||||||
|
noSession := flag.Bool("no-session", false, "do not read or write a transcript")
|
||||||
|
export := flag.String("export", "", "write the active session as HTML and exit")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *sessionPath == "" && !*noSession {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
*sessionPath = filepath.Join(home, ".agent-native", "session.jsonl")
|
||||||
|
}
|
||||||
|
var store *session.Store
|
||||||
|
if !*noSession {
|
||||||
|
st, err := session.Open(*sessionPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
store = st
|
||||||
|
}
|
||||||
|
if *export != "" {
|
||||||
|
if store == nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "export requires a session")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := store.ExportHTML(*export); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ref, err := llm.ParseModelRef(*model)
|
||||||
|
if *model != "" && err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
agent := runtime.NewAgentSession(runtime.Config{
|
||||||
|
Model: ref.Model,
|
||||||
|
ThinkingLevel: ref.Thinking,
|
||||||
|
Session: store,
|
||||||
|
Stream: liveStream(ref),
|
||||||
|
OnEvent: func(ev runtime.Event) {
|
||||||
|
if *mode != "json" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(map[string]any{"type": ev.Type, "text": ev.Text, "tool": ev.Tool})
|
||||||
|
fmt.Println(string(raw))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
srv := &rpc.Server{Agent: agent, Store: store, NoStore: *noSession}
|
||||||
|
ctx := context.Background()
|
||||||
|
switch *mode {
|
||||||
|
case "rpc":
|
||||||
|
in := bufio.NewScanner(os.Stdin)
|
||||||
|
in.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
|
||||||
|
for in.Scan() {
|
||||||
|
resp, err := srv.Handle(ctx, in.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
os.Stdout.Write(resp)
|
||||||
|
}
|
||||||
|
case "print", "json":
|
||||||
|
if *prompt == "" {
|
||||||
|
*prompt = readAll(os.Stdin)
|
||||||
|
}
|
||||||
|
res, err := agent.Prompt(ctx, llm.Message{Role: llm.RoleUser, Content: *prompt})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if res.Err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, res.Err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if *mode == "print" {
|
||||||
|
fmt.Println(res.Text)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
fmt.Fprintln(os.Stderr, "unknown mode")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func liveStream(ref llm.ModelRef) llm.StreamFunc {
|
||||||
|
provider, ok := llm.Lookup(ref.Provider, nil)
|
||||||
|
if !ok {
|
||||||
|
provider = llm.Provider{Protocol: llm.ProtocolOpenAIChat, BaseURL: os.Getenv("OPENAI_BASE_URL"), APIKey: os.Getenv("OPENAI_API_KEY")}
|
||||||
|
}
|
||||||
|
client := &llm.Client{BaseURL: provider.BaseURL, APIKey: provider.APIKey}
|
||||||
|
return llm.WithRetry(client.Stream, 2, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readAll(r io.Reader) string {
|
||||||
|
raw, _ := io.ReadAll(r)
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
@@ -89,3 +89,19 @@ baseline_fixed: true
|
|||||||
|
|
||||||
安全声明(不冒充已解决):run_command **不是 OS 沙箱**(宿主负责隔离);技能正文与 MCP
|
安全声明(不冒充已解决):run_command **不是 OS 沙箱**(宿主负责隔离);技能正文与 MCP
|
||||||
server 描述是提示注入面(只信受信根 + 长度上限);fetch 的 DNS 解析存在 rebinding 窗口。
|
server 描述是提示注入面(只信受信根 + 长度上限);fetch 的 DNS 解析存在 rebinding 窗口。
|
||||||
|
扩展进程、安装的包和外部 CLI 都以调用者的完整权限运行,同样不是沙箱。
|
||||||
|
|
||||||
|
## R16 无界面全量(pi-headless,2026-10-01)
|
||||||
|
|
||||||
|
对标口径改为 pi 的无界面能力。不做 TUI、主题、快捷键,也不做订阅制 OAuth。
|
||||||
|
|
||||||
|
| 域 | 现状 |
|
||||||
|
|---|---|
|
||||||
|
| 会话树 | available:id/parentId、fork/clone、叶子切换、continue/resume、追加式压缩、旧线性文件仍可 Load、export_html |
|
||||||
|
| 供应商 | available:Anthropic、OpenAI Chat 与 Responses、Google、Azure、兼容预设、models.json、`provider/id:thinking`、图片、缓存 token、自动重试;上下文溢出不重试 |
|
||||||
|
| AgentSession | available:steer/follow-up、one 与 all、abort、clear_queue、中途换模型;事件含 message_start、tool_execution_update、queue_update、agent_settled |
|
||||||
|
| 资源 | available:pi 工具名与原名并存、上下文文件、提示词模板、`/skill:name`、项目信任 |
|
||||||
|
| 扩展与包 | available:stdio JSONL 子进程;install/remove/update/list/config |
|
||||||
|
| 模式 | available:`cmd/agent-native` 的 print/json/rpc,以及 Go `rpc.Client` |
|
||||||
|
| exec.Backend | available:`adapters/native` 的 Execute 经 AgentSession,请求/结果字段不变 |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client is the Go SDK for the JSONL RPC mode.
|
||||||
|
type Client struct {
|
||||||
|
in io.Writer
|
||||||
|
out *bufio.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient wraps a bidirectional pipe. Commands are one JSON object per line.
|
||||||
|
func NewClient(in io.Writer, out io.Reader) *Client {
|
||||||
|
return &Client{in: in, out: bufio.NewReader(out)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call sends one command and reads the matching response.
|
||||||
|
func (c *Client) Call(ctx context.Context, req Request) (Response, error) {
|
||||||
|
raw, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return Response{}, err
|
||||||
|
}
|
||||||
|
if _, err := c.in.Write(append(raw, '\n')); err != nil {
|
||||||
|
return Response{}, err
|
||||||
|
}
|
||||||
|
type result struct {
|
||||||
|
resp Response
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
ch := make(chan result, 1)
|
||||||
|
go func() {
|
||||||
|
line, err := c.out.ReadBytes('\n')
|
||||||
|
if err != nil {
|
||||||
|
ch <- result{err: err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var resp Response
|
||||||
|
if err := json.Unmarshal(line, &resp); err != nil {
|
||||||
|
ch <- result{err: err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ch <- result{resp: resp}
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return Response{}, ctx.Err()
|
||||||
|
case got := <-ch:
|
||||||
|
return got.resp, got.err
|
||||||
|
}
|
||||||
|
}
|
||||||
+193
@@ -0,0 +1,193 @@
|
|||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"agent-native/runtime"
|
||||||
|
"agent-native/runtime/llm"
|
||||||
|
"agent-native/runtime/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Request is one RPC command. Type matches the pi headless command set.
|
||||||
|
type Request struct {
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
ThinkingLevel string `json:"thinkingLevel,omitempty"`
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Leaf string `json:"leaf,omitempty"`
|
||||||
|
Keep int `json:"keep,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response is the synchronous result of one command.
|
||||||
|
type Response struct {
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Result any `json:"result,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server serves RPC commands against one agent session and its transcript.
|
||||||
|
type Server struct {
|
||||||
|
Agent *runtime.AgentSession
|
||||||
|
Store *session.Store
|
||||||
|
NoStore bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle runs one command and returns the JSON response line.
|
||||||
|
func (s *Server) Handle(ctx context.Context, line []byte) ([]byte, error) {
|
||||||
|
var req Request
|
||||||
|
if err := json.Unmarshal(line, &req); err != nil {
|
||||||
|
return encode(Response{Type: "response", OK: false, Error: err.Error()})
|
||||||
|
}
|
||||||
|
resp := s.dispatch(ctx, req)
|
||||||
|
resp.ID = req.ID
|
||||||
|
if resp.Type == "" {
|
||||||
|
resp.Type = "response"
|
||||||
|
}
|
||||||
|
return encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) dispatch(ctx context.Context, req Request) Response {
|
||||||
|
switch req.Type {
|
||||||
|
case "prompt":
|
||||||
|
if s.Agent == nil {
|
||||||
|
return fail("no agent")
|
||||||
|
}
|
||||||
|
res, err := s.Agent.Prompt(ctx, llm.Message{Role: llm.RoleUser, Content: req.Message})
|
||||||
|
if err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
if res.Err != nil {
|
||||||
|
return fail(res.Err.Error())
|
||||||
|
}
|
||||||
|
return Response{OK: true, Text: res.Text}
|
||||||
|
case "steer":
|
||||||
|
s.Agent.Steer(llm.Message{Role: llm.RoleUser, Content: req.Message})
|
||||||
|
return Response{OK: true}
|
||||||
|
case "follow_up":
|
||||||
|
s.Agent.FollowUp(llm.Message{Role: llm.RoleUser, Content: req.Message})
|
||||||
|
return Response{OK: true}
|
||||||
|
case "abort":
|
||||||
|
s.Agent.Abort()
|
||||||
|
return Response{OK: true}
|
||||||
|
case "clear_queue":
|
||||||
|
s.Agent.ClearQueue()
|
||||||
|
return Response{OK: true}
|
||||||
|
case "set_model":
|
||||||
|
if err := s.Agent.SetModel(req.Model, req.ThinkingLevel); err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
return Response{OK: true}
|
||||||
|
case "compact":
|
||||||
|
keep := req.Keep
|
||||||
|
if keep <= 0 {
|
||||||
|
keep = 4
|
||||||
|
}
|
||||||
|
done, err := s.Agent.Compact(keep, func(messages []llm.Message) (string, error) {
|
||||||
|
return fmt.Sprintf("%d messages", len(messages)), nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
return Response{OK: true, Result: done}
|
||||||
|
case "get_state":
|
||||||
|
leaf, _ := s.Store.Leaf()
|
||||||
|
return Response{OK: true, Result: map[string]any{"leaf": leaf, "name": s.Store.Name(), "path": s.Store.Path()}}
|
||||||
|
case "get_tree":
|
||||||
|
entries, err := s.Store.Entries()
|
||||||
|
if err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
return Response{OK: true, Result: entries}
|
||||||
|
case "get_entries":
|
||||||
|
entries, err := s.Store.Entries()
|
||||||
|
if err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
return Response{OK: true, Result: entries}
|
||||||
|
case "get_last_assistant_text":
|
||||||
|
msgs, err := s.Store.Load()
|
||||||
|
if err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
text := ""
|
||||||
|
for _, m := range msgs {
|
||||||
|
if m.Role == llm.RoleAssistant {
|
||||||
|
text = m.Content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Response{OK: true, Text: text}
|
||||||
|
case "set_session_name":
|
||||||
|
if err := s.Store.SetName(req.Name); err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
return Response{OK: true}
|
||||||
|
case "fork":
|
||||||
|
dest := req.Path
|
||||||
|
if dest == "" {
|
||||||
|
return fail("path is required")
|
||||||
|
}
|
||||||
|
if _, err := s.Store.Fork(dest, req.Leaf); err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
return Response{OK: true, Text: dest}
|
||||||
|
case "clone":
|
||||||
|
if req.Path == "" {
|
||||||
|
return fail("path is required")
|
||||||
|
}
|
||||||
|
if _, err := s.Store.Clone(req.Path); err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
return Response{OK: true, Text: req.Path}
|
||||||
|
case "switch_session":
|
||||||
|
if req.Path == "" {
|
||||||
|
return fail("path is required")
|
||||||
|
}
|
||||||
|
st, err := session.Open(req.Path)
|
||||||
|
if err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
s.Store = st
|
||||||
|
return Response{OK: true}
|
||||||
|
case "new_session":
|
||||||
|
if req.Path == "" {
|
||||||
|
return fail("path is required")
|
||||||
|
}
|
||||||
|
st, err := session.Open(req.Path)
|
||||||
|
if err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
s.Store = st
|
||||||
|
return Response{OK: true, Text: req.Path}
|
||||||
|
case "export_html":
|
||||||
|
if req.Path == "" {
|
||||||
|
return fail("path is required")
|
||||||
|
}
|
||||||
|
if err := s.Store.ExportHTML(req.Path); err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
return Response{OK: true, Text: req.Path}
|
||||||
|
case "get_session_stats":
|
||||||
|
st := s.Store.Stats()
|
||||||
|
return Response{OK: true, Result: st}
|
||||||
|
default:
|
||||||
|
return fail("unknown command " + req.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fail(msg string) Response { return Response{OK: false, Error: msg} }
|
||||||
|
|
||||||
|
func encode(resp Response) ([]byte, error) {
|
||||||
|
raw, err := json.Marshal(resp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return append(raw, '\n'), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"agent-native/runtime"
|
||||||
|
"agent-native/runtime/llm"
|
||||||
|
"agent-native/runtime/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRPCSessionCommandsAndClient(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store, err := session.Open(filepath.Join(dir, "s.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := store.Append(llm.Message{Role: llm.RoleAssistant, Content: "ready"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
agent := runtime.NewAgentSession(runtime.Config{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: func(ctx context.Context, model string, messages []llm.Message, td []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||||
|
ch := make(chan llm.Event, 1)
|
||||||
|
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "echo", StopReason: llm.StopEnd}}
|
||||||
|
close(ch)
|
||||||
|
return ch
|
||||||
|
},
|
||||||
|
})
|
||||||
|
srv := &Server{Agent: agent, Store: store}
|
||||||
|
clientIn, serverOut := io.Pipe()
|
||||||
|
serverIn, clientOut := io.Pipe()
|
||||||
|
go func() {
|
||||||
|
defer serverOut.Close()
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
n, err := serverIn.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
resp, herr := srv.Handle(context.Background(), buf[:n])
|
||||||
|
if herr == nil {
|
||||||
|
_, _ = serverOut.Write(resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
client := NewClient(clientOut, clientIn)
|
||||||
|
resp, err := client.Call(context.Background(), Request{ID: "1", Type: "get_last_assistant_text"})
|
||||||
|
if err != nil || !resp.OK || resp.Text != "ready" {
|
||||||
|
t.Fatalf("%+v %v", resp, err)
|
||||||
|
}
|
||||||
|
fork := filepath.Join(dir, "fork.jsonl")
|
||||||
|
resp, err = client.Call(context.Background(), Request{Type: "fork", Path: fork})
|
||||||
|
if err != nil || !resp.OK {
|
||||||
|
t.Fatalf("fork %+v %v", resp, err)
|
||||||
|
}
|
||||||
|
resp, err = client.Call(context.Background(), Request{Type: "prompt", Message: "hi"})
|
||||||
|
if err != nil || !resp.OK || resp.Text != "echo" {
|
||||||
|
t.Fatalf("prompt %+v %v", resp, err)
|
||||||
|
}
|
||||||
|
htmlPath := filepath.Join(dir, "out.html")
|
||||||
|
resp, err = client.Call(context.Background(), Request{Type: "export_html", Path: htmlPath})
|
||||||
|
if err != nil || !resp.OK {
|
||||||
|
t.Fatalf("export %+v %v", resp, err)
|
||||||
|
}
|
||||||
|
_ = clientOut.Close()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user