Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
58 changes: 54 additions & 4 deletions cmd/entire/cli/agent/factoryaidroid/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"io"
"os"
"strings"
"time"

"github.com/entireio/cli/cmd/entire/cli/agent"
Expand Down Expand Up @@ -231,11 +232,15 @@ func (f *FactoryAIDroidAgent) parseSubagentStart(stdin io.Reader) (*agent.Event,
if err != nil {
return nil, err
}
toolUseID := raw.ToolUseID
if toolUseID == "" {
toolUseID = fallbackToolUseID(raw.SessionID, raw.ToolName, raw.ToolInput)
}
return &agent.Event{
Type: agent.SubagentStart,
SessionID: raw.SessionID,
SessionRef: raw.TranscriptPath,
ToolUseID: raw.ToolUseID,
ToolUseID: toolUseID,
ToolInput: raw.ToolInput,
Timestamp: time.Now(),
}, nil
Expand All @@ -246,16 +251,20 @@ func (f *FactoryAIDroidAgent) parseSubagentEnd(stdin io.Reader) (*agent.Event, e
if err != nil {
return nil, err
}
toolUseID := raw.ToolUseID
if toolUseID == "" {
toolUseID = fallbackToolUseID(raw.SessionID, raw.ToolName, raw.ToolInput)
}
event := &agent.Event{
Type: agent.SubagentEnd,
SessionID: raw.SessionID,
SessionRef: raw.TranscriptPath,
ToolUseID: raw.ToolUseID,
ToolUseID: toolUseID,
ToolInput: raw.ToolInput,
Timestamp: time.Now(),
}
if raw.ToolResponse.AgentID != "" {
event.SubagentID = raw.ToolResponse.AgentID
if agentID := parseHookToolResponseAgentID(raw.ToolResponse); agentID != "" {
event.SubagentID = agentID
}
return event, nil
}
Expand All @@ -272,3 +281,44 @@ func (f *FactoryAIDroidAgent) parseCompaction(stdin io.Reader) (*agent.Event, er
Timestamp: time.Now(),
}, nil
}

func parseHookToolResponseAgentID(raw json.RawMessage) string {
if len(raw) == 0 || string(raw) == "null" {
return ""
}

var obj struct {
AgentID string `json:"agentId"`
}
if err := json.Unmarshal(raw, &obj); err == nil && obj.AgentID != "" {
return obj.AgentID
}

var text string
if err := json.Unmarshal(raw, &text); err == nil {
return extractHookToolResponseAgentID(text)
}

return ""
}

func extractHookToolResponseAgentID(text string) string {
const prefix = "agentId: "
_, after, found := strings.Cut(text, prefix)
if !found {
return ""
}

after = strings.TrimSpace(after)
end := 0
for end < len(after) {
ch := after[end]
if ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' || ch >= '0' && ch <= '9' || ch == '-' || ch == '_' {
end++
continue
}
break
}

return after[:end]
}
36 changes: 36 additions & 0 deletions cmd/entire/cli/agent/factoryaidroid/lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,42 @@ func TestParseHookEvent_SubagentEnd(t *testing.T) {
}
}

func TestParseHookEvent_SubagentStart_MissingToolUseID(t *testing.T) {
t.Parallel()

ag := &FactoryAIDroidAgent{}
input := `{"session_id": "sess-4", "transcript_path": "/tmp/t.jsonl", "tool_name": "Task", "tool_input": {"prompt": "do something"}}`

event, err := ag.ParseHookEvent(context.Background(), HookNamePreToolUse, strings.NewReader(input))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if event.ToolUseID == "" {
t.Fatal("expected fallback tool_use_id, got empty string")
}
}

func TestParseHookEvent_SubagentEnd_StringToolResponse(t *testing.T) {
t.Parallel()

ag := &FactoryAIDroidAgent{}
input := `{"session_id": "sess-5", "transcript_path": "/tmp/t.jsonl", "tool_name": "Task", "tool_input": {}, "tool_response": "agentId: agent-789"}`

event, err := ag.ParseHookEvent(context.Background(), HookNamePostToolUse, strings.NewReader(input))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if event.Type != agent.SubagentEnd {
t.Errorf("expected SubagentEnd, got %v", event.Type)
}
if event.SubagentID != "agent-789" {
t.Errorf("expected SubagentID 'agent-789', got %q", event.SubagentID)
}
if event.ToolUseID == "" {
t.Fatal("expected fallback tool_use_id, got empty string")
}
}

