Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion actionagent/app/assets/builds/action_agent.css

Large diffs are not rendered by default.

120 changes: 60 additions & 60 deletions actionagent/app/assets/builds/action_agent.js

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ module ActionAgent
module Api
# Headless Claude Code sessions inside one of the caller's checkout
# sandboxes (Settings -> Integrations): start one with a prompt, poll its
# transcript, cancel it.
# transcript, cancel it, and carry its conversation on with more
# messages (#messages, CodeConversation).
#
# A session runs on the owner's Anthropic API key (or, with
# ActionAgent.claude_code_auth = :local_login, this machine's own Claude
Expand All @@ -18,9 +19,9 @@ class CodeSessionsController < BaseController
VERIFICATION_HOLD = 1.hour

before_action :require_owner!
before_action :require_execution_enabled!, only: [ :create ]
before_action :require_execution_enabled!, only: [ :create, :messages ]
before_action :set_sandbox
before_action :set_code_session, only: [ :show, :cancel ]
before_action :set_code_session, only: [ :show, :cancel, :messages ]

# A model name as Claude Code's --model takes it ("sonnet",
# "claude-sonnet-4-5", "claude-sonnet-4-5[1m]"). It becomes an argument
Expand Down Expand Up @@ -48,9 +49,12 @@ def create

code_session = CodeSession.new(
sandbox_session: @sandbox,
prompt: string_param(:prompt),
prompt: CodeSession.strip_note_tags(string_param(:prompt)).presence,
model: string_param(:model).presence,
runner: runner,
# A conversation started to chat (the project's Sessions tab, or
# Interactions) rather than from Settings.
origin: params[:origin] == "chat" ? "chat" : "settings",
# Subscription use always belongs to the person who clicked Run.
user_id: current_user.respond_to?(:id) ? current_user.id : @sandbox.try(:user_id),
account_id: @sandbox.try(:account_id)
Expand Down Expand Up @@ -110,6 +114,43 @@ def cancel
render json: { code_session: @code_session.summary }
end

# POST /api/sandboxes/:sandbox_id/code_sessions/:id/messages
# { prompt, model?, handoff?: { by, taken_at, released_at, page_url } }
# Adds a turn to the conversation +:id+ belongs to. 201 when it starts
# now, 202 when it waits behind the turn that runs.
def messages
root = @code_session.root
return unless authorize_action!(:chat_code_session, root)

if (refusal = refusal_for(@sandbox, runner: root.runner))
return render json: { error: refusal }, status: :unprocessable_entity
end
if root.credential_mode == "sandbox_login" && @sandbox.claude_login_user_id != current_user&.id
return render json: { error: "Only the person signed in to Claude Code in this sandbox can continue its conversations" },
status: :forbidden
end
prompt = CodeSession.strip_note_tags(string_param(:prompt)).strip
return render json: { error: "Write a message" }, status: :unprocessable_entity if prompt.empty?
model = string_param(:model).presence
return render json: { error: "model is not a Claude Code model name" }, status: :unprocessable_entity if model && !model.match?(MODEL_NAME)

enforce_execution_quota!
return if performed?

handoff = params[:handoff].respond_to?(:permit) ? params[:handoff].permit(:by, :taken_at, :released_at, :page_url).to_h : nil
mode = root.runner == "claude_code" ? ClaudeCodeAuth.credential_mode(@sandbox, user_id: current_user&.id) : nil
turn = CodeConversation.post!(root, prompt: prompt, user_id: current_user.respond_to?(:id) ? current_user.id : nil,
credential_mode: mode, handoff: handoff, model: model)
record_execution_usage

behind = root.reload.turns.any? { |other| other.id != turn.id && (other.running? || other.queued?) && other.id < turn.id }
render json: { code_session: turn.reload.summary, waiting: behind }, status: behind ? :accepted : :created
rescue CodeConversation::Refused => e
render json: { error: e.message }, status: :conflict
rescue ActiveRecord::RecordInvalid => e
render json: { error: e.record.errors.full_messages.to_sentence }, status: :unprocessable_entity
end

private

# A checkout runs on its account's GitHub token and Claude Code
Expand Down Expand Up @@ -185,7 +226,7 @@ def attach_fix!(session)
raise EvaluationFix::Invalid, "The previous session must implement this same fix"
end
session.assign_attributes(evaluation_run: run, fix_item: fix.item, previous_code_session: previous,
prompt: fix.prompt(previous: previous))
prompt: fix.prompt(previous: previous), origin: "evaluation_fix")
end

