diff --git a/src/chat/chat-view.c b/src/chat/chat-view.c index 171aea59..7b2742dc 100644 --- a/src/chat/chat-view.c +++ b/src/chat/chat-view.c @@ -22,6 +22,7 @@ #include "util/git-info.h" #include "util/subagent-tool.h" #include "util/workflow-run.h" +#include "util/workspace-block.h" #include "util/worktree.h" /* @@ -993,10 +994,16 @@ append_reply (XdChatView *self, gboolean answerable) { g_autoptr (XdAsk) ask = NULL; + g_autofree char *workspace = NULL; + g_autofree char *without_workspace = NULL; g_autofree char *prose = NULL; + const char *shown = text; XdMessageRow *row; - ask = xd_ask_parse (text, &prose); + workspace = xd_workspace_block_parse (text, &without_workspace); + if (workspace != NULL) + shown = without_workspace; + ask = xd_ask_parse (shown, &prose); { g_autofree char *said = NULL; @@ -1007,7 +1014,7 @@ append_reply (XdChatView *self, said = *prose != '\0' ? g_strdup_printf ("%s\n\n**%s**", prose, ask->question) : g_strdup_printf ("**%s**", ask->question); - row = append_row (self, XD_MESSAGE_ASSISTANT, said != NULL ? said : text); + row = append_row (self, XD_MESSAGE_ASSISTANT, said != NULL ? said : shown); xd_message_row_set_source (row, source); } @@ -1584,12 +1591,22 @@ on_remote_messages (GObject *source, static void close_remote_segment (XdChatView *self) { + g_autofree char *workspace = NULL; + g_autofree char *without_workspace = NULL; + const char *text; + if (self->remote_said == NULL || self->remote_said->len == 0) return; + text = self->remote_said->str; + workspace = + xd_workspace_block_parse (self->remote_said->str, &without_workspace); + if (workspace != NULL) + text = without_workspace; + { - gsize visible = xd_ask_visible_length (self->remote_said->str); - g_autofree char *prose = g_strndup (self->remote_said->str, visible); + gsize visible = xd_ask_visible_length (text); + g_autofree char *prose = g_strndup (text, visible); g_strchomp (prose); if (*prose != '\0') @@ -1769,8 +1786,38 @@ on_remote_event (XdRemoteClient *client, if (g_strcmp0 (name, "tool") == 0 && text != NULL) { + const char *workdir = member_string (event, "workdir", NULL); + const char *context = member_string (event, "context", NULL); + close_remote_segment (self); + if (workdir != NULL) + { + g_autofree char *tooltip = g_strdup_printf ( + "Terminal on %s in %s", + xd_remote_client_get_host (self->remote), workdir); + + if (context != NULL) + { + gtk_label_set_label (self->context_label, context); + gtk_widget_set_tooltip_text ( + GTK_WIDGET (self->context_label), context); + } + xd_terminal_panel_set_workdir (self->terminal, workdir); + xd_file_pane_set_workdir (self->files, workdir); + xd_diff_pane_set_workdir (self->diff, workdir); + gtk_widget_set_sensitive ( + GTK_WIDGET (self->terminal_button), TRUE); + gtk_widget_set_sensitive (GTK_WIDGET (self->file_button), TRUE); + gtk_widget_set_sensitive (GTK_WIDGET (self->diff_button), TRUE); + gtk_widget_set_tooltip_text ( + GTK_WIDGET (self->terminal_button), tooltip); + gtk_widget_set_tooltip_text ( + GTK_WIDGET (self->file_button), "Browse files"); + gtk_widget_set_tooltip_text ( + GTK_WIDGET (self->diff_button), "Changed files"); + } show_tool_use (self, text); + xd_diff_pane_refresh (self->diff); queue_scroll_to_bottom (self); return; } @@ -2011,9 +2058,7 @@ on_remote_options_received (GObject *source, } else { - XdMessageRow *row = append_row (self, XD_MESSAGE_ASSISTANT, text); - - xd_message_row_set_source (row, self->remote_label); + append_reply (self, text, self->remote_label, FALSE); } } @@ -2386,6 +2431,56 @@ on_text_delta (XdChatSession *session, */ } +/* + * Moves the chat only to a checkout Git already associates with its current + * repository. The agent can point xd at work it created without gaining a way + * to enlarge the next turn's write sandbox to an unrelated directory. + */ +static gboolean +switch_turn_workspace (Turn *turn, + const char *reported) +{ + XdChatView *self = turn->view; + g_autofree char *workdir = + xd_worktree_registered_path (turn->workdir, reported); + g_autoptr (GError) error = NULL; + + if (workdir == NULL) + { + g_warning ("ignoring unregistered workspace reported by agent: %s", + reported); + return FALSE; + } + + if (xd_worktree_path_equal (turn->workdir, workdir)) + return TRUE; + + if (!xd_storage_set_workdir ( + self->storage, turn->chat_id, workdir, &error)) + { + g_warning ("cannot switch to the agent's workspace: %s", error->message); + return FALSE; + } + + g_free (turn->workdir); + turn->workdir = g_steal_pointer (&workdir); + g_clear_pointer (&turn->diff_tracker, xd_git_diff_tracker_free); + turn->diff_tracker = xd_git_diff_tracker_new (turn->workdir); + + if (turn_is_visible (turn)) + { + g_autoptr (XdChat) chat = + xd_storage_get_chat (self->storage, turn->chat_id, NULL); + + if (chat != NULL) + update_context_bar (self, chat); + xd_diff_pane_refresh (self->diff); + xd_git_actions_refresh (self->git_actions); + } + + return TRUE; +} + /* * Ends the message being written, if there is one. * @@ -2399,28 +2494,44 @@ static void close_segment (Turn *turn, gboolean answerable) { + g_autofree char *reported = NULL; + g_autofree char *without_workspace = NULL; + const char *text; + if (turn->segment->len == 0) return; + text = turn->segment->str; + reported = + xd_workspace_block_parse (turn->segment->str, &without_workspace); + if (reported != NULL && switch_turn_workspace (turn, reported)) + text = without_workspace; + + if (*text == '\0') + { + g_string_truncate (turn->segment, 0); + return; + } + /* Held rather than written until the turn ends, alongside its tool calls, * so interruption can store the exact order that happened. */ - remember_turn_item (turn, FALSE, turn->segment->str); + remember_turn_item (turn, FALSE, text); if (turn_is_visible (turn)) { g_autoptr (XdAsk) ask = answerable - ? xd_ask_parse (turn->segment->str, NULL) : NULL; + ? xd_ask_parse (text, NULL) : NULL; if (ask != NULL) { - append_reply (turn->view, turn->segment->str, turn->label, TRUE); + append_reply (turn->view, text, turn->label, TRUE); } else { /* Hide a question block until the turn finishes and it can become * buttons, rather than briefly showing its machine-facing markup. */ - gsize visible = xd_ask_visible_length (turn->segment->str); - g_autofree char *prose = g_strndup (turn->segment->str, visible); + gsize visible = xd_ask_visible_length (text); + g_autofree char *prose = g_strndup (text, visible); g_strchomp (prose); if (*prose != '\0') diff --git a/src/meson.build b/src/meson.build index 47be0598..46394354 100644 --- a/src/meson.build +++ b/src/meson.build @@ -15,6 +15,7 @@ xd_core_sources = files( 'util/unified-diff.c', 'util/update-channel.c', 'util/workflow-run.c', + 'util/workspace-block.c', 'util/worktree.c', 'util/host-launch.c', 'util/app-paths.c', diff --git a/src/remote/server.c b/src/remote/server.c index 5dce4464..045d77b7 100644 --- a/src/remote/server.c +++ b/src/remote/server.c @@ -1729,14 +1729,61 @@ on_turn_text (XdDaemonTurn *turn, broadcast_event (running->server, "text", running->chat_id, "text", delta); } +static char * +describe_turn_context (const char *workdir) +{ + g_autoptr (XdGitInfo) git = xd_git_info_for_path (workdir); + g_autoptr (GString) text = g_string_new (NULL); + g_autofree char *shown = NULL; + + if (workdir == NULL) + return g_strdup ("No working directory"); + + shown = g_str_has_prefix (workdir, g_get_home_dir ()) + ? g_strconcat ("~", workdir + strlen (g_get_home_dir ()), NULL) + : g_strdup (workdir); + + if (git == NULL) + return g_strdup_printf ("%s — not a repository", shown); + + if (git->branch != NULL) + g_string_append_printf (text, "%s %s", git->detached ? "detached at" : "⎇", + git->branch); + g_string_append_printf (text, "%s%s", text->len > 0 ? " · " : "", git->name); + if (git->linked_worktree) + g_string_append (text, " (worktree)"); + g_string_append_printf (text, " · %s", shown); + + return g_string_free (g_steal_pointer (&text), FALSE); +} + static void on_turn_tool (XdDaemonTurn *turn, const char *name, gpointer user_data) { Running *running = user_data; + const char *workdir = xd_daemon_turn_get_workdir (turn); + g_autofree char *context = describe_turn_context (workdir); + g_autoptr (JsonBuilder) builder = json_builder_new (); - broadcast_event (running->server, "tool", running->chat_id, "text", name); + json_builder_begin_object (builder); + json_builder_set_member_name (builder, "event"); + json_builder_add_string_value (builder, "tool"); + json_builder_set_member_name (builder, "chat"); + json_builder_add_string_value (builder, running->chat_id); + json_builder_set_member_name (builder, "text"); + json_builder_add_string_value (builder, name); + if (workdir != NULL) + { + json_builder_set_member_name (builder, "workdir"); + json_builder_add_string_value (builder, workdir); + } + json_builder_set_member_name (builder, "context"); + json_builder_add_string_value (builder, context); + json_builder_end_object (builder); + + broadcast (running->server, builder); } static gboolean forget_turn (gpointer user_data); diff --git a/src/remote/turn.c b/src/remote/turn.c index 1f91a91e..9d6ec8ec 100644 --- a/src/remote/turn.c +++ b/src/remote/turn.c @@ -8,6 +8,8 @@ #include "util/ask-block.h" #include "util/git-diff.h" #include "util/workflow-run.h" +#include "util/workspace-block.h" +#include "util/worktree.h" #include @@ -40,6 +42,7 @@ struct _XdDaemonTurn XdGitDiffTracker *diff_tracker; GCancellable *diff_cancellable; guint diff_timeout_id; + guint diff_generation; GString *text; /* everything said this turn */ GString *segment; /* what belongs to the message being written */ gint64 segment_message_id; /* live row extended as this segment streams */ @@ -69,14 +72,28 @@ static guint signals[N_SIGNALS]; G_DEFINE_FINAL_TYPE (XdDaemonTurn, xd_daemon_turn, G_TYPE_OBJECT) +typedef struct +{ + char *workdir; + guint generation; +} DiffInit; + +static void +diff_init_free (DiffInit *init) +{ + g_free (init->workdir); + g_free (init); +} + static void build_diff_tracker (GTask *task, gpointer source_object, gpointer task_data, GCancellable *cancellable) { + DiffInit *init = task_data; XdGitDiffTracker *tracker = - xd_git_diff_tracker_new_cancellable (task_data, cancellable); + xd_git_diff_tracker_new_cancellable (init->workdir, cancellable); if (g_cancellable_is_cancelled (cancellable)) { @@ -107,8 +124,14 @@ on_diff_tracker_ready (GObject *source, { g_autoptr (XdDaemonTurn) self = user_data; g_autoptr (GError) error = NULL; - XdGitDiffTracker *tracker = + g_autoptr (XdGitDiffTracker) tracker = g_task_propagate_pointer (G_TASK (result), &error); + DiffInit *init = g_task_get_task_data (G_TASK (result)); + + /* A workspace report can start a newer snapshot while this one is still + * running. An old checkout must never replace the new tracker. */ + if (init->generation != self->diff_generation) + return; g_clear_handle_id (&self->diff_timeout_id, g_source_remove); g_clear_object (&self->diff_cancellable); @@ -116,7 +139,7 @@ on_diff_tracker_ready (GObject *source, if (tracker != NULL) { g_clear_pointer (&self->diff_tracker, xd_git_diff_tracker_free); - self->diff_tracker = tracker; + self->diff_tracker = g_steal_pointer (&tracker); } } @@ -124,11 +147,15 @@ static void start_diff_tracker (XdDaemonTurn *self) { g_autoptr (GTask) task = NULL; + DiffInit *init = g_new0 (DiffInit, 1); + self->diff_generation++; self->diff_cancellable = g_cancellable_new (); task = g_task_new (NULL, self->diff_cancellable, on_diff_tracker_ready, g_object_ref (self)); - g_task_set_task_data (task, g_strdup (self->workdir), g_free); + init->workdir = g_strdup (self->workdir); + init->generation = self->diff_generation; + g_task_set_task_data (task, init, (GDestroyNotify) diff_init_free); g_task_run_in_thread (task, build_diff_tracker); /* @@ -329,12 +356,76 @@ remember (XdDaemonTurn *self, g_ptr_array_add (self->items, item); } +static gboolean +switch_workspace (XdDaemonTurn *self, + const char *reported) +{ + g_autofree char *workdir = + xd_worktree_registered_path (self->workdir, reported); + g_autoptr (GError) error = NULL; + + if (workdir == NULL) + { + g_warning ("ignoring unregistered workspace reported by agent: %s", + reported); + return FALSE; + } + + if (xd_worktree_path_equal (self->workdir, workdir)) + return TRUE; + + if (!xd_storage_set_workdir ( + self->storage, self->chat_id, workdir, &error)) + { + g_warning ("cannot switch to the agent's workspace: %s", error->message); + return FALSE; + } + + g_free (self->workdir); + self->workdir = g_steal_pointer (&workdir); + + g_clear_handle_id (&self->diff_timeout_id, g_source_remove); + if (self->diff_cancellable != NULL) + g_cancellable_cancel (self->diff_cancellable); + g_clear_object (&self->diff_cancellable); + g_clear_pointer (&self->diff_tracker, xd_git_diff_tracker_free); + start_diff_tracker (self); + + return TRUE; +} + static void close_segment (XdDaemonTurn *self) { + g_autofree char *reported = NULL; + g_autofree char *without_workspace = NULL; + g_autoptr (GError) error = NULL; + if (self->segment->len == 0) return; + reported = + xd_workspace_block_parse (self->segment->str, &without_workspace); + if (reported != NULL && switch_workspace (self, reported)) + { + g_string_assign (self->segment, without_workspace); + + if (self->segment_message_id != 0 && + !(self->segment->len > 0 + ? xd_storage_update_message ( + self->storage, self->segment_message_id, + self->segment->str, &error) + : xd_storage_delete_message ( + self->storage, self->segment_message_id, &error))) + g_warning ("cannot hide workspace control markup: %s", error->message); + } + + if (self->segment->len == 0) + { + self->segment_message_id = 0; + return; + } + remember (self, FALSE, self->segment->str); g_string_truncate (self->segment, 0); self->segment_message_id = 0; @@ -619,6 +710,14 @@ xd_daemon_turn_get_label (XdDaemonTurn *self) return self->label; } +const char * +xd_daemon_turn_get_workdir (XdDaemonTurn *self) +{ + g_return_val_if_fail (XD_IS_DAEMON_TURN (self), NULL); + + return self->workdir; +} + gint64 xd_daemon_turn_get_transcript_id (XdDaemonTurn *self) { diff --git a/src/remote/turn.h b/src/remote/turn.h index 05de9626..485c0448 100644 --- a/src/remote/turn.h +++ b/src/remote/turn.h @@ -62,6 +62,7 @@ char *xd_daemon_turn_resolve_workdir (XdDaemonTurn *self, /* Who is answering: the model and effort the turn actually started on. */ const char *xd_daemon_turn_get_label (XdDaemonTurn *self); +const char *xd_daemon_turn_get_workdir (XdDaemonTurn *self); /* Last finished transcript row when this turn started. Live output has larger * ids and is replayed from the turn until it ends. */ diff --git a/src/storage/storage.c b/src/storage/storage.c index cc13a685..edfe66cd 100644 --- a/src/storage/storage.c +++ b/src/storage/storage.c @@ -1617,6 +1617,34 @@ xd_storage_update_message (XdStorage *self, return ok; } +gboolean +xd_storage_delete_message (XdStorage *self, + gint64 message_id, + GError **error) +{ + sqlite3_stmt *stmt = NULL; + gboolean ok; + + g_return_val_if_fail (XD_IS_STORAGE (self), FALSE); + g_return_val_if_fail (message_id > 0, FALSE); + + if (sqlite3_prepare_v2 (self->db, + "DELETE FROM messages WHERE id = ?;", + -1, &stmt, NULL) != SQLITE_OK) + { + set_sqlite_error (error, self->db, "Cannot remove the message"); + return FALSE; + } + + sqlite3_bind_int64 (stmt, 1, message_id); + ok = sqlite3_step (stmt) == SQLITE_DONE && sqlite3_changes (self->db) == 1; + if (!ok) + set_sqlite_error (error, self->db, "Cannot remove the message"); + + sqlite3_finalize (stmt); + return ok; +} + static XdMessage * message_from_row (sqlite3_stmt *stmt) { diff --git a/src/storage/storage.h b/src/storage/storage.h index d6e68c13..14365d29 100644 --- a/src/storage/storage.h +++ b/src/storage/storage.h @@ -310,6 +310,11 @@ gboolean xd_storage_update_message (XdStorage *self, const char *content, GError **error); +/* Removes an empty live row after it turns out to contain only control markup. */ +gboolean xd_storage_delete_message (XdStorage *self, + gint64 message_id, + GError **error); + /* Oldest first. Elements are XdMessage*. */ GPtrArray *xd_storage_list_messages (XdStorage *self, const char *chat_id, diff --git a/src/util/ask-block.c b/src/util/ask-block.c index 838f6aa5..194e97f2 100644 --- a/src/util/ask-block.c +++ b/src/util/ask-block.c @@ -62,7 +62,15 @@ xd_ask_instructions (void) "[#35](https://github.com/owner/repo/issues/35) and " "[PR #12](https://github.com/owner/repo/pull/12). Do not leave a " "resolvable #number as bare text.\n" - ""; + "\n\n" + "\n" + "If you move the work to another Git worktree, report the new checkout " + "root on its own line as:\n\n" + "/absolute/path/to/checkout\n\n" + "Report it as soon as the new worktree becomes the directory where you " + "are working. Use an absolute path. Do not report ordinary subdirectory " + "changes or emit the tag when the checkout did not change.\n" + ""; } diff --git a/src/util/workspace-block.c b/src/util/workspace-block.c new file mode 100644 index 00000000..aa4a644d --- /dev/null +++ b/src/util/workspace-block.c @@ -0,0 +1,99 @@ +#include "workspace-block.h" + +#include + +#define WORKSPACE_OPEN "" +#define WORKSPACE_CLOSE "" + +static gboolean +starts_line (const char *text, + const char *at) +{ + return at == text || at[-1] == '\n'; +} + +static gboolean +ends_line (const char *at) +{ + return *at == '\0' || *at == '\n' || + (*at == '\r' && (at[1] == '\0' || at[1] == '\n')); +} + +static char * +block_path (const char *open, + const char *close) +{ + g_autofree char *path = + g_strndup (open + strlen (WORKSPACE_OPEN), + close - (open + strlen (WORKSPACE_OPEN))); + + if (strchr (path, '\n') != NULL || strchr (path, '\r') != NULL) + return NULL; + + g_strstrip (path); + if (*path == '\0') + return NULL; + + return g_steal_pointer (&path); +} + +char * +xd_workspace_block_parse (const char *text, + char **remainder) +{ + g_autoptr (GString) prose = NULL; + g_autofree char *last_path = NULL; + const char *cursor; + const char *open; + + if (remainder != NULL) + *remainder = NULL; + if (text == NULL) + return NULL; + + prose = g_string_new (NULL); + cursor = text; + open = strstr (cursor, WORKSPACE_OPEN); + + while (open != NULL) + { + const char *close = strstr (open + strlen (WORKSPACE_OPEN), + WORKSPACE_CLOSE); + const char *after = + close != NULL ? close + strlen (WORKSPACE_CLOSE) : NULL; + g_autofree char *path = + close != NULL && starts_line (text, open) && ends_line (after) + ? block_path (open, close) : NULL; + + if (path == NULL) + { + g_string_append_len (prose, cursor, + open + strlen (WORKSPACE_OPEN) - cursor); + cursor = open + strlen (WORKSPACE_OPEN); + open = strstr (cursor, WORKSPACE_OPEN); + continue; + } + + g_string_append_len (prose, cursor, open - cursor); + g_free (last_path); + last_path = g_steal_pointer (&path); + cursor = after; + if (*cursor == '\r') + cursor++; + if (*cursor == '\n') + cursor++; + open = strstr (cursor, WORKSPACE_OPEN); + } + + if (last_path == NULL) + return NULL; + + g_string_append (prose, cursor); + g_strstrip (prose->str); + prose->len = strlen (prose->str); + + if (remainder != NULL) + *remainder = g_string_free (g_steal_pointer (&prose), FALSE); + + return g_steal_pointer (&last_path); +} diff --git a/src/util/workspace-block.h b/src/util/workspace-block.h new file mode 100644 index 00000000..529f45ac --- /dev/null +++ b/src/util/workspace-block.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +G_BEGIN_DECLS + +/* + * Lifts workspace control blocks out of assistant text. + * + * A block is only control markup when it starts on its own line, ends on that + * line, and names one non-empty path: + * + * /path/to/checkout + * + * All valid blocks are removed from @text and the last reported path is + * returned. Prose that merely mentions the tag remains prose. + */ +char *xd_workspace_block_parse (const char *text, + char **remainder); + +G_END_DECLS diff --git a/src/util/worktree.c b/src/util/worktree.c index e1b04cc9..9f177b81 100644 --- a/src/util/worktree.c +++ b/src/util/worktree.c @@ -359,6 +359,34 @@ xd_worktree_list (const char *workdir, return g_steal_pointer (&result); } +char * +xd_worktree_registered_path (const char *workdir, + const char *requested) +{ + g_autoptr (GPtrArray) worktrees = NULL; + g_autofree char *normalized = NULL; + + if (workdir == NULL || requested == NULL || + !g_path_is_absolute (requested) || + !g_file_test (requested, G_FILE_TEST_IS_DIR)) + return NULL; + + worktrees = xd_worktree_list (workdir, NULL); + if (worktrees == NULL) + return NULL; + + normalized = normalize_worktree_path (requested); + for (guint i = 0; i < worktrees->len; i++) + { + XdWorktreeInfo *item = g_ptr_array_index (worktrees, i); + + if (xd_worktree_path_equal (item->path, normalized)) + return g_strdup (item->path); + } + + return NULL; +} + char * xd_worktree_create (const char *workdir, const char *chat_id, diff --git a/src/util/worktree.h b/src/util/worktree.h index 60d2db68..d1bb47d0 100644 --- a/src/util/worktree.h +++ b/src/util/worktree.h @@ -27,6 +27,15 @@ gboolean xd_worktree_path_equal (const char *a, GPtrArray *xd_worktree_list (const char *workdir, GError **error); +/* + * Resolves @requested to one checkout registered with the repository + * containing @workdir. This is deliberately narrower than accepting any + * directory: a model must not expand a later turn's write sandbox by naming + * an unrelated path. + */ +char *xd_worktree_registered_path (const char *workdir, + const char *requested); + /* * Creates the private checkout used by a new chat. * diff --git a/tests/meson.build b/tests/meson.build index b12d464b..27d7e94a 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -20,6 +20,7 @@ xd_tests = { 'unified-diff': 'test-unified-diff.c', 'update-channel': 'test-update-channel.c', 'workflow-run': 'test-workflow-run.c', + 'workspace-block': 'test-workspace-block.c', 'worktree': 'test-worktree.c', 'discord-presence': 'test-discord-presence.c', 'node': 'test-node.c', diff --git a/tests/test-ask.c b/tests/test-ask.c index 4166b16c..5cca1e4c 100644 --- a/tests/test-ask.c +++ b/tests/test-ask.c @@ -186,6 +186,9 @@ test_instructions_require_reporting_links (void) "Do not leave a resolvable #number as bare text")); g_assert_nonnull (strstr (instructions, "")); g_assert_nonnull (strstr (instructions, "This shows a text field")); + g_assert_nonnull ( + strstr (instructions, + "/absolute/path/to/checkout")); } int diff --git a/tests/test-storage.c b/tests/test-storage.c index e1571511..09a8e5a2 100644 --- a/tests/test-storage.c +++ b/tests/test-storage.c @@ -200,6 +200,30 @@ test_messages_round_trip (Fixture *fixture, "Claude Opus 5 · High"); } +static void +test_live_message_can_be_removed (Fixture *fixture, + gconstpointer user_data) +{ + g_autoptr (GError) error = NULL; + g_autofree char *chat_id = NULL; + g_autoptr (GPtrArray) messages = NULL; + gint64 message_id = 0; + + chat_id = xd_storage_create_chat (fixture->storage, "folder", "Chat", + "claude", NULL, NULL, NULL, &error); + g_assert_true (xd_storage_append_message_with_id ( + fixture->storage, chat_id, "assistant", "", + NULL, NULL, &message_id, &error)); + g_assert_cmpint (message_id, >, 0); + g_assert_true (xd_storage_delete_message ( + fixture->storage, message_id, &error)); + g_assert_no_error (error); + + messages = xd_storage_list_messages (fixture->storage, chat_id, &error); + g_assert_no_error (error); + g_assert_cmpuint (messages->len, ==, 0); +} + static void test_recent_messages_are_bounded (Fixture *fixture, gconstpointer user_data) @@ -744,6 +768,7 @@ main (int argc, ADD ("/storage/new-chats-inherit-agent", test_new_chats_inherit_last_changed_agent); ADD ("/storage/chats-follow-folder-id", test_chats_follow_folder_id); ADD ("/storage/messages-round-trip", test_messages_round_trip); + ADD ("/storage/live-message-removed", test_live_message_can_be_removed); ADD ("/storage/recent-messages-bounded", test_recent_messages_are_bounded); ADD ("/storage/sessions-per-backend", test_sessions_are_per_backend); ADD ("/storage/forget-one-session", test_forgetting_one_session); diff --git a/tests/test-workspace-block.c b/tests/test-workspace-block.c new file mode 100644 index 00000000..170f0b56 --- /dev/null +++ b/tests/test-workspace-block.c @@ -0,0 +1,69 @@ +#include "util/workspace-block.h" + +static void +test_extracts_and_hides_workspace (void) +{ + g_autofree char *remainder = NULL; + g_autofree char *path = xd_workspace_block_parse ( + "Moved the work.\n\n" + "/tmp/project worktree\n", + &remainder); + + g_assert_cmpstr (path, ==, "/tmp/project worktree"); + g_assert_cmpstr (remainder, ==, "Moved the work."); +} + +static void +test_last_workspace_wins (void) +{ + g_autofree char *remainder = NULL; + g_autofree char *path = xd_workspace_block_parse ( + "/tmp/first\n" + "Changed again.\n" + "/tmp/second", + &remainder); + + g_assert_cmpstr (path, ==, "/tmp/second"); + g_assert_cmpstr (remainder, ==, "Changed again."); +} + +static void +test_tag_mentioned_in_prose_stays (void) +{ + const char *text = + "Use /tmp/repo to report it."; + g_autofree char *remainder = NULL; + g_autofree char *path = xd_workspace_block_parse (text, &remainder); + + g_assert_null (path); + g_assert_null (remainder); +} + +static void +test_malformed_blocks_stay (void) +{ + const char *multiline = + "\n/tmp/repo\n"; + const char *unclosed = "/tmp/repo"; + + g_assert_null (xd_workspace_block_parse (multiline, NULL)); + g_assert_null (xd_workspace_block_parse (unclosed, NULL)); +} + +int +main (int argc, + char *argv[]) +{ + g_test_init (&argc, &argv, NULL); + + g_test_add_func ("/workspace-block/extract", + test_extracts_and_hides_workspace); + g_test_add_func ("/workspace-block/last-wins", + test_last_workspace_wins); + g_test_add_func ("/workspace-block/tag-in-prose", + test_tag_mentioned_in_prose_stays); + g_test_add_func ("/workspace-block/malformed", + test_malformed_blocks_stay); + + return g_test_run (); +} diff --git a/tests/test-worktree.c b/tests/test-worktree.c index de75e2c5..dcc196f0 100644 --- a/tests/test-worktree.c +++ b/tests/test-worktree.c @@ -188,6 +188,43 @@ test_requires_a_repository (void) remove_tree (dir); } +static void +test_registered_path_stays_in_repository (void) +{ + g_autoptr (GError) error = NULL; + g_autofree char *dir = g_dir_make_tmp ("xd-worktree-path-XXXXXX", &error); + g_autofree char *repo = g_build_filename (dir, "repo", NULL); + g_autofree char *other = g_build_filename (dir, "other", NULL); + g_autofree char *linked = g_build_filename (dir, "linked", NULL); + g_autofree char *resolved = NULL; + const char *init[] = { "git", "init", "-q", NULL }; + const char *identity[] = { + "git", "-c", "user.name=xd", "-c", "user.email=xd@example.com", + "commit", "--allow-empty", "-qm", "initial", NULL + }; + const char *add[] = { + "git", "worktree", "add", "-q", "-b", "linked", NULL, "HEAD", NULL + }; + + g_assert_no_error (error); + g_assert_cmpint (g_mkdir (repo, 0700), ==, 0); + g_assert_cmpint (g_mkdir (other, 0700), ==, 0); + run (repo, init); + run (repo, identity); + add[6] = linked; + run (repo, add); + + resolved = xd_worktree_registered_path (repo, linked); + g_assert_nonnull (resolved); + g_assert_true (xd_worktree_path_equal (resolved, linked)); + + g_clear_pointer (&resolved, g_free); + resolved = xd_worktree_registered_path (repo, other); + g_assert_null (resolved); + + remove_tree (dir); +} + int main (int argc, char *argv[]) @@ -196,6 +233,8 @@ main (int argc, g_test_add_func ("/worktree/create-and-reuse", test_create_and_reuse); g_test_add_func ("/worktree/requires-repository", test_requires_a_repository); + g_test_add_func ("/worktree/registered-path", + test_registered_path_stays_in_repository); return g_test_run (); }