]> git.giacomolli.uk - onechat/commitdiff
Import WhatsApp history into a child
authorAnderson A. Giacomolli <redacted>
Tue, 10 Nov 2026 10:48:50 +0000 (07:48 -0300)
committerAnderson A. Giacomolli <redacted>
Tue, 10 Nov 2026 11:05:50 +0000 (08:05 -0300)
The import command selects an existing direct child with --agent NAME
and uses its log, prompt, tools, cache key, and usage records. The root
stays the default target and supplies live cutoff history for a child.
Plans name the target without model calls or changes to its files.

Child rebuilds keep their staging directories and timestamped backups
outside children. Only complete child configs appear in that directory,
so a backup or interrupted rebuild cannot stop daemon startup.

README.rst
command_test.go
main.go
rebuild.go
whatsapp_import.go
whatsapp_import_test.go

index 49963a10e4bae960b772da5067f0a5241dd7e83f..7ca3d74315c900dd4a41e61dfab3b03ef8ba9211 100644 (file)
@@ -556,20 +556,32 @@ first:
 
    onechat import -n CHAT.zip OTHER.zip
    onechat import CHAT.zip OTHER.zip
-
-``-n`` makes no model call and prints the chats, chat-day batches, local
-dates, cutoff, text bytes, merged message count, and estimated calls.
-The import keeps multiline text, system lines, and omitted-media
-markers. It maps the session's profile name to ``You``. Without that
-name, it keeps the senders as written and says so in the plan. An exact,
-unambiguous local contact or batch name supplies the chat ID when known.
-No network lookup is made.
+   onechat import -n --agent House CHAT.zip OTHER.zip
+   onechat import --agent House CHAT.zip OTHER.zip
+
+The target is the root unless ``--agent NAME`` selects an existing
+direct child. A missing child is refused. The rebuild uses the target's
+log, prompt, cache key, and usage records. The root keeps its history
+when the target is a child. An import or plan is refused while the
+target has pending agent deliveries. Start the daemon and let those
+turns finish before you stop it and try again. This keeps their saved
+log IDs valid.
+
+``-n`` makes no model call and prints the target name, chats, chat-day
+batches, local dates, cutoffs, text bytes, merged message count, and
+estimated calls. The import keeps multiline text, system lines, and
+omitted-media markers. It maps the session's profile name to ``You``.
+Without that name, it keeps the senders as written and says so in the
+plan. An exact, unambiguous local contact or batch name supplies the
+chat ID when known. No network lookup is made.
 
 Each chat and local day becomes one work message marked ``(imported)``.
-Only messages before the first message of the earliest live WhatsApp
-batch for the same chat enter the import. Live times have minute
-precision, so the cutoff excludes that whole minute. A chat-day already
-imported is skipped. Overlapping chat-days in the supplied ZIPs are
+For each chat, the cutoff is the first message time of its earliest live
+WhatsApp batch across the root and target logs. Only messages before
+that cutoff enter the import. Live times have minute precision, so the
+cutoff excludes that whole minute. A chat-day already imported into the
+target is skipped. An imported day held only by the root does not skip
+that day for a child. Overlapping chat-days in the supplied ZIPs are
 refused. Review the exports before import: their text goes to the model
 as chat history.
 
@@ -580,9 +592,11 @@ the same usage report. Its estimate is about 1.3 calls per merged
 message, not a cost guarantee. A stopped or failed run leaves the agent
 unchanged. A new run starts over. Success keeps the other agent files
 and renames the old directory to ``agent-backup-TIMESTAMP`` before it
-installs the new one. Keep the backup until the new memory is checked,
-then start the daemon. Message IDs change to their positions in the
-merged log.
+installs the new one. A child's staging directory and backup stay in the
+root agent directory, outside ``children/``. Its backup is
+``NAME-backup-TIMESTAMP``. Keep the backup until the new memory is
+checked, then start the daemon. Message IDs change to their positions in
+the merged log.
 
 ***********
  Schedules
