From 9b22862a2dd9aed8461459cd5c7a32dd75377a8c Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 09:47:19 +0000 Subject: [PATCH] feat(studio): confirm a change with the owner's own text The proposal card lets the owner correct the text before confirming. POST /api/chats/changes/{id}/confirm now accepts an optional JSON body {"content": "..."}. The text has the same size limit and text check as a proposal, the file and the audit hash hold what was written, and a refused text leaves the proposal waiting. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto --- core/http/endpoints/studio/changes.go | 22 +++++++++++++++++++--- core/http/routes/studio_changes_test.go | 7 ++++++- core/services/studio/changes.go | 21 +++++++++++++++++++++ core/services/studio/changes_test.go | 22 ++++++++++++++++++++++ swagger/docs.go | 2 +- swagger/swagger.json | 2 +- swagger/swagger.yaml | 7 ++++--- 7 files changed, 74 insertions(+), 9 deletions(-) diff --git a/core/http/endpoints/studio/changes.go b/core/http/endpoints/studio/changes.go index d3a2c79a1f..eef78120f5 100644 --- a/core/http/endpoints/studio/changes.go +++ b/core/http/endpoints/studio/changes.go @@ -1,6 +1,9 @@ package studio import ( + "encoding/json" + "errors" + "io" "net/http" "strconv" @@ -67,7 +70,7 @@ func (h *Handlers) GetChange(c echo.Context) error { // ConfirmChange writes a proposed change. // @Summary Confirm a proposed change. -// @Description Writes the change to your files as one change set that Undo can take back. It is refused with 409 when the file changed since the proposal, or when the proposal expired or was answered. +// @Description Writes the change to your files as one change set that Undo can take back. A JSON body {"content": "..."} writes your own text in place of the proposed one; it has the same size limit. It is refused with 409 when the file changed since the proposal, or when the proposal expired or was answered. // @Tags chats // @Param id path string true "the proposal" // @Success 200 {object} contract.ChangeProposal "The applied proposal" @@ -75,7 +78,21 @@ func (h *Handlers) GetChange(c echo.Context) error { // @Failure 409 {object} schema.ErrorResponse "Stale, expired or answered" // @Router /api/chats/changes/{id}/confirm [post] func (h *Handlers) ConfirmChange(c echo.Context) error { - p, err := h.svc.Changes().Confirm(c.Request().Context(), userID(c), c.Param("id")) + var body struct { + Content *string `json:"content"` + } + if c.Request().ContentLength != 0 { + if err := json.NewDecoder(io.LimitReader(c.Request().Body, 2*contract.MaxChangeBytes)).Decode(&body); err != nil && !errors.Is(err, io.EOF) { + return fail(c, http.StatusBadRequest, typeInvalid, "the body must be JSON") + } + } + var p contract.ChangeProposal + var err error + if body.Content != nil { + p, err = h.svc.Changes().ConfirmEdited(c.Request().Context(), userID(c), c.Param("id"), *body.Content) + } else { + p, err = h.svc.Changes().Confirm(c.Request().Context(), userID(c), c.Param("id")) + } if err != nil { return failChange(c, err) } @@ -134,7 +151,6 @@ func (h *Handlers) ChangeAudit(c echo.Context) error { return c.JSON(http.StatusOK, evs) } - // ListContextFiles lists the notes the host puts in the prompt of a chat. // @Summary List your context files. // @Description The short index files that the host loads into every chat (your memory, an agent's notes, a conversation's notes), with the topic files next to them, the agent profiles and the caps that keep a turn small. It only reads; change a file on the Files page or confirm a proposal. diff --git a/core/http/routes/studio_changes_test.go b/core/http/routes/studio_changes_test.go index b6c3b0fd57..826569b46a 100644 --- a/core/http/routes/studio_changes_test.go +++ b/core/http/routes/studio_changes_test.go @@ -60,6 +60,11 @@ var _ = Describe("Chats proposed changes", func() { Expect(s.do("alice", http.MethodGet, "/api/chats/files/content?path=/Memory/a.md", nil).Code).To(Equal(http.StatusNotFound)) Expect(s.do("alice", http.MethodPost, url+"/undo", nil).Code).To(Equal(http.StatusConflict)) + c := propose("c.md") + edited := s.do("alice", http.MethodPost, "/api/chats/changes/"+c+"/confirm", map[string]string{"content": "- mine\n"}) + Expect(edited.Code).To(Equal(http.StatusOK), edited.Body.String()) + Expect(s.do("alice", http.MethodGet, "/api/chats/files/content?path=/Memory/c.md", nil).Body.String()).To(Equal("- mine\n")) + b := propose("b.md") dis := s.do("alice", http.MethodPost, "/api/chats/changes/"+b+"/dismiss", nil) Expect(dis.Code).To(Equal(http.StatusOK), dis.Body.String()) @@ -70,7 +75,7 @@ var _ = Describe("Chats proposed changes", func() { rec := s.do("alice", http.MethodGet, "/api/chats/changes/audit", nil) Expect(rec.Code).To(Equal(http.StatusOK)) Expect(json.Unmarshal(rec.Body.Bytes(), &audit)).To(Succeed()) - Expect(audit).To(HaveLen(5), "propose, confirm, revert, propose, dismiss: %v", audit) + Expect(audit).To(HaveLen(7), "propose, confirm, revert, propose, confirm, propose, dismiss: %v", audit) Expect(rec.Body.String()).NotTo(ContainSubstring("fact")) var none []map[string]any Expect(json.Unmarshal(s.do("bob", http.MethodGet, "/api/chats/changes", nil).Body.Bytes(), &none)).To(Succeed()) diff --git a/core/services/studio/changes.go b/core/services/studio/changes.go index 257d604d89..3a26c90bbd 100644 --- a/core/services/studio/changes.go +++ b/core/services/studio/changes.go @@ -216,6 +216,18 @@ func notPending(p contract.ChangeProposal) error { // Confirm writes a pending proposal as the owner, in one change set. func (c *Changes) Confirm(ctx context.Context, user, id string) (contract.ChangeProposal, error) { + return c.confirm(ctx, user, id, nil) +} + +// ConfirmEdited writes the pending proposal with the owner's own text in place +// of the model's. The text has the same limits as a proposal. The stored +// proposal keeps what the model asked for; the file and the audit hash hold +// what was written. +func (c *Changes) ConfirmEdited(ctx context.Context, user, id, content string) (contract.ChangeProposal, error) { + return c.confirm(ctx, user, id, &content) +} + +func (c *Changes) confirm(ctx context.Context, user, id string, edited *string) (contract.ChangeProposal, error) { if err := c.ready(); err != nil { return contract.ChangeProposal{}, err } @@ -231,6 +243,15 @@ func (c *Changes) Confirm(ctx context.Context, user, id string) (contract.Change if !ok { return contract.ChangeProposal{}, fmt.Errorf("%w: this server no longer offers %s changes", ErrBadChange, p.Kind) } + if edited != nil { + switch { + case len(*edited) > kind.Limit(): + return contract.ChangeProposal{}, fmt.Errorf("%w: the text is %d bytes and a %s change may have %d", ErrBadChange, len(*edited), kind.Name, kind.Limit()) + case !utf8.ValidString(*edited) || strings.ContainsRune(*edited, 0): + return contract.ChangeProposal{}, fmt.Errorf("%w: the text must be text", ErrBadChange) + } + p.Content = *edited + } cur, exists, err := c.s.d.Files.ReadHome(ctx, owner, p.Target, maxTargetRead) if err != nil { return contract.ChangeProposal{}, err diff --git a/core/services/studio/changes_test.go b/core/services/studio/changes_test.go index 6d17c7d517..f1e1060e13 100644 --- a/core/services/studio/changes_test.go +++ b/core/services/studio/changes_test.go @@ -226,6 +226,28 @@ var _ = Describe("change proposals", func() { Expect(ok).To(BeFalse()) }) + It("writes the owner's own text when the owner edits before confirming", func() { + r := changeShared(nil).rig(changeScript("user", "- home: Rome\n"), studio.TriggerOptions{}) + ctx := context.Background() + id := create(r.svc, alice) + sent(r.svc, alice, id, "remember") + idle(r.svc, alice, id) + p := onlyProposal(r) + _, err := r.svc.Changes().ConfirmEdited(ctx, alice, p.ID, strings.Repeat("x", 16<<10+1)) + Expect(err).To(MatchError(studio.ErrBadChange)) + got, _ := r.svc.Changes().Get(ctx, alice, p.ID) + Expect(got.Status).To(Equal(contract.ProposalPending), "a refused edit leaves the proposal waiting") + done, err := r.svc.Changes().ConfirmEdited(ctx, alice, p.ID, "- home: Milan\n") + Expect(err).NotTo(HaveOccurred()) + Expect(done.Status).To(Equal(contract.ProposalApplied)) + text, _ := readHome(r, "Memory/index.md") + Expect(text).To(Equal("- home: Milan\n")) + _, err = r.svc.Changes().Revert(ctx, alice, p.ID) + Expect(err).NotTo(HaveOccurred()) + _, ok := readHome(r, "Memory/index.md") + Expect(ok).To(BeFalse()) + }) + It("refuses a stale proposal and keeps the newer file", func() { r := changeShared(nil).rig(changeScript("user", "- home: Rome\n"), studio.TriggerOptions{}) ctx := context.Background() diff --git a/swagger/docs.go b/swagger/docs.go index 3416be469a..895522f0eb 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -4841,7 +4841,7 @@ const docTemplate = `{ }, "/api/chats/changes/{id}/confirm": { "post": { - "description": "Writes the change to your files as one change set that Undo can take back. It is refused with 409 when the file changed since the proposal, or when the proposal expired or was answered.", + "description": "Writes the change to your files as one change set that Undo can take back. A JSON body {\"content\": \"...\"} writes your own text in place of the proposed one; it has the same size limit. It is refused with 409 when the file changed since the proposal, or when the proposal expired or was answered.", "tags": [ "chats" ], diff --git a/swagger/swagger.json b/swagger/swagger.json index 39091aab1e..3995c5448a 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -4838,7 +4838,7 @@ }, "/api/chats/changes/{id}/confirm": { "post": { - "description": "Writes the change to your files as one change set that Undo can take back. It is refused with 409 when the file changed since the proposal, or when the proposal expired or was answered.", + "description": "Writes the change to your files as one change set that Undo can take back. A JSON body {\"content\": \"...\"} writes your own text in place of the proposed one; it has the same size limit. It is refused with 409 when the file changed since the proposal, or when the proposal expired or was answered.", "tags": [ "chats" ], diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index 68d4b71ec0..6da4a1f479 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -14464,9 +14464,10 @@ paths: - chats /api/chats/changes/{id}/confirm: post: - description: Writes the change to your files as one change set that Undo can - take back. It is refused with 409 when the file changed since the proposal, - or when the proposal expired or was answered. + description: 'Writes the change to your files as one change set that Undo can + take back. A JSON body {"content": "..."} writes your own text in place of + the proposed one; it has the same size limit. It is refused with 409 when + the file changed since the proposal, or when the proposal expired or was answered.' parameters: - description: the proposal in: path