Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
3c37cf5
Add Factory AI Droid agent integration
alishakawaguchi Feb 19, 2026
bdf4c1a
Fix Factory AI Droid transcript parsing and session lifecycle issues
alishakawaguchi Feb 19, 2026
da2c18a
Simplify Droid transcript parsing code
alishakawaguchi Feb 19, 2026
c83248a
Add Factory AI Droid integration tests
alishakawaguchi Feb 19, 2026
cd0220e
Merge branch 'main' into alisha/factoryai-agent
alishakawaguchi Feb 19, 2026
12d2099
Fix Droid "(no prompt)" after commit by adding agent-specific transcr…
alishakawaguchi Feb 20, 2026
ac7c443
Add Factory AI Droid E2E test runner
alishakawaguchi Feb 20, 2026
94a1d70
Implement GetSessionDir for Factory AI Droid
alishakawaguchi Feb 20, 2026
c5a1d93
Merge branch 'main' into alisha/factoryai-agent
alishakawaguchi Feb 20, 2026
c73ebfb
Add AgentTypeFactoryAIDroid to exhaustive switches and extract Droid …
alishakawaguchi Feb 20, 2026
8fa5dc3
Audit Droid test suite: remove 13 trivial tests, add 9 high-value tests
alishakawaguchi Feb 20, 2026
d4fa282
Implement ReadSession/WriteSession for Droid and fix E2E test blockers
alishakawaguchi Feb 23, 2026
852963e
Merge branch 'main' into alisha/factoryai-agent
alishakawaguchi Feb 23, 2026
576302e
Fix droid exec
alishakawaguchi Feb 24, 2026
a374219
Merge branch 'main' into alisha/factoryai-agent
alishakawaguchi Feb 24, 2026
1a02ae6
Fix relocated repo test
alishakawaguchi Feb 24, 2026
632f98d
Merge branch 'main' into alisha/factoryai-agent
alishakawaguchi Feb 24, 2026
ffb79f9
Add droid to condense session switch
alishakawaguchi Feb 24, 2026
c9f67b8
Fix Droid startOffset applied at raw JSONL level in token calculation
alishakawaguchi Feb 24, 2026
d2cf521
Remove low-value factoryaidroid tests and consolidate pass-through hooks
alishakawaguchi Feb 24, 2026
89e927b
Remove dead methods, redundant indirection, and simplify hook helpers…
alishakawaguchi Feb 24, 2026
4c6c877
Clean up
alishakawaguchi Feb 24, 2026
e310224
Add Droid E2E test support with BYOK Anthropic API auth
alishakawaguchi Feb 24, 2026
d862941
Clean up
alishakawaguchi Feb 24, 2026
efa39a0
Merge branch 'main' into alisha/factoryai-agent
alishakawaguchi Feb 25, 2026
eb08ada
Add Factory AI Droid agent installation to E2E workflow
alishakawaguchi Feb 25, 2026
69ec586
Add FACTORY_API_KEY to E2E workflows for Droid platform auth
alishakawaguchi Feb 25, 2026
0dd7698
Merge branch 'main' into alisha/factoryai-agent
alishakawaguchi Feb 25, 2026
5b2b6c0
Merge branch 'main' into alisha/factoryai-agent
alishakawaguchi Feb 26, 2026
4d21c46
Merge branch 'main' into alisha/factoryai-agent
alishakawaguchi Feb 26, 2026
e0489ea
Add droid to e2e tests
alishakawaguchi Feb 26, 2026
686abda
Update droid e2e agent to use --skip-permissions-unsafe flag
alishakawaguchi Feb 26, 2026
2221d03
Merge branch 'main' into alisha/factoryai-agent
alishakawaguchi Feb 26, 2026
08acbc5
Merge branch 'main' into alisha/factoryai-agent
alishakawaguchi Feb 26, 2026
f087462
Add Droid transcript tests and fix E2E PATH setup
alishakawaguchi Feb 26, 2026
4b380d8
Fix integration test compilation errors after single-strategy refactor
alishakawaguchi Feb 26, 2026
4b0b0bc
Extract testMainGoFile constant to fix goconst lint error
alishakawaguchi Feb 26, 2026
0e67969
Fix Droid E2E tests for v0.63+ custom model selection and repo settings
alishakawaguchi Feb 26, 2026
c8f51b6
reduce parallel e2e tests
alishakawaguchi Feb 26, 2026
68625dc
Merge branch 'main' into alisha/factoryai-agent
alishakawaguchi Feb 27, 2026
fa2c49c
Merge branch 'main' into alisha/factoryai-agent
alishakawaguchi Feb 27, 2026
7f1cdc8
fix: use types.AgentName/AgentType from agent/types subpackage in fac…
alishakawaguchi Feb 27, 2026
b804345
fix: align e2e workflow agent names and add factoryai-droid concurren…
alishakawaguchi Feb 27, 2026
d964694
fix: set factoryai-droid E2E concurrency limit to 3
alishakawaguchi Feb 27, 2026
0238f88
fix: lower factoryai-droid E2E concurrency limit to 1
alishakawaguchi Feb 27, 2026
8118d5e
handle factory ai welcome message, new HookResponseWriter interface
Soph Feb 27, 2026
0153b07
Merge pull request #552 from entireio/soph/welcome-message-factory-ai
alishakawaguchi Feb 27, 2026
20d623b
Merge branch 'main' into alisha/factoryai-agent
alishakawaguchi Feb 27, 2026
8e9c62e
fix: respect GIT_TERMINAL_PROMPT=0 in hasTTY() to prevent Droid hang
alishakawaguchi Feb 27, 2026
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
5 changes: 4 additions & 1 deletion .github/workflows/e2e-isolated.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ on:
required: true
default: "gemini-cli"
type: choice
options: [claude-code, opencode, gemini-cli]
options: [claude-code, opencode, gemini-cli, factoryai-droid]
test:
description: "Test name filter (regex)"
required: true
Expand Down Expand Up @@ -38,19 +38,22 @@ jobs:
claude-code) curl -fsSL https://claude.ai/install.sh | bash ;;
opencode) curl -fsSL https://opencode.ai/install | bash ;;
gemini-cli) npm install -g @google/gemini-cli ;;
factoryai-droid) curl -fsSL https://app.factory.ai/cli | sh ;;
esac
echo "$HOME/.local/bin" >> $GITHUB_PATH