rescue_from EvaluationFix::Invalid do |error|
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,32 @@ module Api
# A context is one agent's interaction stream (AgentContext +
# AgentMessages + AgentGenerations, recorded by SolidAgent::HasContext
# during execution), scoped to the current user's agents.
#
# Claude Code and Codex conversations in the caller's checkout sandboxes
# are interactions too ("code-<id>", CodeConversationSerializer).
#
# GET /api/interactions takes:
# source all (the default), agent, claude_code or codex
# project_id only that project's code conversations
# sandbox_id only that sandbox's (its session id)
# agent_id only that agent's contexts and traces; no code conversations
class InteractionsController < BaseController
before_action :require_owner!

DEFAULT_LIMIT = 50
MAX_WINDOW_MINUTES = 60 * 24 * 90
SOURCES = %w[all agent claude_code codex].freeze

# GET /api/interactions
def index
limit = clamped_param(:limit, default: DEFAULT_LIMIT, min: 1, max: 200)
source = SOURCES.include?(params[:source]) ? params[:source] : "all"
coded = source == "all" ? code_conversations(limit) : (source == "agent" ? [] : code_conversations(limit, runner: source))
# A project or sandbox filter, or a code source, names code
# conversations only.
if source != "all" && source != "agent" || params[:project_id].present? || params[:sandbox_id].present?
return render json: { interactions: coded.first(limit) }
end

contexts = interactions_scope
.includes(:contextable)
Expand Down Expand Up @@ -53,12 +70,20 @@ def index
reported = reported_traces(limit).map { |trace| TraceInteractionSerializer.summary(trace) }

render json: {
interactions: (persisted + reported).sort_by { |row| row[:last_activity_at].to_s }.reverse.first(limit)
interactions: (persisted + reported + coded).sort_by { |row| row[:last_activity_at].to_s }.reverse.first(limit)
}
end

# GET /api/interactions/:id
# For "code-<id>", ?after[<turn id>]=N sends each turn's transcript from
# event N onward.
def show
if (root_id = params[:id].to_s[/\Acode-(\d+)\z/, 1])
root = code_session_scope.conversation_roots.find(root_id)
after = params[:after].respond_to?(:permit) ? params[:after].permit!.to_h : {}
return render json: { interaction: CodeConversationSerializer.detail(root, after: after) }
end

if (trace_id = params[:id].to_s[/\Atrace-(\d+)\z/, 1])
trace = owned_traces.find(trace_id)
return render json: { interaction: TraceInteractionSerializer.detail(trace) }
Expand All @@ -78,6 +103,35 @@ def show

private

# Code sessions in the caller's checkout sandboxes, of their current
# account in a multi-tenant install, as CodeSessionsController reaches
# them. Per-agent views list none.
def code_session_scope
sandboxes = owned(SandboxSession)
sandboxes = sandboxes.where(account_id: current_account.id) if current_account
CodeSession.where(sandbox_session_id: sandboxes.select(:id))
end

# The newest code conversations, as interaction rows.
def code_conversations(limit, runner: nil)
return [] if params[:agent_id].present?

roots = code_session_scope.conversation_roots
roots = roots.where(runner: runner) if runner
if params[:sandbox_id].present?
roots = roots.where(sandbox_session_id: SandboxSession.where(session_id: params[:sandbox_id].to_s).select(:id))
end
if params[:project_id].present?
roots = roots.where(sandbox_session_id: SandboxSession.where(project_id: params[:project_id].to_i).select(:id))
end
roots = roots.where(updated_at: window_minutes.minutes.ago..) if window_minutes
roots = roots.includes(:sandbox_session).order(created_at: :desc).limit(limit).to_a
follow_ups = CodeSession.where(conversation_root_id: roots.map(&:id)).order(:turn_index, :id).group_by(&:conversation_root_id)

