From: Anderson A. Giacomolli Date: Tue, 10 Nov 2026 10:48:50 +0000 (-0300) Subject: Import WhatsApp history into a child X-Git-Url: http://git.giacomolli.uk/onechat/commitdiff_plain?ds=sidebyside Import WhatsApp history into a child 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. --- diff --git a/README.rst b/README.rst index 49963a1..7ca3d74 100644 --- a/README.rst +++ b/README.rst @@ -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 diff --git a/command_test.go b/command_test.go index 3d89327..602424d 100644 --- a/command_test.go +++ b/command_test.go @@ -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 efecde3..843bf90 100644 --- 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 diff --git a/rebuild.go b/rebuild.go index 11c6bfc..598371c 100644 --- a/rebuild.go +++ b/rebuild.go @@ -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) diff --git a/whatsapp_import.go b/whatsapp_import.go index 2361915..df871c1 100644 --- a/whatsapp_import.go +++ b/whatsapp_import.go @@ -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. diff --git a/whatsapp_import_test.go b/whatsapp_import_test.go index 1e24039..1f9c9af 100644 --- a/whatsapp_import_test.go +++ b/whatsapp_import_test.go @@ -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()