// 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()
}()
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)
// 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
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)
}
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.
import (
"archive/zip"
+ "encoding/json"
+ "net/http"
"os"
"path/filepath"
"strings"
}
}
+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()