func TestParseHookEvent_Compaction(t *testing.T) {
t.Parallel()

Expand Down
17 changes: 13 additions & 4 deletions cmd/entire/cli/agent/factoryaidroid/types.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package factoryaidroid

import "encoding/json"
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
)

// FactorySettings represents the .factory/settings.json structure.
type FactorySettings struct {
Expand Down Expand Up @@ -57,6 +61,7 @@ type taskHookInputRaw struct {
SessionID string `json:"session_id"`
TranscriptPath string `json:"transcript_path"`
ToolUseID string `json:"tool_use_id"`
ToolName string `json:"tool_name"`
ToolInput json.RawMessage `json:"tool_input"`
}

Expand All @@ -65,10 +70,14 @@ type postToolHookInputRaw struct {
SessionID string `json:"session_id"`
TranscriptPath string `json:"transcript_path"`
ToolUseID string `json:"tool_use_id"`
ToolName string `json:"tool_name"`
ToolInput json.RawMessage `json:"tool_input"`
ToolResponse struct {
AgentID string `json:"agentId"`
} `json:"tool_response"`
ToolResponse json.RawMessage `json:"tool_response"`
}

func fallbackToolUseID(sessionID, toolName string, toolInput json.RawMessage) string {
sum := sha256.Sum256([]byte(sessionID + "\n" + toolName + "\n" + string(toolInput)))
return "factorytask_" + hex.EncodeToString(sum[:8])
}

// Tool names used in Factory Droid transcripts.
Expand Down
36 changes: 36 additions & 0 deletions e2e/tests/factory_hooks_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//go:build e2e

package tests

import (
"context"
"testing"
"time"

"github.com/entireio/cli/e2e/testutil"
)

func TestFactoryTaskHooksDoNotFail(t *testing.T) {
testutil.ForEachAgent(t, 3*time.Minute, func(t *testing.T, s *testutil.RepoState, ctx context.Context) {
if s.Agent.Name() != "factoryai-droid" {
t.Skip("factory-only regression test")
}

session := s.StartSession(t, ctx)
if session == nil {
t.Skip("factoryai-droid does not support interactive mode")
}

s.WaitFor(t, session, s.Agent.PromptPattern(), 30*time.Second)
s.Send(t, session,
"Can you run a Worker that inspects this code and comes up with a short summary about what it is about? Have the Worker write that summary to docs/factory-hook-check.md as one short paragraph followed by exactly 3 bullet points. Do not create or edit the file in the main agent process. Only the Worker should write the file. Do not commit. Do not ask for confirmation.")
s.WaitFor(t, session, s.Agent.PromptPattern(), 90*time.Second)

testutil.WaitForFileExists(t, s.Dir, "docs/factory-hook-check.md", 10*time.Second)
testutil.AssertConsoleLogDoesNotContain(t, s,
"tool_use_id is required",
"failed to parse hook event",
"postToolHookInputRaw.tool_response",
)
})
}
16 changes: 16 additions & 0 deletions e2e/testutil/assertions.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,22 @@ func AssertFileExists(t *testing.T, dir string, glob string) {
assert.NotEmpty(t, matches, "expected files matching %s in %s", glob, dir)
}

// AssertConsoleLogDoesNotContain asserts that the current test's console.log
// does not contain any of the forbidden substrings.
func AssertConsoleLogDoesNotContain(t *testing.T, s *RepoState, forbidden ...string) {
t.Helper()

require.NoError(t, s.ConsoleLog.Sync())

data, err := os.ReadFile(filepath.Join(s.ArtifactDir, "console.log"))
require.NoError(t, err)

log := string(data)
for _, needle := range forbidden {
assert.NotContains(t, log, needle, "console.log should not contain %q", needle)
}
}

// WaitForFileExists polls until at least one file matches the glob pattern
// relative to dir, or fails the test after timeout. Handles the race where an
// interactive agent's prompt pattern appears before file writes land on disk.
Expand Down
Loading