001/*
002 * Copyright 2022-2026 Revetware LLC.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package com.soklet;
018
019import com.soklet.converter.TypeReference;
020import com.soklet.internal.mcp.schema.McpRuntimeTypedSchemaBridge;
021import org.jspecify.annotations.NonNull;
022import org.jspecify.annotations.Nullable;
023
024import javax.annotation.concurrent.NotThreadSafe;
025import javax.annotation.concurrent.ThreadSafe;
026import java.lang.reflect.Type;
027import java.util.Collections;
028import java.util.LinkedHashMap;
029import java.util.Map;
030import java.util.Optional;
031
032import static java.util.Objects.requireNonNull;
033
034/**
035 * Immutable input responses supplied with an MCP multi-round-trip retry or
036 * task update.
037 *
038 * <p>Raw lookup preserves the exact MCP JSON value. Typed lookup uses
039 * Soklet's closed intrinsic MCP binding and is provided both to live request
040 * contexts and to application-created test fixtures.
041 *
042 * <p>For ordinary multi-round request retries, Soklet validates that each
043 * wire value belongs to the open MCP {@code InputResponse} union. A task
044 * update is different: the task manager must first correlate each key with
045 * the task's outstanding requests, then ignore unknown, consumed,
046 * superseded, or union-mismatched values. Its input-response collection can
047 * therefore contain an arbitrary bounded MCP JSON value under a key that the
048 * manager must ignore.
049 *
050 * <p>Applications must still branch explicitly on elicitation
051 * {@code accept}, {@code decline}, and {@code cancel} actions, and validate
052 * accepted form content against the exact requested schema before causing a
053 * side effect. Typed lookup converts a value; it does not establish that
054 * request-specific correlation or policy.
055 *
056 * @author <a href="https://www.revetkn.com">Mark Allen</a>
057 */
058@ThreadSafe
059public final class McpInputResponses {
060        @NonNull
061        private static final McpInputResponses EMPTY =
062                        new McpInputResponses(Map.of());
063        @NonNull
064        private final Map<@NonNull String, @NonNull McpJsonValue> responses;
065
066        /**
067         * Returns the shared empty response collection.
068         *
069         * @return empty input responses
070         */
071        @NonNull
072        public static McpInputResponses emptyInstance() {
073                return EMPTY;
074        }
075
076        /**
077         * Creates an application-test fixture from exact JSON responses.
078         *
079         * @param responses response keys and exact JSON values
080         * @return immutable input responses
081         * @throws NullPointerException if the map, a key, or a value is null
082         */
083        @NonNull
084        public static McpInputResponses fromResponses(
085                        @NonNull Map<@NonNull String,
086                                        ? extends @NonNull McpJsonValue> responses) {
087                requireNonNull(responses);
088                if (responses.isEmpty())
089                        return emptyInstance();
090                return new McpInputResponses(responses);
091        }
092
093        /**
094         * Vends a mutable builder for an application-test fixture.
095         *
096         * @return input-response builder
097         */
098        @NonNull
099        public static Builder builder() {
100                return new Builder();
101        }
102
103        private McpInputResponses(
104                        @NonNull Map<@NonNull String,
105                                        ? extends @NonNull McpJsonValue> responses) {
106                Map<String, McpJsonValue> copiedResponses =
107                                new LinkedHashMap<>(responses.size());
108                for (Map.Entry<@NonNull String,
109                                ? extends @NonNull McpJsonValue> entry : responses.entrySet())
110                        copiedResponses.put(requireNonNull(entry.getKey()),
111                                        requireNonNull(entry.getValue()));
112                this.responses = Collections.unmodifiableMap(copiedResponses);
113        }
114
115        /**
116         * Finds an exact JSON response by its application-assigned key.
117         *
118         * @param key response key
119         * @return exact JSON response, if present
120         * @throws NullPointerException if {@code key} is null
121         */
122        @NonNull
123        public Optional<@NonNull McpJsonValue> find(@NonNull String key) {
124                return Optional.ofNullable(this.responses.get(requireNonNull(key)));
125        }
126
127        /**
128         * Finds and converts a response with Soklet's intrinsic MCP binding.
129         *
130         * @param key response key
131         * @param type requested Java type
132         * @param <T> requested Java type
133         * @return converted response, if the key is present
134         * @throws NullPointerException if {@code key} or {@code type} is null
135         * @throws IllegalArgumentException if the type is unsupported or the
136         * response cannot be converted to it
137         */
138        @NonNull
139        public <T> Optional<@NonNull T> find(@NonNull String key,
140                        @NonNull Class<@NonNull T> type) {
141                return findConverted(key, requireNonNull(type));
142        }
143
144        /**
145         * Finds and converts a response to a generic type with Soklet's intrinsic
146         * MCP binding.
147         *
148         * @param key response key
149         * @param type requested generic Java type
150         * @param <T> requested Java type
151         * @return converted response, if the key is present
152         * @throws NullPointerException if {@code key} or {@code type} is null
153         * @throws IllegalArgumentException if the type is unsupported or the
154         * response cannot be converted to it
155         */
156        @NonNull
157        public <T> Optional<@NonNull T> find(@NonNull String key,
158                        @NonNull TypeReference<@NonNull T> type) {
159                return findConverted(key, requireNonNull(type).getType());
160        }
161
162        /**
163         * Returns the immutable response map in wire order.
164         *
165         * @return immutable responses
166         */
167        @NonNull
168        public Map<@NonNull String, @NonNull McpJsonValue> asMap() {
169                return this.responses;
170        }
171
172        /** @return whether every keyed input response is structurally equal */
173        @Override
174        public boolean equals(@Nullable Object other) {
175                return this == other
176                                || other instanceof McpInputResponses responses
177                                && this.responses.equals(responses.responses);
178        }
179
180        /** @return structural input-response hash code */
181        @Override
182        public int hashCode() {
183                return this.responses.hashCode();
184        }
185
186        @NonNull
187        private <T> Optional<@NonNull T> findConverted(@NonNull String key,
188                        @NonNull Type type) {
189                McpJsonValue response = this.responses.get(requireNonNull(key));
190                requireNonNull(type);
191                if (response == null)
192                        return Optional.empty();
193                McpRuntimeTypedSchemaBridge<T> bridge =
194                                McpRuntimeTypedSchemaBridge.compileJsonValue(type);
195                return Optional.of(bridge.decode(response));
196        }
197
198        /**
199         * Mutable builder for immutable application-test input responses.
200         *
201         * @author <a href="https://www.revetkn.com">Mark Allen</a>
202         */
203        @NotThreadSafe
204        public static final class Builder {
205                @NonNull
206                private final Map<@NonNull String, @NonNull McpJsonValue> responses =
207                                new LinkedHashMap<>();
208
209                private Builder() {
210                }
211
212                /**
213                 * Adds an exact JSON response under an application-assigned key.
214                 *
215                 * <p>Repeated keys are last-call-wins.
216                 *
217                 * @param key response key
218                 * @param inputResponse exact JSON response
219                 * @return this builder
220                 */
221                @NonNull
222                public Builder addResponse(@NonNull String key,
223                                @NonNull McpJsonValue inputResponse) {
224                        this.responses.put(requireNonNull(key), requireNonNull(inputResponse));
225                        return this;
226                }
227
228                /**
229                 * Adds all supplied response entries.
230                 *
231                 * <p>Entries are applied in iteration order and repeated keys are
232                 * last-call-wins.
233                 *
234                 * @param responses response entries
235                 * @return this builder
236                 * @throws NullPointerException if the map, a key, or a value is null
237                 */
238                @NonNull
239                public Builder addResponses(
240                                @NonNull Map<@NonNull String,
241                                                ? extends @NonNull McpJsonValue> responses) {
242                        McpInputResponses copiedResponses =
243                                        McpInputResponses.fromResponses(requireNonNull(responses));
244                        this.responses.putAll(copiedResponses.responses);
245                        return this;
246                }
247
248                /**
249                 * Builds immutable input responses.
250                 *
251                 * @return immutable input responses
252                 */
253                @NonNull
254                public McpInputResponses build() {
255                        return McpInputResponses.fromResponses(this.responses);
256                }
257        }
258}