Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 44 additions & 7 deletions ax_cli/commands/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,39 @@ def _wait_for_reply(client, message_id: str, timeout: int = 60) -> dict | None:
)


def _message_items(data) -> list[dict]:
if isinstance(data, list):
return data
if isinstance(data, dict):
return data.get("messages", [])
return []


def _resolve_message_id(client, message_id: str) -> str:
"""Resolve table-friendly short message IDs against recent messages."""
candidate = message_id.strip()
if not candidate or "-" in candidate or len(candidate) >= 32:
return candidate

data = client.list_messages(limit=100)
matches = [
str(message.get("id") or "")
for message in _message_items(data)
if str(message.get("id") or "").startswith(candidate)
]
matches = [match for match in matches if match]

if len(matches) == 1:
return matches[0]
if len(matches) > 1:
typer.echo(
f"Error: message ID prefix '{candidate}' is ambiguous. Use the full ID from --json.",
err=True,
)
raise typer.Exit(1)
return candidate


def _attachment_ref(
*,
attachment_id: str,
Expand Down Expand Up @@ -294,11 +327,12 @@ def send(
final_content = f"{mention} {content}"

try:
parent_id = _resolve_message_id(client, parent) if parent else None
data = client.send_message(
sid,
final_content,
channel=channel,
parent_id=parent,
parent_id=parent_id,
attachments=attachments or None,
)
except httpx.HTTPStatusError as e:
Expand Down Expand Up @@ -341,18 +375,20 @@ def list_messages(
data = client.list_messages(limit=limit, channel=channel)
except httpx.HTTPStatusError as e:
handle_error(e)
messages = data if isinstance(data, list) else data.get("messages", [])
messages = _message_items(data)
if as_json:
print_json(messages)
else:
for m in messages:
c = str(m.get("content", ""))
m["content_short"] = c[:60] + "..." if len(c) > 60 else c
m["sender"] = m.get("display_name") or m.get("sender_handle") or m.get("sender_type", "")
full_id = str(m.get("id", ""))
m["short_id"] = full_id[:8] if full_id else ""
print_table(
["ID", "Sender", "Content", "Created At"],
messages,
keys=["id", "sender", "content_short", "created_at"],
keys=["short_id", "sender", "content_short", "created_at"],
)


Expand All @@ -364,7 +400,7 @@ def get(
"""Get a single message."""
client = get_client()
try:
data = client.get_message(message_id)
data = client.get_message(_resolve_message_id(client, message_id))
except httpx.HTTPStatusError as e:
handle_error(e)
if as_json:
Expand All @@ -382,7 +418,7 @@ def edit(
"""Edit a message."""
client = get_client()
try:
data = client.edit_message(message_id, content)
data = client.edit_message(_resolve_message_id(client, message_id), content)
except httpx.HTTPStatusError as e:
handle_error(e)
if as_json:
Expand All @@ -399,11 +435,12 @@ def delete(
"""Delete a message."""
client = get_client()
try:
client.delete_message(message_id)
resolved_message_id = _resolve_message_id(client, message_id)
client.delete_message(resolved_message_id)
except httpx.HTTPStatusError as e:
handle_error(e)
if as_json:
print_json({"status": "deleted", "message_id": message_id})
print_json({"status": "deleted", "message_id": resolved_message_id})
else:
typer.echo("Deleted.")

Expand Down
2 changes: 1 addition & 1 deletion tests/test_handoff.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ def test_handoff_is_registered_and_old_tone_verbs_are_removed():

handoff_help = runner.invoke(app, ["handoff", "--help"])
assert handoff_help.exit_code == 0
assert "--follow-up" in handoff_help.output
assert "follow-up" in handoff_help.output

old_command = runner.invoke(app, ["ship", "--help"])
assert old_command.exit_code != 0
Expand Down
109 changes: 109 additions & 0 deletions tests/test_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,112 @@ def send_message(
assert attachment["content_type"] == "text/plain"
assert attachment["size"] == sample.stat().st_size
assert attachment["size_bytes"] == sample.stat().st_size


def test_messages_list_shows_short_ids_but_json_keeps_full_ids(monkeypatch):
message_id = "12345678-90ab-cdef-1234-567890abcdef"

class FakeClient:
def list_messages(self, limit=20, channel="main"):
return {
"messages": [
{
"id": message_id,
"content": "hello",
"display_name": "orion",
"created_at": "2026-04-13T15:00:00Z",
}
]
}

monkeypatch.setattr("ax_cli.commands.messages.get_client", lambda: FakeClient())

table_result = runner.invoke(app, ["messages", "list"])
assert table_result.exit_code == 0, table_result.output
assert "12345678" in table_result.output
assert message_id not in table_result.output

json_result = runner.invoke(app, ["messages", "list", "--json"])
assert json_result.exit_code == 0, json_result.output
assert json.loads(json_result.output)[0]["id"] == message_id


def test_messages_get_resolves_short_id_prefix(monkeypatch):
message_id = "12345678-90ab-cdef-1234-567890abcdef"
calls = {}

class FakeClient:
def list_messages(self, limit=20, channel="main"):
calls["list_limit"] = limit
return {"messages": [{"id": message_id}]}

def get_message(self, requested_id):
calls["get_id"] = requested_id
return {"id": requested_id, "content": "hello"}

monkeypatch.setattr("ax_cli.commands.messages.get_client", lambda: FakeClient())

result = runner.invoke(app, ["messages", "get", "12345678", "--json"])
assert result.exit_code == 0, result.output
assert calls["list_limit"] == 100
assert calls["get_id"] == message_id
assert json.loads(result.output)["id"] == message_id


def test_messages_send_resolves_short_parent_id(monkeypatch):
parent_id = "abcdef12-3456-7890-abcd-ef1234567890"
calls = {}

class FakeClient:
_base_headers = {}

def list_messages(self, limit=20, channel="main"):
calls["list_limit"] = limit
return {"messages": [{"id": parent_id}]}

def send_message(self, space_id, content, *, channel="main", parent_id=None, attachments=None):
calls["message"] = {
"space_id": space_id,
"content": content,
"channel": channel,
"parent_id": parent_id,
"attachments": attachments,
}
return {"id": "reply-message-id"}

monkeypatch.setattr("ax_cli.commands.messages.get_client", lambda: FakeClient())
monkeypatch.setattr("ax_cli.commands.messages.resolve_space_id", lambda client, explicit=None: "space-1")
monkeypatch.setattr("ax_cli.commands.messages.resolve_agent_name", lambda client=None: None)

result = runner.invoke(app, ["messages", "send", "reply", "--parent", "abcdef12", "--skip-ax", "--json"])
assert result.exit_code == 0, result.output
assert calls["list_limit"] == 100
assert calls["message"]["parent_id"] == parent_id


def test_messages_edit_and_delete_resolve_short_id_prefix(monkeypatch):
message_id = "12345678-90ab-cdef-1234-567890abcdef"
calls = {}

class FakeClient:
def list_messages(self, limit=20, channel="main"):
calls["list_calls"] = calls.get("list_calls", 0) + 1
return {"messages": [{"id": message_id}]}

def edit_message(self, requested_id, content):
calls["edit"] = {"id": requested_id, "content": content}
return {"id": requested_id, "content": content}

def delete_message(self, requested_id):
calls["delete_id"] = requested_id

monkeypatch.setattr("ax_cli.commands.messages.get_client", lambda: FakeClient())

edit_result = runner.invoke(app, ["messages", "edit", "12345678", "updated", "--json"])
assert edit_result.exit_code == 0, edit_result.output
assert calls["edit"] == {"id": message_id, "content": "updated"}

delete_result = runner.invoke(app, ["messages", "delete", "12345678", "--json"])
assert delete_result.exit_code == 0, delete_result.output
assert calls["delete_id"] == message_id
assert json.loads(delete_result.output)["message_id"] == message_id
Loading