feat: extend gateway session patch
This commit is contained in:
26
docs/refactor/tui.md
Normal file
26
docs/refactor/tui.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
summary: "Refactor plan: Gateway TUI parity with pi-mono interactive UI"
|
||||||
|
read_when:
|
||||||
|
- Building or refactoring the Gateway TUI
|
||||||
|
- Syncing TUI slash commands with Clawdis behavior
|
||||||
|
---
|
||||||
|
# Gateway TUI refactor plan
|
||||||
|
|
||||||
|
Updated: 2026-01-03
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
- Match pi-mono interactive TUI feel (editor, streaming, tool cards, selectors).
|
||||||
|
- Keep Clawdis semantics: Gateway WS only, session store owns state, no branching/export.
|
||||||
|
- Work locally or remotely via Gateway URL/token.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
- Branching, export, OAuth flows, or hook UIs.
|
||||||
|
- File-system operations on the Gateway host from the TUI.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- [x] Protocol + server: sessions.patch supports model overrides; agent events include tool results (text-only payloads).
|
||||||
|
- [ ] Gateway TUI client: add session/model helpers + stricter typing.
|
||||||
|
- [ ] TUI UI kit: theme + components (editor, message feed, tool cards, selectors).
|
||||||
|
- [ ] TUI controller: keybindings + Clawdis slash commands + history/stream wiring.
|
||||||
|
- [ ] Docs + changelog updated for the new TUI behavior.
|
||||||
|
- [ ] Gate: lint, build, tests, docs list.
|
||||||
@@ -16,6 +16,36 @@ import {
|
|||||||
const THINKING_TAG_RE = /<\s*\/?\s*think(?:ing)?\s*>/gi;
|
const THINKING_TAG_RE = /<\s*\/?\s*think(?:ing)?\s*>/gi;
|
||||||
const THINKING_OPEN_RE = /<\s*think(?:ing)?\s*>/i;
|
const THINKING_OPEN_RE = /<\s*think(?:ing)?\s*>/i;
|
||||||
const THINKING_CLOSE_RE = /<\s*\/\s*think(?:ing)?\s*>/i;
|
const THINKING_CLOSE_RE = /<\s*\/\s*think(?:ing)?\s*>/i;
|
||||||
|
const TOOL_RESULT_MAX_CHARS = 8000;
|
||||||
|
|
||||||
|
function truncateToolText(text: string): string {
|
||||||
|
if (text.length <= TOOL_RESULT_MAX_CHARS) return text;
|
||||||
|
return `${text.slice(0, TOOL_RESULT_MAX_CHARS)}\n…(truncated)…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeToolResult(result: unknown): unknown {
|
||||||
|
if (!result || typeof result !== "object") return result;
|
||||||
|
const record = result as Record<string, unknown>;
|
||||||
|
const content = Array.isArray(record.content) ? record.content : null;
|
||||||
|
if (!content) return record;
|
||||||
|
const sanitized = content.map((item) => {
|
||||||
|
if (!item || typeof item !== "object") return item;
|
||||||
|
const entry = item as Record<string, unknown>;
|
||||||
|
const type = typeof entry.type === "string" ? entry.type : undefined;
|
||||||
|
if (type === "text" && typeof entry.text === "string") {
|
||||||
|
return { ...entry, text: truncateToolText(entry.text) };
|
||||||
|
}
|
||||||
|
if (type === "image") {
|
||||||
|
const data = typeof entry.data === "string" ? entry.data : undefined;
|
||||||
|
const bytes = data ? data.length : undefined;
|
||||||
|
const cleaned = { ...entry };
|
||||||
|
delete cleaned.data;
|
||||||
|
return { ...cleaned, bytes, omitted: true };
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
});
|
||||||
|
return { ...record, content: sanitized };
|
||||||
|
}
|
||||||
|
|
||||||
function stripThinkingSegments(text: string): string {
|
function stripThinkingSegments(text: string): string {
|
||||||
if (!text || !THINKING_TAG_RE.test(text)) return text;
|
if (!text || !THINKING_TAG_RE.test(text)) return text;
|
||||||
@@ -187,6 +217,36 @@ export function subscribeEmbeddedPiSession(params: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (evt.type === "tool_execution_update") {
|
||||||
|
const toolName = String(
|
||||||
|
(evt as AgentEvent & { toolName: string }).toolName,
|
||||||
|
);
|
||||||
|
const toolCallId = String(
|
||||||
|
(evt as AgentEvent & { toolCallId: string }).toolCallId,
|
||||||
|
);
|
||||||
|
const partial = (evt as AgentEvent & { partialResult?: unknown })
|
||||||
|
.partialResult;
|
||||||
|
const sanitized = sanitizeToolResult(partial);
|
||||||
|
emitAgentEvent({
|
||||||
|
runId: params.runId,
|
||||||
|
stream: "tool",
|
||||||
|
data: {
|
||||||
|
phase: "update",
|
||||||
|
name: toolName,
|
||||||
|
toolCallId,
|
||||||
|
partialResult: sanitized,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
params.onAgentEvent?.({
|
||||||
|
stream: "tool",
|
||||||
|
data: {
|
||||||
|
phase: "update",
|
||||||
|
name: toolName,
|
||||||
|
toolCallId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (evt.type === "tool_execution_end") {
|
if (evt.type === "tool_execution_end") {
|
||||||
const toolName = String(
|
const toolName = String(
|
||||||
(evt as AgentEvent & { toolName: string }).toolName,
|
(evt as AgentEvent & { toolName: string }).toolName,
|
||||||
@@ -197,6 +257,8 @@ export function subscribeEmbeddedPiSession(params: {
|
|||||||
const isError = Boolean(
|
const isError = Boolean(
|
||||||
(evt as AgentEvent & { isError: boolean }).isError,
|
(evt as AgentEvent & { isError: boolean }).isError,
|
||||||
);
|
);
|
||||||
|
const result = (evt as AgentEvent & { result?: unknown }).result;
|
||||||
|
const sanitizedResult = sanitizeToolResult(result);
|
||||||
const meta = toolMetaById.get(toolCallId);
|
const meta = toolMetaById.get(toolCallId);
|
||||||
toolMetas.push({ toolName, meta });
|
toolMetas.push({ toolName, meta });
|
||||||
toolDebouncer.push(toolName, meta);
|
toolDebouncer.push(toolName, meta);
|
||||||
@@ -210,6 +272,7 @@ export function subscribeEmbeddedPiSession(params: {
|
|||||||
toolCallId,
|
toolCallId,
|
||||||
meta,
|
meta,
|
||||||
isError,
|
isError,
|
||||||
|
result: sanitizedResult,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
params.onAgentEvent?.({
|
params.onAgentEvent?.({
|
||||||
|
|||||||
@@ -297,6 +297,7 @@ export const SessionsPatchParamsSchema = Type.Object(
|
|||||||
key: NonEmptyString,
|
key: NonEmptyString,
|
||||||
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
|
model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
groupActivation: Type.Optional(
|
groupActivation: Type.Optional(
|
||||||
Type.Union([
|
Type.Union([
|
||||||
Type.Literal("mention"),
|
Type.Literal("mention"),
|
||||||
|
|||||||
@@ -4074,6 +4074,16 @@ describe("gateway server", () => {
|
|||||||
expect(main2?.thinkingLevel).toBe("medium");
|
expect(main2?.thinkingLevel).toBe("medium");
|
||||||
expect(main2?.verboseLevel).toBeUndefined();
|
expect(main2?.verboseLevel).toBeUndefined();
|
||||||
|
|
||||||
|
piSdkMock.enabled = true;
|
||||||
|
piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }];
|
||||||
|
const modelPatched = await rpcReq<{
|
||||||
|
ok: true;
|
||||||
|
entry: { modelOverride?: string; providerOverride?: string };
|
||||||
|
}>(ws, "sessions.patch", { key: "main", model: "openai/gpt-test-a" });
|
||||||
|
expect(modelPatched.ok).toBe(true);
|
||||||
|
expect(modelPatched.payload?.entry.modelOverride).toBe("gpt-test-a");
|
||||||
|
expect(modelPatched.payload?.entry.providerOverride).toBe("openai");
|
||||||
|
|
||||||
const compacted = await rpcReq<{ ok: true; compacted: boolean }>(
|
const compacted = await rpcReq<{ ok: true; compacted: boolean }>(
|
||||||
ws,
|
ws,
|
||||||
"sessions.compact",
|
"sessions.compact",
|
||||||
|
|||||||
@@ -20,7 +20,13 @@ import {
|
|||||||
type ModelCatalogEntry,
|
type ModelCatalogEntry,
|
||||||
resetModelCatalogCacheForTest,
|
resetModelCatalogCacheForTest,
|
||||||
} from "../agents/model-catalog.js";
|
} from "../agents/model-catalog.js";
|
||||||
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
|
import {
|
||||||
|
buildAllowedModelSet,
|
||||||
|
buildModelAliasIndex,
|
||||||
|
modelKey,
|
||||||
|
resolveConfiguredModelRef,
|
||||||
|
resolveModelRefFromString,
|
||||||
|
} from "../agents/model-selection.js";
|
||||||
import { installSkill } from "../agents/skills-install.js";
|
import { installSkill } from "../agents/skills-install.js";
|
||||||
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
|
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
|
||||||
import { DEFAULT_AGENT_WORKSPACE_DIR } from "../agents/workspace.js";
|
import { DEFAULT_AGENT_WORKSPACE_DIR } from "../agents/workspace.js";
|
||||||
@@ -2958,6 +2964,74 @@ export async function startGatewayServer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ("model" in p) {
|
||||||
|
const raw = p.model;
|
||||||
|
if (raw === null) {
|
||||||
|
delete next.providerOverride;
|
||||||
|
delete next.modelOverride;
|
||||||
|
} else if (raw !== undefined) {
|
||||||
|
const trimmed = String(raw).trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message: "invalid model: empty",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const resolvedDefault = resolveConfiguredModelRef({
|
||||||
|
cfg,
|
||||||
|
defaultProvider: DEFAULT_PROVIDER,
|
||||||
|
defaultModel: DEFAULT_MODEL,
|
||||||
|
});
|
||||||
|
const aliasIndex = buildModelAliasIndex({
|
||||||
|
cfg,
|
||||||
|
defaultProvider: resolvedDefault.provider,
|
||||||
|
});
|
||||||
|
const resolved = resolveModelRefFromString({
|
||||||
|
raw: trimmed,
|
||||||
|
defaultProvider: resolvedDefault.provider,
|
||||||
|
aliasIndex,
|
||||||
|
});
|
||||||
|
if (!resolved) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message: `invalid model: ${trimmed}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const catalog = await loadGatewayModelCatalog();
|
||||||
|
const allowed = buildAllowedModelSet({
|
||||||
|
cfg,
|
||||||
|
catalog,
|
||||||
|
defaultProvider: resolvedDefault.provider,
|
||||||
|
});
|
||||||
|
const key = modelKey(resolved.ref.provider, resolved.ref.model);
|
||||||
|
if (!allowed.allowAny && !allowed.allowedKeys.has(key)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message: `model not allowed: ${key}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
resolved.ref.provider === resolvedDefault.provider &&
|
||||||
|
resolved.ref.model === resolvedDefault.model
|
||||||
|
) {
|
||||||
|
delete next.providerOverride;
|
||||||
|
delete next.modelOverride;
|
||||||
|
} else {
|
||||||
|
next.providerOverride = resolved.ref.provider;
|
||||||
|
next.modelOverride = resolved.ref.model;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ("groupActivation" in p) {
|
if ("groupActivation" in p) {
|
||||||
const raw = p.groupActivation;
|
const raw = p.groupActivation;
|
||||||
if (raw === null) {
|
if (raw === null) {
|
||||||
|
|||||||
Reference in New Issue
Block a user