index 3d89327078432ad9bd5f3075226ddb83eb51de7e..602424dfb0815472f2d22246d5c0bffc0b0c485b 100644 (file)
@@ -22,6 +22,12 @@ func TestHelpListsVersionCommand(t *testing.T) {
        require.Contains(t, commandOutput(t, "help"), "  version                  show the build version\n")
 }
 
+func TestHelpListsTheImportTargetFlag(t *testing.T) {
+       help := commandOutput(t, "help")
+       require.Contains(t, help, "import [-n] [--agent NAME] ZIP...")
+       require.Contains(t, help, "--agent NAME           import into an existing direct child (default: root)")
+}
+
 func commandOutput(t *testing.T, args ...string) string {
        t.Helper()
 
diff --git a/main.go b/main.go
index efecde3b65b7ceca1652fe97511ba87bd04e7c39..843bf9016905ab9d80fc6958f812cb5bf6a0b5d9 100644 (file)
--- a/main.go
+++ b/main.go
@@ -18,8 +18,10 @@ Commands:
   restore FILE             restore into an empty data directory
   chat                     talk to the agent in this terminal (default)
   daemon                   run the agent, Telegram, and WhatsApp
-  import [-n] ZIP...        import WhatsApp history (stop the daemon first)
+  import [-n] [--agent NAME] ZIP...
+                           import WhatsApp history (stop the daemon first)
     -n                     print the plan without model calls
+    --agent NAME           import into an existing direct child (default: root)
   login                    log in to ChatGPT with a device code
   probe LOG COUNT [BUILDS] measure the compactor on a log, with real calls
   version                  show the build version
index 11c6bfcc536d1fbff9eef1d0d7c9a83412e5410c..598371caa5343965f4e27cd481cd5a289132ae1c 100644 (file)
@@ -26,7 +26,9 @@ const rebuildCallLimit = 1500
 // removes the new directory and leaves the original directory unchanged.
 // Success copies the other agent files, adds this run's usage, and swaps the
 // directories, with the original kept as a timestamped backup. Message IDs are
-// new positions in the merged log, but kind, text, and date stay exact.
+// new positions in the merged log, but kind, text, and date stay exact. A child
+// keeps its staging directory and backup outside children so that neither is
+// read as a configured child.
 func rebuild(ctx context.Context, agent *Agent, added []DatedMessage, client *ModelClient, summarizer Summarizer, out io.Writer) (backup string, err error) {
        err = rebuildIdle(agent.dir)
        if err != nil {
@@ -43,7 +45,14 @@ func rebuild(ctx context.Context, agent *Agent, added []DatedMessage, client *Mo
                return "", fmt.Errorf("merge rebuild messages: %w", err)
        }
 
-       root, err := os.MkdirTemp(filepath.Dir(agent.dir), ".agent-rebuild-")
+       parent := filepath.Dir(agent.dir)
+       backupDir := agent.dir
+       if agent.config.Parent != "" {
+               parent = filepath.Dir(parent)
+               backupDir = filepath.Join(parent, agent.config.Name)
+       }
+
+       root, err := os.MkdirTemp(parent, ".agent-rebuild-")
        if err != nil {
                return "", fmt.Errorf("create rebuild directory: %w", err)
        }
@@ -110,7 +119,7 @@ func rebuild(ctx context.Context, agent *Agent, added []DatedMessage, client *Mo
                return "", ctx.Err()
        }
 
-       backup = agent.dir + "-backup-" + time.Now().Format("20060102-150405.000000000")
+       backup = backupDir + "-backup-" + time.Now().Format("20060102-150405.000000000")
        err = os.Rename(agent.dir, backup)
        if err != nil {
                return "", fmt.Errorf("back up agent: %w", err)
index 23619157ac437de4074b1b8b1f87eefca9cf3513..df871c19670029756c94f5e4d26e72e4ace9549a 100644 (file)
@@ -37,21 +37,12 @@ var (
 // whatsappImport runs the offline import command. It holds the same token lock
 // as the daemon and probe, even for a plan. The plan reads no credentials for a
 // model and makes no network call. Only a completed rebuild replaces the agent
-// directory, and unfinished subagents prevent either mode.
+// directory. Unfinished subagents and pending target deliveries prevent either
+// mode.
 func whatsappImport(ctx context.Context, args []string, out io.Writer) (err error) {
-       planOnly := len(args) > 0 && args[0] == "-n"
-       if planOnly {
-               args = args[1:]
-       }
-
-       if len(args) == 0 {
-               return errors.New("usage: onechat import [-n] ZIP [ZIP...]")
-       }
-
-       for _, arg := range args {
-               if strings.HasPrefix(arg, "-") {
-                       return errors.New("usage: onechat import [-n] ZIP [ZIP...]")
-               }
+       options, err := whatsappImportParse(args)
+       if err != nil {
+               return fmt.Errorf("parse import arguments: %w", err)
        }
 
        dir, err := dataDir()
@@ -72,47 +63,57 @@ func whatsappImport(ctx context.Context, args []string, out io.Writer) (err erro
        }()
 
        root := filepath.Join(dir, "agent")
-       err = rebuildIdle(root)
+       agent, err := whatsappImportAgent(root, options.agent)
+       if err != nil {
+               return fmt.Errorf("select import agent: %w", err)
+       }
+
+       err = whatsappImportIdle(root, agent)
        if err != nil {
                return fmt.Errorf("check import work: %w", err)
        }
 
-       old, err := rebuildReadLog(ctx, root)
+       old, err := rebuildReadLog(ctx, agent.dir)
        if err != nil {
                return fmt.Errorf("read import log: %w", err)
        }
 
+       var rootLog []DatedMessage
+
+       if agent.dir != root {
+               rootLog, err = rebuildReadLog(ctx, root)
+               if err != nil {
+                       return fmt.Errorf("read root import history: %w", err)
+               }
+       }
+
        names, owner, err := whatsappImportNames(ctx, dir)
        if err != nil {
                return fmt.Errorf("read import names: %w", err)
        }
 
-       plan, err := whatsappImportPlan(ctx, args, old, nil, names, owner, time.Local)
+       plan, err := whatsappImportPlan(ctx, options.paths, old, rootLog, names, owner, time.Local)
        if err != nil {
                return fmt.Errorf("plan import: %w", err)
        }
 
+       plan.agent = agent.config.Name
        err = plan.Write(out)
        if err != nil {
                return fmt.Errorf("write import plan: %w", err)
        }
 
-       if planOnly || len(plan.added) == 0 {
+       if options.planOnly || len(plan.added) == 0 {
                return nil
        }
 
        // An existing agent is required so that a failed import never creates
        // or changes the original agent's configuration.
-       _, err = os.Stat(filepath.Join(root, "config.json"))
+       _, err = os.Stat(filepath.Join(agent.dir, "config.json"))
        if err != nil {
                return fmt.Errorf("read agent config: %w", err)
        }
 
-       agent, err := openAgent(root)
-       if err != nil {
-               return fmt.Errorf("open import agent: %w", err)
-       }
-
        config, err := loadConfig(dir)
        if err != nil {
                return fmt.Errorf("load import configuration: %w", err)
@@ -159,6 +160,7 @@ func whatsappImport(ctx context.Context, args []string, out io.Writer) (err erro
 // log. Its estimate uses the measured cost of 1.3 calls per message, not a
 // promise about a particular export or compaction retry.
 type WhatsAppImportPlan struct {
+       agent      string
        cutoffs    map[string]time.Time
        added      []DatedMessage
        chats      []string
@@ -188,7 +190,7 @@ func (p WhatsAppImportPlan) Write(out io.Writer) error {
                owner = "unknown (sender names kept as written)"
        }
 
-       _, err := fmt.Fprintf(out, "chats: %d\n", len(p.chats))
+       _, err := fmt.Fprintf(out, "agent: %s\nchats: %d\n", p.agent, len(p.chats))
        if err != nil {
                return fmt.Errorf("write import plan: %w", err)
        }
@@ -229,6 +231,140 @@ func (p WhatsAppImportPlan) Write(out io.Writer) error {
        return nil
 }
 
+// WhatsAppImportOptions selects the target, exports, and plan-only mode.
+type WhatsAppImportOptions struct {
+       planOnly bool
+       agent    string
+       paths    []string
+}
+
+// whatsappImportParse accepts GNU-style flags, including --agent=NAME and --
+// before paths that start with a dash.
+func whatsappImportParse(args []string) (WhatsAppImportOptions, error) {
+       var options WhatsAppImportOptions
+
+       flags := true
+       for i := 0; i < len(args); i++ {
+               arg := args[i]
+               switch {
+               case flags && arg == "--":
+                       flags = false
+               case flags && arg == "-n":
+                       options.planOnly = true
+               case flags && (arg == "--agent" || strings.HasPrefix(arg, "--agent=")):
+                       _, name, found := strings.Cut(arg, "=")
+                       if !found {
+                               i++
+                               if i == len(args) {
+                                       return options, errors.New("--agent needs a child name")
+                               }
+
+                               name = args[i]
+                       }
+
+                       if name == "" {
+                               return options, errors.New("--agent needs a child name")
+                       }
+
+                       options.agent = name
+               case flags && strings.HasPrefix(arg, "-"):
+                       return options, fmt.Errorf("unknown import flag %q", arg)
+               default:
+                       options.paths = append(options.paths, arg)
+               }
+       }
+
+       if len(options.paths) == 0 {
+               return options, errors.New("usage: onechat import [-n] [--agent NAME] ZIP [ZIP...]")
+       }
+
+       return options, nil
+}
+
+// whatsappImportAgent selects the root or an existing direct child without
+// creating a directory or config. A root without a config can have a plan,
+// using the default name.
+func whatsappImportAgent(root, name string) (*Agent, error) {
+       _, err := os.Stat(filepath.Join(root, "config.json"))
+       if errors.Is(err, os.ErrNotExist) && name == "" {
+               var config AgentConfig
+
+               config, err = defaultAgentConfig()
+               if err != nil {
+                       return nil, fmt.Errorf("read default import agent: %w", err)
+               }
+
+               return &Agent{
+                       dir:    root,
+                       config: config,
+               }, nil
+       }
+
+       if err != nil {
+               return nil, fmt.Errorf("read root agent config: %w", err)
+       }
+
+       agent, err := openAgent(root)
+       if err != nil {
+               return nil, fmt.Errorf("open root agent: %w", err)
+       }
+
+       if name == "" {
+               return agent, nil
+       }
+
+       if agent.config.Parent != "" {
+               return nil, errors.New("import root cannot have a parent")
+       }
+
+       children, err := agent.Children()
+       if err != nil {
+               return nil, fmt.Errorf("read import children: %w", err)
+       }
+
+       for _, child := range children {
+               if child.config.Name == name {
+                       return child, nil
+               }
+       }
+
+       return nil, fmt.Errorf("child %q does not exist", name)
+}
+
+// whatsappImportIdle refuses unfinished subagents and pending deliveries to the
+// target. Rebuilding renumbers its log, so saved delivery IDs must not refer to
+// it until their turns finish. The caller holds the token lock.
+func whatsappImportIdle(root string, agent *Agent) error {
+       err := rebuildIdle(agent.dir)
+       if err != nil {
+               return fmt.Errorf("check target work: %w", err)
+       }
+
+       data, err := os.ReadFile(filepath.Join(root, "messages.json"))
+       if errors.Is(err, os.ErrNotExist) {
+               return nil
+       }
+
+       if err != nil {
+               return fmt.Errorf("read agent deliveries: %w", err)
+       }
+
+       var queue DaemonMessageFile
+
+       err = json.Unmarshal(data, &queue)
+       if err != nil {
+               return fmt.Errorf("decode agent deliveries: %w", err)
+       }
+
+       for _, message := range queue.Pending {
+               if message.Target == agent.config.Name {
+                       return fmt.Errorf("import refused: agent %q has pending deliveries", agent.config.Name)
+               }
+       }
+
+       return nil
+}
+
 // whatsappImportNames reads only local session data. Group names that the
 // library keeps only in memory can also come from logged or pending batches.
 // Ambiguous names have no ID. A missing session keeps sender names unchanged.
index 1e24039da497f31381327604efcc6eedbe1ebada..1f9c9afc23de996095d84a93856be679b9ac17f9 100644 (file)
@@ -2,6 +2,8 @@ package main
 
 import (
        "archive/zip"
+       "encoding/json"
+       "net/http"
        "os"
        "path/filepath"
        "strings"
@@ -368,6 +370,380 @@ func TestWhatsAppImportCutoffUsesEachChatAndBothLogs(t *testing.T) {
        }
 }
 
+func TestWhatsAppImportSelectsOnlyAnExistingDirectChild(t *testing.T) {
+       root := newRebuildTestAgent(t)
+       dir := filepath.Dir(root.dir)
+       t.Setenv("ONECHAT_DIR", dir)
+       daemonChildConfig(t, dir, "House")
+
+       before := rebuildTestSnapshot(t, root.dir)
+       for _, name := range []string{"Missing", "../House", root.config.Name} {
+               err := whatsappImport(t.Context(), []string{"-n", "--agent", name, whatsappImportSample(t)}, &strings.Builder{})
+               require.ErrorContains(t, err, "does not exist")
+               require.Equal(t, before, rebuildTestSnapshot(t, root.dir))
+       }
+}
+
+func TestWhatsAppImportParsesGNUFlags(t *testing.T) {
+       testCases := []struct {
+               name string
+               args []string
+               want WhatsAppImportOptions
+       }{
+               {
+                       name: "default target remains root",
+                       args: []string{"One.zip", "Two.zip"},
+                       want: WhatsAppImportOptions{paths: []string{"One.zip", "Two.zip"}},
+               },
+               {
+                       name: "long flag takes a separate child name",
+                       args: []string{"-n", "--agent", "House", "One.zip"},
+                       want: WhatsAppImportOptions{
+                               planOnly: true,
+                               agent:    "House",
+                               paths:    []string{"One.zip"},
+                       },
+               },
+               {
+                       name: "flags can follow paths",
+                       args: []string{"One.zip", "--agent=House", "-n"},
+                       want: WhatsAppImportOptions{
+                               planOnly: true,
+                               agent:    "House",
+                               paths:    []string{"One.zip"},
+                       },
+               },
+               {
+                       name: "double dash ends flag parsing",
+                       args: []string{"-n", "--", "-Example.zip"},
+                       want: WhatsAppImportOptions{
+                               planOnly: true,
+                               paths:    []string{"-Example.zip"},
+                       },
+               },
+       }
+
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       options, err := whatsappImportParse(tc.args)
+                       require.NoError(t, err)
+                       require.Equal(t, tc.want, options)
+               })
+       }
+}
+
+func TestWhatsAppImportRefusesInvalidFlags(t *testing.T) {
+       testCases := []struct {
+               name string
+               args []string
+               want string
+       }{
+               {
+                       name: "missing exports are refused",
+                       args: []string{"-n", "--agent", "House"},
+                       want: "usage:",
+               },
+               {
+                       name: "missing child name is refused",
+                       args: []string{"--agent"},
+                       want: "needs a child name",
+               },
+               {
+                       name: "empty child name is refused",
+                       args: []string{"--agent=", "One.zip"},
+                       want: "needs a child name",
+               },
+               {
+                       name: "single dash long flag is refused",
+                       args: []string{"-agent", "House", "One.zip"},
+                       want: "unknown import flag",
+               },
+       }
+
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       _, err := whatsappImportParse(tc.args)
+                       require.ErrorContains(t, err, tc.want)
+               })
+       }
+}
+
+func TestWhatsAppImportChildPlanReadsRootCutoffButNotRootImportedDays(t *testing.T) {
+       root := newRebuildTestAgent(t)
+       dir := filepath.Dir(root.dir)
+       t.Setenv("ONECHAT_DIR", dir)
+
+       childDir := daemonChildConfig(t, dir, "House")
+       rebuildTestLog(t, root.dir, []DatedMessage{
+               {
+                       Kind: "work",
+                       Text: "[WhatsApp] Plans, Wednesday 2025-01-01 (imported)\n08:00 Ada: Root history",
+                       Date: "2025-01-01T08:00:00Z",
+               },
+               {
+                       Kind: "work",
+                       Text: "[WhatsApp] Plans, group example@g.us, Friday 2025-01-03\n10:00 Ada: Root live\n10:20 Ada: Last",
+                       Date: "2025-01-03T10:20:00Z",
+               },
+       })
+       rebuildTestLog(t, childDir, []DatedMessage{
+               {
+                       Kind: "work",
+                       Text: "[WhatsApp] Plans, Thursday 2025-01-02 (imported)\n08:00 Ada: Child history",
+                       Date: "2025-01-02T08:00:00Z",
+               },
+               {
+                       Kind: "work",
+                       Text: "[WhatsApp] Plans, group example@g.us, Saturday 2025-01-04\n10:00 Ada: Child live",
+                       Date: "2025-01-04T10:00:00Z",
+               },
+       })
+
+       path := whatsappImportZip(t, "Plans", "[01/01/2025, 08:00:00] Ada: Root history\n[02/01/2025, 08:00:00] Ada: Child history\n[03/01/2025, 09:59:59] Ada: Before\n[03/01/2025, 10:00:00] Ada: Boundary\n[03/01/2025, 10:01:00] Ada: After\n")
+       before := rebuildTestSnapshot(t, root.dir)
+
+       var report strings.Builder
+
+       require.NoError(t, whatsappImport(t.Context(), []string{"-n", "--agent", "House", path}, &report))
+       require.Contains(t, report.String(), "agent: House\n")
+       require.Contains(t, report.String(), "batches: 2 (1 chat-days skipped)")
+       require.Contains(t, report.String(), "2025-01-03T10:00:00")
+       require.Contains(t, report.String(), "messages: 4 (merged log)")
+       require.Equal(t, before, rebuildTestSnapshot(t, root.dir))
+
+       report.Reset()
+       require.NoError(t, whatsappImport(t.Context(), []string{"-n", path}, &report))
+       require.Contains(t, report.String(), "agent: "+root.config.Name+"\n")
+       require.Contains(t, report.String(), "batches: 2 (1 chat-days skipped)")
+       require.Equal(t, before, rebuildTestSnapshot(t, root.dir))
+}
+
+func TestWhatsAppImportChildRebuildUsesOwnPromptAndCache(t *testing.T) {
+       root := newRebuildTestAgent(t)
+       dir := filepath.Dir(root.dir)
+       childDir := daemonChildConfig(t, dir, "House")
+       daemonChildConfig(t, dir, "Calendar")
+       require.NoError(t, os.WriteFile(filepath.Join(childDir, "instructions.md"), []byte("Keep room dimensions exact."), 0o600))
+       rebuildTestLog(t, root.dir, []DatedMessage{{
+               Kind: "user",
+               Text: "Keep the root history.",
+               Date: "2025-02-01T00:00:00Z",
+       }})
+       rebuildTestLog(t, childDir, []DatedMessage{{
+               Kind: "user",
+               Text: "Keep the child history.",
+               Date: "2025-02-02T00:00:00Z",
+       }})
+
+       child, err := whatsappImportAgent(root.dir, "House")
+       require.NoError(t, err)
+
+       old, err := rebuildReadLog(t.Context(), child.dir)
+       require.NoError(t, err)
+
+       rootLog, err := rebuildReadLog(t.Context(), root.dir)
+       require.NoError(t, err)
+
+       path := whatsappImportZip(t, "Plans", "[01/01/2025, 08:00:00] Ada: "+strings.Repeat("The room is ready. ", 80)+"\n")
+       plan, err := whatsappImportPlan(t.Context(), []string{path}, old, rootLog, nil, "", time.UTC)
+       require.NoError(t, err)
+
+       before := rebuildTestSnapshot(t, child.dir)
+       sibling := rebuildTestSnapshot(t, filepath.Join(root.dir, "children", "Calendar"))
+       server := newModelFake(t)
+       server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
+               children, err := root.Children()
+               if err != nil || len(children) != 2 {
+                       w.WriteHeader(http.StatusBadRequest)
+
+                       return
+               }
+
+               modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("The room is ready.")}, 100, 0, 5))
+       }
+
+       client := newModelClient(server.URL, ModelAPIKey(modelTestKey), child.CacheKey(), filepath.Join(t.TempDir(), "usage.jsonl"))
+       backup, err := rebuild(t.Context(), child, plan.added, client, newCompactor(client, child.Prompt, defaultConfig().Compactor, child.Tools()), &strings.Builder{})
+       require.NoError(t, err)
+       require.Equal(t, before, rebuildTestSnapshot(t, backup))
+       require.Equal(t, root.dir, filepath.Dir(backup))
+       require.Equal(t, sibling, rebuildTestSnapshot(t, filepath.Join(root.dir, "children", "Calendar")))
+
+       children, err := root.Children()
+       require.NoError(t, err)
+       require.Len(t, children, 2)
+
+       entries, err := rebuildReadLog(t.Context(), child.dir)
+       require.NoError(t, err)
+       require.Equal(t, append(plan.added, old...), entries)
+
+       unchanged, err := rebuildReadLog(t.Context(), root.dir)
+       require.NoError(t, err)
+       require.Equal(t, rootLog, unchanged)
+
+       prompt, err := child.Prompt()
+       require.NoError(t, err)
+       require.NotEqual(t, root.CacheKey(), child.CacheKey())
+       require.Positive(t, server.Calls())
+
+       for i := range server.Calls() {
+               call := server.Call(i)
+               require.Equal(t, child.CacheKey(), call.body["prompt_cache_key"])
+               require.Equal(t, prompt, call.body["instructions"])
+               require.Contains(t, prompt, "Keep room dimensions exact.")
+               require.NotContains(t, string(call.raw), `"name":"whatsapp_send"`)
+       }
+}
+
+func TestWhatsAppImportChildFailureKeepsConfiguredChildren(t *testing.T) {
+       root := newRebuildTestAgent(t)
+       daemonChildConfig(t, filepath.Dir(root.dir), "House")
+
+       child, err := whatsappImportAgent(root.dir, "House")
+       require.NoError(t, err)
+
+       before := rebuildTestSnapshot(t, root.dir)
+       server := newModelFake(t)
+       server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
+               w.WriteHeader(http.StatusBadRequest)
+       }
+
+       client := newModelClient(server.URL, ModelAPIKey(modelTestKey), child.CacheKey(), filepath.Join(t.TempDir(), "usage.jsonl"))
+       _, err = rebuild(t.Context(), child, []DatedMessage{{
+               Kind: "work",
+               Text: strings.Repeat("The plan is ready. ", 80),
+               Date: "2025-01-01T08:00:00Z",
+       }}, client, newCompactor(client, child.Prompt, defaultConfig().Compactor, child.Tools()), &strings.Builder{})
+       require.ErrorContains(t, err, "rebuild")
+       require.Equal(t, before, rebuildTestSnapshot(t, root.dir))
+
+       children, err := root.Children()
+       require.NoError(t, err)
+       require.Len(t, children, 1)
+}
+
+func TestWhatsAppImportRefusesPendingDeliveriesAndKeepsRestartSafe(t *testing.T) {
+       testCases := []struct {
+               name  string
+               child bool
+       }{
+               {
+                       name:  "pending child delivery keeps its log ID",
+                       child: true,
+               },
+               {
+                       name: "pending root report keeps its log ID",
+               },
+       }
+
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       dir := t.TempDir()
+                       t.Setenv("ONECHAT_DIR", dir)
+
+                       server := newModelFake(t)
+                       server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
+                               modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
+                       }
+
+                       test := newDaemonMessageTest(t, dir, server)
+                       index := 0
+                       args := []string{whatsappImportZip(t, "Plans", "[01/01/2025, 08:00:00] Ada: The room is ready.\n")}
+                       if tc.child {
+                               index = 1
+                               args = append([]string{"--agent", "Garden"}, args...)
+
+                               require.NoError(t, test.messages.Tell("OneChat", "Garden", "Keep the plan."))
+                       } else {
+                               require.NoError(t, test.messages.Finish(test.loops[1], nil, "The plan is ready."))
+                       }
+
+                       queue := test.Queue(t)
+                       require.Len(t, queue.Pending, 1)
+                       require.NotNil(t, queue.Pending[0].LogID)
+
+                       before, err := test.agents[index].memory.Message(*queue.Pending[0].LogID)
+                       require.NoError(t, err)
+                       test.Close(t)
+
+                       root := filepath.Join(dir, "agent")
+                       snapshot := rebuildTestSnapshot(t, root)
+                       require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte("invalid model settings"), 0o600))
+
+                       for _, planOnly := range []bool{true, false} {
+                               input := append([]string(nil), args...)
+                               if planOnly {
+                                       input = append([]string{"-n"}, input...)
+                               }
+
+                               err = whatsappImport(t.Context(), input, &strings.Builder{})
+                               require.ErrorContains(t, err, "has pending deliveries")
+                               require.Equal(t, snapshot, rebuildTestSnapshot(t, root))
+                               require.Zero(t, server.Calls())
+                       }
+
+                       restarted := newDaemonMessageTest(t, dir, server)
+                       require.Equal(t, queue, restarted.Queue(t))
+                       require.Equal(t, 1, restarted.agents[index].memory.Len())
+
+                       after, err := restarted.agents[index].memory.Message(before.ID)
+                       require.NoError(t, err)
+                       require.Equal(t, before, after)
+                       restarted.Start(t, index)
+                       restarted.events[index].WaitFor(t, "idle", 1)
+                       require.Empty(t, restarted.Queue(t).Pending)
+                       require.Equal(t, 1, server.Calls())
+                       restarted.Close(t)
+
+                       again := newDaemonMessageTest(t, dir, server)
+                       require.False(t, again.loops[index].waiting())
+                       require.Equal(t, 1, server.Calls())
+               })
+       }
+}
+
+func TestWhatsAppImportPendingDeliveriesBlockOnlyTheirTarget(t *testing.T) {
+       dir := t.TempDir()
+       t.Setenv("ONECHAT_DIR", dir)
+
+       server := newModelFake(t)
+       test := newDaemonMessageTest(t, dir, server)
+       require.NoError(t, test.messages.Tell("OneChat", "Garden", "Keep the plan."))
+
+       queue := test.Queue(t)
+       test.Close(t)
+
+       path := whatsappImportZip(t, "Plans", "[01/01/2025, 08:00:00] Ada: The room is ready.\n")
+       for _, name := range []string{"", "Library"} {
+               args := []string{"-n", path}
+               if name != "" {
+                       args = append(args, "--agent", name)
+               }
+
+               require.NoError(t, whatsappImport(t.Context(), args, &strings.Builder{}))
+       }
+
+       queue.Pending[0].LogID = nil
+       data, err := json.Marshal(queue)
+       require.NoError(t, err)
+       require.NoError(t, os.WriteFile(filepath.Join(dir, "agent", "messages.json"), data, 0o600))
+       require.ErrorContains(t, whatsappImport(t.Context(), []string{"-n", "--agent", "Garden", path}, &strings.Builder{}), "has pending deliveries")
+       require.Zero(t, server.Calls())
+}
+
+func TestWhatsAppImportDefaultPlanDoesNotCreateAgent(t *testing.T) {
+       dir := t.TempDir()
+       t.Setenv("ONECHAT_DIR", dir)
+
+       var report strings.Builder
+
+       require.NoError(t, whatsappImport(t.Context(), []string{"-n", whatsappImportSample(t)}, &report))
+       require.Contains(t, report.String(), "agent: OneChat\n")
+
+       _, err := os.Stat(filepath.Join(dir, "agent"))
+       require.ErrorIs(t, err, os.ErrNotExist)
+}
+
 func whatsappImportSample(t *testing.T) string {
        t.Helper()