- name: Bootstrap agent
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
run: go run ./e2e/bootstrap

- name: Run isolated test
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts
E2E_ENTIRE_BIN: /usr/local/bin/entire
run: |
Expand Down
7 changes: 5 additions & 2 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
strategy:
fail-fast: false
matrix:
agent: [claude-code, opencode, gemini-cli]
agent: [claude-code, opencode, gemini-cli, factoryai-droid]

steps:
- name: Checkout repository
Expand All @@ -36,20 +36,23 @@ jobs:
claude-code) curl -fsSL https://claude.ai/install.sh | bash ;;
opencode) curl -fsSL https://opencode.ai/install | bash ;;
gemini-cli) npm install -g @google/gemini-cli ;;
factoryai-droid) curl -fsSL https://app.factory.ai/cli | sh ;;
esac
echo "$HOME/.local/bin" >> $GITHUB_PATH

- name: Bootstrap agent
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
run: go run ./e2e/bootstrap

- name: Run E2E Tests
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
E2E_CONCURRENT_TEST_LIMIT: ${{ matrix.agent == 'gemini-cli' && '6' || '' }}
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
E2E_CONCURRENT_TEST_LIMIT: ${{ matrix.agent == 'gemini-cli' && '6' || matrix.agent == 'factoryai-droid' && '1' || '' }}
run: mise run test:e2e --agent ${{ matrix.agent }}

- name: Upload artifacts
Expand Down
12 changes: 12 additions & 0 deletions cmd/entire/cli/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,18 @@ type TokenCalculator interface {
CalculateTokenUsage(transcriptData []byte, fromOffset int) (*TokenUsage, error)
}

// HookResponseWriter is implemented by agents that support structured hook responses.
// Agents that implement this can output messages (e.g., banners) to the user via
// the agent's response protocol. For example, Claude Code outputs JSON with a
// systemMessage field to stdout. Agents that don't implement this will silently
// skip hook response output.
type HookResponseWriter interface {
Agent

// WriteHookResponse outputs a message to the user via the agent's hook response protocol.
WriteHookResponse(message string) error
}

