Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 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
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 .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"
type: choice
options: [claude, opencode, gemini]
options: [claude, opencode, gemini, factoryai-droid]
test:
description: "Test name filter (regex)"
required: true
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
strategy:
fail-fast: false
matrix:
agent: [claude, opencode]
agent: [claude, opencode, factoryai-droid]

steps:
- name: Checkout repository
Expand All @@ -33,6 +33,7 @@ jobs:
case "${{ matrix.agent }}" in
claude) curl -fsSL https://claude.ai/install.sh | bash ;;
opencode) curl -fsSL https://opencode.ai/install | bash ;;
factoryai-droid) curl -fsSL https://app.factory.ai/cli | sh ;;
esac
echo "$HOME/.local/bin" >> $GITHUB_PATH
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ E2E tests:
- Located in `cmd/entire/cli/e2e_test/`
- Test real agent interactions (Claude Code, Gemini CLI, or OpenCode creating files, committing, etc.)
- Validate checkpoint scenarios documented in `docs/architecture/checkpoint-scenarios.md`
- Support multiple agents via `E2E_AGENT` env var (`claude-code`, `gemini`, `opencode`)
- Support multiple agents via `E2E_AGENT` env var (`claude-code`, `gemini`, `opencode`, `factoryai-droid`)

**Environment variables:**
- `E2E_AGENT` - Agent to test with (default: `claude-code`)
Expand Down
174 changes: 174 additions & 0 deletions cmd/entire/cli/agent/factoryaidroid/factoryaidroid.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// Package factoryaidroid implements the Agent interface for Factory AI Droid.
package factoryaidroid

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

"github.com/entireio/cli/cmd/entire/cli/agent"
"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() agent.AgentName { return agent.AgentNameFactoryAIDroid }

// Type returns the agent type identifier.
func (f *FactoryAIDroidAgent) Type() agent.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() (bool, error) {
repoRoot, err := paths.WorktreeRoot()
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(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(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