roots.map { |root| CodeConversationSerializer.summary(root, [ root, *follow_ups.fetch(root.id, []) ]) }
.sort_by { |row| row[:last_activity_at].to_s }.reverse
end

# What each stream opened with and what it finally answered, so a
# collapsed interaction row says what it was about — the same two lines a
# collapsed trace row shows. Two grouped queries rather than loading
Expand Down
21 changes: 19 additions & 2 deletions actionagent/app/jobs/action_agent/code_session_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,30 @@ def perform(code_session_id)
finish(code_session, outcome.to_h, result_event, secrets)
verify(code_session)
resume_boot(code_session)
advance(code_session)
rescue StandardError => e
message = SecretScrubber.scrub(e.message.to_s, secrets || safe_secrets(code_session))
Rails.logger.error("Code session #{code_session_id} failed: #{message}")
fail!(code_session, message) if code_session
resume_boot(code_session) if code_session
advance(code_session) if code_session
end

# What a turn after the first says when the CLI no longer has the
# session it resumes.
RESUME_LOST = "The conversation could not be resumed: Claude Code no longer has its session " \
"(the sandbox restarted, or its Claude Code login changed). Start a new conversation."

private

# Starts the next queued turn of the conversation, now this one settled
# (CodeConversation).
def advance(code_session)
CodeConversation.advance!(code_session.reload)
rescue StandardError => e
Rails.logger.warn("Code session #{code_session.id}: the conversation's next turn was not started: #{e.class}: #{e.message}")
end

# A project's boot fix resumes the boot it fixed (ProjectBootFix). Its
# failure is the boot's, not the session's, so it is logged and left on
# the project rather than raised.
Expand Down Expand Up @@ -112,7 +127,8 @@ def finish(code_session, outcome, result_event, secrets)
else
code_session.assign_attributes(
status: :failed,
error_message: failure_message(result_event, outcome, secrets, label: code_session.runner == "codex" ? "Codex" : "Claude Code"),
error_message: failure_message(result_event, outcome, secrets, label: code_session.runner == "codex" ? "Codex" : "Claude Code",
resumed: code_session.turn_index.to_i.positive?),
finished_at: Time.current
)
end
Expand All @@ -134,8 +150,9 @@ def reported_error?(result_event)

# What went wrong, in Claude Code's own words when it reported a failure
# and from the process otherwise.
def failure_message(result_event, outcome, secrets, label: "Claude Code")
def failure_message(result_event, outcome, secrets, label: "Claude Code", resumed: false)
message = reported_failure(result_event) || process_failure(result_event, outcome, label: label)
message = RESUME_LOST if resumed && message.match?(/no conversation found|session.{0,40}not found|thread.{0,40}not found/i)
SecretScrubber.scrub(message, secrets).truncate(MAX_ERROR_MESSAGE)
end

Expand Down
80 changes: 80 additions & 0 deletions actionagent/app/models/action_agent/code_session.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ module ActionAgent
# outcome Claude Code reported, and the diff the session left in the
# working tree.
#
# A session is one turn of a conversation (CodeConversation). The first
# turn is the conversation's root; each later one names it
# (conversation_root_id), runs after the one before it, and resumes the same
# CLI session (resume_session_id), so Claude Code or Codex remembers what
# came before. Every existing session is a conversation of one turn.
#
# The transcript is scrubbed of the sandbox's secrets (its GitHub token and
# Claude Code credential) before it is stored, and bounded: long strings
# are truncated and events past MAX_EVENTS are counted rather than kept.
Expand All @@ -17,6 +23,9 @@ class CodeSession < ApplicationRecord
belongs_to :evaluation_run, optional: true
belongs_to :verification_run, class_name: "EvaluationRun", optional: true
belongs_to :previous_code_session, class_name: "CodeSession", optional: true
belongs_to :conversation_root, class_name: "CodeSession", optional: true
has_many :follow_up_turns, -> { order(:turn_index, :id) }, class_name: "CodeSession",
foreign_key: :conversation_root_id, inverse_of: :conversation_root