// SubagentAwareExtractor provides methods for extracting files and tokens including subagents.
// Agents that support spawning subagents (like Claude Code's Task tool) should implement this
// to ensure subagent contributions are included in checkpoints.
Expand Down
13 changes: 13 additions & 0 deletions cmd/entire/cli/agent/claudecode/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,21 @@ var (
_ agent.TranscriptPreparer = (*ClaudeCodeAgent)(nil)
_ agent.TokenCalculator = (*ClaudeCodeAgent)(nil)
_ agent.SubagentAwareExtractor = (*ClaudeCodeAgent)(nil)
_ agent.HookResponseWriter = (*ClaudeCodeAgent)(nil)
)

// WriteHookResponse outputs a JSON hook response to stdout.
// Claude Code reads this JSON and displays the systemMessage to the user.
func (c *ClaudeCodeAgent) WriteHookResponse(message string) error {
resp := struct {
SystemMessage string `json:"systemMessage,omitempty"`
}{SystemMessage: message}
if err := json.NewEncoder(os.Stdout).Encode(resp); err != nil {
return fmt.Errorf("failed to encode hook response: %w", err)
}
return nil
}

// HookNames returns the hook verbs Claude Code supports.
// These become subcommands: entire hooks claude-code <verb>
func (c *ClaudeCodeAgent) HookNames() []string {
Expand Down
176 changes: 176 additions & 0 deletions cmd/entire/cli/agent/factoryaidroid/factoryaidroid.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// Package factoryaidroid implements the Agent interface for Factory AI Droid.
package factoryaidroid

import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"time"

"github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/entireio/cli/cmd/entire/cli/agent/types"
"github.com/entireio/cli/cmd/entire/cli/paths"
)

// nonAlphanumericRegex matches any non-alphanumeric character for path sanitization.
// Same pattern as claudecode.SanitizePathForClaude — duplicated to avoid cross-package dependency.
var nonAlphanumericRegex = regexp.MustCompile(`[^a-zA-Z0-9]`)

func sanitizeRepoPath(path string) string {
return nonAlphanumericRegex.ReplaceAllString(path, "-")
}

//nolint:gochecknoinits // Agent self-registration is the intended pattern
func init() {
agent.Register(agent.AgentNameFactoryAIDroid, NewFactoryAIDroidAgent)
}

// FactoryAIDroidAgent implements the agent.Agent interface for Factory AI Droid.
//
//nolint:revive // FactoryAIDroidAgent is clearer than Agent in this context
type FactoryAIDroidAgent struct{}

// NewFactoryAIDroidAgent creates a new Factory AI Droid agent instance.
func NewFactoryAIDroidAgent() agent.Agent {
return &FactoryAIDroidAgent{}
}

// Name returns the agent registry key.
func (f *FactoryAIDroidAgent) Name() types.AgentName { return agent.AgentNameFactoryAIDroid }

// Type returns the agent type identifier.
func (f *FactoryAIDroidAgent) Type() types.AgentType { return agent.AgentTypeFactoryAIDroid }

// Description returns a human-readable description.
func (f *FactoryAIDroidAgent) Description() string {
return "Factory AI Droid - agent-native development platform"
}

// IsPreview returns true as Factory AI Droid integration is in preview.
func (f *FactoryAIDroidAgent) IsPreview() bool { return true }

// ProtectedDirs returns directories that Factory AI Droid uses for config/state.
func (f *FactoryAIDroidAgent) ProtectedDirs() []string { return []string{".factory"} }

// DetectPresence checks if Factory AI Droid is configured in the repository.
func (f *FactoryAIDroidAgent) DetectPresence(ctx context.Context) (bool, error) {
repoRoot, err := paths.WorktreeRoot(ctx)
if err != nil {
repoRoot = "."
}
if _, err := os.Stat(filepath.Join(repoRoot, ".factory")); err == nil {
return true, nil
}
return false, nil
}

