summary |
shortlog |
log |
commit | commitdiff |
tree
raw |
inline | side by side (from parent 1:
496569a)
Table cases describe their accepted, rejected, routed, or displayed
behavior instead of naming only the input. Assertions, inputs, and
production behavior stay unchanged; test reports identify the contract
of each case.
Issue:
3cdc678
20 files changed:
+ name: "rejects unknown field",
config: `{"id": "00112233445566778899aabbccddeeff", "nmae": "Lena"}`,
err: `unknown field "nmae"`,
},
{
config: `{"id": "00112233445566778899aabbccddeeff", "nmae": "Lena"}`,
err: `unknown field "nmae"`,
},
{
+ name: "rejects missing id",
config: `{"name": "Lena"}`,
err: "id must be 32 hexadecimal digits",
},
{
config: `{"name": "Lena"}`,
err: "id must be 32 hexadecimal digits",
},
{
- name: "relative workdir",
+ name: "rejects relative workdir",
config: `{"id": "00112233445566778899aabbccddeeff", "workdir": "src"}`,
err: `workdir "src" is not an absolute path`,
},
{
config: `{"id": "00112233445566778899aabbccddeeff", "workdir": "src"}`,
err: `workdir "src" is not an absolute path`,
},
{
- name: "name of two lines",
+ name: "rejects name of two lines",
config: `{"id": "00112233445566778899aabbccddeeff", "name": "Le\nna"}`,
err: "name must be one line of text",
},
config: `{"id": "00112233445566778899aabbccddeeff", "name": "Le\nna"}`,
err: "name must be one line of text",
},
+ name: "continues without recorded calls",
records: nil,
want: "",
},
{
records: nil,
want: "",
},
{
- name: "growth within the limit",
+ name: "continues within the quota limit",
records: []ModelUsageRecord{quota("10", "40"), quota("25", "41")},
want: "",
},
{
records: []ModelUsageRecord{quota("10", "40"), quota("25", "41")},
want: "",
},
{
- name: "growth over the limit",
+ name: "stops above the quota limit",
records: []ModelUsageRecord{quota("10", "40"), quota("12", "55.5")},
want: "x-codex-secondary-used-percent grew from 40 to 55.5",
},
{
records: []ModelUsageRecord{quota("10", "40"), quota("12", "55.5")},
want: "x-codex-secondary-used-percent grew from 40 to 55.5",
},
{
+ name: "stops after a failed call",
records: []ModelUsageRecord{quota("10", "40"), {
Status: 400,
Error: "model call: HTTP 400",
records: []ModelUsageRecord{quota("10", "40"), {
Status: 400,
Error: "model call: HTTP 400",
+ name: "reports the model call failure",
reply: func(w http.ResponseWriter, _ ModelFakeCall) {
w.WriteHeader(http.StatusInternalServerError)
reply: func(w http.ResponseWriter, _ ModelFakeCall) {
w.WriteHeader(http.StatusInternalServerError)
- name: "tool call and no text",
+ name: "refuses a response without summary text",
reply: func(w http.ResponseWriter, _ ModelFakeCall) {
modelWriteEvents(w, modelCompletedEvent([]any{map[string]any{"type": "function_call", "name": "zoom", "arguments": "{}"}}, 100, 0, 5))
},
reply: func(w http.ResponseWriter, _ ModelFakeCall) {
modelWriteEvents(w, modelCompletedEvent([]any{map[string]any{"type": "function_call", "name": "zoom", "arguments": "{}"}}, 100, 0, 5))
},
+ name: "rejects unknown field",
text: `{"endpont": "api"}`,
},
{
text: `{"endpont": "api"}`,
},
{
- name: "unknown nested field",
+ name: "rejects unknown nested field",
text: `{"compactor": {"modle": "x"}}`,
},
{
text: `{"compactor": {"modle": "x"}}`,
},
{
+ name: "rejects endpoint URL",
text: `{"endpoint": "https://example.com/v1/responses"}`,
},
{
text: `{"endpoint": "https://example.com/v1/responses"}`,
},
{
+ name: "rejects empty model",
text: `{"compactor": {"model": ""}}`,
},
{
text: `{"compactor": {"model": ""}}`,
},
{
+ name: "rejects second object",
+ name: "rejects broken JSON",
text: `{"endpoint": `,
},
}
text: `{"endpoint": `,
},
}
+ name: "accepts house area",
+ name: "accepts calendar area",
+ name: "accepts two letters",
- name: "capital letters after the first",
+ name: "accepts capital letters after the first",
- name: "thirty-two letters",
+ name: "accepts thirty-two letters",
child: "A" + strings.Repeat("b", 31),
},
}
child: "A" + strings.Repeat("b", 31),
},
}
+ name: "refuses empty name",
+ name: "refuses one letter",
- name: "thirty-three letters",
+ name: "refuses thirty-three letters",
child: "A" + strings.Repeat("b", 32),
},
{
child: "A" + strings.Repeat("b", 32),
},
{
- name: "lowercase initial",
+ name: "refuses lowercase initial",
+ name: "refuses two words",
+ name: "refuses punctuation",
- name: "non-ASCII initial",
+ name: "refuses non-ASCII initial",
- name: "non-ASCII remainder",
+ name: "refuses non-ASCII remainder",
child: "Hous\u00e9",
},
}
child: "Hous\u00e9",
},
}
- name: "empty final reply",
+ name: "sends nothing for an empty final reply",
- name: "whitespace final reply",
+ name: "sends nothing for a whitespace final reply",
- name: "unknown sender",
+ name: "refuses an unknown sender",
source: "Other",
target: "Garden",
},
{
source: "Other",
target: "Garden",
},
{
- name: "unknown recipient",
+ name: "refuses an unknown recipient",
source: "OneChat",
target: "Other",
},
source: "OneChat",
target: "Other",
},
+ name: "stops startup for broken JSON",
- name: "unknown sender",
+ name: "stops startup for unknown sender",
file: `{"next":1,"pending":[{"id":1,"source":"Other","target":"Garden","kind":"user"}]}`,
},
{
file: `{"next":1,"pending":[{"id":1,"source":"Other","target":"Garden","kind":"user"}]}`,
},
{
- name: "sibling delivery",
+ name: "stops startup for sibling delivery",
file: `{"next":1,"pending":[{"id":1,"source":"Library","target":"Garden","kind":"user"}]}`,
},
{
file: `{"next":1,"pending":[{"id":1,"source":"Library","target":"Garden","kind":"user"}]}`,
},
{
- name: "invalid sequence",
+ name: "stops startup for invalid sequence",
file: `{"next":0,"pending":[{"id":1,"source":"OneChat","target":"Garden","kind":"user"}]}`,
},
}
file: `{"next":0,"pending":[{"id":1,"source":"OneChat","target":"Garden","kind":"user"}]}`,
},
}
+ name: "routes a private chat only to its child",
chat: WhatsAppChat{
ID: whatsappTestAna.String(),
Name: "Ana",
},
},
{
chat: WhatsAppChat{
ID: whatsappTestAna.String(),
Name: "Ana",
},
},
{
- name: "group without a mention",
+ name: "routes an unmentioned group only to its child",
chat: WhatsAppChat{
ID: whatsappTestFamily.String(),
Name: "Garden plans",
chat: WhatsAppChat{
ID: whatsappTestFamily.String(),
Name: "Garden plans",
- name: "before log ID is stored",
+ name: "recovers one message before its log ID is stored",
- name: "after log ID is stored",
+ name: "recovers one message after its log ID is stored",
- name: "ONECHAT_DIR first",
+ name: "uses ONECHAT_DIR before other paths",
dir: filepath.Join(root, "a"),
xdg: filepath.Join(root, "xdg"),
home: root,
want: filepath.Join(root, "a"),
},
{
dir: filepath.Join(root, "a"),
xdg: filepath.Join(root, "xdg"),
home: root,
want: filepath.Join(root, "a"),
},
{
- name: "absolute XDG_DATA_HOME",
+ name: "uses absolute XDG_DATA_HOME without ONECHAT_DIR",
dir: "",
xdg: filepath.Join(root, "xdg"),
home: root,
want: filepath.Join(root, "xdg", "onechat"),
},
{
dir: "",
xdg: filepath.Join(root, "xdg"),
home: root,
want: filepath.Join(root, "xdg", "onechat"),
},
{
- name: "relative XDG_DATA_HOME ignored",
+ name: "ignores relative XDG_DATA_HOME",
dir: "",
xdg: "xdg",
home: root,
want: filepath.Join(root, ".local", "share", "onechat"),
},
{
dir: "",
xdg: "xdg",
home: root,
want: filepath.Join(root, ".local", "share", "onechat"),
},
{
- name: "home directory last",
+ name: "falls back to the home data directory",
dir: "",
xdg: "",
home: root,
dir: "",
xdg: "",
home: root,
- name: "receipt points to different text",
+ name: "refuses when receipt points to different text",
id: 0,
cause: "does not match",
},
{
id: 0,
cause: "does not match",
},
{
- name: "receipt skips a log position",
+ name: "refuses when receipt skips a log position",
id: 2,
cause: "beyond the log",
},
{
id: 2,
cause: "beyond the log",
},
{
- name: "receipt has a negative position",
+ name: "refuses when receipt has a negative position",
id: -1,
cause: "read queued message",
},
id: -1,
cause: "read queued message",
},
- name: "ChatGPT backend",
+ name: "sends the session header to ChatGPT",
auth: auth,
want: "agent-key",
},
{
auth: auth,
want: "agent-key",
},
{
+ name: "omits the session header for the API",
auth: ModelAPIKey(modelTestKey),
want: "",
},
auth: ModelAPIKey(modelTestKey),
want: "",
},
- name: "failed response",
+ name: "reports the response failure cause",
events: []any{map[string]any{"type": "response.failed", "response": map[string]any{"error": map[string]any{"code": "server_error", "message": "busy"}}}},
want: "server_error: busy",
},
{
events: []any{map[string]any{"type": "response.failed", "response": map[string]any{"error": map[string]any{"code": "server_error", "message": "busy"}}}},
want: "server_error: busy",
},
{
- name: "incomplete response",
+ name: "reports why the response is incomplete",
events: []any{map[string]any{"type": "response.incomplete", "response": map[string]any{"incomplete_details": map[string]any{"reason": "content_filter"}}}},
want: "content_filter",
},
{
events: []any{map[string]any{"type": "response.incomplete", "response": map[string]any{"incomplete_details": map[string]any{"reason": "content_filter"}}}},
want: "content_filter",
},
{
+ name: "reports the stream error cause",
events: []any{map[string]any{"type": "error", "code": "rate_limit", "message": "too many"}},
want: "rate_limit: too many",
},
{
events: []any{map[string]any{"type": "error", "code": "rate_limit", "message": "too many"}},
want: "rate_limit: too many",
},
{
- name: "stream ends early",
+ name: "refuses an unfinished response stream",
events: []any{map[string]any{"type": "response.output_item.done", "item": modelMessageItem("cut")}},
want: "ended before the response completed",
},
events: []any{map[string]any{"type": "response.output_item.done", "item": modelMessageItem("cut")}},
want: "ended before the response completed",
},
+ name: "refuses unknown kind",
source: "mail:inbox",
agent: "Garden",
cause: "unknown source kind",
},
{
source: "mail:inbox",
agent: "Garden",
cause: "unknown source kind",
},
{
+ name: "refuses missing kind",
source: whatsappTestFamily.String(),
agent: "Garden",
cause: "unknown source kind",
},
{
source: whatsappTestFamily.String(),
agent: "Garden",
cause: "unknown source kind",
},
{
+ name: "refuses empty chat",
source: "whatsapp:",
agent: "Garden",
cause: "invalid WhatsApp source",
},
{
source: "whatsapp:",
agent: "Garden",
cause: "invalid WhatsApp source",
},
{
- name: "unknown chat kind",
+ name: "refuses unknown chat kind",
source: "whatsapp:example@unknown",
agent: "Garden",
cause: "invalid WhatsApp source",
},
{
source: "whatsapp:example@unknown",
agent: "Garden",
cause: "invalid WhatsApp source",
},
{
- name: "root is not a child",
+ name: "refuses root is not a child",
source: "whatsapp:" + whatsappTestFamily.String(),
agent: "OneChat",
cause: "must be a child",
},
{
source: "whatsapp:" + whatsappTestFamily.String(),
agent: "OneChat",
cause: "must be a child",
},
{
+ name: "refuses unknown child",
source: "whatsapp:" + whatsappTestFamily.String(),
agent: "Other",
cause: "must be a child",
},
{
source: "whatsapp:" + whatsappTestFamily.String(),
agent: "Other",
cause: "must be a child",
},
{
+ name: "refuses empty child",
source: "whatsapp:" + whatsappTestFamily.String(),
agent: "",
cause: "must be a child",
source: "whatsapp:" + whatsappTestFamily.String(),
agent: "",
cause: "must be a child",
+ name: "stops startup for invalid JSON",
- name: "unknown source",
+ name: "stops startup for unknown source",
text: `{"mail:inbox":"Garden"}`,
},
{
text: `{"mail:inbox":"Garden"}`,
},
{
- name: "empty destination",
+ name: "stops startup for empty destination",
text: `{"whatsapp:example@lid":""}`,
},
}
text: `{"whatsapp:example@lid":""}`,
},
}
expression: "*/5 * * * *",
},
{
expression: "*/5 * * * *",
},
{
+ name: "accepts a weekday schedule at 8",
expression: "0 8 * * 1-5",
},
{
expression: "0 8 * * 1-5",
},
{
- name: "lists and ranges in office hours",
+ name: "accepts lists and ranges in office hours",
expression: "0,30 9-17 * * 1-5",
},
{
expression: "0,30 9-17 * * 1-5",
},
{
- name: "a range with a step",
+ name: "accepts a stepped range",
expression: "0 0 1-31/10 * *",
},
{
expression: "0 0 1-31/10 * *",
},
{
- name: "February 29 of the leap years",
+ name: "accepts a leap-day schedule",
expression: "0 12 29 2 *",
},
{
expression: "0 12 29 2 *",
},
{
- name: "5 minutes across the end of an hour",
+ name: "accepts five-minute spacing across an hour",
expression: "0,55 * * * *",
},
}
expression: "0,55 * * * *",
},
}
+ name: "rejects four fields",
expression: "* * * *",
message: "has 4 fields, not 5",
},
{
expression: "* * * *",
message: "has 4 fields, not 5",
},
{
- name: "a seconds field",
+ name: "rejects a seconds field",
expression: "0 0 8 * * *",
message: "has 6 fields, not 5",
},
{
expression: "0 0 8 * * *",
message: "has 6 fields, not 5",
},
{
+ name: "rejects a macro",
expression: "@daily",
message: "has 1 fields, not 5",
},
{
expression: "@daily",
message: "has 1 fields, not 5",
},
{
+ name: "rejects a day name",
expression: "0 8 * * MON",
message: `the day of week field of "0 8 * * MON": "MON" is not a number from 0 to 7`,
},
{
expression: "0 8 * * MON",
message: `the day of week field of "0 8 * * MON": "MON" is not a number from 0 to 7`,
},
{
+ name: "rejects a month name",
expression: "0 8 * JAN *",
message: `"JAN" is not a number from 1 to 12`,
},
{
expression: "0 8 * JAN *",
message: `"JAN" is not a number from 1 to 12`,
},
{
- name: "a question mark",
+ name: "rejects a question mark",
expression: "0 8 ? * *",
message: `"?" is not a number from 1 to 31`,
},
{
expression: "0 8 ? * *",
message: `"?" is not a number from 1 to 31`,
},
{
+ name: "rejects the last day",
expression: "0 8 L * *",
message: `"L" is not a number`,
},
{
expression: "0 8 L * *",
message: `"L" is not a number`,
},
{
- name: "the nearest weekday",
+ name: "rejects the nearest weekday",
expression: "0 8 15W * *",
message: `"15W" is not a number`,
},
{
expression: "0 8 15W * *",
message: `"15W" is not a number`,
},
{
- name: "the nth weekday",
+ name: "rejects the nth weekday",
expression: "0 8 * * 1#2",
message: `"1#2" is not a number`,
},
{
expression: "0 8 * * 1#2",
message: `"1#2" is not a number`,
},
{
- name: "a step after a single value",
+ name: "rejects a step after a single value",
expression: "5/10 * * * *",
message: "has a step after a single value",
},
{
expression: "5/10 * * * *",
message: "has a step after a single value",
},
{
- name: "a minute out of range",
+ name: "rejects a minute out of range",
expression: "60 * * * *",
message: `"60" is not a number from 0 to 59`,
},
{
expression: "60 * * * *",
message: `"60" is not a number from 0 to 59`,
},
{
- name: "an hour out of range",
+ name: "rejects an hour out of range",
expression: "0 24 * * *",
message: `"24" is not a number from 0 to 23`,
},
{
expression: "0 24 * * *",
message: `"24" is not a number from 0 to 23`,
},
{
expression: "0 8 0 * *",
message: `"0" is not a number from 1 to 31`,
},
{
expression: "0 8 0 * *",
message: `"0" is not a number from 1 to 31`,
},
{
+ name: "rejects month 13",
expression: "0 8 * 13 *",
message: `"13" is not a number from 1 to 12`,
},
{
expression: "0 8 * 13 *",
message: `"13" is not a number from 1 to 12`,
},
{
+ name: "rejects weekday 8",
expression: "0 8 * * 8",
message: `"8" is not a number from 0 to 7`,
},
{
expression: "0 8 * * 8",
message: `"8" is not a number from 0 to 7`,
},
{
- name: "a negative number",
+ name: "rejects a negative number",
expression: "0 8 * * -1",
message: `the range "-1"`,
},
{
expression: "0 8 * * -1",
message: `the range "-1"`,
},
{
- name: "a range with its start after its end",
+ name: "rejects a range with its start after its end",
expression: "0 10-5 * * *",
message: "the start is after the end",
},
{
expression: "0 10-5 * * *",
message: "the start is after the end",
},
{
+ name: "rejects a step of 0",
expression: "0 */0 * * *",
message: `the step of "*/0"`,
},
{
expression: "0 */0 * * *",
message: `the step of "*/0"`,
},
{
+ name: "rejects an empty item",
expression: "0,,30 * * * *",
message: `"" is not a number`,
},
{
expression: "0,,30 * * * *",
message: `"" is not a number`,
},
{
- name: "a day that never comes",
+ name: "rejects a day that never comes",
expression: "0 0 30 2 *",
message: "matches no day in 8 years",
},
{
expression: "0 0 30 2 *",
message: "matches no day in 8 years",
},
{
+ name: "rejects every minute",
expression: "* * * * *",
message: "the runs of a schedule must be at least 5 minutes apart",
},
{
expression: "* * * * *",
message: "the runs of a schedule must be at least 5 minutes apart",
},
{
- name: "every 2 minutes",
+ name: "rejects every 2 minutes",
expression: "*/2 * * * *",
message: "the runs of a schedule must be at least 5 minutes apart",
},
{
expression: "*/2 * * * *",
message: "the runs of a schedule must be at least 5 minutes apart",
},
{
- name: "two runs in one minute of the hour",
+ name: "rejects two runs in one minute of the hour",
expression: "58,59 * * * *",
message: "the runs of a schedule must be at least 5 minutes apart",
},
{
expression: "58,59 * * * *",
message: "the runs of a schedule must be at least 5 minutes apart",
},
{
- name: "two runs across the end of an hour",
+ name: "rejects two runs across the end of an hour",
expression: "0,59 * * * *",
message: "the runs of a schedule must be at least 5 minutes apart",
},
{
expression: "0,59 * * * *",
message: "the runs of a schedule must be at least 5 minutes apart",
},
{
- name: "two runs across midnight",
+ name: "rejects two runs across midnight",
expression: "0,59 0,23 * * *",
message: "the runs of a schedule must be at least 5 minutes apart",
},
expression: "0,59 0,23 * * *",
message: "the runs of a schedule must be at least 5 minutes apart",
},
next: time.Date(2026, time.November, 7, 8, 0, 0, 0, time.UTC),
},
{
next: time.Date(2026, time.November, 7, 8, 0, 0, 0, time.UTC),
},
{
- name: "every 5 minutes",
+ name: "advances to the next five-minute boundary",
expression: "*/5 * * * *",
after: time.Date(2026, time.November, 6, 10, 3, 0, 0, time.UTC),
next: time.Date(2026, time.November, 6, 10, 5, 0, 0, time.UTC),
expression: "*/5 * * * *",
after: time.Date(2026, time.November, 6, 10, 3, 0, 0, time.UTC),
next: time.Date(2026, time.November, 6, 10, 5, 0, 0, time.UTC),
next: time.Date(2026, time.December, 31, 0, 0, 0, 0, time.UTC),
},
{
next: time.Date(2026, time.December, 31, 0, 0, 0, 0, time.UTC),
},
{
- name: "the first day after the end of a year",
+ name: "advances to the first day of the next year",
expression: "0 9 1 * *",
after: time.Date(2026, time.December, 31, 10, 0, 0, 0, time.UTC),
next: time.Date(2027, time.January, 1, 9, 0, 0, 0, time.UTC),
expression: "0 9 1 * *",
after: time.Date(2026, time.December, 31, 10, 0, 0, 0, time.UTC),
next: time.Date(2027, time.January, 1, 9, 0, 0, 0, time.UTC),
- name: "a normal wall time",
+ name: "keeps a normal wall time unchanged",
hour: 9,
day: 1,
time: time.Date(2026, time.November, 1, 14, 0, 0, 0, time.UTC),
hour: 9,
day: 1,
time: time.Date(2026, time.November, 1, 14, 0, 0, 0, time.UTC),
- name: "a one-time schedule at a local time",
+ name: "sets a one-time run at its local time",
text: "Remind the user to drink water.",
at: new("2026-11-09 09:00"),
answer: "Schedule 1 set. Next: Monday 2026-11-09 09:00 -03:00.",
listed: "1 | Monday 2026-11-09 09:00 -03:00 | once | Remind the user to drink water.",
},
{
text: "Remind the user to drink water.",
at: new("2026-11-09 09:00"),
answer: "Schedule 1 set. Next: Monday 2026-11-09 09:00 -03:00.",
listed: "1 | Monday 2026-11-09 09:00 -03:00 | once | Remind the user to drink water.",
},
{
- name: "a repeating schedule at the next match",
+ name: "sets a repeating run at its next match",
text: "Tell the user\nthe time.",
cron: new("*/5 * * * *"),
answer: "Schedule 1 set. Next: Sunday 2026-11-08 20:05 -03:00.",
text: "Tell the user\nthe time.",
cron: new("*/5 * * * *"),
answer: "Schedule 1 set. Next: Sunday 2026-11-08 20:05 -03:00.",
+ name: "refuses an empty text",
text: " \n",
at: new("2026-11-09 09:00"),
message: "the text must have 1 to 4096 bytes",
},
{
text: " \n",
at: new("2026-11-09 09:00"),
message: "the text must have 1 to 4096 bytes",
},
{
- name: "a text that is too long",
+ name: "refuses a text that is too long",
text: strings.Repeat("x", scheduleTextBytes+1),
at: new("2026-11-09 09:00"),
message: "the text must have 1 to 4096 bytes",
},
{
text: strings.Repeat("x", scheduleTextBytes+1),
at: new("2026-11-09 09:00"),
message: "the text must have 1 to 4096 bytes",
},
{
- name: "neither at nor cron",
+ name: "refuses neither at nor cron",
text: "Tell the user the time.",
message: "give either at or cron, and null for the other",
},
{
text: "Tell the user the time.",
message: "give either at or cron, and null for the other",
},
{
- name: "both at and cron",
+ name: "refuses both at and cron",
text: "Tell the user the time.",
at: new("2026-11-09 09:00"),
cron: new("0 9 * * *"),
message: "give either at or cron, and null for the other",
},
{
text: "Tell the user the time.",
at: new("2026-11-09 09:00"),
cron: new("0 9 * * *"),
message: "give either at or cron, and null for the other",
},
{
- name: "a time in another layout",
+ name: "refuses a time in another layout",
text: "Tell the user the time.",
at: new("2026-11-09T09:00:00-03:00"),
message: "at must be a local time such as 2026-11-08 20:00",
},
{
text: "Tell the user the time.",
at: new("2026-11-09T09:00:00-03:00"),
message: "at must be a local time such as 2026-11-08 20:00",
},
{
- name: "a time that is past",
+ name: "refuses a time that is past",
text: "Tell the user the time.",
at: new("2026-11-08 20:00"),
message: "the time Sunday 2026-11-08 20:00 -03:00 is not after now, Sunday 2026-11-08 20:00 -03:00",
},
{
text: "Tell the user the time.",
at: new("2026-11-08 20:00"),
message: "the time Sunday 2026-11-08 20:00 -03:00 is not after now, Sunday 2026-11-08 20:00 -03:00",
},
{
- name: "runs less than 5 minutes apart",
+ name: "refuses runs less than 5 minutes apart",
text: "Tell the user the time.",
cron: new("* * * * *"),
message: "at least 5 minutes apart",
text: "Tell the user the time.",
cron: new("* * * * *"),
message: "at least 5 minutes apart",
- name: "a one-time run on time",
+ name: "labels an on-time run as once",
schedule: Schedule{
ID: 3,
Text: "Remind the user to drink water.",
schedule: Schedule{
ID: 3,
Text: "Remind the user to drink water.",
message: "[Schedule 3] Monday 2026-11-09 09:00 -03:00, once\nRemind the user to drink water.\n" + scheduleEmptyReply,
},
{
message: "[Schedule 3] Monday 2026-11-09 09:00 -03:00, once\nRemind the user to drink water.\n" + scheduleEmptyReply,
},
{
- name: "a late repeating run",
+ name: "marks a late run with its repeating expression",
schedule: Schedule{
ID: 4,
Text: "Tell the user the news.",
schedule: Schedule{
ID: 4,
Text: "Tell the user the news.",
- name: "a name after the command",
+ name: "reads the name after the stop command",
line: " /stop GreenOtter ",
found: true,
agent: "GreenOtter",
},
{
line: " /stop GreenOtter ",
found: true,
agent: "GreenOtter",
},
{
- name: "the command alone",
+ name: "finds no subagent name after the bare command",
- name: "the command with spaces only",
+ name: "finds no subagent name in trailing spaces",
- name: "a word that starts with the command",
+ name: "rejects a command joined to its name",
line: "/stopGreenOtter",
},
{
line: "/stopGreenOtter",
},
{
+ name: "leaves a mention of the command as text",
line: "please /stop GreenOtter",
},
}
line: "please /stop GreenOtter",
},
}
- name: "headings and a full width rule",
+ name: "renders headings and fills the rule width",
text: "# First\n###### Last ###\n---",
columns: 12,
want: []string{"\x1b[1mFirst\x1b[0m", "\x1b[1mLast\x1b[0m", "\x1b[2m------------\x1b[0m"},
},
{
text: "# First\n###### Last ###\n---",
columns: 12,
want: []string{"\x1b[1mFirst\x1b[0m", "\x1b[1mLast\x1b[0m", "\x1b[2m------------\x1b[0m"},
},
{
- name: "bold italic and code",
+ name: "renders bold italic and inline code styles",
text: "**bold** __also__ *lean* _also_ `code`",
columns: 80,
want: []string{"\x1b[1mbold\x1b[0m \x1b[1malso\x1b[0m \x1b[3mlean\x1b[0m \x1b[3malso\x1b[0m \x1b[7mcode\x1b[0m"},
text: "**bold** __also__ *lean* _also_ `code`",
columns: 80,
want: []string{"\x1b[1mbold\x1b[0m \x1b[1malso\x1b[0m \x1b[3mlean\x1b[0m \x1b[3malso\x1b[0m \x1b[7mcode\x1b[0m"},
- name: "reply as it is",
+ name: "keeps the complete reply text",
event: Event{
Kind: "reply",
Text: "Done.",
event: Event{
Kind: "reply",
Text: "Done.",
- name: "own user message not shown",
+ name: "hides the current terminal user message",
event: Event{
Kind: "user",
Text: "typed",
event: Event{
Kind: "user",
Text: "typed",
- name: "user message of another source marked",
+ name: "marks a user message from another source",
event: Event{
Kind: "user",
Text: "from the phone",
event: Event{
Kind: "user",
Text: "from the phone",
text: "[telegram] from the phone",
},
{
text: "[telegram] from the phone",
},
{
- name: "start of a subagent",
+ name: "marks the start of a subagent",
event: Event{
Kind: "subagent",
Text: "GreenOtter",
event: Event{
Kind: "subagent",
Text: "GreenOtter",
text: "[GreenOtter] started",
},
{
text: "[GreenOtter] started",
},
{
- name: "report of a subagent whole",
+ name: "keeps the complete subagent report",
event: Event{
Kind: "work",
Text: "[GreenOtter]\nThere are 3 files.\n\nFull chat: zoom(\"GreenOtter\")",
event: Event{
Kind: "work",
Text: "[GreenOtter]\nThere are 3 files.\n\nFull chat: zoom(\"GreenOtter\")",
text: "[GreenOtter]\nThere are 3 files.\n\nFull chat: zoom(\"GreenOtter\")",
},
{
text: "[GreenOtter]\nThere are 3 files.\n\nFull chat: zoom(\"GreenOtter\")",
},
{
- name: "long result cut",
+ name: "clips a long tool result with its omitted count",
event: Event{
Kind: "echo",
Text: strings.Repeat("line\n", 12),
event: Event{
Kind: "echo",
Text: strings.Repeat("line\n", 12),
- name: "working directory",
+ name: "runs in the configured working directory",
command: "pwd",
output: dir + "\n",
},
{
command: "pwd",
output: dir + "\n",
},
{
- name: "error output and exit status",
+ name: "returns both streams and the failed exit status",
command: "echo out; echo err >&2; exit 3",
output: "out\nerr\n\n[exit status 3]",
},
{
command: "echo out; echo err >&2; exit 3",
output: "out\nerr\n\n[exit status 3]",
},
{
+ name: "marks an empty successful output",
command: "true",
output: "(no output)",
},
{
command: "true",
output: "(no output)",
},
{
+ name: "closes standard input without user input",
command: "cat",
output: "(no output)",
},
command: "cat",
output: "(no output)",
},
- name: "one message whole",
+ name: "returns one message without shortening it",
arguments: `{"id":2,"n":1}`,
output: "user: two",
},
arguments: `{"id":2,"n":1}`,
output: "user: two",
},
output: "0+2|user: zero user: one\n2+2|user: two user: three",
},
{
output: "0+2|user: zero user: one\n2+2|user: two user: three",
},
{
- name: "an id that is not a multiple of n",
+ name: "rejects an unaligned line ID",
arguments: `{"id":1,"n":2}`,
output: "error: line 1+2 does not exist: n must be a power of 2 and id a multiple of n",
},
{
arguments: `{"id":1,"n":2}`,
output: "error: line 1+2 does not exist: n must be a power of 2 and id a multiple of n",
},
{
- name: "a line past the last message",
+ name: "rejects a line beyond the chat",
arguments: `{"id":4,"n":4}`,
output: "error: line 4+4 goes past the last message, 3",
},
arguments: `{"id":4,"n":4}`,
output: "error: line 4+4 goes past the last message, 3",
},
answer: "15550002222@s.whatsapp.net | Ana Souza | contact\n120363000000000001@g.us | Souza Family | group",
},
{
answer: "15550002222@s.whatsapp.net | Ana Souza | contact\n120363000000000001@g.us | Souza Family | group",
},
{
- name: "a business name",
+ name: "finds the business by its name",
query: "bakery",
answer: "15550005555@s.whatsapp.net | Corner Bakery | contact",
},
{
query: "bakery",
answer: "15550005555@s.whatsapp.net | Corner Bakery | contact",
},
{
+ name: "reports that no name matches",
query: "Zed",
answer: "No contact or group has a name with \"Zed\".",
},
query: "Zed",
answer: "No contact or group has a name with \"Zed\".",
},
+ name: "keeps plain group text without starting a turn",
message: func(_ *events.Message) {
},
line: "12:00 Bruno Lima: Who brings the cake?",
},
{
message: func(_ *events.Message) {
},
line: "12:00 Bruno Lima: Who brings the cake?",
},
{
+ name: "starts a turn for a mention of the user",
message: func(event *events.Message) {
event.Message = &waE2E.Message{
ExtendedTextMessage: &waE2E.ExtendedTextMessage{
message: func(event *events.Message) {
event.Message = &waE2E.Message{
ExtendedTextMessage: &waE2E.ExtendedTextMessage{
+ name: "starts a turn for a reply to the user",
message: func(event *events.Message) {
event.Message = &waE2E.Message{
ExtendedTextMessage: &waE2E.ExtendedTextMessage{
message: func(event *events.Message) {
event.Message = &waE2E.Message{
ExtendedTextMessage: &waE2E.ExtendedTextMessage{
change func(event *events.Message)
}{
{
change func(event *events.Message)
}{
{
+ name: "ignores an edit",
change: func(event *events.Message) {
event.IsEdit = true
},
},
{
change: func(event *events.Message) {
event.IsEdit = true
},
},
{
+ name: "ignores old history",
change: func(event *events.Message) {
event.SourceWebMsg = &waWeb.WebMessageInfo{}
},
},
{
change: func(event *events.Message) {
event.SourceWebMsg = &waWeb.WebMessageInfo{}
},
},
{
- name: "a view-once message",
+ name: "ignores a view-once message",
change: func(event *events.Message) {
event.IsViewOnce = true
},
},
{
change: func(event *events.Message) {
event.IsViewOnce = true
},
},
{
- name: "a disappearing message",
+ name: "ignores a disappearing message",
change: func(event *events.Message) {
event.IsEphemeral = true
},
},
{
change: func(event *events.Message) {
event.IsEphemeral = true
},
},
{
- name: "the chat of the user with itself",
+ name: "ignores the chat of the user with itself",
change: func(event *events.Message) {
event.Info.Chat = whatsappTestOwn
},
},
{
change: func(event *events.Message) {
event.Info.Chat = whatsappTestOwn
},
},
{
- name: "a status broadcast",
+ name: "ignores a status broadcast",
change: func(event *events.Message) {
event.Info.Chat = types.StatusBroadcastJID
},
},
{
change: func(event *events.Message) {
event.Info.Chat = types.StatusBroadcastJID
},
},
{
- name: "a text of spaces",
+ name: "ignores a text of spaces",
change: func(event *events.Message) {
event.Message.Conversation = new(" ")
},
change: func(event *events.Message) {
event.Message.Conversation = new(" ")
},
id: " ",
chat: whatsappTestAna.String(),
text: "hi",
err: "the id must have 1 to 100 bytes",
},
{
id: " ",
chat: whatsappTestAna.String(),
text: "hi",
err: "the id must have 1 to 100 bytes",
},
{
+ name: "refuses no text",
id: "a",
chat: whatsappTestAna.String(),
text: "",
err: "the text must have 1 to 65536 bytes",
},
{
id: "a",
chat: whatsappTestAna.String(),
text: "",
err: "the text must have 1 to 65536 bytes",
},
{
- name: "a name for a chat",
+ name: "refuses a name for a chat",
id: "a",
chat: "Ana Souza",
text: "hi",
err: `"Ana Souza" is not the ID of a WhatsApp chat: find the ID with whatsapp_find`,
},
{
id: "a",
chat: "Ana Souza",
text: "hi",
err: `"Ana Souza" is not the ID of a WhatsApp chat: find the ID with whatsapp_find`,
},
{
- name: "a status broadcast",
+ name: "refuses a status broadcast",
id: "a",
chat: types.StatusBroadcastJID.String(),
text: "hi",
err: `"status@broadcast" is not a private chat or a group`,
},
{
id: "a",
chat: types.StatusBroadcastJID.String(),
text: "hi",
err: `"status@broadcast" is not a private chat or a group`,
},
{
- name: "the chat of the user",
+ name: "refuses the chat of the user",
id: "a",
chat: whatsappTestOwn.String(),
text: "hi",
id: "a",
chat: whatsappTestOwn.String(),
text: "hi",
stop func(test *WhatsAppRetryTest)
}{
{
stop func(test *WhatsAppRetryTest)
}{
{
+ name: "stops retrying after unlink",
stop: func(test *WhatsAppRetryTest) {
test.whatsapp.Command("unlink", func(_ string) {
})
},
},
{
stop: func(test *WhatsAppRetryTest) {
test.whatsapp.Command("unlink", func(_ string) {
})
},
},
{
+ name: "stops retrying after logout",
stop: func(test *WhatsAppRetryTest) {
test.fake.Emit(&events.LoggedOut{})
},
},
{
stop: func(test *WhatsAppRetryTest) {
test.fake.Emit(&events.LoggedOut{})
},
},
{
- name: "permanent disconnect",
+ name: "stops retrying after a permanent disconnect",
stop: func(test *WhatsAppRetryTest) {
test.fake.Emit(&events.StreamReplaced{})
},
stop: func(test *WhatsAppRetryTest) {
test.fake.Emit(&events.StreamReplaced{})
},
session func(t *testing.T, dir string)
}{
{
session func(t *testing.T, dir string)
}{
{
- name: "no session file",
+ name: "stays unlinked without a session file",
session: func(_ *testing.T, _ string) {
},
},
{
session: func(_ *testing.T, _ string) {
},
},
{
- name: "a session of a pairing that did not end",
+ name: "stays unlinked after incomplete pairing",
session: func(t *testing.T, dir string) {
whatsappTestSession(t, dir, false)
},
session: func(t *testing.T, dir string) {
whatsappTestSession(t, dir, false)
},
- name: "the code expires",
+ name: "ends pairing when the code expires",
end: func(items chan whatsmeow.QRChannelItem) {
items <- whatsmeow.QRChannelTimeout
},
answer: whatsappPairExpired,
},
{
end: func(items chan whatsmeow.QRChannelItem) {
items <- whatsmeow.QRChannelTimeout
},
answer: whatsappPairExpired,
},
{
- name: "the library closes the pairing",
+ name: "ends pairing when the library closes it",
end: func(items chan whatsmeow.QRChannelItem) {
close(items)
},
answer: whatsappPairExpired,
},
{
end: func(items chan whatsmeow.QRChannelItem) {
close(items)
},
answer: whatsappPairExpired,
},
{
- name: "the pairing fails",
+ name: "ends pairing when the library reports failure",
end: func(items chan whatsmeow.QRChannelItem) {
items <- whatsmeow.QRChannelItem{
Event: whatsmeow.QRChannelEventError,
end: func(items chan whatsmeow.QRChannelItem) {
items <- whatsmeow.QRChannelItem{
Event: whatsmeow.QRChannelEventError,
+ name: "logs out before removing the session",
answer: "WhatsApp is unlinked.",
},
{
answer: "WhatsApp is unlinked.",
},
{
- name: "with no logout",
+ name: "removes an offline session without logout",
logoutErr: errors.New("not connected"),
answer: "WhatsApp is unlinked here, but the phone did not get the logout. In WhatsApp on your phone, open Linked devices and remove the device.",
},
logoutErr: errors.New("not connected"),
answer: "WhatsApp is unlinked here, but the phone did not get the logout. In WhatsApp on your phone, open Linked devices and remove the device.",
},
- name: "a number with a plus and dashes",
+ name: "accepts a number with a plus and dashes",
phone: "+1-555-000-1111",
valid: true,
},
{
phone: "+1-555-000-1111",
valid: true,
},
{
- name: "a number with no country code",
+ name: "refuses a number without a country code",
phone: "0115550001111",
valid: false,
},
{
phone: "0115550001111",
valid: false,
},
{
- name: "a short number",
+ name: "refuses a number shorter than the minimum",
phone: "123456",
valid: false,
},
{
phone: "123456",
valid: false,
},
{
+ name: "refuses a number longer than the maximum",
phone: "1234567890123456",
valid: false,
},
{
phone: "1234567890123456",
valid: false,
},
{
- name: "a number with letters",
+ name: "refuses a number with letters",
phone: "1555ABC1111",
valid: false,
},
phone: "1555ABC1111",
valid: false,
},
- name: "a command with its arguments",
+ name: "reads and trims the command arguments",
line: " /whatsapp pair +1 555 ",
args: "pair +1 555",
found: true,
},
{
line: " /whatsapp pair +1 555 ",
args: "pair +1 555",
found: true,
},
{
- name: "the command alone",
+ name: "accepts the command without arguments",
line: "/whatsapp",
args: "",
found: true,
},
{
line: "/whatsapp",
args: "",
found: true,
},
{
- name: "a word that starts with the command",
+ name: "leaves a joined command word as text",
line: "/whatsappstatus",
args: "",
found: false,
},
{
line: "/whatsappstatus",
args: "",
found: false,
},
{
- name: "a message that names the command",
+ name: "leaves a mention of the command as text",
line: "what does /whatsapp status do?",
args: "",
found: false,
line: "what does /whatsapp status do?",
args: "",
found: false,