Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fed7adf6b | ||
|
|
7308ed9df2 |
No files matched your search
@@ -3159,7 +3159,9 @@ def create_app(
|
||||
# no score floor, then keep the rows that contain the words, so
|
||||
# a word in the library is found and a word in none of it finds
|
||||
# nothing. The whole library is never listed in Core.
|
||||
fetch = 500
|
||||
# A larger window made one search take long enough to freeze
|
||||
# the page; 20 is the bounded window the library searches.
|
||||
fetch = 20
|
||||
search_raw = await asyncio.to_thread(
|
||||
client.search,
|
||||
search_query,
|
||||
|
||||
@@ -47,6 +47,19 @@ class ContextSearchRequest(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
class SdkContextSearchRequest(ContextSearchRequest):
|
||||
"""`POST /api/v2/context/search`: the three SDK recall lanes are set apart.
|
||||
|
||||
`limit` is the normal lane's top-k and the cap on returned items;
|
||||
`schema_limit` and `intention_limit` are the SDK's schema (L6) and
|
||||
proactive intention (L7) lanes. A caller's values are passed through.
|
||||
"""
|
||||
|
||||
limit: int = Field(default=10, ge=1, le=20, strict=True)
|
||||
schema_limit: int = Field(default=3, ge=0, le=20, strict=True)
|
||||
intention_limit: int = Field(default=1, ge=0, le=20, strict=True)
|
||||
|
||||
|
||||
class ContextMessage(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ from one_memory.context_models import (
|
||||
MemoryContextItem,
|
||||
RawContextItem,
|
||||
SdkContextItem,
|
||||
SdkContextSearchRequest,
|
||||
SdkContextSearchResponse,
|
||||
SdkMemoryContextItem,
|
||||
SdkRawContextItem,
|
||||
@@ -439,7 +440,7 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
|
||||
@router.post("/search", response_model=SdkContextSearchResponse)
|
||||
async def search(
|
||||
value: ContextSearchRequest,
|
||||
value: SdkContextSearchRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
) -> SdkContextSearchResponse:
|
||||
@@ -514,8 +515,8 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
limit=value.limit,
|
||||
min_score=sdk_relevance_floor(),
|
||||
schema_min_score=sdk_relevance_floor(),
|
||||
schema_limit=value.limit,
|
||||
intention_limit=value.limit,
|
||||
schema_limit=value.schema_limit,
|
||||
intention_limit=value.intention_limit,
|
||||
scene="normal",
|
||||
reader=HY_MEMORY_READER,
|
||||
)
|
||||
|
||||
@@ -409,7 +409,7 @@ def test_sdk_memory_library_uses_sdk_search_and_skips_empty_rows_without_mutatio
|
||||
assert [item["memory_id"] for item in body["memories"]] == ["release"]
|
||||
assert search_calls[0][0] == "release"
|
||||
assert search_calls[0][1]["include_sources"] == ["cursor"]
|
||||
assert search_calls[0][1]["limit"] == 500
|
||||
assert search_calls[0][1]["limit"] == 20
|
||||
assert search_calls[0][1]["intention_limit"] == 0
|
||||
assert search_calls[0][1]["min_score"] == 0.0
|
||||
assert list_calls == []
|
||||
@@ -440,3 +440,54 @@ def test_sdk_memory_library_uses_sdk_search_and_skips_empty_rows_without_mutatio
|
||||
assert "source:default" not in node_ids
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_sdk_context_search_sets_each_recall_lane_apart(tmp_path):
|
||||
"""Normal, schema and intention lanes default to 10 / 3 / 1 and pass through."""
|
||||
|
||||
async def scenario():
|
||||
app = application(tmp_path)
|
||||
calls = []
|
||||
|
||||
class Client:
|
||||
def search(self, query, **kwargs):
|
||||
calls.append(kwargs)
|
||||
return {"memories": {"schema": [], "proactive": [], "normal": []}}
|
||||
|
||||
def get(self, memory_id):
|
||||
return None
|
||||
|
||||
sdk = Client()
|
||||
|
||||
class Handle:
|
||||
async def client(self):
|
||||
return sdk
|
||||
|
||||
async def close(self):
|
||||
return None
|
||||
|
||||
async with app.router.lifespan_context(app):
|
||||
app.state.sdk_client_handle = Handle()
|
||||
async with client_for(app) as client:
|
||||
defaulted = await client.post(
|
||||
"/api/v2/context/search",
|
||||
json={"query": "release", "kinds": ["memory"]},
|
||||
)
|
||||
chosen = await client.post(
|
||||
"/api/v2/context/search",
|
||||
json={
|
||||
"query": "release",
|
||||
"kinds": ["memory"],
|
||||
"limit": 5,
|
||||
"schema_limit": 0,
|
||||
"intention_limit": 2,
|
||||
},
|
||||
)
|
||||
assert defaulted.status_code == 200
|
||||
assert chosen.status_code == 200
|
||||
assert [
|
||||
(call["limit"], call["schema_limit"], call["intention_limit"]) for call in calls
|
||||
] == [(10, 3, 1), (5, 0, 2)]
|
||||
|
||||
asyncio.run(scenario())
|
||||
@@ -181,8 +181,9 @@ it("OBS-22: the graph inspector has no editing entry and never says changes cann
|
||||
expect(screen.queryByText(/暂不能保存更改/)).toBeNull();
|
||||
});
|
||||
|
||||
it("OBS-19: Enter in the memory filter re-reads, exactly like the refresh button", async () => {
|
||||
it("OBS-19: Enter in the memory filter submits it; submitting it again re-reads", async () => {
|
||||
const onRetry = vi.fn();
|
||||
const onQuery = vi.fn();
|
||||
const page = {
|
||||
items: memories,
|
||||
total: memories.length,
|
||||
@@ -200,7 +201,7 @@ it("OBS-19: Enter in the memory filter re-reads, exactly like the refresh button
|
||||
loading: false,
|
||||
failed: false,
|
||||
stale: false,
|
||||
onQuery: vi.fn(),
|
||||
onQuery,
|
||||
onSource: vi.fn(),
|
||||
resetPage: vi.fn(),
|
||||
onPage: vi.fn(),
|
||||
@@ -211,11 +212,39 @@ it("OBS-19: Enter in the memory filter re-reads, exactly like the refresh button
|
||||
await act(async () => {});
|
||||
await userEvent.click(screen.getByRole("button", { name: "刷新地图记忆" }));
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
// Typing alone does not search; Enter submits the words.
|
||||
const input = screen.getByRole("textbox", { name: "筛选地图记忆" });
|
||||
await userEvent.type(input, "第一");
|
||||
expect(onQuery).not.toHaveBeenCalled();
|
||||
await userEvent.type(input, "{Enter}");
|
||||
expect(onQuery).toHaveBeenCalledWith("第一");
|
||||
// Submitting the words already applied re-reads, like the refresh button.
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, "{Enter}");
|
||||
expect(onRetry).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("searches only when 搜索 is pressed, not on every keystroke", async () => {
|
||||
const onQuery = vi.fn();
|
||||
const resetPage = vi.fn();
|
||||
render(
|
||||
<Host
|
||||
simplified
|
||||
libraryView={{
|
||||
...pagedView({ items: memories, total: 2, offset: 0, limit: 100 }),
|
||||
onQuery,
|
||||
resetPage,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
await userEvent.type(
|
||||
screen.getByRole("textbox", { name: "筛选地图记忆" }),
|
||||
"第一{Enter}",
|
||||
"版本号",
|
||||
);
|
||||
expect(onRetry).toHaveBeenCalledTimes(2);
|
||||
expect(onQuery).not.toHaveBeenCalled();
|
||||
await userEvent.click(screen.getByRole("button", { name: "搜索" }));
|
||||
expect(onQuery).toHaveBeenCalledWith("版本号");
|
||||
expect(resetPage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("OBS-15: switching memory shows a read in progress instead of the previous memory", async () => {
|
||||
|
||||
@@ -197,6 +197,13 @@ export function MemoryMap(props: Props) {
|
||||
const [axis, setAxis] = useState<MapAxis>("source");
|
||||
const [groupKey, setGroupKey] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
// On a server-paged list, typing only edits the words; the search runs when
|
||||
// the reader submits it (the 搜索 button or Enter), not on every keystroke.
|
||||
const [searchDraft, setSearchDraft] = useState(library?.query ?? "");
|
||||
const submittedQuery = library?.query;
|
||||
useEffect(() => {
|
||||
if (submittedQuery !== undefined) setSearchDraft(submittedQuery);
|
||||
}, [submittedQuery]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [groupNode, setGroupNode] = useState<MemoryGraphNode | null>(null);
|
||||
const [question, setQuestion] = useState("");
|
||||
@@ -827,7 +834,23 @@ export function MemoryMap(props: Props) {
|
||||
))}
|
||||
</fieldset>
|
||||
)}
|
||||
<div className="map-search">
|
||||
<form
|
||||
className="map-search"
|
||||
role="search"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!library || libraryPane === "dialogues") return;
|
||||
const words = searchDraft.trim();
|
||||
setFilterNotice("");
|
||||
clear();
|
||||
if (words === library.query.trim()) {
|
||||
library.onRetry();
|
||||
return;
|
||||
}
|
||||
library.onQuery(words);
|
||||
library.resetPage();
|
||||
}}
|
||||
>
|
||||
<Search size={16} aria-hidden="true" />
|
||||
<input
|
||||
aria-label={text("筛选地图记忆")}
|
||||
@@ -843,31 +866,39 @@ export function MemoryMap(props: Props) {
|
||||
value={
|
||||
libraryPane === "dialogues"
|
||||
? dialogueFilter
|
||||
: (library?.query ?? search)
|
||||
: library
|
||||
? searchDraft
|
||||
: search
|
||||
}
|
||||
maxLength={library ? 512 : undefined}
|
||||
onKeyDown={(e) => {
|
||||
// The refresh button next to this list re-reads the page;
|
||||
// Enter has to do the same thing, not sit there doing nothing.
|
||||
if (e.key !== "Enter" || e.nativeEvent.isComposing) return;
|
||||
e.preventDefault();
|
||||
library?.onRetry();
|
||||
// Enter that confirms an IME candidate is not a search.
|
||||
if (e.key === "Enter" && e.nativeEvent.isComposing)
|
||||
e.preventDefault();
|
||||
}}
|
||||
onChange={(e) => {
|
||||
if (libraryPane === "dialogues") {
|
||||
setDialogueFilter(e.target.value);
|
||||
return;
|
||||
}
|
||||
if (library) {
|
||||
setSearchDraft(e.target.value);
|
||||
return;
|
||||
}
|
||||
setFilterNotice("");
|
||||
setSearch(e.target.value);
|
||||
if (library) {
|
||||
clear();
|
||||
library.onQuery(e.target.value);
|
||||
library.resetPage();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{library && libraryPane === "memories" ? (
|
||||
<button
|
||||
type="submit"
|
||||
className="map-search-submit"
|
||||
disabled={library.loading || hidden}
|
||||
>
|
||||
{text("搜索")}
|
||||
</button>
|
||||
) : null}
|
||||
</form>
|
||||
{library && libraryPane === "memories" && (
|
||||
<div className="map-list-heading" aria-live="polite">
|
||||
<span>
|
||||
|
||||
@@ -218,7 +218,7 @@ it("default map search finds an existing 501st memory rather than declaring no m
|
||||
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
|
||||
target: { value: "第501条独有目标" },
|
||||
});
|
||||
fireEvent.keyDown(screen.getByLabelText("筛选地图记忆"), { key: "Enter" });
|
||||
fireEvent.submit(screen.getByRole("search"));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(screen.getByRole("list", { name: "地图记忆列表" })).queryByText(
|
||||
@@ -310,6 +310,7 @@ it("OBS-13: the settled-count headline names the filter it is counting", async (
|
||||
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
|
||||
target: { value: "第501条独有目标" },
|
||||
});
|
||||
fireEvent.submit(screen.getByRole("search"));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(
|
||||
@@ -320,6 +321,7 @@ it("OBS-13: the settled-count headline names the filter it is counting", async (
|
||||
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
|
||||
target: { value: "" },
|
||||
});
|
||||
fireEvent.submit(screen.getByRole("search"));
|
||||
await chooseSource("codex");
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
@@ -473,6 +475,7 @@ it("distinguishes failed search from empty, then retries the same query", async
|
||||
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
|
||||
target: { value: "目标" },
|
||||
});
|
||||
fireEvent.submit(screen.getByRole("search"));
|
||||
await screen.findByText("记忆读取失败,请重试。");
|
||||
expect(screen.queryByText("没有匹配的记忆")).toBeNull();
|
||||
expect(screen.queryByText("还没有已生成的记忆")).toBeNull();
|
||||
@@ -490,6 +493,7 @@ it("distinguishes failed search from empty, then retries the same query", async
|
||||
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
|
||||
target: { value: "确实没有这项" },
|
||||
});
|
||||
fireEvent.submit(screen.getByRole("search"));
|
||||
await screen.findByText("没有匹配的记忆");
|
||||
expect(screen.queryByText("还没有已生成的记忆")).toBeNull();
|
||||
expect(
|
||||
@@ -510,6 +514,7 @@ it("aborts an older query after a source change and never replaces the newer res
|
||||
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
|
||||
target: { value: "旧搜索" },
|
||||
});
|
||||
fireEvent.submit(screen.getByRole("search"));
|
||||
await waitFor(() => expect(resolve).toHaveLength(1));
|
||||
const signals = vi
|
||||
.mocked(api.getMemoryLibrary)
|
||||
@@ -521,6 +526,7 @@ it("aborts an older query after a source change and never replaces the newer res
|
||||
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
|
||||
target: { value: "目标" },
|
||||
});
|
||||
fireEvent.submit(screen.getByRole("search"));
|
||||
await within(screen.getByRole("list", { name: "地图记忆列表" })).findByText(
|
||||
"第501条独有目标",
|
||||
);
|
||||
@@ -553,6 +559,7 @@ it("hidden map aborts page reads and does not accept late body or poll until rea
|
||||
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
|
||||
target: { value: "迟到" },
|
||||
});
|
||||
fireEvent.submit(screen.getByRole("search"));
|
||||
await act(async () => {});
|
||||
const signals = vi
|
||||
.mocked(api.getMemoryLibrary)
|
||||
@@ -611,6 +618,7 @@ it("keeps the populated map while a search is in flight and does not treat it as
|
||||
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
|
||||
target: { value: "onememory" },
|
||||
});
|
||||
fireEvent.submit(screen.getByRole("search"));
|
||||
await act(async () => {});
|
||||
expect(screen.queryByText("正在读取…")).toBeNull();
|
||||
expect(screen.getByText("已沉淀 501 条 · 当前展示 100 条")).toBeTruthy();
|
||||
@@ -700,6 +708,7 @@ it("explicit refresh keeps the search scope and replaces the page with the lates
|
||||
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
|
||||
target: { value: "目标" },
|
||||
});
|
||||
fireEvent.submit(screen.getByRole("search"));
|
||||
const list = screen.getByRole("list", { name: "地图记忆列表" });
|
||||
await within(list).findByText("第501条独有目标");
|
||||
vi.mocked(api.getMemoryLibrary).mockResolvedValueOnce({
|
||||
|
||||
@@ -689,6 +689,23 @@ button.rail-button.active::before {
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
}
|
||||
.map-search input {
|
||||
flex: 1;
|
||||
}
|
||||
.map-search-submit {
|
||||
flex: none;
|
||||
padding: 4px 10px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
background: #3d5a70;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.map-search-submit:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.map-search:focus-within,
|
||||
.map-question:focus-within {
|
||||
outline: 2px solid #7892a6;
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "one-memory",
|
||||
"version": "1.0.5",
|
||||
"release": {
|
||||
"build_number": 25,
|
||||
"build_number": 22,
|
||||
"macos_min_version": "13.0",
|
||||
"app_update": "manual",
|
||||
"runtime_manifest_schema": 1,
|
||||
|
||||
@@ -317,6 +317,39 @@ it("selects SDK Context v2 after cutover and passes native fields unchanged", as
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[10, 200],
|
||||
[11, 502],
|
||||
])(
|
||||
"accepts up to Core's v2 default of 10 items when no limit is sent (%i items)",
|
||||
async (count, status) => {
|
||||
const f = await fixture();
|
||||
const items = Array.from({ length: count }, (_, index) => ({
|
||||
kind: "memory",
|
||||
memory_id: `memory-${index}`,
|
||||
content: "m",
|
||||
layer: "l2_fact",
|
||||
status: "active",
|
||||
source: "codex",
|
||||
updated_at: "2026-10-21T00:00:00Z",
|
||||
score: 0.5,
|
||||
truncated: false,
|
||||
}));
|
||||
f.setAuthority("sdk");
|
||||
f.respond({
|
||||
schema_version: 2,
|
||||
request_id: "sdk-request",
|
||||
state: "grounded",
|
||||
items,
|
||||
used_characters: count,
|
||||
truncated: false,
|
||||
total_ms: 1,
|
||||
});
|
||||
|
||||
expect((await f.post({ query: "original" })).status).toBe(status);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not fall back to legacy context when the selected SDK response is invalid", async () => {
|
||||
const f = await fixture();
|
||||
f.setAuthority("sdk");
|
||||
|
||||
@@ -74,7 +74,7 @@ export const CONTEXT_INPUT_SCHEMA = {
|
||||
description:
|
||||
"Filters raw only, within already granted sources; does not change memory scope.",
|
||||
},
|
||||
limit: { type: "integer", minimum: 1, maximum: 20, default: 8 },
|
||||
limit: { type: "integer", minimum: 1, maximum: 20, default: 10 },
|
||||
character_budget: {
|
||||
type: "integer",
|
||||
minimum: 200,
|
||||
@@ -285,8 +285,10 @@ export function isContextResponseForRequest(
|
||||
): value is HostContextSearchResponse {
|
||||
if (!isHostContextSearchResponse(value)) return false;
|
||||
const kinds = request.kinds ?? ["raw", "memory"];
|
||||
// Core's default item cap: 8 on v1, 10 on SDK-native v2.
|
||||
const limit = request.limit ?? (isSdkContextSearchResponse(value) ? 10 : 8);
|
||||
return (
|
||||
value.items.length <= (request.limit ?? 8) &&
|
||||
value.items.length <= limit &&
|
||||
value.used_characters <= (request.character_budget ?? 4000) &&
|
||||
value.items.every(
|
||||
(item) =>
|
||||
|
||||
Reference in new issue
Block a user