// ReadTranscript reads the raw JSONL transcript bytes for a session.
func (f *FactoryAIDroidAgent) ReadTranscript(sessionRef string) ([]byte, error) {
data, err := os.ReadFile(sessionRef) //nolint:gosec // Path comes from agent hook input
if err != nil {
return nil, fmt.Errorf("failed to read transcript: %w", err)
}
return data, nil
}

// ChunkTranscript splits a JSONL transcript at line boundaries.
func (f *FactoryAIDroidAgent) ChunkTranscript(_ context.Context, content []byte, maxSize int) ([][]byte, error) {
chunks, err := agent.ChunkJSONL(content, maxSize)
if err != nil {
return nil, fmt.Errorf("failed to chunk transcript: %w", err)
}
return chunks, nil
}

// ReassembleTranscript concatenates JSONL chunks with newlines.
func (f *FactoryAIDroidAgent) ReassembleTranscript(chunks [][]byte) ([]byte, error) {
return agent.ReassembleJSONL(chunks), nil
}

// GetSessionID extracts the session ID from hook input.
func (f *FactoryAIDroidAgent) GetSessionID(input *agent.HookInput) string { return input.SessionID }

// GetSessionDir returns the directory where Factory AI Droid stores session transcripts.
// Path: ~/.factory/sessions/<sanitized-repo-path>/
func (f *FactoryAIDroidAgent) GetSessionDir(repoPath string) (string, error) {
if override := os.Getenv("ENTIRE_TEST_DROID_PROJECT_DIR"); override != "" {
return override, nil
}
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get home directory: %w", err)
}
projectDir := sanitizeRepoPath(repoPath)
return filepath.Join(homeDir, ".factory", "sessions", projectDir), nil
}

// ResolveSessionFile returns the path to a Factory AI Droid session file.
func (f *FactoryAIDroidAgent) ResolveSessionFile(sessionDir, agentSessionID string) string {
return filepath.Join(sessionDir, agentSessionID+".jsonl")
}

// ReadSession reads a session from Factory AI Droid's storage (JSONL transcript file).
// The session data is stored in NativeData as raw JSONL bytes.
// ModifiedFiles is computed by parsing the transcript.
func (f *FactoryAIDroidAgent) ReadSession(input *agent.HookInput) (*agent.AgentSession, error) {
if input.SessionRef == "" {
return nil, errors.New("session reference (transcript path) is required")
}

data, err := os.ReadFile(input.SessionRef)
if err != nil {
return nil, fmt.Errorf("failed to read transcript: %w", err)
}

lines, _, err := ParseDroidTranscriptFromBytes(data, 0)
if err != nil {
return nil, fmt.Errorf("failed to parse transcript: %w", err)
}

return &agent.AgentSession{
SessionID: input.SessionID,
AgentName: f.Name(),
SessionRef: input.SessionRef,
StartTime: time.Now(),
NativeData: data,
ModifiedFiles: ExtractModifiedFiles(lines),
}, nil
}

// WriteSession writes a session to Factory AI Droid's storage (JSONL transcript file).
// Uses the NativeData field which contains raw JSONL bytes.
func (f *FactoryAIDroidAgent) WriteSession(_ context.Context, session *agent.AgentSession) error {
if session == nil {
return errors.New("session is nil")
}

if session.AgentName != "" && session.AgentName != f.Name() {
return fmt.Errorf("session belongs to agent %q, not %q", session.AgentName, f.Name())
}

if session.SessionRef == "" {
return errors.New("session reference (transcript path) is required")
}

if len(session.NativeData) == 0 {
return errors.New("session has no native data to write")
}

if err := os.MkdirAll(filepath.Dir(session.SessionRef), 0o750); err != nil {
return fmt.Errorf("failed to create session directory: %w", err)
}

if err := os.WriteFile(session.SessionRef, session.NativeData, 0o600); err != nil {
return fmt.Errorf("failed to write transcript: %w", err)
}

return nil
}

// FormatResumeCommand returns the command to resume a Factory AI Droid session.
func (f *FactoryAIDroidAgent) FormatResumeCommand(sessionID string) string {
return "droid --session-id " + sessionID
}
Loading
Loading