Implement continuation support for Claude CLI commands
- Refactor BuildCommand to support session continuation using --continue flag. - Introduce BuildCommandWithContinuation to handle both initial and continuation requests. - Add BuildLastUserMessageJSONL for generating JSONL from the last user message in continuation scenarios. - Enhance Executor to check for existing sessions and adjust command building accordingly. - Update environment setup to ensure session data is stored correctly for persistence. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
d78b4ea54a
commit
702b743de7
3 changed files with 138 additions and 20 deletions
|
|
@ -44,19 +44,35 @@ When working with GitHub and a token is provided:
|
|||
|
||||
// BuildCommand builds the Claude CLI command and environment variables
|
||||
// Uses stdin with --input-format stream-json for unlimited prompt length
|
||||
// isContinuation: if true, uses --continue to resume previous session (only sends last user message)
|
||||
func BuildCommand(messages []agentContext.Message, opts *Options) ([]string, map[string]string, error) {
|
||||
// Build system prompt from conversation history
|
||||
systemPrompt, _ := buildPrompts(messages)
|
||||
return BuildCommandWithContinuation(messages, opts, false)
|
||||
}
|
||||
|
||||
// Inject sandbox environment prompt
|
||||
if systemPrompt != "" {
|
||||
systemPrompt = systemPrompt + "\n\n" + sandboxEnvPrompt
|
||||
} else {
|
||||
systemPrompt = sandboxEnvPrompt
|
||||
// BuildCommandWithContinuation builds the Claude CLI command with continuation support
|
||||
// isContinuation: if true, uses --continue to resume previous session
|
||||
func BuildCommandWithContinuation(messages []agentContext.Message, opts *Options, isContinuation bool) ([]string, map[string]string, error) {
|
||||
// Build system prompt from conversation history (only for first request)
|
||||
var systemPrompt string
|
||||
if !isContinuation {
|
||||
systemPrompt, _ = buildPrompts(messages)
|
||||
// Inject sandbox environment prompt
|
||||
if systemPrompt != "" {
|
||||
systemPrompt = systemPrompt + "\n\n" + sandboxEnvPrompt
|
||||
} else {
|
||||
systemPrompt = sandboxEnvPrompt
|
||||
}
|
||||
}
|
||||
|
||||
// Build input JSONL for Claude CLI (stream-json format)
|
||||
inputJSONL, err := BuildInputJSONL(messages)
|
||||
// For continuation, only send the last user message
|
||||
var inputJSONL []byte
|
||||
var err error
|
||||
if isContinuation {
|
||||
inputJSONL, err = BuildLastUserMessageJSONL(messages)
|
||||
} else {
|
||||
inputJSONL, err = BuildFirstRequestJSONL(messages)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to build input JSONL: %w", err)
|
||||
}
|
||||
|
|
@ -80,6 +96,12 @@ func BuildCommand(messages []agentContext.Message, opts *Options) ([]string, map
|
|||
claudeArgs = append(claudeArgs, "--include-partial-messages") // Enable realtime streaming
|
||||
claudeArgs = append(claudeArgs, "--verbose")
|
||||
|
||||
// For continuation, use --continue to resume the previous session
|
||||
// Claude CLI will read session data from $HOME/.claude/ (which is /workspace/.claude/)
|
||||
if isContinuation {
|
||||
claudeArgs = append(claudeArgs, "--continue")
|
||||
}
|
||||
|
||||
// Add max_turns if specified
|
||||
if opts != nil && opts.Arguments != nil {
|
||||
if maxTurns, ok := opts.Arguments["max_turns"]; ok {
|
||||
|
|
@ -99,7 +121,7 @@ func BuildCommand(messages []agentContext.Message, opts *Options) ([]string, map
|
|||
// System prompt may contain quotes, newlines, special characters that break shell quoting
|
||||
var bashCmd strings.Builder
|
||||
|
||||
// If we have a system prompt, write it to a temp file via heredoc first
|
||||
// If we have a system prompt (first request only), write it to a temp file via heredoc first
|
||||
// then use --append-system-prompt-file
|
||||
if systemPrompt != "" {
|
||||
bashCmd.WriteString("cat << 'PROMPTEOF' > /tmp/.system-prompt.txt\n")
|
||||
|
|
@ -127,12 +149,18 @@ func BuildCommand(messages []agentContext.Message, opts *Options) ([]string, map
|
|||
}
|
||||
|
||||
// BuildInputJSONL converts messages to Claude CLI stream-json input format
|
||||
// Each message becomes a line in JSONL format
|
||||
// Deprecated: Use BuildFirstRequestJSONL or BuildLastUserMessageJSONL instead
|
||||
func BuildInputJSONL(messages []agentContext.Message) ([]byte, error) {
|
||||
return BuildFirstRequestJSONL(messages)
|
||||
}
|
||||
|
||||
// BuildFirstRequestJSONL builds JSONL for the first request (all messages)
|
||||
// Sends all user and assistant messages to establish context
|
||||
func BuildFirstRequestJSONL(messages []agentContext.Message) ([]byte, error) {
|
||||
var lines []string
|
||||
|
||||
for _, msg := range messages {
|
||||
// Skip system messages (handled via --system-prompt or env var)
|
||||
// Skip system messages (handled via --system-prompt)
|
||||
if msg.Role == "system" {
|
||||
continue
|
||||
}
|
||||
|
|
@ -147,9 +175,9 @@ func BuildInputJSONL(messages []agentContext.Message) ([]byte, error) {
|
|||
|
||||
// Create stream-json message
|
||||
streamMsg := map[string]interface{}{
|
||||
"type": msg.Role, // "user" or "assistant"
|
||||
"type": string(msg.Role), // "user" or "assistant"
|
||||
"message": map[string]interface{}{
|
||||
"role": msg.Role,
|
||||
"role": string(msg.Role),
|
||||
"content": content,
|
||||
},
|
||||
}
|
||||
|
|
@ -164,6 +192,45 @@ func BuildInputJSONL(messages []agentContext.Message) ([]byte, error) {
|
|||
return []byte(strings.Join(lines, "\n")), nil
|
||||
}
|
||||
|
||||
// BuildLastUserMessageJSONL builds JSONL with only the last user message
|
||||
// Used for continuation requests where Claude CLI manages history via --continue
|
||||
func BuildLastUserMessageJSONL(messages []agentContext.Message) ([]byte, error) {
|
||||
// Find the last user message
|
||||
var lastUserMessage *agentContext.Message
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
if messages[i].Role == "user" {
|
||||
lastUserMessage = &messages[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if lastUserMessage == nil {
|
||||
return nil, fmt.Errorf("no user message found")
|
||||
}
|
||||
|
||||
var content interface{}
|
||||
if lastUserMessage.Content != nil {
|
||||
content = lastUserMessage.Content
|
||||
} else {
|
||||
content = ""
|
||||
}
|
||||
|
||||
userMsg := map[string]interface{}{
|
||||
"type": "user",
|
||||
"message": map[string]interface{}{
|
||||
"role": "user",
|
||||
"content": content,
|
||||
},
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(userMsg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal user message: %w", err)
|
||||
}
|
||||
|
||||
return jsonBytes, nil
|
||||
}
|
||||
|
||||
// buildPrompts extracts system prompt and user prompt from messages
|
||||
func buildPrompts(messages []agentContext.Message) (systemPrompt string, userPrompt string) {
|
||||
var systemParts []string
|
||||
|
|
@ -234,6 +301,11 @@ func buildEnvironment(opts *Options, systemPrompt string) map[string]string {
|
|||
return env
|
||||
}
|
||||
|
||||
// Set HOME to /workspace so Claude CLI stores session data in the workspace
|
||||
// This allows session persistence across requests for the same chat
|
||||
// Session data is stored in $HOME/.claude/ (i.e., /workspace/.claude/)
|
||||
env["HOME"] = "/workspace"
|
||||
|
||||
// claude-proxy runs on localhost:3456, Claude CLI connects to it
|
||||
env["ANTHROPIC_BASE_URL"] = "http://127.0.0.1:3456"
|
||||
env["ANTHROPIC_API_KEY"] = "dummy" // Proxy doesn't verify this
|
||||
|
|
|
|||
|
|
@ -184,8 +184,11 @@ func (e *Executor) Stream(ctx *agentContext.Context, messages []agentContext.Mes
|
|||
}, nil
|
||||
}
|
||||
|
||||
// Check if this is a continuation (Claude CLI session exists in workspace)
|
||||
isContinuation := e.hasExistingSession(stdCtx)
|
||||
|
||||
// Build Claude CLI command using stored options
|
||||
cmd, env, err := BuildCommand(messages, e.opts)
|
||||
cmd, env, err := BuildCommandWithContinuation(messages, e.opts, isContinuation)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build command: %w", err)
|
||||
}
|
||||
|
|
@ -262,6 +265,22 @@ func (e *Executor) shouldSkipClaudeCLI() bool {
|
|||
return !hasPrompts && !hasSkills && !hasMCP
|
||||
}
|
||||
|
||||
// hasExistingSession checks if Claude CLI has an existing session in the workspace
|
||||
// Claude CLI stores session data in $HOME/.claude/projects/ (which is /workspace/.claude/projects/)
|
||||
// If session data exists, we should use --continue to resume the session
|
||||
func (e *Executor) hasExistingSession(ctx context.Context) bool {
|
||||
// Check if /workspace/.claude/projects/ directory has any content
|
||||
// This indicates a previous session exists
|
||||
sessionDir := e.workDir + "/.claude/projects"
|
||||
files, err := e.manager.ListDir(ctx, e.containerName, sessionDir)
|
||||
if err != nil {
|
||||
// Directory doesn't exist or error reading - no existing session
|
||||
return false
|
||||
}
|
||||
// If there are any files/directories in the projects folder, session exists
|
||||
return len(files) > 0
|
||||
}
|
||||
|
||||
// prepareEnvironment prepares the container environment before execution
|
||||
// This includes: claude-proxy config, MCP config, and Skills directory
|
||||
func (e *Executor) prepareEnvironment(ctx context.Context) error {
|
||||
|
|
|
|||
|
|
@ -190,9 +190,24 @@ func (m *Manager) GetOrCreate(ctx context.Context, userID, chatID string) (*Cont
|
|||
if c, ok := m.containers.Load(name); ok {
|
||||
cont := c.(*Container)
|
||||
cont.LastUsedAt = time.Now()
|
||||
// Ensure IPC session exists (may have been closed)
|
||||
m.ensureIPCSession(ctx, userID, chatID)
|
||||
return cont, nil
|
||||
|
||||
// Verify container actually exists in Docker
|
||||
// (container may have been removed externally or Docker restarted)
|
||||
_, err := m.dockerClient.ContainerInspect(ctx, cont.ID)
|
||||
if err != nil {
|
||||
// Container no longer exists in Docker, remove from cache and recreate
|
||||
m.containers.Delete(name)
|
||||
m.mu.Lock()
|
||||
if m.running > 0 {
|
||||
m.running--
|
||||
}
|
||||
m.mu.Unlock()
|
||||
// Fall through to create new container
|
||||
} else {
|
||||
// Container exists, ensure IPC session exists (may have been closed)
|
||||
m.ensureIPCSession(ctx, userID, chatID)
|
||||
return cont, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Use mutex for creation to avoid race condition
|
||||
|
|
@ -203,9 +218,21 @@ func (m *Manager) GetOrCreate(ctx context.Context, userID, chatID string) (*Cont
|
|||
if c, ok := m.containers.Load(name); ok {
|
||||
cont := c.(*Container)
|
||||
cont.LastUsedAt = time.Now()
|
||||
// Ensure IPC session exists (may have been closed)
|
||||
m.ensureIPCSession(ctx, userID, chatID)
|
||||
return cont, nil
|
||||
|
||||
// Verify container actually exists in Docker
|
||||
_, err := m.dockerClient.ContainerInspect(ctx, cont.ID)
|
||||
if err != nil {
|
||||
// Container no longer exists in Docker, remove from cache
|
||||
m.containers.Delete(name)
|
||||
if m.running > 0 {
|
||||
m.running--
|
||||
}
|
||||
// Fall through to create new container
|
||||
} else {
|
||||
// Container exists, ensure IPC session exists (may have been closed)
|
||||
m.ensureIPCSession(ctx, userID, chatID)
|
||||
return cont, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Check running container limit
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue