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
125 changes: 125 additions & 0 deletions clients/go/ahptypes/chat_source_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package ahptypes

import (
"encoding/json"
"fmt"
"testing"
)

func TestChatSourceRoutesByKind(t *testing.T) {
t.Run("fork", func(t *testing.T) {
var value ChatSource
if err := json.Unmarshal([]byte(`{"kind":"fork","chat":"ahp-chat:/main","turnId":"turn-12"}`), &value); err != nil {
t.Fatalf("decode fork: %v", err)
}
fork, ok := value.Value.(*ForkChatSource)
if !ok {
t.Fatalf("expected *ForkChatSource, got %T", value.Value)
}
if fork.Kind != ChatSourceKindFork || fork.TurnId != "turn-12" {
t.Fatalf("unexpected fork payload: %#v", fork)
}
})

t.Run("sideChat", func(t *testing.T) {
var value ChatSource
if err := json.Unmarshal([]byte(`{"kind":"sideChat","chat":"ahp-chat:/main","turnId":"turn-active","selection":{"text":"const value = compute()","responsePartId":"part-7"}}`), &value); err != nil {
t.Fatalf("decode sideChat: %v", err)
}
sideChat, ok := value.Value.(*SideChatSource)
if !ok {
t.Fatalf("expected *SideChatSource, got %T", value.Value)
}
if sideChat.Kind != ChatSourceKindSideChat || sideChat.TurnId != "turn-active" {
t.Fatalf("unexpected sideChat payload: %#v", sideChat)
}
if sideChat.Selection == nil || sideChat.Selection.Text != "const value = compute()" || sideChat.Selection.ResponsePartId == nil || *sideChat.Selection.ResponsePartId != "part-7" {
t.Fatalf("unexpected sideChat selection: %#v", sideChat.Selection)
}
})
}

func TestChatSourceRejectsMissingOrUnknownKind(t *testing.T) {
for name, raw := range map[string]string{
"missing kind": `{"chat":"ahp-chat:/main","turnId":"turn-12"}`,
"unknown kind": `{"kind":"future","chat":"ahp-chat:/main","turnId":"turn-12"}`,
} {
t.Run(name, func(t *testing.T) {
var value ChatSource
err := json.Unmarshal([]byte(raw), &value)
if err == nil {
t.Fatalf("expected decode failure for %s", name)
}
if got := fmt.Sprint(err); got == "" {
t.Fatalf("expected printable error for %s", name)
}
})
}
}

func TestChatSourceSerializationForcesExactKinds(t *testing.T) {
t.Run("fork branch and union ignore contradictory kind", func(t *testing.T) {
branch := ForkChatSource{
Kind: ChatSourceKindSideChat,
Chat: "ahp-chat:/main",
TurnId: "turn-12",
}

branchRaw, err := json.Marshal(branch)
if err != nil {
t.Fatalf("marshal fork branch: %v", err)
}
unionRaw, err := json.Marshal(ChatSource{Value: &branch})
if err != nil {
t.Fatalf("marshal fork union: %v", err)
}

for _, raw := range [][]byte{branchRaw, unionRaw} {
var decoded map[string]any
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("decode serialized fork payload: %v", err)
}
if got := decoded["kind"]; got != "fork" {
t.Fatalf("expected fork kind, got %#v in %s", got, raw)
}
}
})

t.Run("sideChat branch and union ignore zero kind", func(t *testing.T) {
branch := SideChatSource{
Chat: "ahp-chat:/main",
TurnId: "turn-active",
Selection: &SideChatSelection{
Text: "const value = compute()",
ResponsePartId: stringPtr("part-7"),
},
}

branchRaw, err := json.Marshal(branch)
if err != nil {
t.Fatalf("marshal sideChat branch: %v", err)
}
unionRaw, err := json.Marshal(ChatSource{Value: &branch})
if err != nil {
t.Fatalf("marshal sideChat union: %v", err)
}

for _, raw := range [][]byte{branchRaw, unionRaw} {
var decoded map[string]any
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("decode serialized sideChat payload: %v", err)
}
if got := decoded["kind"]; got != "sideChat" {
t.Fatalf("expected sideChat kind, got %#v in %s", got, raw)
}
selection, ok := decoded["selection"].(map[string]any)
if !ok || selection["text"] != "const value = compute()" || selection["responsePartId"] != "part-7" {
t.Fatalf("expected sideChat selection to round-trip, got %#v in %s", decoded["selection"], raw)
}
}
})
}

func stringPtr(value string) *string {
return &value
}
184 changes: 172 additions & 12 deletions clients/go/ahptypes/commands.generated.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ const (
ReconnectResultTypeSnapshot ReconnectResultType = "snapshot"
)

// How a new chat uses its source chat and turn.
type ChatSourceKind string

const (
// Copy source history through the referenced turn into the new chat.
ChatSourceKindFork ChatSourceKind = "fork"
// Supply source context without copying it into the new chat's visible history.
ChatSourceKindSideChat ChatSourceKind = "sideChat"
)

// Encoding of fetched content data.
type ContentEncoding string

Expand Down Expand Up @@ -374,14 +384,42 @@ type DisposeSessionParams struct {
Channel URI `json:"channel"`
}

// Identifies a source chat and turn to fork from.
type ChatForkSource struct {
// URI of the existing chat to fork from
// Copies source history through a completed turn into the new chat.
type ForkChatSource struct {
// Discriminant
Kind ChatSourceKind `json:"kind"`
// URI of the existing source chat.
Chat URI `json:"chat"`
// Turn ID in the source chat; content up to and including this turn's response is copied
// Completed turn identifier in the source chat.
//
// Content through this turn is copied into the new chat's visible `turns`.
TurnId string `json:"turnId"`
}

// Supplies source context to a new side chat without copying it into the side
// chat's visible history.
type SideChatSource struct {
// Discriminant
Kind ChatSourceKind `json:"kind"`
// URI of the existing source chat.
Chat URI `json:"chat"`
// Stable source-turn identifier in the source chat.
//
// Hosts resolve this id against the source chat's current `activeTurn` or its
// retained `turns` when accepting `createChat`. If it names the current
// active turn, the host snapshots the source chat's retained history plus
// that turn's current user message and any partial assistant response already
// available. Once that turn later becomes historical, it is still referenced
// by this same identifier.
TurnId string `json:"turnId"`
// Optional immutable selected-text snapshot to carry into the created side
// chat's origin.
//
// When present, the host MUST snapshot and preserve this exact selection when
// it accepts `createChat`; later source-turn deltas do not alter it.
Selection *SideChatSelection `json:"selection,omitempty"`
}

// Creates a new chat within a session.
type CreateChatParams struct {
// Channel URI this command targets.
Expand All @@ -390,13 +428,26 @@ type CreateChatParams struct {
Chat URI `json:"chat"`
// Optional initial message for the new chat.
InitialMessage *Message `json:"initialMessage,omitempty"`
// Optional source chat and turn to fork from.
Source *ChatForkSource `json:"source,omitempty"`
// Optional source chat and source turn.
//
// The source chat MUST belong to this session. Clients MUST only request
// `kind: "fork"` when the selected agent advertises
// `capabilities.multipleChats.fork`, and `kind: "sideChat"` when the
// selected agent advertises `capabilities.multipleChats.sideChat`. Both
// source forms carry a stable top-level `turnId`. Forks target completed
// turns. Side chats also carry a stable `turnId`, which the host resolves
// against the source chat's current active turn or retained history. If it
// resolves to the active turn, the host snapshots the currently available
// partial response when accepting `createChat`. When
// `source.kind === "sideChat"` and `source.selection` is present, the host
// also snapshots and preserves that exact selected text in the created chat's
// origin; any `responsePartId` there is provenance only, not a live range.
Source *ChatSource `json:"source,omitempty"`
// Initial working-directory subset for this chat. Every entry MUST be
// present in the owning session's `workingDirectories`; the server MUST
// reject any entry that is not. When absent, the chat inherits the full
// session set. Forked chats (`source`) inherit the source chat's
// `workingDirectories`; this field is ignored for forked chats.
// session set. Forked chats (those whose `source.kind` is `"fork"`) inherit
// the source chat's `workingDirectories`; this field is ignored for forks.
//
// A client MUST NOT supply this field unless the agent advertises
// {@link AgentCapabilities.multipleWorkingDirectories}.
Expand All @@ -408,8 +459,8 @@ type CreateChatParams struct {
// {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY
// reject creation that omits it, or fall back to the first of the chat's
// directories. Fixed at creation and reported (read-only) on
// {@link ChatState.primaryWorkingDirectory}. Ignored for forked chats (a fork
// inherits the source chat's primary).
// {@link ChatState.primaryWorkingDirectory}. Ignored for forks (a chat whose
// `source.kind` is `"fork"` inherits the source chat's primary).
PrimaryWorkingDirectory *URI `json:"primaryWorkingDirectory,omitempty"`
}

Expand Down Expand Up @@ -1077,6 +1128,112 @@ type ChangesetOperationFollowUp struct {
External *bool `json:"external,omitempty"`
}

func (v *ForkChatSource) UnmarshalJSON(data []byte) error {
disc, ok, err := readDiscriminator(data, "kind")
if err != nil {
return err
}
if !ok {
return missingDiscriminatorError("ForkChatSource", "kind")
}
if disc != "fork" {
return unknownDiscriminatorError("ForkChatSource", "kind", disc)
}
type wire ForkChatSource
var raw wire
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
*v = ForkChatSource(raw)
v.Kind = ChatSourceKindFork
return nil
}

func (v ForkChatSource) MarshalJSON() ([]byte, error) {
type wire ForkChatSource
raw := wire(v)
raw.Kind = ChatSourceKindFork
return json.Marshal(raw)
}

func (v *SideChatSource) UnmarshalJSON(data []byte) error {
disc, ok, err := readDiscriminator(data, "kind")
if err != nil {
return err
}
if !ok {
return missingDiscriminatorError("SideChatSource", "kind")
}
if disc != "sideChat" {
return unknownDiscriminatorError("SideChatSource", "kind", disc)
}
type wire SideChatSource
var raw wire
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
*v = SideChatSource(raw)
v.Kind = ChatSourceKindSideChat
return nil
}

func (v SideChatSource) MarshalJSON() ([]byte, error) {
type wire SideChatSource
raw := wire(v)
raw.Kind = ChatSourceKindSideChat
return json.Marshal(raw)
}

// ─── ChatSource Union ─────────────────────────────────────────────────

// ChatSource identifies how a new chat uses a source chat.
type ChatSource struct {
Value isChatSource
}

// isChatSource is the marker interface implemented by every
// concrete variant of ChatSource.
type isChatSource interface{ isChatSource() }

func (*ForkChatSource) isChatSource() {}
func (*SideChatSource) isChatSource() {}

// UnmarshalJSON decodes the variant indicated by the "kind" discriminator.
func (u *ChatSource) UnmarshalJSON(data []byte) error {
disc, ok, err := readDiscriminator(data, "kind")
if err != nil {
return err
}
if !ok {
return missingDiscriminatorError("ChatSource", "kind")
}
switch disc {
case "fork":
var value ForkChatSource
if err := json.Unmarshal(data, &value); err != nil {
return err
}
u.Value = &value
case "sideChat":
var value SideChatSource
if err := json.Unmarshal(data, &value); err != nil {
return err
}
u.Value = &value
default:
return unknownDiscriminatorError("ChatSource", "kind", disc)
}
return nil
}

// MarshalJSON encodes the active variant back to JSON.
func (u ChatSource) MarshalJSON() ([]byte, error) {
if u.Value == nil {
return []byte("null"), nil
}
return json.Marshal(u.Value)
}

// ─── ReconnectResult Union ────────────────────────────────────────────

// ReconnectResult is the result of the `reconnect` command.
Expand All @@ -1093,10 +1250,13 @@ func (*ReconnectSnapshotResult) isReconnectResult() {}

// UnmarshalJSON decodes the variant indicated by the "type" discriminator.
func (u *ReconnectResult) UnmarshalJSON(data []byte) error {
disc, _, err := readDiscriminator(data, "type")
disc, ok, err := readDiscriminator(data, "type")
if err != nil {
return err
}
if !ok {
return missingDiscriminatorError("ReconnectResult", "type")
}
switch disc {
case "replay":
var value ReconnectReplayResult
Expand All @@ -1111,7 +1271,7 @@ func (u *ReconnectResult) UnmarshalJSON(data []byte) error {
}
u.Value = &value
default:
return &json.UnmarshalTypeError{Value: "ReconnectResult", Type: nil}
return unknownDiscriminatorError("ReconnectResult", "type", disc)
}
return nil
}
Expand Down
8 changes: 8 additions & 0 deletions clients/go/ahptypes/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,11 @@ func readDiscriminator(raw []byte, field string) (string, bool, error) {
}
return s, true, nil
}

func missingDiscriminatorError(typeName string, field string) error {
return fmt.Errorf("%s: missing %q discriminator", typeName, field)
}

func unknownDiscriminatorError(typeName string, field string, value string) error {
return fmt.Errorf("%s: unknown %q discriminator %q", typeName, field, value)
}
4 changes: 4 additions & 0 deletions clients/go/ahptypes/roundtrip_fixture_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,10 @@ func decodeAndReencode(t *testing.T, name, typ, inputJSON string) string {
var v InitializeResult
dec(&v)
return enc(&v)
case "ChatSource":
var v ChatSource
dec(&v)
return enc(&v)
default:
t.Fatalf("%s: round-trip fixture: unknown wire type %q. Add a decode entry to decodeAndReencode.", name, typ)
return ""
Expand Down
Loading
Loading