enum :status, { queued: 0, running: 1, succeeded: 2, failed: 3, cancelled: 4 }

Expand All @@ -26,11 +35,32 @@ class CodeSession < ApplicationRecord
MAX_EVENT_STRING = 4_000
MAX_DIFF_BYTES = 500_000
RUNNERS = %w[claude_code codex].freeze
# Where a conversation was started. Older sessions recorded none; see
# #origin.
ORIGINS = %w[settings boot_fix evaluation_fix chat].freeze
# A Claude Code session id, as --session-id and --resume take it.
SESSION_UUID = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
# A note from the dashboard is fenced in this tag ahead of the person's
# message, and the tag is taken out of anything the person typed.
NOTE_TAG = "dashboard-note"

validates :prompt, presence: true, length: { maximum: MAX_PROMPT_CHARACTERS }
validates :runner, inclusion: { in: RUNNERS }

validates :origin, inclusion: { in: ORIGINS }, allow_nil: true
# A Codex conversation resumes the thread id its first turn reported.
validates :resume_session_id, format: { with: SESSION_UUID }, allow_nil: true, if: -> { runner == "claude_code" }

# A conversation's first Claude Code turn names the CLI session it
# starts (--session-id), so every later turn can resume it even when the
# first ended before reporting its own id.
before_validation(on: :create) do
self.resume_session_id ||= SecureRandom.uuid if root? && runner == "claude_code"
end

scope :recent, -> { order(created_at: :desc) }
# The first turn of every conversation.
scope :conversation_roots, -> { where(conversation_root_id: nil) }

# JSON columns carry no default on MySQL or SQLite (see the migration).
def events
Expand All @@ -41,6 +71,51 @@ def finished?
succeeded? || failed? || cancelled?
end

def root?
conversation_root_id.nil?
end

# The conversation's first turn: this one, or the one it names.
def root
root? ? self : conversation_root
end

# Every turn of this session's conversation, in order.
#
# @return [Array<CodeSession>]
def turns
# Read afresh: turns are added under the sandbox's lock while this
# session may hold an older list.
[ root, *CodeSession.where(conversation_root_id: root.id).order(:turn_index, :id).to_a ]
end

# Where the conversation was started. Sessions from before origins were
# recorded are told apart by what they ran for: an evaluation fix names
# its run, and a boot fix is one its project recorded
# (ProjectBootFix.project_for).
def origin
recorded = super.presence
return recorded if recorded
return "evaluation_fix" if evaluation_run_id
return "boot_fix" if persisted? && root? && ProjectBootFix.project_for(self)

"settings"
end

# What the CLI is given on stdin: the dashboard's note, fenced, ahead of
# the person's message. The prompt itself stays what they typed.
def stdin_text
return prompt if context_note.blank?

"<#{NOTE_TAG}>\n#{context_note}\n</#{NOTE_TAG}>\n\n#{prompt}"
end

# +text+ without the dashboard's note tags, so a person cannot make their
# own words read as the dashboard's.
def self.strip_note_tags(text)
text.to_s.gsub(%r{</?\s*#{NOTE_TAG}[^>]*>}i, "")
end

# Whether an outcome and a diff may still be recorded for this session.
# finished_at is set once nothing more will be: by CodeSessionJob when it
# settles a session it ran (whatever the backend returned, raised or
Expand Down Expand Up @@ -111,6 +186,10 @@ def summary
evaluation_run_id: evaluation_run_id,
evaluation_id: evaluation_run&.evaluation_id,
fix_item: fix_item,
conversation_id: root? ? id : conversation_root_id,
turn_index: turn_index,
origin: origin,
context_note: context_note,
verification_run_id: verification_run_id,
verification_error: verification_error,
result: result,
Expand All @@ -121,6 +200,7 @@ def summary
input_tokens: input_tokens,
output_tokens: output_tokens,
event_count: events.size + dropped_events_count.to_i,
diff_stat: finished? ? DiffStat.call(diff) : nil,
# What a client polls until: false once nothing about the session
# changes any more.
diff_pending: diff_pending?,
Expand Down
Loading
Loading